mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-06 12:09:12 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 950cce6f57 | |||
| 0b72f39023 | |||
| 30a0937a7d | |||
| 5b962b6c58 | |||
| 0ee5408b94 | |||
| cb3100a252 | |||
| 3a4afe9b38 | |||
| 71859ff83c | |||
| cb72df269a |
@@ -582,59 +582,13 @@ jobs:
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Build debug binary
|
||||
run: |
|
||||
python3 - <<'PYBUILD'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
def git(*args):
|
||||
return subprocess.check_output(["git", *args], text=True).strip()
|
||||
|
||||
def sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with pathlib.Path(path).open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
argv = ["cargo", "build", "-p", "rustfs", "--bins", "--features", "e2e-test-hooks"]
|
||||
commit, tree = git("rev-parse", "HEAD"), git("rev-parse", "HEAD^{tree}")
|
||||
clean_before = not git("status", "--porcelain", "--untracked-files=normal")
|
||||
if not clean_before:
|
||||
raise SystemExit("hooks binary requires a clean build checkout")
|
||||
lock_sha256 = sha256("Cargo.lock")
|
||||
lock_git_blob = git("hash-object", "Cargo.lock")
|
||||
rustc = subprocess.check_output(["rustc", "-vV"], text=True)
|
||||
host = next(line.removeprefix("host: ") for line in rustc.splitlines() if line.startswith("host: "))
|
||||
if os.environ.get("CARGO_BUILD_TARGET") or pathlib.Path(os.environ.get("CARGO_TARGET_DIR", "target")).resolve() != pathlib.Path("target").resolve():
|
||||
raise SystemExit("this artifact requires the native target/debug output")
|
||||
subprocess.run(argv, check=True)
|
||||
clean_after = not git("status", "--porcelain", "--untracked-files=normal")
|
||||
if not clean_after or commit != git("rev-parse", "HEAD") or tree != git("rev-parse", "HEAD^{tree}") or lock_sha256 != sha256("Cargo.lock"):
|
||||
raise SystemExit("hooks binary source changed while building")
|
||||
manifest = {
|
||||
"schema": 1, "commit": commit, "tree": tree,
|
||||
"clean_before": clean_before, "clean_after": clean_after,
|
||||
"lock_sha256": lock_sha256, "lock_git_blob": lock_git_blob,
|
||||
"argv": argv, "profile": "debug", "target": host,
|
||||
"features": ["e2e-test-hooks"],
|
||||
"rustc_verbose": rustc,
|
||||
"build_flags": {key: os.environ[key] for key in ("RUSTFLAGS", "CARGO_ENCODED_RUSTFLAGS", "CARGO_BUILD_TARGET", "CARGO_TARGET_DIR", "RUSTUP_TOOLCHAIN") if key in os.environ},
|
||||
"binary_sha256": sha256("target/debug/rustfs"),
|
||||
}
|
||||
pathlib.Path("target/debug/rustfs.e2e-startup-cas-build.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
PYBUILD
|
||||
run: cargo build -p rustfs --bins --features e2e-test-hooks
|
||||
|
||||
- name: Upload debug binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-debug-binary
|
||||
path: |
|
||||
target/debug/rustfs
|
||||
target/debug/rustfs.e2e-startup-cas-build.json
|
||||
path: target/debug/rustfs
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -952,36 +906,6 @@ jobs:
|
||||
- name: Make binary executable
|
||||
run: chmod +x ./target/debug/rustfs
|
||||
|
||||
- name: Preserve startup CAS binary input
|
||||
env:
|
||||
STARTUP_CAS_INPUT: ${{ runner.temp }}/rustfs-startup-cas-input
|
||||
run: |
|
||||
python3 - <<'PYINPUT'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
source = pathlib.Path("target/debug/rustfs")
|
||||
manifest_path = source.with_name("rustfs.e2e-startup-cas-build.json")
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
target = pathlib.Path(os.environ["STARTUP_CAS_INPUT"])
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
binary = target / "rustfs"
|
||||
shutil.copy2(source, binary)
|
||||
digest = hashlib.sha256()
|
||||
with binary.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
commit = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
|
||||
if manifest["binary_sha256"] != digest.hexdigest() or manifest["commit"] != commit:
|
||||
raise SystemExit("downloaded hooks binary identity mismatch")
|
||||
shutil.copy2(manifest_path, target / manifest_path.name)
|
||||
binary.chmod(0o755)
|
||||
PYINPUT
|
||||
|
||||
- name: Verify e2e full membership
|
||||
env:
|
||||
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-full-list.json
|
||||
@@ -994,10 +918,6 @@ jobs:
|
||||
# extend that filter, never add ad-hoc e2e jobs here. Reuses the downloaded
|
||||
# debug binary; each test spawns its own rustfs server on a random port.
|
||||
- name: Run e2e full suite
|
||||
env:
|
||||
RUSTFS_E2E_STARTUP_CAS_BINARY: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs
|
||||
RUSTFS_E2E_STARTUP_CAS_BUILD_MANIFEST: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
|
||||
RUSTFS_E2E_STARTUP_CAS_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence
|
||||
run: cargo nextest run --profile e2e-full -p e2e_test
|
||||
|
||||
- name: Upload junit
|
||||
@@ -1010,17 +930,6 @@ jobs:
|
||||
${{ runner.temp }}/rustfs-e2e-full-list.json
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload startup CAS evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: fresh-startup-cas-evidence-${{ github.run_number }}
|
||||
path: |
|
||||
${{ runner.temp }}/rustfs-startup-cas-evidence
|
||||
${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
e2e-tests-rio-v2:
|
||||
name: End-to-End Tests (rio-v2)
|
||||
# Inherits the schedule/dispatch-only gate through needs: on every other
|
||||
|
||||
Generated
+1
-1
@@ -4057,7 +4057,6 @@ dependencies = [
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"suppaftp",
|
||||
"tempfile",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -10746,6 +10745,7 @@ dependencies = [
|
||||
"rustfs-data-usage",
|
||||
"rustfs-ecstore",
|
||||
"rustfs-filemeta",
|
||||
"rustfs-heal",
|
||||
"rustfs-heal-contracts",
|
||||
"rustfs-lifecycle",
|
||||
"rustfs-lock",
|
||||
|
||||
@@ -422,9 +422,9 @@ fn unix_now_ms() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A repair the MRF consumer landed, fanned out so retry ledgers can drop
|
||||
/// entries the journal no longer tracks (backlog#1894 axis B). The payload
|
||||
/// mirrors the intent identity so consumers match without re-parsing.
|
||||
/// Legacy, unverified repair notice. Its identity lacks kind, set scope,
|
||||
/// bucket incarnation and responsibility generation. Consumers must not use
|
||||
/// it to discharge persisted repair responsibility.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MrfRepairedEvent {
|
||||
pub bucket: Arc<str>,
|
||||
@@ -439,8 +439,8 @@ const MRF_REPAIRED_EVENT_CAP: usize = 4096;
|
||||
|
||||
static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new();
|
||||
|
||||
/// Record that the MRF consumer landed a repair. Never blocks: the critical
|
||||
/// section is a deque push under a std mutex.
|
||||
/// Record a legacy notification for compatibility. This is not an
|
||||
/// acknowledgement of storage verification or durable repair completion.
|
||||
pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) {
|
||||
let registry = MRF_REPAIRED_EVENTS.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
|
||||
let Ok(mut events) = registry.lock() else {
|
||||
@@ -515,6 +515,9 @@ mod tests {
|
||||
}
|
||||
coalescer_release(&key, Some(lease));
|
||||
let retry_lease = coalescer_admit(key.clone()).expect("released identity must admit a retry");
|
||||
assert_ne!(lease, retry_lease);
|
||||
coalescer_release(&key, Some(lease));
|
||||
assert_eq!(coalescer_admit(key.clone()), Err(MrfIngressResult::Coalesced));
|
||||
coalescer_release(&key, Some(retry_lease));
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,3 @@ russh = { workspace = true, features = ["serde"] }
|
||||
russh-sftp = { workspace = true }
|
||||
zip.workspace = true
|
||||
clap = { workspace = true, features = ["derive", "env"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -118,8 +118,6 @@ hotpath-cpu = [
|
||||
# injection, xl.meta transition assertions) via `api::tier::test_util`.
|
||||
# Enable only from `[dev-dependencies]` (rustfs/backlog#1148 ilm-6).
|
||||
test-util = []
|
||||
# Observes real startup CAS only in the dedicated E2E binary.
|
||||
e2e-test-hooks = []
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
|
||||
@@ -293,7 +293,7 @@ pub mod cache {
|
||||
pub mod capacity {
|
||||
pub use crate::core::pools::{
|
||||
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolStatus, get_total_usable_capacity, get_total_usable_capacity_free,
|
||||
path2_bucket_object, path2_bucket_object_with_base_path,
|
||||
is_pool_activation_fleet_proof_error, path2_bucket_object, path2_bucket_object_with_base_path,
|
||||
};
|
||||
pub use crate::store::utils::is_reserved_or_invalid_bucket;
|
||||
}
|
||||
@@ -368,8 +368,6 @@ pub mod data_usage {
|
||||
pub mod disk {
|
||||
pub use crate::disk::disk_store::get_object_disk_read_timeout;
|
||||
pub use crate::disk::local::ScanGuard;
|
||||
#[cfg(all(feature = "test-util", not(windows)))]
|
||||
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
|
||||
pub use crate::disk::{
|
||||
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
|
||||
CheckPartsResp, ConditionalFileUpdate, DeleteOptions, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption,
|
||||
@@ -546,8 +544,8 @@ pub mod storage {
|
||||
pub use crate::core::pools::HealLifecycleExpiryContext;
|
||||
pub use crate::store::HealWalkVersion;
|
||||
pub use crate::store::{
|
||||
BootstrapLocalTarget, ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk,
|
||||
all_local_disk_path, find_local_disk_by_ref, init_local_disks, init_local_disks_with_instance_ctx, init_lock_clients,
|
||||
ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk, all_local_disk_path,
|
||||
find_local_disk_by_ref, init_local_disks, init_local_disks_with_instance_ctx, init_lock_clients,
|
||||
prewarm_local_disk_id_map, prewarm_local_disk_id_map_with_instance_ctx,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3385,7 +3385,7 @@ pub(crate) async fn acquire_pool_activation_fleet_proof(
|
||||
.ok_or_else(|| Error::other(POOL_ACTIVATION_FLEET_PROOF_REQUIRED))
|
||||
}
|
||||
|
||||
pub(crate) fn is_pool_activation_fleet_proof_error(err: &Error) -> bool {
|
||||
pub fn is_pool_activation_fleet_proof_error(err: &Error) -> bool {
|
||||
// Save-stage helpers add context by formatting the original error, so the
|
||||
// marker may be nested in the display string. Restrict matching to the
|
||||
// `Error::other` I/O shape used by this activation path.
|
||||
@@ -5108,49 +5108,7 @@ async fn read_pool_meta_replicas<S>(pools: Vec<Arc<S>>, no_lock: bool) -> Vec<Po
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
let reads = join_all(pools.into_iter().map(|pool| read_pool_meta_replica(pool, no_lock))).await;
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if STARTUP_CAS_OBSERVATION.try_with(|_| ()).is_ok() {
|
||||
let batch = uuid::Uuid::new_v4();
|
||||
for (pool, read) in reads.iter().enumerate() {
|
||||
let mut observation = serde_json::json!({
|
||||
"kind": "replica-read", "object": POOL_META_NAME, "batch": batch, "pool": pool,
|
||||
"cas": match &read.cas {
|
||||
PoolMetaCasToken::Missing => "missing",
|
||||
PoolMetaCasToken::Existing(_) => "existing",
|
||||
PoolMetaCasToken::Unsafe => "unsafe",
|
||||
},
|
||||
"etag": match &read.cas { PoolMetaCasToken::Existing(etag) => Some(etag), _ => None },
|
||||
});
|
||||
match &read.replica {
|
||||
PoolMetaReplica::Valid {
|
||||
raw,
|
||||
canonical,
|
||||
meta,
|
||||
revision,
|
||||
committed,
|
||||
..
|
||||
} => {
|
||||
observation["state"] = serde_json::json!("valid");
|
||||
observation["committed"] = serde_json::json!(committed);
|
||||
observation["version"] = serde_json::json!(revision.version);
|
||||
observation["cluster_id"] = serde_json::json!(revision.cluster_id);
|
||||
observation["epoch"] = serde_json::json!(revision.epoch);
|
||||
observation["generation"] = serde_json::json!(revision.generation);
|
||||
observation["transaction_id"] = serde_json::json!(revision.transaction_id);
|
||||
observation["pool_count"] = serde_json::json!(meta.pools.len());
|
||||
observation["payload_sha256"] = serde_json::json!(rustfs_utils::crypto::hex(Sha256::digest(canonical)));
|
||||
observation["raw_sha256"] = serde_json::json!(rustfs_utils::crypto::hex(Sha256::digest(raw)));
|
||||
}
|
||||
PoolMetaReplica::Missing => observation["state"] = serde_json::json!("missing"),
|
||||
PoolMetaReplica::Corrupt(_) => observation["state"] = serde_json::json!("corrupt"),
|
||||
PoolMetaReplica::Incompatible(_) => observation["state"] = serde_json::json!("incompatible"),
|
||||
PoolMetaReplica::Unreadable(_) => observation["state"] = serde_json::json!("unreadable"),
|
||||
}
|
||||
startup_cas_test_observe(observation);
|
||||
}
|
||||
}
|
||||
reads
|
||||
join_all(pools.into_iter().map(|pool| read_pool_meta_replica(pool, no_lock))).await
|
||||
}
|
||||
|
||||
fn select_pool_meta_replicas_observing<R>(write_state: &mut PoolMetaWriteState, replicas: Vec<R>) -> Result<PoolMetaSelection>
|
||||
@@ -5522,60 +5480,6 @@ fn pool_meta_cas_preconditions(token: &PoolMetaCasToken, object: &str) -> Result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
struct StartupCasObservation {
|
||||
attempt: uuid::Uuid,
|
||||
phase: &'static str,
|
||||
pools: Vec<usize>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
tokio::task_local! {
|
||||
static STARTUP_CAS_OBSERVATION: StartupCasObservation;
|
||||
}
|
||||
|
||||
// This scope follows only the directly polled startup future. Spawned work
|
||||
// does not inherit it; receiver evidence retains its existing RPC tuple.
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
pub(crate) async fn startup_cas_test_scope<S, F: std::future::Future>(
|
||||
attempt: uuid::Uuid,
|
||||
phase: &'static str,
|
||||
pools: &[Arc<S>],
|
||||
future: F,
|
||||
) -> F::Output {
|
||||
STARTUP_CAS_OBSERVATION
|
||||
.scope(
|
||||
StartupCasObservation {
|
||||
attempt,
|
||||
phase,
|
||||
// These identities are never dereferenced or logged. The
|
||||
// caller and operation keep the same pool Arcs alive.
|
||||
pools: pools.iter().map(|pool| Arc::as_ptr(pool) as usize).collect(),
|
||||
},
|
||||
future,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Direct JSON diagnostics are independent of the startup tracing subscriber.
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
pub(crate) fn startup_cas_test_observe(mut observation: serde_json::Value) {
|
||||
let Some(nonce) = std::env::var("RUSTFS_E2E_STARTUP_CAS_NONCE")
|
||||
.ok()
|
||||
.and_then(|value| uuid::Uuid::parse_str(&value).ok())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
observation["nonce"] = serde_json::json!(nonce);
|
||||
observation["pid"] = serde_json::json!(std::process::id());
|
||||
let _ = STARTUP_CAS_OBSERVATION.try_with(|scope| {
|
||||
observation["attempt"] = serde_json::json!(scope.attempt);
|
||||
observation["startup_phase"] = serde_json::json!(scope.phase);
|
||||
});
|
||||
let line = format!("RUSTFS_E2E_STARTUP_CAS {observation}\n");
|
||||
let _ = std::io::Write::write_all(&mut std::io::stderr().lock(), line.as_bytes());
|
||||
}
|
||||
|
||||
async fn save_pool_meta_object_cas<S>(
|
||||
pool: Arc<S>,
|
||||
object: &str,
|
||||
@@ -5596,43 +5500,13 @@ where
|
||||
..Default::default()
|
||||
};
|
||||
fence.add_to_options(&mut opts);
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
let observation = std::env::var_os("RUSTFS_E2E_STARTUP_CAS_NONCE").map(|_| {
|
||||
serde_json::json!({
|
||||
"kind": "cas", "object": object, "phase": phase,
|
||||
"pool": STARTUP_CAS_OBSERVATION.try_with(|scope| {
|
||||
scope.pools.iter().position(|identity| *identity == Arc::as_ptr(&pool) as usize)
|
||||
}).ok().flatten(),
|
||||
"payload_sha256": rustfs_utils::crypto::hex(Sha256::digest(&data)),
|
||||
"if_match": opts.http_preconditions.as_ref().and_then(|p| p.if_match.as_deref()),
|
||||
"if_none_match": opts.http_preconditions.as_ref().and_then(|p| p.if_none_match.as_deref()),
|
||||
"tail_drained": opts.write_completion == crate::object_api::WriteCompletion::TailDrained,
|
||||
"no_lock": opts.no_lock,
|
||||
})
|
||||
});
|
||||
let result = save_config_with_opts_and_metadata(pool, object, data, &opts).await;
|
||||
if matches!(&result, Err(Error::PreconditionFailed)) {
|
||||
record_pool_meta_stale_write_rejection(phase);
|
||||
}
|
||||
let result = result.and_then(|object_info| {
|
||||
fence.ensure_held()?;
|
||||
Ok(object_info)
|
||||
});
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if let Some(mut observation) = observation {
|
||||
observation["ok"] = serde_json::json!(result.is_ok());
|
||||
observation["etag"] = serde_json::json!(result.as_ref().ok().and_then(|info| info.etag.as_deref()));
|
||||
observation["mod_time"] = serde_json::json!(
|
||||
result
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|info| info.mod_time)
|
||||
.map(|time| time.unix_timestamp_nanos().to_string())
|
||||
);
|
||||
observation["error"] = serde_json::json!(result.as_ref().err().map(ToString::to_string));
|
||||
startup_cas_test_observe(observation);
|
||||
}
|
||||
result
|
||||
let object_info = result?;
|
||||
fence.ensure_held()?;
|
||||
Ok(object_info)
|
||||
}
|
||||
|
||||
async fn persist_pool_meta_identity<S>(
|
||||
@@ -6931,13 +6805,6 @@ impl PoolMeta {
|
||||
};
|
||||
if confirmed.revision == revision && confirmed.canonical.as_ref() == Some(&durable) {
|
||||
persist_pool_meta_identity(pools, write_state, true, fence).await?;
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
startup_cas_test_observe(serde_json::json!({
|
||||
"kind": "confirmed", "object": POOL_META_NAME,
|
||||
"payload_sha256": rustfs_utils::crypto::hex(Sha256::digest(&durable)),
|
||||
"generation": confirmed.revision.generation,
|
||||
"transaction_id": confirmed.revision.transaction_id,
|
||||
}));
|
||||
return Ok(confirmed.meta);
|
||||
}
|
||||
if !commit_succeeded {
|
||||
|
||||
@@ -324,6 +324,46 @@ impl DiskStoreRenameDataExt for LocalDiskWrapper {
|
||||
}
|
||||
|
||||
impl LocalDiskWrapper {
|
||||
pub(in crate::disk) async fn delete_version_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
fi: FileInfo,
|
||||
force_del_marker: bool,
|
||||
opts: DeleteOptions,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
self.track_disk_health_mutation(
|
||||
"delete_version",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async {
|
||||
Box::pin(
|
||||
self.disk
|
||||
.delete_version_with_namespace_owner(volume, path, fi, force_del_marker, opts, namespace_owner),
|
||||
)
|
||||
.await
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(in crate::disk) async fn delete_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
opts: DeleteOptions,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
self.track_disk_health_mutation(
|
||||
"delete",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async { Box::pin(self.disk.delete_with_namespace_owner(volume, path, opts, namespace_owner)).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(in crate::disk) async fn undo_write_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
|
||||
+458
-108
@@ -191,11 +191,33 @@ fn restore_part_transaction_file(current: &Path, backup: &Path, absent: &Path, r
|
||||
}
|
||||
|
||||
async fn write_metadata_rollback_backup(object_dir: &Path, rollback_dir: Uuid, data: &[u8]) -> Result<()> {
|
||||
write_delete_rollback_file(object_dir, rollback_dir, STORAGE_FORMAT_FILE_BACKUP, data, None).await
|
||||
}
|
||||
|
||||
async fn write_delete_rollback_file(
|
||||
object_dir: &Path,
|
||||
rollback_dir: Uuid,
|
||||
name: &str,
|
||||
data: &[u8],
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
let backup_dir = object_dir.join(rollback_dir.to_string());
|
||||
fs::create_dir_all(&backup_dir).await.map_err(to_file_error)?;
|
||||
fs::write(backup_dir.join(STORAGE_FORMAT_FILE_BACKUP), data)
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
let path = backup_dir.join(name);
|
||||
if namespace_owner.is_none() {
|
||||
fs::create_dir_all(&backup_dir).await.map_err(to_file_error)?;
|
||||
fs::write(path, data).await.map_err(to_file_error)?;
|
||||
return Ok(());
|
||||
}
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&path, namespace_owner).await;
|
||||
let data = data.to_vec();
|
||||
os::run_blocking_namespace_operation(lease, move || {
|
||||
std::fs::create_dir_all(&backup_dir)?;
|
||||
#[cfg(test)]
|
||||
run_owned_file_write_before_open(&path);
|
||||
std::fs::write(path, data)
|
||||
})
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -342,6 +364,7 @@ struct DeleteVersionMutation {
|
||||
struct DeleteRollbackFailure {
|
||||
stage: &'static str,
|
||||
error: DiskError,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
}
|
||||
|
||||
async fn restore_delete_rollback_after_error(
|
||||
@@ -353,12 +376,18 @@ async fn restore_delete_rollback_after_error(
|
||||
failure: DeleteRollbackFailure,
|
||||
publication_root: &os::PublicationRoot,
|
||||
) -> DiskError {
|
||||
let DeleteRollbackFailure { stage, error } = failure;
|
||||
let DeleteRollbackFailure {
|
||||
stage,
|
||||
error,
|
||||
namespace_owner,
|
||||
} = failure;
|
||||
let Some(rollback_dir) = rollback_dir else {
|
||||
return error;
|
||||
};
|
||||
|
||||
if let Err(restore_err) = restore_delete_rollback(object_dir, xl_path, rollback_dir, publication_root).await {
|
||||
if let Err(restore_err) =
|
||||
restore_delete_rollback_with_namespace_owner(object_dir, xl_path, rollback_dir, publication_root, namespace_owner).await
|
||||
{
|
||||
warn!(
|
||||
volume,
|
||||
path,
|
||||
@@ -5654,6 +5683,123 @@ impl LocalDisk {
|
||||
// })
|
||||
// }
|
||||
|
||||
#[tracing::instrument(name = "delete_version", level = "trace", skip_all)]
|
||||
pub(in crate::disk) async fn delete_version_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
fi: FileInfo,
|
||||
force_del_marker: bool,
|
||||
opts: DeleteOptions,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
self.delete_version_inner(
|
||||
volume,
|
||||
path,
|
||||
fi,
|
||||
DeleteVersionMutation {
|
||||
force_del_marker,
|
||||
opts,
|
||||
namespace_owner,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "write_metadata", level = "trace", skip_all)]
|
||||
async fn write_metadata_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
fi: FileInfo,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
crate::hp_guard!("LocalDisk::write_metadata");
|
||||
fi.validate_for_metadata_read()?;
|
||||
let p = self.io_get_object_path(volume, format!("{path}/{STORAGE_FORMAT_FILE}").as_str())?;
|
||||
|
||||
let mut meta = FileMeta::new();
|
||||
if !fi.fresh {
|
||||
let (buf, _) = read_file_exists(&p).await?;
|
||||
if !buf.is_empty() {
|
||||
let _ = meta.unmarshal_msg(&buf).map_err(|_| {
|
||||
meta = FileMeta::new();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
meta.add_version(fi)?;
|
||||
|
||||
let fm_data = meta.marshal_msg()?;
|
||||
|
||||
// Atomic temp+rename: this path also rewrites live xl.meta (delete markers,
|
||||
// decommission), where an in-place truncate would expose torn metadata.
|
||||
self.write_all_meta_with_namespace_owner(
|
||||
volume,
|
||||
format!("{path}/{STORAGE_FORMAT_FILE}").as_str(),
|
||||
&fm_data,
|
||||
true,
|
||||
namespace_owner,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_data_dir_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
opts: DeleteOptions,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<DataDirDeleteStatus> {
|
||||
let key = SnapshotLeaseKey {
|
||||
volume: volume.to_string(),
|
||||
path: path.to_string(),
|
||||
};
|
||||
{
|
||||
let mut registry = self.snapshot_leases.lock().await;
|
||||
if let Some(entry) = registry.entries.get_mut(&key) {
|
||||
if !entry.tokens.is_empty() {
|
||||
entry.pending_delete.get_or_insert_with(|| opts.clone());
|
||||
return Ok(DataDirDeleteStatus::Deferred);
|
||||
}
|
||||
if entry.deleting {
|
||||
entry.pending_delete.get_or_insert_with(|| opts.clone());
|
||||
return Ok(DataDirDeleteStatus::Deferred);
|
||||
}
|
||||
entry.deleting = true;
|
||||
entry.pending_delete.get_or_insert_with(|| opts.clone());
|
||||
} else {
|
||||
registry.entries.insert(
|
||||
key.clone(),
|
||||
SnapshotLeaseEntry {
|
||||
pending_delete: Some(opts.clone()),
|
||||
deleting: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let result = self
|
||||
.delete_unleased_with_namespace_owner(volume, path, &opts, namespace_owner)
|
||||
.await;
|
||||
let mut registry = self.snapshot_leases.lock().await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
registry.entries.remove(&key);
|
||||
Ok(DataDirDeleteStatus::Deleted)
|
||||
}
|
||||
Err(err) => {
|
||||
if let Some(entry) = registry.entries.get_mut(&key) {
|
||||
entry.deleting = false;
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_version_inner(&self, volume: &str, path: &str, fi: FileInfo, mutation: DeleteVersionMutation) -> Result<()> {
|
||||
let DeleteVersionMutation {
|
||||
force_del_marker,
|
||||
@@ -5696,7 +5842,7 @@ impl LocalDisk {
|
||||
|
||||
if fi.deleted && force_del_marker {
|
||||
return self
|
||||
.write_missing_delete_marker(volume, path, fi, file_path.as_path(), &xl_path, rollback_dir)
|
||||
.write_missing_delete_marker(volume, path, fi, file_path.as_path(), rollback_dir, namespace_owner.clone())
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -5712,7 +5858,14 @@ impl LocalDisk {
|
||||
let old_dir = meta.delete_version(&fi)?;
|
||||
let mut reserved_version_delete = false;
|
||||
if let Some(rollback_dir) = rollback_dir {
|
||||
write_metadata_rollback_backup(file_path.as_path(), rollback_dir, &buf).await?;
|
||||
write_delete_rollback_file(
|
||||
file_path.as_path(),
|
||||
rollback_dir,
|
||||
STORAGE_FORMAT_FILE_BACKUP,
|
||||
&buf,
|
||||
namespace_owner.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(uuid) = old_dir {
|
||||
@@ -5728,6 +5881,7 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_version_metadata_update",
|
||||
error: err,
|
||||
namespace_owner: namespace_owner.clone(),
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
@@ -5745,6 +5899,7 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_version_data_path",
|
||||
error: err,
|
||||
namespace_owner: namespace_owner.clone(),
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
@@ -5753,7 +5908,7 @@ impl LocalDisk {
|
||||
|
||||
if let Some(rollback_dir) = rollback_dir {
|
||||
let rollback_path = file_path.join(rollback_dir.to_string());
|
||||
if let Err(err) = fs::create_dir_all(&rollback_path).await {
|
||||
if let Err(err) = os::create_dir_all_with_namespace_owner(&rollback_path, namespace_owner.clone()).await {
|
||||
let err: DiskError = to_file_error(err).into();
|
||||
return Err(restore_delete_rollback_after_error(
|
||||
file_path.as_path(),
|
||||
@@ -5764,12 +5919,16 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_version_rollback_dir",
|
||||
error: err,
|
||||
namespace_owner: namespace_owner.clone(),
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
reserved_version_delete = match self.reserve_version_delete(volume, path, uuid, rollback_dir).await {
|
||||
reserved_version_delete = match self
|
||||
.reserve_version_delete_with_namespace_owner(volume, path, uuid, rollback_dir, namespace_owner.clone())
|
||||
.await
|
||||
{
|
||||
Ok(reserved) => reserved,
|
||||
Err(err) => {
|
||||
return Err(restore_delete_rollback_after_error(
|
||||
@@ -5781,6 +5940,7 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_version_reserve_data",
|
||||
error: err,
|
||||
namespace_owner: namespace_owner.clone(),
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
@@ -5789,9 +5949,14 @@ impl LocalDisk {
|
||||
};
|
||||
let rollback_data_path = rollback_path.join(uuid.to_string());
|
||||
if !reserved_version_delete
|
||||
&& let Err(err) =
|
||||
rename_all_ignore_missing_source(&old_path, &rollback_data_path, &rollback_path, &self.publication_root)
|
||||
.await
|
||||
&& let Err(err) = os::rename_all_ignore_missing_source_with_owner(
|
||||
&old_path,
|
||||
&rollback_data_path,
|
||||
&rollback_path,
|
||||
&self.publication_root,
|
||||
namespace_owner.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(restore_delete_rollback_after_error(
|
||||
file_path.as_path(),
|
||||
@@ -5802,6 +5967,7 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_version_stage_data",
|
||||
error: err,
|
||||
namespace_owner: namespace_owner.clone(),
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
@@ -5810,13 +5976,16 @@ impl LocalDisk {
|
||||
if should_fail_after_delete_data_staged(path) {
|
||||
if reserved_version_delete {
|
||||
return Err(self
|
||||
.abort_reserved_version_delete(
|
||||
.abort_reserved_version_delete_with_failure(
|
||||
file_path.as_path(),
|
||||
rollback_dir,
|
||||
volume,
|
||||
path,
|
||||
"delete_version_test_after_stage",
|
||||
DiskError::Unexpected,
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_version_test_after_stage",
|
||||
error: DiskError::Unexpected,
|
||||
namespace_owner: namespace_owner.clone(),
|
||||
},
|
||||
)
|
||||
.await);
|
||||
}
|
||||
@@ -5829,6 +5998,7 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_version_test_after_stage",
|
||||
error: DiskError::Unexpected,
|
||||
namespace_owner: namespace_owner.clone(),
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
@@ -5858,13 +6028,16 @@ impl LocalDisk {
|
||||
let err: DiskError = err.into();
|
||||
if reserved_version_delete && let Some(rollback_dir) = rollback_dir {
|
||||
return Err(self
|
||||
.abort_reserved_version_delete(
|
||||
.abort_reserved_version_delete_with_failure(
|
||||
file_path.as_path(),
|
||||
rollback_dir,
|
||||
volume,
|
||||
path,
|
||||
"delete_version_metadata_encode",
|
||||
err,
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_version_metadata_encode",
|
||||
error: err,
|
||||
namespace_owner: namespace_owner.clone(),
|
||||
},
|
||||
)
|
||||
.await);
|
||||
}
|
||||
@@ -5877,6 +6050,7 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_version_metadata_encode",
|
||||
error: err,
|
||||
namespace_owner: namespace_owner.clone(),
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
@@ -5899,7 +6073,17 @@ impl LocalDisk {
|
||||
if let Err(err) = commit_result {
|
||||
if reserved_version_delete && let Some(rollback_dir) = rollback_dir {
|
||||
return Err(self
|
||||
.abort_reserved_version_delete(file_path.as_path(), rollback_dir, volume, path, "delete_version_commit", err)
|
||||
.abort_reserved_version_delete_with_failure(
|
||||
file_path.as_path(),
|
||||
rollback_dir,
|
||||
volume,
|
||||
path,
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_version_commit",
|
||||
error: err,
|
||||
namespace_owner: namespace_owner.clone(),
|
||||
},
|
||||
)
|
||||
.await);
|
||||
}
|
||||
return Err(restore_delete_rollback_after_error(
|
||||
@@ -5911,6 +6095,7 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_version_commit",
|
||||
error: err,
|
||||
namespace_owner: namespace_owner.clone(),
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
@@ -5919,16 +6104,21 @@ impl LocalDisk {
|
||||
|
||||
if reserved_version_delete
|
||||
&& let Some(rollback_dir) = rollback_dir
|
||||
&& let Err(err) = self.commit_reserved_version_delete(volume, path, rollback_dir).await
|
||||
&& let Err(err) = self
|
||||
.commit_reserved_version_delete_with_namespace_owner(volume, path, rollback_dir, namespace_owner.clone())
|
||||
.await
|
||||
{
|
||||
return Err(self
|
||||
.abort_reserved_version_delete(
|
||||
.abort_reserved_version_delete_with_failure(
|
||||
file_path.as_path(),
|
||||
rollback_dir,
|
||||
volume,
|
||||
path,
|
||||
"delete_version_commit_intent",
|
||||
err,
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_version_commit_intent",
|
||||
error: err,
|
||||
namespace_owner: namespace_owner.clone(),
|
||||
},
|
||||
)
|
||||
.await);
|
||||
}
|
||||
@@ -6098,7 +6288,7 @@ impl LocalDisk {
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "delete", level = "trace", skip_all)]
|
||||
async fn delete_with_namespace_owner(
|
||||
pub(in crate::disk) async fn delete_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
@@ -6111,7 +6301,8 @@ impl LocalDisk {
|
||||
&& let Some((object, transaction_id)) = path.rsplit_once('/')
|
||||
&& let Ok(transaction_id) = Uuid::parse_str(transaction_id)
|
||||
{
|
||||
self.finish_version_delete(volume, object, transaction_id).await?
|
||||
self.finish_version_delete(volume, object, transaction_id, namespace_owner.clone())
|
||||
.await?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -6453,19 +6644,27 @@ impl LocalDisk {
|
||||
path: &str,
|
||||
fi: FileInfo,
|
||||
object_dir: &Path,
|
||||
xl_path: &Path,
|
||||
rollback_dir: Option<Uuid>,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
let xl_path = object_dir.join(STORAGE_FORMAT_FILE);
|
||||
if let Some(rollback_dir) = rollback_dir {
|
||||
let rollback_path = object_dir.join(rollback_dir.to_string());
|
||||
fs::create_dir_all(&rollback_path).await.map_err(to_file_error)?;
|
||||
fs::write(rollback_path.join(DELETE_MARKER_ROLLBACK_FILE), [])
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
write_delete_rollback_file(object_dir, rollback_dir, DELETE_MARKER_ROLLBACK_FILE, &[], namespace_owner.clone())
|
||||
.await?;
|
||||
}
|
||||
if let Err(err) = self.write_metadata("", volume, path, fi).await {
|
||||
if let Err(err) = self
|
||||
.write_metadata_with_namespace_owner(volume, path, fi, namespace_owner.clone())
|
||||
.await
|
||||
{
|
||||
if let Some(rollback_dir) = rollback_dir
|
||||
&& let Err(restore_err) = restore_delete_rollback(object_dir, xl_path, rollback_dir, &self.publication_root).await
|
||||
&& let Err(restore_err) = restore_delete_rollback_with_namespace_owner(
|
||||
object_dir,
|
||||
&xl_path,
|
||||
rollback_dir,
|
||||
&self.publication_root,
|
||||
namespace_owner,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_DELETE_ROLLBACK_FAILED,
|
||||
@@ -6510,7 +6709,7 @@ impl LocalDisk {
|
||||
return Err(DiskError::FileNotFound);
|
||||
};
|
||||
return self
|
||||
.write_missing_delete_marker(volume, path, delete_marker, object_dir, &xlpath, opts.old_data_dir)
|
||||
.write_missing_delete_marker(volume, path, delete_marker, object_dir, opts.old_data_dir, None)
|
||||
.await;
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
@@ -6559,6 +6758,7 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_versions_metadata_update",
|
||||
error: err,
|
||||
namespace_owner: None,
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
@@ -6594,6 +6794,7 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_versions_data_path",
|
||||
error: err,
|
||||
namespace_owner: None,
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
@@ -6625,6 +6826,7 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_versions_rollback_dir",
|
||||
error: err,
|
||||
namespace_owner: None,
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
@@ -6665,6 +6867,7 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_versions_stage_data",
|
||||
error: err,
|
||||
namespace_owner: None,
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
@@ -6692,6 +6895,7 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_versions_test_after_stage",
|
||||
error: DiskError::Unexpected,
|
||||
namespace_owner: None,
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
@@ -6734,6 +6938,7 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_versions_commit_delete",
|
||||
error: err,
|
||||
namespace_owner: None,
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
@@ -6780,6 +6985,7 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_versions_metadata_encode",
|
||||
error: err,
|
||||
namespace_owner: None,
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
@@ -6805,6 +7011,7 @@ impl LocalDisk {
|
||||
DeleteRollbackFailure {
|
||||
stage: "delete_versions_commit_write",
|
||||
error: err,
|
||||
namespace_owner: None,
|
||||
},
|
||||
&self.publication_root,
|
||||
)
|
||||
@@ -8015,6 +8222,18 @@ impl LocalDisk {
|
||||
}
|
||||
|
||||
async fn reserve_version_delete(&self, volume: &str, object: &str, data_dir: Uuid, rollback_dir: Uuid) -> Result<bool> {
|
||||
self.reserve_version_delete_with_namespace_owner(volume, object, data_dir, rollback_dir, None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn reserve_version_delete_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
data_dir: Uuid,
|
||||
rollback_dir: Uuid,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<bool> {
|
||||
let path = format!("{object}/{data_dir}");
|
||||
let data_path = self.io_get_object_path(volume, &path)?;
|
||||
match fs::metadata(&data_path).await {
|
||||
@@ -8024,6 +8243,28 @@ impl LocalDisk {
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
}
|
||||
let marker_path = data_path.join(format!("{RESERVED_DELETE_DATA_DIR_MARKER_PREFIX}{rollback_dir}"));
|
||||
if namespace_owner.is_some() {
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&marker_path, namespace_owner.clone()).await;
|
||||
let volume = volume.to_string();
|
||||
let sync = os::run_blocking_namespace_operation(lease, move || {
|
||||
#[cfg(test)]
|
||||
run_owned_file_write_before_open(&marker_path);
|
||||
let marker = std::fs::File::create(marker_path)?;
|
||||
let sync = effective_durability(&volume).syncs_commit_metadata();
|
||||
if sync {
|
||||
marker.sync_all()?;
|
||||
}
|
||||
Ok(sync)
|
||||
})
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
if sync {
|
||||
os::fsync_dir_with_owner(&data_path, namespace_owner)
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
let marker = File::create(marker_path).await.map_err(to_file_error)?;
|
||||
if effective_durability(volume).syncs_commit_metadata() {
|
||||
marker.sync_all().await.map_err(to_file_error)?;
|
||||
@@ -8033,6 +8274,17 @@ impl LocalDisk {
|
||||
}
|
||||
|
||||
async fn commit_reserved_version_delete(&self, volume: &str, object: &str, rollback_dir: Uuid) -> Result<()> {
|
||||
self.commit_reserved_version_delete_with_namespace_owner(volume, object, rollback_dir, None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn commit_reserved_version_delete_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
rollback_dir: Uuid,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
let object_path = self.io_get_object_path(volume, object)?;
|
||||
let mut entries = match fs::read_dir(object_path).await {
|
||||
Ok(entries) => entries,
|
||||
@@ -8048,10 +8300,14 @@ impl LocalDisk {
|
||||
continue;
|
||||
}
|
||||
let reserved_path = entry.path().join(&reserved_name);
|
||||
match fs::rename(&reserved_path, entry.path().join(&committed_name)).await {
|
||||
match os::rename_with_namespace_owner(&reserved_path, &entry.path().join(&committed_name), namespace_owner.clone())
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
if effective_durability(volume).syncs_commit_metadata() {
|
||||
os::fsync_dir(&entry.path()).await.map_err(to_file_error)?;
|
||||
os::fsync_dir_with_owner(&entry.path(), namespace_owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
@@ -8061,7 +8317,13 @@ impl LocalDisk {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn finish_version_delete(&self, volume: &str, object: &str, rollback_dir: Uuid) -> Result<bool> {
|
||||
async fn finish_version_delete(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
rollback_dir: Uuid,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<bool> {
|
||||
let object_path = self.io_get_object_path(volume, object)?;
|
||||
let mut entries = match fs::read_dir(object_path).await {
|
||||
Ok(entries) => entries,
|
||||
@@ -8082,13 +8344,14 @@ impl LocalDisk {
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
}
|
||||
if let Err(err) = self
|
||||
.delete_data_dir(
|
||||
.delete_data_dir_with_namespace_owner(
|
||||
volume,
|
||||
&format!("{object}/{data_dir}"),
|
||||
DeleteOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
},
|
||||
namespace_owner.clone(),
|
||||
)
|
||||
.await
|
||||
&& first_err.is_none()
|
||||
@@ -8109,6 +8372,28 @@ impl LocalDisk {
|
||||
object: &str,
|
||||
stage: &'static str,
|
||||
err: DiskError,
|
||||
) -> DiskError {
|
||||
self.abort_reserved_version_delete_with_failure(
|
||||
object_dir,
|
||||
rollback_dir,
|
||||
volume,
|
||||
object,
|
||||
DeleteRollbackFailure {
|
||||
stage,
|
||||
error: err,
|
||||
namespace_owner: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn abort_reserved_version_delete_with_failure(
|
||||
&self,
|
||||
object_dir: &Path,
|
||||
rollback_dir: Uuid,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
failure: DeleteRollbackFailure,
|
||||
) -> DiskError {
|
||||
let xl_path = object_dir.join(STORAGE_FORMAT_FILE);
|
||||
restore_delete_rollback_after_error(
|
||||
@@ -8117,7 +8402,7 @@ impl LocalDisk {
|
||||
Some(rollback_dir),
|
||||
volume,
|
||||
object,
|
||||
DeleteRollbackFailure { stage, error: err },
|
||||
failure,
|
||||
&self.publication_root,
|
||||
)
|
||||
.await
|
||||
@@ -9608,49 +9893,7 @@ impl DiskAPI for LocalDisk {
|
||||
}
|
||||
|
||||
async fn delete_data_dir(&self, volume: &str, path: &str, opts: DeleteOptions) -> Result<DataDirDeleteStatus> {
|
||||
let key = SnapshotLeaseKey {
|
||||
volume: volume.to_string(),
|
||||
path: path.to_string(),
|
||||
};
|
||||
{
|
||||
let mut registry = self.snapshot_leases.lock().await;
|
||||
if let Some(entry) = registry.entries.get_mut(&key) {
|
||||
if !entry.tokens.is_empty() {
|
||||
entry.pending_delete.get_or_insert_with(|| opts.clone());
|
||||
return Ok(DataDirDeleteStatus::Deferred);
|
||||
}
|
||||
if entry.deleting {
|
||||
entry.pending_delete.get_or_insert_with(|| opts.clone());
|
||||
return Ok(DataDirDeleteStatus::Deferred);
|
||||
}
|
||||
entry.deleting = true;
|
||||
entry.pending_delete.get_or_insert_with(|| opts.clone());
|
||||
} else {
|
||||
registry.entries.insert(
|
||||
key.clone(),
|
||||
SnapshotLeaseEntry {
|
||||
pending_delete: Some(opts.clone()),
|
||||
deleting: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let result = self.delete_unleased(volume, path, &opts).await;
|
||||
let mut registry = self.snapshot_leases.lock().await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
registry.entries.remove(&key);
|
||||
Ok(DataDirDeleteStatus::Deleted)
|
||||
}
|
||||
Err(err) => {
|
||||
if let Some(entry) = registry.entries.get_mut(&key) {
|
||||
entry.deleting = false;
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
self.delete_data_dir_with_namespace_owner(volume, path, opts, None).await
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
@@ -9689,32 +9932,8 @@ impl DiskAPI for LocalDisk {
|
||||
Err(Error::other("Invalid Argument"))
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
async fn write_metadata(&self, _org_volume: &str, volume: &str, path: &str, fi: FileInfo) -> Result<()> {
|
||||
crate::hp_guard!("LocalDisk::write_metadata");
|
||||
fi.validate_for_metadata_read()?;
|
||||
let p = self.io_get_object_path(volume, format!("{path}/{STORAGE_FORMAT_FILE}").as_str())?;
|
||||
|
||||
let mut meta = FileMeta::new();
|
||||
if !fi.fresh {
|
||||
let (buf, _) = read_file_exists(&p).await?;
|
||||
if !buf.is_empty() {
|
||||
let _ = meta.unmarshal_msg(&buf).map_err(|_| {
|
||||
meta = FileMeta::new();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
meta.add_version(fi)?;
|
||||
|
||||
let fm_data = meta.marshal_msg()?;
|
||||
|
||||
// Atomic temp+rename: this path also rewrites live xl.meta (delete markers,
|
||||
// decommission), where an in-place truncate would expose torn metadata.
|
||||
self.write_all_meta(volume, format!("{path}/{STORAGE_FORMAT_FILE}").as_str(), &fm_data, true)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
self.write_metadata_with_namespace_owner(volume, path, fi, None).await
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
@@ -22177,4 +22396,135 @@ mod test {
|
||||
assert_eq!(mount_id_from_mountinfo_contents(mountinfo, Path::new("/mnt/replacement disk")), Some(202));
|
||||
assert_eq!(mount_id_from_mountinfo_contents(mountinfo, Path::new("/mnt/replacement")), None);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(capacity_dirty_scope)]
|
||||
async fn single_delete_internal_restore_keeps_owner_after_cancellation() {
|
||||
use crate::disk::os::prepared_publication_test_hooks as hooks;
|
||||
use futures::FutureExt;
|
||||
|
||||
let dir = tempfile::tempdir().expect("fixture directory");
|
||||
let endpoint = Endpoint::try_from(dir.path().to_str().expect("UTF-8 fixture path")).expect("endpoint");
|
||||
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk"));
|
||||
let bucket = "single-delete-internal-restore";
|
||||
let object = format!("object-{}", Uuid::new_v4());
|
||||
ensure_test_volume(&disk, bucket).await;
|
||||
let version = Uuid::new_v4();
|
||||
let data_dir = Uuid::new_v4();
|
||||
let rollback_dir = Uuid::new_v4();
|
||||
let fi = test_file_info(&object, version, Some(data_dir), None);
|
||||
let original = test_meta(fi.clone());
|
||||
let object_dir = disk.io_get_object_path(bucket, &object).expect("object IO path");
|
||||
let part = object_dir.join(data_dir.to_string()).join("part.1");
|
||||
let metadata = object_dir.join(STORAGE_FORMAT_FILE);
|
||||
let backup = object_dir.join(rollback_dir.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
|
||||
fs::create_dir_all(part.parent().expect("data parent"))
|
||||
.await
|
||||
.expect("data directory");
|
||||
fs::write(&part, b"x").await.expect("real shard");
|
||||
fs::write(&metadata, &original).await.expect("real version metadata");
|
||||
set_delete_version_fail_after_data_staged(&object);
|
||||
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
||||
let (release, release_rx) = std::sync::mpsc::channel::<()>();
|
||||
let hook = hooks::install_at(hooks::Stage::Rename, &metadata, move || {
|
||||
let _ = entered_tx.send(());
|
||||
let _ = release_rx.recv();
|
||||
});
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
let before = ctx.namespace_commit_generation();
|
||||
let owner = ctx.begin_namespace_commit();
|
||||
let deleting_disk = Arc::clone(&disk);
|
||||
let deleting_object = object.clone();
|
||||
let mut delete = tokio::spawn(async move {
|
||||
deleting_disk
|
||||
.delete_version_inner(
|
||||
bucket,
|
||||
&deleting_object,
|
||||
fi,
|
||||
DeleteVersionMutation {
|
||||
force_del_marker: false,
|
||||
opts: DeleteOptions {
|
||||
old_data_dir: Some(rollback_dir),
|
||||
..Default::default()
|
||||
},
|
||||
namespace_owner: Some(owner),
|
||||
},
|
||||
)
|
||||
.await
|
||||
});
|
||||
let mut joined = false;
|
||||
let mut entered = false;
|
||||
let mut counts = None;
|
||||
let observations = std::panic::AssertUnwindSafe(async {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
tokio::select! {
|
||||
result = entered_rx => {
|
||||
result.expect("actual internal restore entry");
|
||||
entered = true;
|
||||
}
|
||||
result = &mut delete => {
|
||||
joined = true;
|
||||
panic!("delete returned before internal physical restore: {result:?}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("internal restore must reach the physical rename");
|
||||
assert_eq!(std::fs::read(&backup).expect("real undo backup"), original);
|
||||
assert_eq!(std::fs::read(&part).expect("reserved shard"), b"x");
|
||||
delete.abort();
|
||||
let result = tokio::time::timeout(Duration::from_secs(5), &mut delete).await;
|
||||
joined = result.is_ok();
|
||||
assert!(
|
||||
result
|
||||
.expect("cancelled caller joins")
|
||||
.expect_err("cancelled caller")
|
||||
.is_cancelled()
|
||||
);
|
||||
counts = Some((ctx.namespace_commits_pending(), ctx.namespace_commit_generation()));
|
||||
assert!(hooks::drain_namespace_key(&metadata).now_or_never().is_none());
|
||||
})
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
drop(release);
|
||||
drop(hook);
|
||||
let coordinator_drained = joined || tokio::time::timeout(Duration::from_secs(10), &mut delete).await.is_ok();
|
||||
if !coordinator_drained {
|
||||
delete.abort();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), &mut delete).await;
|
||||
}
|
||||
let physical_drained = tokio::time::timeout(Duration::from_secs(5), hooks::drain_namespace_key(&metadata))
|
||||
.await
|
||||
.is_ok();
|
||||
let owner_drained = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while ctx.namespace_commits_pending() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
if !entered || !coordinator_drained || !physical_drained || !owner_drained {
|
||||
eprintln!("internal restore cleanup incomplete; retained root: {:?}", dir.keep());
|
||||
if let Err(panic) = observations {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
panic!("internal restore cleanup must drain before removing its root");
|
||||
}
|
||||
if let Err(panic) = observations {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
assert_eq!(std::fs::read(&metadata).expect("late restored metadata"), original);
|
||||
assert!(!backup.exists(), "the actual backup rename must have completed");
|
||||
assert_eq!(std::fs::read(&part).expect("old shard survives"), b"x");
|
||||
disk.read_version("", bucket, &object, &version.to_string(), &ReadOptions::default())
|
||||
.await
|
||||
.expect("restored version");
|
||||
let (pending, generation) = counts.expect("observations completed");
|
||||
assert!(pending, "internal error recovery lost the physical namespace owner");
|
||||
assert_eq!(generation, before + 1);
|
||||
assert_eq!(ctx.namespace_commit_generation(), before + 2);
|
||||
assert!(!ctx.namespace_commits_pending());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -732,6 +732,46 @@ impl Disk {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_version_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
fi: FileInfo,
|
||||
force_del_marker: bool,
|
||||
opts: DeleteOptions,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
match self {
|
||||
Self::Local(disk) => {
|
||||
disk.delete_version_with_namespace_owner(volume, path, fi, force_del_marker, opts, namespace_owner)
|
||||
.await
|
||||
}
|
||||
Self::Remote(disk) => {
|
||||
let result = disk.delete_version(volume, path, fi, force_del_marker, opts).await;
|
||||
// This is sender lifetime only, not proof of a remote physical drain.
|
||||
drop(namespace_owner);
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_with_namespace_owner(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
opts: DeleteOptions,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
match self {
|
||||
Self::Local(disk) => disk.delete_with_namespace_owner(volume, path, opts, namespace_owner).await,
|
||||
Self::Remote(disk) => {
|
||||
let result = disk.delete(volume, path, opts).await;
|
||||
drop(namespace_owner);
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep local undo publication owned independently of the wrapper deadline.
|
||||
/// Remote undo retains its existing RPC contract; this is not a remote drain proof.
|
||||
pub(crate) async fn undo_write_with_namespace_owner(
|
||||
|
||||
@@ -247,7 +247,7 @@ pub(crate) mod fsync_dir_recorder {
|
||||
}
|
||||
|
||||
/// Pause a real namespace mutation inside its physical executor.
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
#[cfg(all(test, not(windows)))]
|
||||
pub(crate) mod prepared_publication_test_hooks {
|
||||
use super::*;
|
||||
|
||||
@@ -256,9 +256,7 @@ pub(crate) mod prepared_publication_test_hooks {
|
||||
PreparedRename,
|
||||
Rename,
|
||||
Remove,
|
||||
#[cfg(test)]
|
||||
Rollback,
|
||||
#[cfg(test)]
|
||||
DirFsync,
|
||||
}
|
||||
|
||||
@@ -274,7 +272,6 @@ pub(crate) mod prepared_publication_test_hooks {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install(path: &Path, hook: impl FnOnce() + Send + 'static) -> Guard {
|
||||
install_at(Stage::PreparedRename, path, hook)
|
||||
}
|
||||
@@ -291,50 +288,45 @@ pub(crate) mod prepared_publication_test_hooks {
|
||||
hook();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Controlled application-test pause at an existing physical executor boundary.
|
||||
#[cfg(all(feature = "test-util", not(windows)))]
|
||||
pub struct LocalPublicationPause {
|
||||
_hook: prepared_publication_test_hooks::Guard,
|
||||
entered: oneshot::Receiver<()>,
|
||||
_release: std::sync::mpsc::Sender<()>,
|
||||
}
|
||||
#[cfg(test)]
|
||||
type RenameDestinationHook = Box<dyn FnOnce(&Path) + Send>;
|
||||
#[cfg(test)]
|
||||
static RENAME_DESTINATIONS: LazyLock<Mutex<HashMap<PathBuf, RenameDestinationHook>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
#[cfg(all(feature = "test-util", not(windows)))]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum LocalPublicationStage {
|
||||
PreparedRename,
|
||||
Rename,
|
||||
Remove,
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) struct RenameDestinationGuard(PathBuf);
|
||||
|
||||
#[cfg(all(feature = "test-util", not(windows)))]
|
||||
impl LocalPublicationPause {
|
||||
pub fn install(disk: &crate::disk::Disk, volume: &str, path: &str, stage: LocalPublicationStage) -> Result<Self> {
|
||||
let path = disk
|
||||
.get_object_path_for_io_if_local(volume, path)
|
||||
.ok_or(DiskError::DiskNotFound)??;
|
||||
let stage = match stage {
|
||||
LocalPublicationStage::PreparedRename => prepared_publication_test_hooks::Stage::PreparedRename,
|
||||
LocalPublicationStage::Rename => prepared_publication_test_hooks::Stage::Rename,
|
||||
LocalPublicationStage::Remove => prepared_publication_test_hooks::Stage::Remove,
|
||||
};
|
||||
let (entered_tx, entered) = oneshot::channel();
|
||||
let (release, release_rx) = std::sync::mpsc::channel::<()>();
|
||||
let hook = prepared_publication_test_hooks::install_at(stage, &path, move || {
|
||||
let _ = entered_tx.send(());
|
||||
let _ = release_rx.recv();
|
||||
});
|
||||
Ok(Self {
|
||||
_hook: hook,
|
||||
entered,
|
||||
_release: release,
|
||||
})
|
||||
#[cfg(test)]
|
||||
impl Drop for RenameDestinationGuard {
|
||||
fn drop(&mut self) {
|
||||
RENAME_DESTINATIONS.lock().remove(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn entered(&mut self) -> std::result::Result<(), oneshot::error::RecvError> {
|
||||
(&mut self.entered).await
|
||||
#[cfg(test)]
|
||||
pub(crate) fn observe_rename_destination(source: &Path, hook: impl FnOnce(&Path) + Send + 'static) -> RenameDestinationGuard {
|
||||
assert!(
|
||||
RENAME_DESTINATIONS
|
||||
.lock()
|
||||
.insert(source.to_path_buf(), Box::new(hook))
|
||||
.is_none()
|
||||
);
|
||||
RenameDestinationGuard(source.to_path_buf())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn drain_namespace_key(path: &Path) {
|
||||
drop(super::acquire_namespace_mutation_lease(path).await);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn run_rename_destination(source: &Path, destination: &Path) {
|
||||
let hook = RENAME_DESTINATIONS.lock().remove(source);
|
||||
if let Some(hook) = hook {
|
||||
hook(destination);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1409,7 +1401,7 @@ async fn acquire_namespace_mutation_lease(path: &Path) -> Arc<NamespaceMutationL
|
||||
acquire_namespace_mutation_lease_with_owner(path, None).await
|
||||
}
|
||||
|
||||
async fn acquire_namespace_mutation_lease_with_owner(
|
||||
pub(in crate::disk) async fn acquire_namespace_mutation_lease_with_owner(
|
||||
path: &Path,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Arc<NamespaceMutationLease> {
|
||||
@@ -1964,7 +1956,7 @@ pub(crate) async fn remove_file_with_owner(
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let lease = acquire_namespace_mutation_lease_with_owner(&path, namespace_owner).await;
|
||||
run_blocking_namespace_operation(lease, move || {
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
#[cfg(all(test, not(windows)))]
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Remove, &path);
|
||||
std::fs::remove_file(path)
|
||||
})
|
||||
@@ -1984,6 +1976,42 @@ pub(crate) async fn remove_dir_with_owner(
|
||||
run_blocking_namespace_operation(lease, move || std::fs::remove_dir(path)).await
|
||||
}
|
||||
|
||||
/// Preserve raw rename semantics while retaining a counted owner in the syscall.
|
||||
/// Unlike reliable rename, this never creates parents or retries a missing source.
|
||||
pub(in crate::disk) async fn rename_with_namespace_owner(
|
||||
src: &Path,
|
||||
dst: &Path,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> io::Result<()> {
|
||||
if namespace_owner.is_none() {
|
||||
return tokio::fs::rename(src, dst).await;
|
||||
}
|
||||
let src = src.to_path_buf();
|
||||
let dst = dst.to_path_buf();
|
||||
let lease = acquire_namespace_mutation_lease_with_owner(&dst, namespace_owner).await;
|
||||
run_blocking_namespace_operation(lease, move || {
|
||||
#[cfg(all(test, not(windows)))]
|
||||
{
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &src);
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &dst);
|
||||
}
|
||||
std::fs::rename(src, dst)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(in crate::disk) async fn create_dir_all_with_namespace_owner(
|
||||
path: &Path,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> io::Result<()> {
|
||||
if namespace_owner.is_none() {
|
||||
return tokio::fs::create_dir_all(path).await;
|
||||
}
|
||||
let path = path.to_path_buf();
|
||||
let lease = acquire_namespace_mutation_lease_with_owner(&path, namespace_owner).await;
|
||||
run_blocking_namespace_operation(lease, move || std::fs::create_dir_all(path)).await
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "rename_all", level = "debug", skip_all)]
|
||||
pub(crate) async fn rename_all_with_owner(
|
||||
src_file_path: impl AsRef<Path>,
|
||||
@@ -2188,7 +2216,7 @@ pub(crate) async fn rename_all_with_prepared_source(
|
||||
move || {
|
||||
validate_prepared_rename_source(&prepared_source, &src_file_path)?;
|
||||
let preparation = prepare_rename_with_retry(&src_file_path, &dst_file_path, &base_dir, &publication_root)?;
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[cfg(test)]
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::PreparedRename, &dst_file_path);
|
||||
rename_prepared(&src_file_path, &dst_file_path, &preparation)
|
||||
}
|
||||
@@ -2319,7 +2347,9 @@ async fn reliable_rename_inner_with_lease(
|
||||
let base_dir = base_dir.clone();
|
||||
move || {
|
||||
let preparation = prepare_rename_with_retry(&src_file_path, &dst_file_path, &base_dir, &publication_root)?;
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
#[cfg(all(test, not(windows)))]
|
||||
prepared_publication_test_hooks::run_rename_destination(&src_file_path, &dst_file_path);
|
||||
#[cfg(all(test, not(windows)))]
|
||||
{
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &src_file_path);
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &dst_file_path);
|
||||
|
||||
@@ -5458,7 +5458,7 @@ impl TierConfigMgr {
|
||||
let manager = handle.read().await;
|
||||
let published_digest = if intents
|
||||
.iter()
|
||||
.any(|recovered| recovered.is_peer_only_terminal() && recovered.intent.state == TierMutationIntentState::Committed)
|
||||
.any(|recovered| recovered.intent.state == TierMutationIntentState::Committed)
|
||||
{
|
||||
Some(tier_config_candidate_digest(&manager).map_err(|err| {
|
||||
let mut admin_err = ERR_TIER_INVALID_CONFIG.clone();
|
||||
@@ -5468,18 +5468,29 @@ impl TierConfigMgr {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let locally_published_committed_mutations = intents
|
||||
.iter()
|
||||
.filter(|recovered| {
|
||||
recovered.intent.state == TierMutationIntentState::Committed
|
||||
&& published_digest == Some(recovered.intent.candidate_digest)
|
||||
})
|
||||
.map(|recovered| recovered.intent.mutation_id)
|
||||
.collect::<HashSet<_>>();
|
||||
let mut prepared_mutation_blocks = HashMap::new();
|
||||
let mut committed_mutation_blocks: HashMap<String, HashSet<uuid::Uuid>> = HashMap::new();
|
||||
for recovered in intents {
|
||||
let settled_tombstone = recovered.is_peer_only_terminal()
|
||||
&& match recovered.intent.state {
|
||||
TierMutationIntentState::Aborted => true,
|
||||
TierMutationIntentState::Committed => {
|
||||
!retain_missing_mutation_blocks || published_digest == Some(recovered.intent.candidate_digest)
|
||||
}
|
||||
TierMutationIntentState::Prepared => false,
|
||||
};
|
||||
if settled_tombstone {
|
||||
// A matching in-memory manager has already crossed the local
|
||||
// publication boundary. Keep replaying and durably cleaning the
|
||||
// record, but do not re-fence object operations while that
|
||||
// terminal work finishes.
|
||||
let skip_runtime_fence = locally_published_committed_mutations.contains(&recovered.intent.mutation_id)
|
||||
|| (recovered.is_peer_only_terminal()
|
||||
&& match recovered.intent.state {
|
||||
TierMutationIntentState::Aborted => true,
|
||||
TierMutationIntentState::Committed => !retain_missing_mutation_blocks,
|
||||
TierMutationIntentState::Prepared => false,
|
||||
});
|
||||
if skip_runtime_fence {
|
||||
continue;
|
||||
}
|
||||
Self::collect_prepared_mutation_intent_block(&mut prepared_mutation_blocks, &recovered.intent)?;
|
||||
@@ -5492,6 +5503,9 @@ impl TierConfigMgr {
|
||||
}
|
||||
if retain_missing_mutation_blocks {
|
||||
for (tier_name, mutation_id) in &runtime.prepared_mutation_blocks {
|
||||
if locally_published_committed_mutations.contains(mutation_id) {
|
||||
continue;
|
||||
}
|
||||
match prepared_mutation_blocks.entry(tier_name.clone()) {
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(*mutation_id);
|
||||
@@ -5505,10 +5519,15 @@ impl TierConfigMgr {
|
||||
}
|
||||
}
|
||||
for (tier_name, mutation_ids) in &runtime.committed_mutation_blocks {
|
||||
committed_mutation_blocks
|
||||
.entry(tier_name.clone())
|
||||
.or_default()
|
||||
.extend(mutation_ids);
|
||||
for mutation_id in mutation_ids {
|
||||
if locally_published_committed_mutations.contains(mutation_id) {
|
||||
continue;
|
||||
}
|
||||
committed_mutation_blocks
|
||||
.entry(tier_name.clone())
|
||||
.or_default()
|
||||
.insert(*mutation_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let changed = runtime.prepared_mutation_blocks != prepared_mutation_blocks
|
||||
@@ -11298,6 +11317,115 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn published_dual_terminal_intent_does_not_restore_local_runtime_fence_during_replay() {
|
||||
use crate::services::tier::tier_mutation_intent::save_tier_mutation_intent_record;
|
||||
|
||||
let store = Arc::new(CasConfigStore::default());
|
||||
let mut persisted = empty_mgr();
|
||||
persisted.tiers.insert("COLD-A".to_string(), build_rustfs_tier("COLD-A"));
|
||||
persisted
|
||||
.save_tiering_config_if_current(store.clone(), None)
|
||||
.await
|
||||
.expect("published tier config fixture should persist");
|
||||
let (_, current_etag) = load_tier_config_for_update(store.clone())
|
||||
.await
|
||||
.expect("published tier config fixture should load with metadata");
|
||||
let current_etag = current_etag.expect("published tier config fixture should have an ETag");
|
||||
let affected_targets = build_tier_mutation_affected_targets(
|
||||
TierMutationIntentKind::Add,
|
||||
HashSet::from(["COLD-A".to_string()]),
|
||||
&empty_mgr(),
|
||||
&persisted,
|
||||
)
|
||||
.expect("published AddTier targets should build");
|
||||
let mut intent = build_coordinator_tier_mutation_intent(TierMutationIntentKind::Add, None, &persisted, affected_targets)
|
||||
.expect("published AddTier intent should build")
|
||||
.expect("published AddTier should require a durable intent");
|
||||
intent
|
||||
.advance(TierMutationIntentState::Committed, Some(current_etag))
|
||||
.expect("published AddTier intent should commit");
|
||||
save_tier_coordinator_mutation_intent_record_if_absent(store.clone(), &intent)
|
||||
.await
|
||||
.expect("published coordinator intent should persist");
|
||||
save_tier_mutation_intent_record(store.clone(), &intent)
|
||||
.await
|
||||
.expect("published peer intent should persist");
|
||||
|
||||
let manager = TierConfigMgr::new();
|
||||
{
|
||||
let mut guard = manager.write().await;
|
||||
install_lease_backend(&mut guard, "COLD-A", LeaseTestBackend::ready("published"));
|
||||
}
|
||||
{
|
||||
let guard = manager.read().await;
|
||||
assert_eq!(
|
||||
tier_config_candidate_digest(&guard).expect("published manager digest should build"),
|
||||
intent.candidate_digest
|
||||
);
|
||||
}
|
||||
TierConfigMgr::apply_committed_mutation_intent_block(&manager, &intent)
|
||||
.await
|
||||
.expect("pre-existing committed runtime fence should install");
|
||||
assert!(
|
||||
TierConfigMgr::acquire_operation_lease(&manager, "COLD-A").await.is_err(),
|
||||
"fixture must begin with the committed runtime fence installed"
|
||||
);
|
||||
|
||||
let started = Arc::new(Notify::new());
|
||||
let release = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
TIER_MUTATION_TEST_PEERS
|
||||
.scope(
|
||||
vec![Arc::new(BlockingCommitTierMutationPeer {
|
||||
started: started.clone(),
|
||||
release: release.clone(),
|
||||
})],
|
||||
async {
|
||||
let reload = TierConfigMgr::reload_handle_with(&manager, store.clone());
|
||||
tokio::pin!(reload);
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
tokio::select! {
|
||||
result = &mut reload => panic!("reload finished before terminal replay was released: {result:?}"),
|
||||
_ = started.notified() => {}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("terminal replay should reach the blocking peer");
|
||||
|
||||
let lease = TierConfigMgr::acquire_operation_lease(&manager, "COLD-A")
|
||||
.await
|
||||
.expect("terminal cleanup must not re-fence an already-published tier");
|
||||
drop(lease);
|
||||
release.add_permits(1);
|
||||
tokio::time::timeout(Duration::from_secs(5), &mut reload)
|
||||
.await
|
||||
.expect("terminal replay should finish after the peer responds")
|
||||
.expect("terminal replay should succeed after the peer responds");
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(manager.read().await.tiers.contains_key("COLD-A"));
|
||||
assert!(
|
||||
TierConfigMgr::load_coordinator_mutation_intents(store.clone())
|
||||
.await
|
||||
.expect("coordinator cleanup should be readable")
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
TierConfigMgr::load_tier_mutation_intents(store)
|
||||
.await
|
||||
.expect("retained peer tombstone should be readable"),
|
||||
vec![intent]
|
||||
);
|
||||
let guard = manager.read().await;
|
||||
let runtime = registered_tier_driver_runtime(&guard).expect("runtime should remain registered");
|
||||
assert!(
|
||||
lock_unpoisoned(&runtime).committed_mutation_blocks.is_empty(),
|
||||
"retained terminal evidence must not restore the published runtime fence"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn peer_terminal_tombstone_gc_uses_etag_and_retains_racing_replacement() {
|
||||
use crate::services::tier::tier_mutation_intent::{
|
||||
|
||||
@@ -7497,6 +7497,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
let transported = delete_file_info_with_replication_transport_metadata(fi);
|
||||
let fi = &transported;
|
||||
let disks = self.disk_inventory().await;
|
||||
let namespace_owner = (!is_meta_bucketname(bucket)).then(|| self.ctx.begin_namespace_commit());
|
||||
let write_quorum = disks.len() / 2 + 1;
|
||||
let rollback_dir = Uuid::new_v4();
|
||||
|
||||
@@ -7504,10 +7505,11 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
let mut errs = Vec::with_capacity(disks.len());
|
||||
|
||||
for disk in disks.iter() {
|
||||
let disk_namespace_owner = namespace_owner.clone().map(|owner| owner as Arc<dyn Send + Sync>);
|
||||
futures.push(async move {
|
||||
if let Some(disk) = disk {
|
||||
match disk
|
||||
.delete_version(
|
||||
.delete_version_with_namespace_owner(
|
||||
bucket,
|
||||
object,
|
||||
fi.clone(),
|
||||
@@ -7516,6 +7518,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
old_data_dir: Some(rollback_dir),
|
||||
..Default::default()
|
||||
},
|
||||
disk_namespace_owner,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -7563,10 +7566,11 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
let bucket = bucket.to_string();
|
||||
let object = object.to_string();
|
||||
let fi = fi.clone();
|
||||
let disk_namespace_owner = namespace_owner.clone().map(|owner| owner as Arc<dyn Send + Sync>);
|
||||
rollback_futures.push(async move {
|
||||
if should_rollback {
|
||||
if let Err(err) = disk
|
||||
.delete_version(
|
||||
.delete_version_with_namespace_owner(
|
||||
&bucket,
|
||||
&object,
|
||||
fi,
|
||||
@@ -7577,6 +7581,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
old_data_dir: Some(rollback_dir),
|
||||
..Default::default()
|
||||
},
|
||||
disk_namespace_owner,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -7591,7 +7596,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
} else {
|
||||
let rollback_path = format!("{object}/{rollback_dir}");
|
||||
if let Err(err) = disk
|
||||
.delete(
|
||||
.delete_with_namespace_owner(
|
||||
&bucket,
|
||||
&rollback_path,
|
||||
DeleteOptions {
|
||||
@@ -7599,6 +7604,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
immediate: true,
|
||||
..Default::default()
|
||||
},
|
||||
disk_namespace_owner,
|
||||
)
|
||||
.await
|
||||
&& err != DiskError::FileNotFound
|
||||
@@ -7617,6 +7623,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
}
|
||||
|
||||
join_all(rollback_futures).await;
|
||||
drop(namespace_owner);
|
||||
quorum_result
|
||||
}
|
||||
|
||||
@@ -21044,3 +21051,417 @@ mod body_cache_hook_e2e_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod single_delete_namespace_owner_tests {
|
||||
use super::hermetic_set_disks_support::hermetic_set_disks_isolated;
|
||||
use super::*;
|
||||
use crate::disk::ReadOptions;
|
||||
#[cfg(not(windows))]
|
||||
use crate::disk::STORAGE_FORMAT_FILE;
|
||||
use crate::object_api::WriteCompletion;
|
||||
#[cfg(not(windows))]
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
async fn seed_version(set: &Arc<SetDisks>, bucket: &str, object: &str, version: Uuid, body: &[u8]) {
|
||||
set.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(body.to_vec()),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(version.to_string()),
|
||||
write_completion: WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed a complete real object version");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(capacity_dirty_scope)]
|
||||
async fn single_delete_advances_namespace_generation_through_cleanup() {
|
||||
let (dirs, disks, set) = hermetic_set_disks_isolated(4).await;
|
||||
let bucket = "single-delete-namespace";
|
||||
let object = "last-version";
|
||||
for disk in &disks {
|
||||
disk.make_volume(bucket).await.expect("fixture bucket");
|
||||
}
|
||||
let version = Uuid::new_v4();
|
||||
seed_version(&set, bucket, object, version, &vec![0x41; 256 * 1024]).await;
|
||||
let before = set.ctx.namespace_commit_generation();
|
||||
assert!(!set.ctx.namespace_commits_pending());
|
||||
let request = FileInfo {
|
||||
name: object.to_string(),
|
||||
version_id: Some(version),
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
let result =
|
||||
tokio::time::timeout(Duration::from_secs(10), set.delete_object_version(bucket, object, &request, false)).await;
|
||||
if !matches!(result, Ok(Ok(()))) {
|
||||
let retained = dirs.into_iter().map(tempfile::TempDir::keep).collect::<Vec<_>>();
|
||||
panic!("single delete and cleanup did not finish: {result:?}; retained roots: {retained:?}");
|
||||
}
|
||||
for (disk, dir) in disks.iter().zip(&dirs) {
|
||||
let result = disk
|
||||
.read_version("", bucket, object, &version.to_string(), &ReadOptions::default())
|
||||
.await;
|
||||
assert!(matches!(result, Err(DiskError::FileNotFound | DiskError::FileVersionNotFound)));
|
||||
assert!(
|
||||
!dir.path().join(bucket).join(object).exists(),
|
||||
"immediate cleanup must remove the rollback object tree"
|
||||
);
|
||||
}
|
||||
assert!(!set.ctx.namespace_commits_pending());
|
||||
assert_eq!(
|
||||
set.ctx.namespace_commit_generation(),
|
||||
before + 2,
|
||||
"single delete must count one complete root lifetime"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
async fn assert_single_delete_physical_owner(case: &'static str) {
|
||||
use crate::disk::os::prepared_publication_test_hooks as hooks;
|
||||
use futures::FutureExt;
|
||||
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, Some("60"))], async {
|
||||
let (dirs, disks, set) = hermetic_set_disks_isolated(4).await;
|
||||
let bucket = "single-delete-physical-owner";
|
||||
let first = Uuid::new_v4();
|
||||
let second = Uuid::new_v4();
|
||||
let first_body = vec![0x51; 256 * 1024];
|
||||
let second_body = vec![0x62; 4096];
|
||||
let missing = case == "missing-marker";
|
||||
let rollback = case == "rollback";
|
||||
let last = case == "last-version";
|
||||
let cleanup = case == "immediate-cleanup";
|
||||
for disk in &disks {
|
||||
disk.make_volume(bucket).await.expect("fixture bucket");
|
||||
}
|
||||
if !missing {
|
||||
seed_version(&set, bucket, case, first, &first_body).await;
|
||||
if !last && !cleanup {
|
||||
seed_version(&set, bucket, case, second, &second_body).await;
|
||||
}
|
||||
}
|
||||
let request = FileInfo {
|
||||
name: case.to_string(),
|
||||
version_id: Some(first),
|
||||
deleted: missing,
|
||||
mark_deleted: missing,
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
let before = set.ctx.namespace_commit_generation();
|
||||
assert!(!set.ctx.namespace_commits_pending());
|
||||
let mut metadata_paths = Vec::new();
|
||||
let mut originals = Vec::new();
|
||||
let mut cleanup_sources = Vec::new();
|
||||
let mut cleanup_parts = Vec::new();
|
||||
for disk in &disks {
|
||||
let crate::disk::Disk::Local(local) = disk.as_ref() else {
|
||||
panic!("local fixture required");
|
||||
};
|
||||
let path = local
|
||||
.get_disk()
|
||||
.get_object_path_for_io(bucket, case)
|
||||
.expect("leased IO path")
|
||||
.join(STORAGE_FORMAT_FILE);
|
||||
originals.push(if missing {
|
||||
None
|
||||
} else {
|
||||
Some(std::fs::read(&path).expect("seeded raw metadata"))
|
||||
});
|
||||
if cleanup {
|
||||
let fi = disk.read_version("", bucket, case, &first.to_string(), &ReadOptions::default())
|
||||
.await.expect("real non-inline data directory");
|
||||
assert!(!fi.inline_data(), "cleanup fixture must have real shard files");
|
||||
let data = path.parent().expect("object parent").join(fi.data_dir.expect("data directory").to_string());
|
||||
cleanup_parts.push(std::fs::read(data.join("part.1")).expect("real pre-delete shard"));
|
||||
cleanup_sources.push(data);
|
||||
}
|
||||
metadata_paths.push(path);
|
||||
}
|
||||
if rollback {
|
||||
// Two disks apply the real deletion and then error. Undo must
|
||||
// restore all four disks, including these post-apply failures.
|
||||
for disk in disks.iter().take(2) {
|
||||
crate::disk::local::set_delete_version_fail_after_commit(disk.path().as_path(), case);
|
||||
}
|
||||
}
|
||||
let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut guards = Vec::new();
|
||||
let mut destination_guards = Vec::new();
|
||||
let later_guards = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let mut releases = Vec::new();
|
||||
for (index, path) in metadata_paths.iter().enumerate() {
|
||||
let tx = entered_tx.clone();
|
||||
let (release, rx) = std::sync::mpsc::channel::<()>();
|
||||
if last || cleanup {
|
||||
let source = if cleanup { &cleanup_sources[index] } else { path };
|
||||
destination_guards.push(hooks::observe_rename_destination(source, move |destination| {
|
||||
let _ = tx.send((index, destination.to_path_buf()));
|
||||
let _ = rx.recv();
|
||||
}));
|
||||
} else {
|
||||
let hook_path = path.clone();
|
||||
let path = path.clone();
|
||||
let pause_path = path.clone();
|
||||
let later_guards = Arc::clone(&later_guards);
|
||||
guards.push(hooks::install_at(hooks::Stage::Rename, &hook_path, move || {
|
||||
let pause = move || {
|
||||
let _ = tx.send((index, pause_path));
|
||||
let _ = rx.recv();
|
||||
};
|
||||
if rollback {
|
||||
// This first callback precedes forward metadata publication.
|
||||
// Arm only the subsequent real backup-restore rename.
|
||||
let next = hooks::install_at(hooks::Stage::Rename, &path, pause);
|
||||
later_guards.lock().expect("fixture hook guards").push(next);
|
||||
} else {
|
||||
pause();
|
||||
}
|
||||
}));
|
||||
}
|
||||
releases.push(release);
|
||||
}
|
||||
drop(entered_tx);
|
||||
let deleting_set = Arc::clone(&set);
|
||||
let mut delete =
|
||||
tokio::spawn(async move { deleting_set.delete_object_version(bucket, case, &request, missing).await });
|
||||
let mut joined = false;
|
||||
let mut counts = None;
|
||||
let mut physical_keys = std::collections::BTreeMap::new();
|
||||
let observations = std::panic::AssertUnwindSafe(async {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while physical_keys.len() < 4 {
|
||||
tokio::select! {
|
||||
entry = entered_rx.recv() => {
|
||||
let (index, key) = entry.expect("actual physical delete entry");
|
||||
assert!(physical_keys.insert(index, key).is_none());
|
||||
}
|
||||
result = &mut delete => {
|
||||
joined = true;
|
||||
panic!("delete returned before physical entry: {result:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("all four physical mutations must enter");
|
||||
let pending_at_entry = set.ctx.namespace_commits_pending();
|
||||
let generation_at_entry = set.ctx.namespace_commit_generation();
|
||||
for (path, original) in metadata_paths.iter().zip(&originals) {
|
||||
if missing {
|
||||
assert!(!path.exists(), "missing marker must still be unpublished at entry");
|
||||
} else if cleanup {
|
||||
assert!(!path.exists(), "cleanup must follow the actual last-version deletion");
|
||||
} else {
|
||||
let bytes = std::fs::read(path).expect("paused metadata is readable");
|
||||
let metadata = rustfs_filemeta::FileMeta::load(&bytes).expect("real metadata must parse");
|
||||
if !last && !cleanup {
|
||||
assert!(metadata.find_version(Some(second)).is_ok());
|
||||
}
|
||||
assert_eq!(
|
||||
metadata.find_version(Some(first)).is_err(),
|
||||
rollback,
|
||||
"undo entry must follow actual deletion"
|
||||
);
|
||||
if !rollback {
|
||||
assert_eq!(Some(&bytes), original.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
if rollback {
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(61)).await;
|
||||
tokio::time::resume();
|
||||
let result = tokio::time::timeout(Duration::from_secs(5), &mut delete).await;
|
||||
joined = result.is_ok();
|
||||
let result = result
|
||||
.expect("ordinary undo deadlines must return")
|
||||
.expect("delete coordinator must not panic");
|
||||
assert!(
|
||||
matches!(&result, Err(StorageError::InsufficientWriteQuorum(error_bucket, error_object)) if error_bucket == bucket && error_object == case),
|
||||
"keep the original failed delete quorum: {result:?}"
|
||||
);
|
||||
} else {
|
||||
delete.abort();
|
||||
let result = tokio::time::timeout(Duration::from_secs(5), &mut delete).await;
|
||||
joined = result.is_ok();
|
||||
assert!(
|
||||
result
|
||||
.expect("cancelled caller must join")
|
||||
.expect_err("the caller must be cancelled")
|
||||
.is_cancelled()
|
||||
);
|
||||
}
|
||||
counts = Some((
|
||||
pending_at_entry,
|
||||
generation_at_entry,
|
||||
set.ctx.namespace_commits_pending(),
|
||||
set.ctx.namespace_commit_generation(),
|
||||
));
|
||||
for path in physical_keys.values() {
|
||||
assert!(
|
||||
hooks::drain_namespace_key(path)
|
||||
.now_or_never()
|
||||
.is_none(),
|
||||
"the physical metadata executor must still own its exact key"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
drop(releases);
|
||||
drop(guards);
|
||||
drop(destination_guards);
|
||||
let coordinator_drained = joined || tokio::time::timeout(Duration::from_secs(10), &mut delete).await.is_ok();
|
||||
if !coordinator_drained {
|
||||
delete.abort();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), &mut delete).await;
|
||||
}
|
||||
later_guards.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
|
||||
let drains = futures::future::join_all(physical_keys.values().map(|path| {
|
||||
tokio::time::timeout(Duration::from_secs(5), hooks::drain_namespace_key(path))
|
||||
})).await;
|
||||
let owner_drained = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while set.ctx.namespace_commits_pending() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
if physical_keys.len() != 4 || !coordinator_drained || !owner_drained || drains.iter().any(|result| result.is_err()) {
|
||||
let retained = dirs.into_iter().map(tempfile::TempDir::keep).collect::<Vec<_>>();
|
||||
eprintln!("single delete cleanup incomplete; retained roots: {retained:?}");
|
||||
if let Err(panic) = observations {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
panic!("single delete physical cleanup did not drain");
|
||||
}
|
||||
if let Err(panic) = observations {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
for (index, (disk, (path, original))) in disks.iter().zip(metadata_paths.iter().zip(&originals)).enumerate() {
|
||||
if cleanup {
|
||||
assert!(!path.exists(), "metadata must remain deleted after cleanup cancellation");
|
||||
assert!(!cleanup_sources[index].exists(), "physical cleanup must remove the shard directory");
|
||||
assert_eq!(
|
||||
std::fs::read(physical_keys[&index].join("part.1")).expect("actual trashed shard"),
|
||||
cleanup_parts[index],
|
||||
"trash must contain the exact original shard"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if last {
|
||||
assert!(!path.exists(), "late trash rename must remove the last metadata");
|
||||
assert_eq!(
|
||||
Some(std::fs::read(&physical_keys[&index]).expect("actual trash destination")),
|
||||
*original,
|
||||
"last-version trash must contain the exact old metadata"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let bytes = std::fs::read(path).expect("late metadata publication must finish");
|
||||
let metadata = rustfs_filemeta::FileMeta::load(&bytes).expect("final metadata must parse");
|
||||
if missing {
|
||||
assert!(
|
||||
metadata
|
||||
.find_version(Some(first))
|
||||
.expect("the marker must be published")
|
||||
.1
|
||||
.delete_marker
|
||||
.is_some()
|
||||
);
|
||||
} else {
|
||||
assert!(metadata.find_version(Some(second)).is_ok());
|
||||
assert_eq!(metadata.find_version(Some(first)).is_ok(), rollback);
|
||||
if rollback {
|
||||
assert_eq!(Some(&bytes), original.as_ref(), "physical undo must restore exact old metadata");
|
||||
}
|
||||
let fi = disk
|
||||
.read_version("", bucket, case, &second.to_string(), &ReadOptions { read_data: true, ..Default::default() })
|
||||
.await
|
||||
.expect("remaining version");
|
||||
if fi.inline_data() {
|
||||
assert!(fi.data.as_ref().is_some_and(|data| !data.is_empty()), "remaining inline shard must survive");
|
||||
} else {
|
||||
let parts = disk.check_parts(bucket, case, &fi).await.expect("remaining shard check");
|
||||
assert_eq!(parts.results, vec![crate::disk::CHECK_PART_SUCCESS; fi.parts.len()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !missing && !last && !cleanup {
|
||||
let mut actual = Vec::new();
|
||||
let read_opts = ObjectOptions {
|
||||
version_id: Some(if rollback { first } else { second }.to_string()),
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut reader = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
set.get_object_reader(bucket, case, None, HeaderMap::new(), &read_opts),
|
||||
)
|
||||
.await
|
||||
.expect("final GET must finish")
|
||||
.expect("the surviving version must be readable");
|
||||
tokio::time::timeout(Duration::from_secs(5), reader.stream.read_to_end(&mut actual))
|
||||
.await
|
||||
.expect("body must drain")
|
||||
.expect("read surviving body");
|
||||
assert_eq!(actual, if rollback { first_body } else { second_body });
|
||||
}
|
||||
let (pending_at_entry, generation_at_entry, pending_after_return, generation_after_return) =
|
||||
counts.expect("complete observations");
|
||||
assert!(
|
||||
pending_at_entry && pending_after_return,
|
||||
"physical single delete outlived namespace accounting: {case}"
|
||||
);
|
||||
assert_eq!(generation_at_entry, before + 1);
|
||||
assert_eq!(generation_after_return, generation_at_entry);
|
||||
assert_eq!(set.ctx.namespace_commit_generation(), before + 2);
|
||||
assert!(!set.ctx.namespace_commits_pending());
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(capacity_dirty_scope)]
|
||||
async fn single_delete_cancel_keeps_owner_until_immediate_data_cleanup() {
|
||||
assert_single_delete_physical_owner("immediate-cleanup").await;
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(capacity_dirty_scope)]
|
||||
async fn single_delete_cancel_keeps_owner_until_last_version_trash() {
|
||||
assert_single_delete_physical_owner("last-version").await;
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(capacity_dirty_scope)]
|
||||
async fn single_delete_cancel_keeps_owner_until_metadata_rewrite() {
|
||||
assert_single_delete_physical_owner("remaining-version").await;
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(capacity_dirty_scope)]
|
||||
async fn single_delete_cancel_keeps_owner_until_missing_marker_publication() {
|
||||
assert_single_delete_physical_owner("missing-marker").await;
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(capacity_dirty_scope)]
|
||||
async fn single_delete_failed_quorum_keeps_owner_until_physical_undo() {
|
||||
assert_single_delete_physical_owner("rollback").await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,27 +630,14 @@ impl ECStore {
|
||||
.pools
|
||||
.first()
|
||||
.is_some_and(|pool| pool_first_endpoint_is_local(&pool.endpoints));
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
let startup_attempt = uuid::Uuid::new_v4();
|
||||
let (meta, pool_meta_replica_state) = {
|
||||
let mut write_state = self.pool_meta_save_gate.lock().await;
|
||||
establish_pool_meta_bootstrap_identity_if_proven(self.pools.clone(), &mut write_state, should_persist_pool_meta)
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("store init failed during establish_pool_meta_bootstrap_identity: {err}")))?;
|
||||
let load = load_pool_meta_for_startup(self.pools.clone(), &mut write_state);
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
let load = crate::core::pools::startup_cas_test_scope(startup_attempt, "load", &self.pools, load);
|
||||
load.await?
|
||||
load_pool_meta_for_startup(self.pools.clone(), &mut write_state).await?
|
||||
};
|
||||
let update = meta.validate(self.pools.clone())?;
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
crate::core::pools::startup_cas_test_observe(serde_json::json!({
|
||||
"kind": "startup-classifier", "attempt": startup_attempt,
|
||||
"elected_writer": should_persist_pool_meta,
|
||||
"needs_repair": pool_meta_replica_state.needs_repair,
|
||||
"repair_write_safe": pool_meta_replica_state.repair_write_safe,
|
||||
"topology_update": update,
|
||||
}));
|
||||
let endpoints = runtime_sources::endpoint_pools_or_default();
|
||||
|
||||
let mut installed_pool_meta = if update {
|
||||
@@ -662,17 +649,15 @@ impl ECStore {
|
||||
// distributed startup can race on the same lock and replay the prior init bug.
|
||||
{
|
||||
let mut write_state = self.pool_meta_save_gate.lock().await;
|
||||
let persist = persist_pool_meta_for_startup_if_safe(
|
||||
installed_pool_meta = persist_pool_meta_for_startup_if_safe(
|
||||
&installed_pool_meta,
|
||||
self.pools.clone(),
|
||||
pool_meta_replica_state,
|
||||
&mut write_state,
|
||||
update,
|
||||
should_persist_pool_meta,
|
||||
);
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
let persist = crate::core::pools::startup_cas_test_scope(startup_attempt, "persist", &self.pools, persist);
|
||||
installed_pool_meta = persist.await?;
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -442,7 +442,7 @@ pub(crate) mod utils;
|
||||
|
||||
use peer::init_local_peer;
|
||||
pub use peer::{
|
||||
BootstrapLocalTarget, all_local_disk, all_local_disk_path, find_local_disk_by_ref, get_disk_infos, init_local_disks,
|
||||
all_local_disk, all_local_disk_path, find_local_disk_by_ref, get_disk_infos, init_local_disks,
|
||||
init_local_disks_with_instance_ctx, init_lock_clients, prewarm_local_disk_id_map,
|
||||
prewarm_local_disk_id_map_with_instance_ctx,
|
||||
};
|
||||
@@ -1787,7 +1787,7 @@ mod tests {
|
||||
|
||||
// Build a minimal ECStore carrying an explicit instance context. Empty
|
||||
// pools/disks are sufficient: the Phase 5 accessors read only `self.ctx`.
|
||||
pub(super) fn build_store_with_ctx(ctx: Arc<InstanceContext>) -> Arc<ECStore> {
|
||||
fn build_store_with_ctx(ctx: Arc<InstanceContext>) -> Arc<ECStore> {
|
||||
let endpoint_pools = EndpointServerPools::default();
|
||||
Arc::new(ECStore {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
|
||||
@@ -13,10 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use crate::bucket::utils::has_bad_path_component;
|
||||
use crate::disk::error::{DiskError, Result as DiskResult};
|
||||
use crate::disk::{DeleteOptions, Disk, RenameDataGuards, RenameDataResp};
|
||||
use crate::runtime::instance::{InstanceContext, NamespaceCommitGuard};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use tracing::{debug, error};
|
||||
|
||||
@@ -25,203 +22,6 @@ const LOG_SUBSYSTEM_DISK_STARTUP: &str = "disk_startup";
|
||||
const EVENT_LOCAL_DISK_ID_PREWARM_SKIPPED: &str = "local_disk_id_prewarm_skipped";
|
||||
const EVENT_LOCK_CLIENT_INITIALIZATION_FAILED: &str = "lock_client_initialization_failed";
|
||||
|
||||
/// An instance-bound capability for internal writes before ECStore/IAM startup.
|
||||
/// Its private context and volume checks cannot be replaced by a caller guard.
|
||||
#[derive(Clone)]
|
||||
pub struct BootstrapLocalTarget {
|
||||
ctx: Arc<InstanceContext>,
|
||||
}
|
||||
|
||||
impl BootstrapLocalTarget {
|
||||
pub fn new(ctx: Arc<InstanceContext>) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
|
||||
pub fn is_for_store(&self, store: &ECStore) -> bool {
|
||||
Arc::ptr_eq(&self.ctx, &store.ctx)
|
||||
}
|
||||
|
||||
pub async fn rename_local_data(
|
||||
&self,
|
||||
disk_ref: &str,
|
||||
source: (&str, &str),
|
||||
fi: &FileInfo,
|
||||
destination: (&str, &str),
|
||||
scanner_token: Option<Uuid>,
|
||||
) -> DiskResult<RenameDataResp> {
|
||||
if scanner_token.is_some() {
|
||||
return Err(DiskError::other("bootstrap rename cannot use a scanner publication lease"));
|
||||
}
|
||||
validate_bootstrap_volume(source.0)?;
|
||||
validate_bootstrap_volume(destination.0)?;
|
||||
rename_local_data_with_ctx(&self.ctx, disk_ref, source, fi, destination, RenameDataGuards::default()).await
|
||||
}
|
||||
|
||||
pub async fn undo_local_write(
|
||||
&self,
|
||||
disk_ref: &str,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
fi: FileInfo,
|
||||
opts: DeleteOptions,
|
||||
) -> DiskResult<()> {
|
||||
validate_bootstrap_volume(volume)?;
|
||||
undo_local_write_with_ctx(&self.ctx, disk_ref, volume, path, fi, opts).await
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_bootstrap_volume(volume: &str) -> DiskResult<()> {
|
||||
// Prefix membership alone permits aliases such as .rustfs.sys/../bucket.
|
||||
// Validate both raw rename volumes before any disk lookup or admission.
|
||||
if has_bad_path_component(volume) || !is_meta_bucketname(volume) {
|
||||
return Err(DiskError::FileAccessDenied);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ECStore {
|
||||
/// Execute on this instance's active local disk through the physical owner.
|
||||
pub async fn rename_local_data(
|
||||
&self,
|
||||
disk_ref: &str,
|
||||
source: (&str, &str),
|
||||
fi: &FileInfo,
|
||||
destination: (&str, &str),
|
||||
scanner_token: Option<Uuid>,
|
||||
) -> DiskResult<RenameDataResp> {
|
||||
let external_guard: Option<Arc<dyn Send + Sync>> = if let Some(token) = scanner_token {
|
||||
Some(Arc::new(
|
||||
self.acquire_scanner_publication_lease_guard(token)
|
||||
.await
|
||||
.map_err(|err| DiskError::other(err.to_string()))?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
rename_local_data_with_ctx(
|
||||
&self.ctx,
|
||||
disk_ref,
|
||||
source,
|
||||
fi,
|
||||
destination,
|
||||
RenameDataGuards {
|
||||
scanner_publication_lease_token: scanner_token,
|
||||
external_guard,
|
||||
namespace_owner: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn undo_local_write(
|
||||
&self,
|
||||
disk_ref: &str,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
fi: FileInfo,
|
||||
opts: DeleteOptions,
|
||||
) -> DiskResult<()> {
|
||||
undo_local_write_with_ctx(&self.ctx, disk_ref, volume, path, fi, opts).await
|
||||
}
|
||||
}
|
||||
|
||||
// The optional ID is a cold lookup to cache only after final admission.
|
||||
async fn local_disk_candidate(ctx: &Arc<InstanceContext>, disk_ref: &str) -> DiskResult<(DiskStore, Option<Uuid>)> {
|
||||
let map = ctx.local_disk_map();
|
||||
if let Some(disk) = map.read().await.get(disk_ref).and_then(Option::as_ref).cloned() {
|
||||
return Ok((disk, None));
|
||||
}
|
||||
let disk_id = Uuid::parse_str(disk_ref).map_err(|_| DiskError::DiskNotFound)?;
|
||||
let cached_path = ctx.local_disk_id_map().read().await.get(&disk_id).cloned();
|
||||
if let Some(path) = cached_path {
|
||||
let cached_disk = map.read().await.get(&path).and_then(Option::as_ref).cloned();
|
||||
if let Some(disk) = cached_disk
|
||||
&& matches!(disk.as_ref(), Disk::Local(_))
|
||||
&& disk.get_disk_id().await? == Some(disk_id)
|
||||
{
|
||||
return Ok((disk, None));
|
||||
}
|
||||
}
|
||||
let disks: Vec<_> = map.read().await.values().filter_map(Clone::clone).collect();
|
||||
// Disk identity may perform format I/O. No registry guard spans this await.
|
||||
for disk in disks {
|
||||
if matches!(disk.as_ref(), Disk::Local(_)) && disk.get_disk_id().await.ok().flatten() == Some(disk_id) {
|
||||
return Ok((disk, Some(disk_id)));
|
||||
}
|
||||
}
|
||||
Err(DiskError::DiskNotFound)
|
||||
}
|
||||
|
||||
async fn admit_local_disk(
|
||||
ctx: &Arc<InstanceContext>,
|
||||
disk: &DiskStore,
|
||||
disk_id: Option<Uuid>,
|
||||
volume: &str,
|
||||
) -> DiskResult<Option<Arc<NamespaceCommitGuard>>> {
|
||||
if !matches!(disk.as_ref(), Disk::Local(_)) {
|
||||
return Err(DiskError::DiskNotFound);
|
||||
}
|
||||
let map = ctx.local_disk_map();
|
||||
let active = map.read().await;
|
||||
if !active
|
||||
.get(&disk.endpoint().to_string())
|
||||
.and_then(Option::as_ref)
|
||||
.is_some_and(|current| Arc::ptr_eq(current, disk))
|
||||
{
|
||||
return Err(DiskError::DiskNotFound);
|
||||
}
|
||||
// Preserve registry -> ID-cache lock order; no filesystem I/O under either.
|
||||
if let Some(disk_id) = disk_id {
|
||||
ctx.local_disk_id_map()
|
||||
.write()
|
||||
.await
|
||||
.insert(disk_id, disk.endpoint().to_string());
|
||||
}
|
||||
// Admission linearizes under the registry read: replacement/quarantine
|
||||
// before this point rejects; later changes do not revoke physical I/O.
|
||||
Ok((!is_meta_bucketname(volume)).then(|| ctx.begin_namespace_commit()))
|
||||
}
|
||||
|
||||
async fn rename_local_data_with_ctx(
|
||||
ctx: &Arc<InstanceContext>,
|
||||
disk_ref: &str,
|
||||
source: (&str, &str),
|
||||
fi: &FileInfo,
|
||||
destination: (&str, &str),
|
||||
mut guards: RenameDataGuards,
|
||||
) -> DiskResult<RenameDataResp> {
|
||||
let (disk, disk_id) = local_disk_candidate(ctx, disk_ref).await?;
|
||||
let owner = admit_local_disk(ctx, &disk, disk_id, destination.0).await?;
|
||||
guards.namespace_owner = owner.as_ref().map(|owner| owner.clone() as Arc<dyn Send + Sync>);
|
||||
let result = disk
|
||||
.rename_data_borrowed_with_fence_observed(source.0, source.1, fi, destination.0, destination.1, guards)
|
||||
.await
|
||||
.result;
|
||||
drop(owner);
|
||||
result
|
||||
}
|
||||
|
||||
async fn undo_local_write_with_ctx(
|
||||
ctx: &Arc<InstanceContext>,
|
||||
disk_ref: &str,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
fi: FileInfo,
|
||||
opts: DeleteOptions,
|
||||
) -> DiskResult<()> {
|
||||
if !opts.undo_write {
|
||||
return Err(DiskError::other("target undo requires undo_write"));
|
||||
}
|
||||
let (disk, disk_id) = local_disk_candidate(ctx, disk_ref).await?;
|
||||
let owner = admit_local_disk(ctx, &disk, disk_id, volume).await?;
|
||||
let physical_owner = owner.as_ref().map(|owner| owner.clone() as Arc<dyn Send + Sync>);
|
||||
let result = disk
|
||||
.undo_write_with_namespace_owner(volume, path, fi, opts, physical_owner)
|
||||
.await;
|
||||
drop(owner);
|
||||
result
|
||||
}
|
||||
|
||||
async fn remember_local_disk_id(disk: &DiskStore) -> Option<Uuid> {
|
||||
remember_local_disk_id_with_instance_ctx(&crate::runtime::global::current_ctx(), disk).await
|
||||
}
|
||||
@@ -465,522 +265,6 @@ mod tests {
|
||||
}])
|
||||
}
|
||||
|
||||
async fn target_disk(ctx: &Arc<InstanceContext>, root: &std::path::Path, id: Uuid) -> DiskStore {
|
||||
let mut format = crate::layout::format::FormatV3::new(1, 1);
|
||||
format.erasure.this = id;
|
||||
format.erasure.sets[0][0] = id;
|
||||
let meta = root.join(crate::disk::RUSTFS_META_BUCKET);
|
||||
tokio::fs::create_dir_all(&meta).await.expect("create format volume");
|
||||
tokio::fs::write(
|
||||
meta.join(crate::disk::FORMAT_CONFIG_FILE),
|
||||
serde_json::to_vec(&format).expect("encode format"),
|
||||
)
|
||||
.await
|
||||
.expect("write real disk identity");
|
||||
let mut endpoint = Endpoint::try_from(root.to_str().expect("UTF-8 root")).expect("endpoint");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(0);
|
||||
let disk = new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("open real local disk");
|
||||
assert_eq!(disk.get_disk_id().await.expect("read disk format identity"), Some(id));
|
||||
ctx.local_disk_map()
|
||||
.write()
|
||||
.await
|
||||
.insert(disk.endpoint().to_string(), Some(disk.clone()));
|
||||
disk
|
||||
}
|
||||
|
||||
fn target_file_info(object: &str, version: Uuid, body: &'static [u8]) -> FileInfo {
|
||||
let mut fi = FileInfo::new(object, 1, 0);
|
||||
fi.erasure.index = 1;
|
||||
fi.version_id = Some(version);
|
||||
fi.mod_time = Some(OffsetDateTime::now_utc());
|
||||
fi.size = i64::try_from(body.len()).expect("fixture length");
|
||||
fi.parts = vec![rustfs_filemeta::ObjectPartInfo {
|
||||
number: 1,
|
||||
size: body.len(),
|
||||
actual_size: fi.size,
|
||||
..Default::default()
|
||||
}];
|
||||
fi.data = Some(bytes::Bytes::from_static(body));
|
||||
fi.set_inline_data();
|
||||
fi
|
||||
}
|
||||
|
||||
async fn seed_target(disk: &DiskStore, volume: &str, object: &str, fi: FileInfo) -> Vec<u8> {
|
||||
let dir = disk.path().join(volume);
|
||||
tokio::fs::create_dir_all(&dir).await.expect("real fixture volume");
|
||||
disk.write_metadata(volume, volume, object, fi.clone())
|
||||
.await
|
||||
.expect("seed real metadata");
|
||||
let read = disk
|
||||
.read_version(
|
||||
volume,
|
||||
volume,
|
||||
object,
|
||||
&fi.version_id.expect("fixture version").to_string(),
|
||||
&crate::disk::ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read fixture before mutation");
|
||||
assert_eq!(read.data, fi.data, "fixture must contain readable inline bytes");
|
||||
tokio::fs::read(dir.join(object).join(crate::disk::STORAGE_FORMAT_FILE))
|
||||
.await
|
||||
.expect("seeded metadata bytes")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_uuid_lookup_binds_real_disk_and_owner_to_one_instance() {
|
||||
for warm in [false, true] {
|
||||
let ctx_a = Arc::new(InstanceContext::new());
|
||||
let ctx_b = Arc::new(InstanceContext::new());
|
||||
let a = tempfile::tempdir().expect("A root");
|
||||
let b = tempfile::tempdir().expect("B root");
|
||||
let id = Uuid::new_v4();
|
||||
let disk_a = target_disk(&ctx_a, a.path(), id).await;
|
||||
let disk_b = target_disk(&ctx_b, b.path(), id).await;
|
||||
if warm {
|
||||
assert!(record_local_disk_id_if_active(&ctx_a, &disk_a, id).await);
|
||||
assert!(record_local_disk_id_if_active(&ctx_b, &disk_b, id).await);
|
||||
}
|
||||
let version = Uuid::new_v4();
|
||||
let fi = target_file_info("destination", version, b"new-A");
|
||||
for disk in [&disk_a, &disk_b] {
|
||||
seed_target(disk, "target-bucket", "staged", fi.clone()).await;
|
||||
}
|
||||
let b_before = seed_target(
|
||||
&disk_b,
|
||||
"target-bucket",
|
||||
"destination",
|
||||
target_file_info("destination", version, b"old-B"),
|
||||
)
|
||||
.await;
|
||||
let store = super::super::tests::build_store_with_ctx(ctx_a.clone());
|
||||
store
|
||||
.rename_local_data(&id.to_string(), ("target-bucket", "staged"), &fi, ("target-bucket", "destination"), None)
|
||||
.await
|
||||
.expect("rename on A");
|
||||
let read = disk_a
|
||||
.read_version(
|
||||
"target-bucket",
|
||||
"target-bucket",
|
||||
"destination",
|
||||
&version.to_string(),
|
||||
&crate::disk::ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read committed A");
|
||||
assert_eq!(read.data, fi.data, "warm={warm}");
|
||||
assert_eq!(
|
||||
tokio::fs::read(b.path().join("target-bucket/destination/xl.meta"))
|
||||
.await
|
||||
.expect("B metadata"),
|
||||
b_before
|
||||
);
|
||||
assert!(b.path().join("target-bucket/staged/xl.meta").exists());
|
||||
assert!(ctx_a.namespace_commit_generation() > 0);
|
||||
assert_eq!(ctx_b.namespace_commit_generation(), 0);
|
||||
assert!(!ctx_a.namespace_commits_pending());
|
||||
assert!(!ctx_b.namespace_commits_pending());
|
||||
assert_eq!(ctx_a.local_disk_id_map().read().await.get(&id), Some(&disk_a.endpoint().to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_admission_rejects_removed_quarantined_and_replaced_arcs() {
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
let disk = target_disk(&ctx, root.path(), Uuid::new_v4()).await;
|
||||
let endpoint = disk.endpoint().to_string();
|
||||
for state in ["removed", "quarantined", "replaced"] {
|
||||
let replacement = new_disk(
|
||||
&disk.endpoint(),
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("separate active Arc");
|
||||
let map = ctx.local_disk_map();
|
||||
let mut entries = map.write().await;
|
||||
match state {
|
||||
"removed" => {
|
||||
entries.remove(&endpoint);
|
||||
}
|
||||
"quarantined" => {
|
||||
entries.insert(endpoint.clone(), None);
|
||||
}
|
||||
_ => {
|
||||
entries.insert(endpoint.clone(), Some(replacement));
|
||||
}
|
||||
}
|
||||
drop(entries);
|
||||
assert!(
|
||||
matches!(admit_local_disk(&ctx, &disk, None, "target-bucket").await, Err(DiskError::DiskNotFound)),
|
||||
"{state}"
|
||||
);
|
||||
assert!(!ctx.namespace_commits_pending());
|
||||
assert_eq!(ctx.namespace_commit_generation(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_uuid_cache_cannot_admit_a_different_format_at_the_same_path() {
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
let old_id = Uuid::new_v4();
|
||||
let old = target_disk(&ctx, root.path(), old_id).await;
|
||||
assert!(record_local_disk_id_if_active(&ctx, &old, old_id).await);
|
||||
let replacement_id = Uuid::new_v4();
|
||||
let replacement = target_disk(&ctx, root.path(), replacement_id).await;
|
||||
assert!(!Arc::ptr_eq(&old, &replacement));
|
||||
assert!(matches!(
|
||||
local_disk_candidate(&ctx, &old_id.to_string()).await,
|
||||
Err(DiskError::DiskNotFound)
|
||||
));
|
||||
let (candidate, verified) = local_disk_candidate(&ctx, &replacement_id.to_string())
|
||||
.await
|
||||
.expect("replacement UUID");
|
||||
assert!(Arc::ptr_eq(&candidate, &replacement));
|
||||
assert_eq!(verified, Some(replacement_id));
|
||||
assert!(!ctx.namespace_commits_pending());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bootstrap_rejects_user_volumes_aliases_and_scanner_tokens_without_mutation() {
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
let disk = target_disk(&ctx, root.path(), Uuid::new_v4()).await;
|
||||
let target = BootstrapLocalTarget::new(ctx.clone());
|
||||
let fi = target_file_info("destination", Uuid::new_v4(), b"body");
|
||||
let user_before = seed_target(&disk, "victim", "staged", fi.clone()).await;
|
||||
let meta_before = seed_target(&disk, ".rustfs.sys/tmp", "staged", fi.clone()).await;
|
||||
for invalid in [
|
||||
"victim",
|
||||
".rustfs.sys/../victim",
|
||||
".rustfs.sys/./tmp",
|
||||
".rustfs.sys/ .. /victim",
|
||||
".rustfs.sys\\..\\victim",
|
||||
".minio.sys/../victim",
|
||||
] {
|
||||
for (src, dst) in [(invalid, ".rustfs.sys/tmp"), (".rustfs.sys/tmp", invalid)] {
|
||||
assert!(
|
||||
target
|
||||
.rename_local_data(&disk.endpoint().to_string(), (src, "staged"), &fi, (dst, "destination"), None)
|
||||
.await
|
||||
.is_err(),
|
||||
"src={src}, dst={dst}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
target
|
||||
.undo_local_write(
|
||||
&disk.endpoint().to_string(),
|
||||
invalid,
|
||||
"staged",
|
||||
fi.clone(),
|
||||
DeleteOptions {
|
||||
undo_write: true,
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"{invalid}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
target
|
||||
.rename_local_data(
|
||||
&disk.endpoint().to_string(),
|
||||
(".rustfs.sys/tmp", "staged"),
|
||||
&fi,
|
||||
(".rustfs.sys/tmp", "destination"),
|
||||
Some(Uuid::new_v4())
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(root.path().join("victim/staged/xl.meta"))
|
||||
.await
|
||||
.expect("user source"),
|
||||
user_before
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(root.path().join(".rustfs.sys/tmp/staged/xl.meta"))
|
||||
.await
|
||||
.expect("metadata source"),
|
||||
meta_before
|
||||
);
|
||||
assert!(!root.path().join("victim/destination").exists());
|
||||
assert!(!root.path().join(".rustfs.sys/tmp/destination").exists());
|
||||
assert_eq!(ctx.namespace_commit_generation(), 0);
|
||||
assert!(!ctx.namespace_commits_pending());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bootstrap_allows_internal_multisegment_rename_without_namespace_owner() {
|
||||
for volume in [".rustfs.sys/tmp", ".rustfs.sys/multipart", ".minio.sys/config"] {
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
let disk = target_disk(&ctx, root.path(), Uuid::new_v4()).await;
|
||||
let fi = target_file_info("destination", Uuid::new_v4(), b"internal-CAS-body");
|
||||
seed_target(&disk, volume, "staged", fi.clone()).await;
|
||||
BootstrapLocalTarget::new(ctx.clone())
|
||||
.rename_local_data(&disk.endpoint().to_string(), (volume, "staged"), &fi, (volume, "destination"), None)
|
||||
.await
|
||||
.expect("legitimate bootstrap metadata write");
|
||||
let read = disk
|
||||
.read_version(
|
||||
volume,
|
||||
volume,
|
||||
"destination",
|
||||
&fi.version_id.expect("version").to_string(),
|
||||
&crate::disk::ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read bootstrap result");
|
||||
assert_eq!(read.data, fi.data);
|
||||
assert_eq!(ctx.namespace_commit_generation(), 0);
|
||||
assert!(!ctx.namespace_commits_pending());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn target_rename_cancellation_retains_real_namespace_and_scanner_owners() {
|
||||
use crate::disk::os::prepared_publication_test_hooks as hooks;
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let sibling = Arc::new(InstanceContext::new());
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
let disk = target_disk(&ctx, root.path(), Uuid::new_v4()).await;
|
||||
let store = super::super::tests::build_store_with_ctx(ctx.clone());
|
||||
let fi = target_file_info("destination", Uuid::new_v4(), b"physically-owned");
|
||||
seed_target(&disk, "target-bucket", "staged", fi.clone()).await;
|
||||
let (token, _) = store
|
||||
.acquire_scanner_publication_lease(0, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
|
||||
.await
|
||||
.expect("real scanner token in A");
|
||||
let destination = disk
|
||||
.get_object_path_for_io_if_local("target-bucket", "destination/xl.meta")
|
||||
.expect("local disk")
|
||||
.expect("destination IO path");
|
||||
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
||||
let _hook = hooks::install(&destination, move || {
|
||||
let _ = entered_tx.send(());
|
||||
let _ = release_rx.recv();
|
||||
});
|
||||
let disk_ref = disk.endpoint().to_string();
|
||||
let mut rename = Box::pin(store.rename_local_data(
|
||||
&disk_ref,
|
||||
("target-bucket", "staged"),
|
||||
&fi,
|
||||
("target-bucket", "destination"),
|
||||
Some(token),
|
||||
));
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
tokio::select! {
|
||||
result = &mut rename => panic!("rename completed before physical pause: {result:?}"),
|
||||
entered = entered_rx => entered.expect("physical rename entered"),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("bounded physical entry");
|
||||
drop(rename);
|
||||
assert!(store.scanner_data_usage_publication_blocked().await);
|
||||
assert!(ctx.namespace_commits_pending());
|
||||
assert!(!sibling.namespace_commits_pending());
|
||||
assert!(
|
||||
store
|
||||
.rename_local_data(&disk_ref, ("target-bucket", "staged"), &fi, ("target-bucket", "another"), Some(token))
|
||||
.await
|
||||
.is_err(),
|
||||
"real pending rename blocks another scanner publication"
|
||||
);
|
||||
assert!(store.release_scanner_publication_lease(token).await, "remove registered token");
|
||||
let gate = ctx.data_movement_operation_gate();
|
||||
assert!(
|
||||
gate.clone().try_write_owned().is_err(),
|
||||
"physical operation still owns the scanner read guard"
|
||||
);
|
||||
drop(release_tx);
|
||||
let _drained = tokio::time::timeout(std::time::Duration::from_secs(10), gate.write_owned())
|
||||
.await
|
||||
.expect("physical tail must release scanner guard");
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
while ctx.namespace_commits_pending() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("namespace owner drains");
|
||||
let read = disk
|
||||
.read_version(
|
||||
"target-bucket",
|
||||
"target-bucket",
|
||||
"destination",
|
||||
&fi.version_id.expect("version").to_string(),
|
||||
&crate::disk::ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read actual late commit");
|
||||
assert_eq!(read.data, fi.data);
|
||||
assert!(ctx.namespace_commit_generation() >= 2);
|
||||
assert_eq!(sibling.namespace_commit_generation(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_ready_rejects_unknown_foreign_released_and_expired_scanner_tokens() {
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let other = Arc::new(InstanceContext::new());
|
||||
let store = super::super::tests::build_store_with_ctx(ctx.clone());
|
||||
let other_store = super::super::tests::build_store_with_ctx(other);
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
let disk = target_disk(&ctx, root.path(), Uuid::new_v4()).await;
|
||||
let fi = target_file_info("destination", Uuid::new_v4(), b"unchanged");
|
||||
let before = seed_target(&disk, "target-bucket", "staged", fi.clone()).await;
|
||||
let ttl = crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL;
|
||||
let (foreign, _) = other_store.acquire_scanner_publication_lease(0, ttl).await.expect("B token");
|
||||
let (released, _) = store.acquire_scanner_publication_lease(0, ttl).await.expect("A token");
|
||||
assert!(store.release_scanner_publication_lease(released).await);
|
||||
let (valid, _) = store.acquire_scanner_publication_lease(0, ttl).await.expect("new A token");
|
||||
for token in [Uuid::new_v4(), foreign, released] {
|
||||
assert!(
|
||||
store
|
||||
.rename_local_data(
|
||||
&disk.endpoint().to_string(),
|
||||
("target-bucket", "staged"),
|
||||
&fi,
|
||||
("target-bucket", "destination"),
|
||||
Some(token)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(ttl + std::time::Duration::from_secs(1)).await;
|
||||
tokio::time::resume();
|
||||
assert!(
|
||||
store
|
||||
.rename_local_data(
|
||||
&disk.endpoint().to_string(),
|
||||
("target-bucket", "staged"),
|
||||
&fi,
|
||||
("target-bucket", "destination"),
|
||||
Some(valid)
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"expired real token"
|
||||
);
|
||||
let _ = other_store.release_scanner_publication_lease(foreign).await;
|
||||
assert_eq!(
|
||||
tokio::fs::read(root.path().join("target-bucket/staged/xl.meta"))
|
||||
.await
|
||||
.expect("source bytes"),
|
||||
before
|
||||
);
|
||||
assert!(!root.path().join("target-bucket/destination").exists());
|
||||
assert!(!ctx.namespace_commits_pending());
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn target_ordinary_timeout_keeps_its_physical_namespace_owner() {
|
||||
use crate::disk::os::prepared_publication_test_hooks as hooks;
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, Some("1"))], async {
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let store = super::super::tests::build_store_with_ctx(ctx.clone());
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
let disk = target_disk(&ctx, root.path(), Uuid::new_v4()).await;
|
||||
let fi = target_file_info("destination", Uuid::new_v4(), b"timed-out-physical-commit");
|
||||
seed_target(&disk, "target-bucket", "staged", fi.clone()).await;
|
||||
let path = disk
|
||||
.get_object_path_for_io_if_local("target-bucket", "destination/xl.meta")
|
||||
.expect("local")
|
||||
.expect("destination IO path");
|
||||
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
||||
let _hook = hooks::install(&path, move || {
|
||||
let _ = entered_tx.send(());
|
||||
let _ = release_rx.recv();
|
||||
});
|
||||
let disk_ref = disk.endpoint().to_string();
|
||||
let mut rename = Box::pin(store.rename_local_data(
|
||||
&disk_ref,
|
||||
("target-bucket", "staged"),
|
||||
&fi,
|
||||
("target-bucket", "destination"),
|
||||
None,
|
||||
));
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
tokio::select! {
|
||||
result = &mut rename => panic!("completed before physical pause: {result:?}"),
|
||||
entered = entered_rx => entered.expect("physical entry"),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("bounded entry");
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(std::time::Duration::from_secs(2)).await;
|
||||
tokio::time::resume();
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(5), &mut rename)
|
||||
.await
|
||||
.expect("ordinary deadline remains enabled");
|
||||
assert!(matches!(result, Err(DiskError::Timeout)), "{result:?}");
|
||||
drop(rename);
|
||||
assert!(ctx.namespace_commits_pending(), "timeout is not a physical drain");
|
||||
drop(release_tx);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
while ctx.namespace_commits_pending() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("late physical owner drains");
|
||||
let read = disk
|
||||
.read_version(
|
||||
"target-bucket",
|
||||
"target-bucket",
|
||||
"destination",
|
||||
&fi.version_id.expect("version").to_string(),
|
||||
&crate::disk::ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read actual timeout tail");
|
||||
assert_eq!(read.data, fi.data);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_rpc_authority_preserves_port_and_ipv6_brackets() {
|
||||
let endpoint = Endpoint::try_from("https://127.0.0.1:9001/d1").expect("URL endpoint");
|
||||
|
||||
+155
-19
@@ -76,6 +76,8 @@ struct HealTaskStatusPayload<'a> {
|
||||
min_seq: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
progress: Option<&'a HealProgress>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
outcome: Option<&'a super::outcome::HealTaskOutcome>,
|
||||
}
|
||||
|
||||
fn u64_is_zero(value: &u64) -> bool {
|
||||
@@ -87,17 +89,18 @@ fn encode_heal_task_status_payload(
|
||||
mut items: Vec<HealResultItem>,
|
||||
progress: Option<&HealProgress>,
|
||||
mut truncated: bool,
|
||||
next_seq: u64,
|
||||
min_seq: u64,
|
||||
sequence: (u64, u64),
|
||||
outcome: Option<&super::outcome::HealTaskOutcome>,
|
||||
) -> Result<(Vec<u8>, bool)> {
|
||||
loop {
|
||||
let data = serde_json::to_vec(&HealTaskStatusPayload {
|
||||
summary,
|
||||
items: &items,
|
||||
truncated,
|
||||
next_seq,
|
||||
min_seq,
|
||||
next_seq: sequence.0,
|
||||
min_seq: sequence.1,
|
||||
progress,
|
||||
outcome,
|
||||
})
|
||||
.map_err(|e| Error::Serialization(format!("failed to serialize heal task status: {e}")))?;
|
||||
if data.len() <= MAX_HEAL_STATUS_PAYLOAD_SIZE {
|
||||
@@ -111,25 +114,21 @@ fn encode_heal_task_status_payload(
|
||||
}
|
||||
}
|
||||
|
||||
fn heal_status_detail(detail: Option<String>, truncated: bool) -> Option<String> {
|
||||
if !truncated {
|
||||
return detail;
|
||||
}
|
||||
let truncation = "heal result items were truncated";
|
||||
Some(detail.map_or_else(|| truncation.to_string(), |detail| format!("{detail}; {truncation}")))
|
||||
}
|
||||
|
||||
fn encode_heal_status_response(
|
||||
summary: &str,
|
||||
items: Vec<HealResultItem>,
|
||||
progress: Option<&HealProgress>,
|
||||
detail: Option<String>,
|
||||
truncated: bool,
|
||||
next_seq: u64,
|
||||
min_seq: u64,
|
||||
sequence: (u64, u64),
|
||||
outcome: Option<&super::outcome::HealTaskOutcome>,
|
||||
) -> Result<(Vec<u8>, Option<String>)> {
|
||||
let (data, truncated) = encode_heal_task_status_payload(summary, items, progress, truncated, next_seq, min_seq)?;
|
||||
Ok((data, heal_status_detail(detail, truncated)))
|
||||
let (summary, detail) = match outcome {
|
||||
Some(outcome) => outcome.legacy_status(summary, detail),
|
||||
None => (summary, detail),
|
||||
};
|
||||
let (data, truncated) = encode_heal_task_status_payload(summary, items, progress, truncated, sequence, outcome)?;
|
||||
Ok((data, super::outcome::heal_status_detail(detail, truncated)))
|
||||
}
|
||||
|
||||
impl HealChannelProcessor {
|
||||
@@ -439,6 +438,7 @@ impl HealChannelProcessor {
|
||||
.await
|
||||
};
|
||||
|
||||
let outcome = report.as_ref().ok().and_then(|report| report.outcome.clone());
|
||||
let (summary, detail, items, truncated, progress, next_seq, min_seq) = match report {
|
||||
Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Pending | HealTaskStatus::Running,
|
||||
@@ -576,8 +576,15 @@ impl HealChannelProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
let (data, detail) =
|
||||
encode_heal_status_response(&summary, items, progress.as_ref(), detail, truncated, next_seq, min_seq)?;
|
||||
let (data, detail) = encode_heal_status_response(
|
||||
&summary,
|
||||
items,
|
||||
progress.as_ref(),
|
||||
detail,
|
||||
truncated,
|
||||
(next_seq, min_seq),
|
||||
outcome.as_deref(),
|
||||
)?;
|
||||
|
||||
let response = HealChannelResponse {
|
||||
request_id: client_token,
|
||||
@@ -866,7 +873,7 @@ mod tests {
|
||||
..Default::default()
|
||||
}];
|
||||
|
||||
let (data, detail) = encode_heal_status_response("running", items, None, None, false, 0, 0).unwrap();
|
||||
let (data, detail) = encode_heal_status_response("running", items, None, None, false, (0, 0), None).unwrap();
|
||||
|
||||
assert!(data.len() <= MAX_HEAL_STATUS_PAYLOAD_SIZE);
|
||||
let payload: serde_json::Value = serde_json::from_slice(&data).unwrap();
|
||||
@@ -875,6 +882,135 @@ mod tests {
|
||||
assert_eq!(detail.as_deref(), Some("heal result items were truncated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_v3_fixture_matches_canonical_owner_and_preserves_legacy_terminals() {
|
||||
use crate::heal::outcome::*;
|
||||
let cases: serde_json::Value = serde_json::from_str(include_str!("../../../madmin/tests/fixtures/heal-outcome-v3.json"))
|
||||
.expect("shared client fixtures");
|
||||
for case in cases.as_array().expect("fixture cases") {
|
||||
if case.get("remoteResponse").is_some() {
|
||||
continue;
|
||||
}
|
||||
let mut outcome = HealTaskOutcome::default();
|
||||
let name = case["name"].as_str().expect("case name");
|
||||
if matches!(name, "unknown" | "completed_with_errors") {
|
||||
let disposition = if name == "unknown" {
|
||||
HealObjectDisposition::Unknown
|
||||
} else {
|
||||
outcome.attempt_failed();
|
||||
HealObjectDisposition::Failed(HealFailureClass::RetryExhausted)
|
||||
};
|
||||
outcome.record(HealObjectOutcome {
|
||||
identity: HealObjectIdentity {
|
||||
kind: HealObjectKind::Object,
|
||||
bucket: "bucket".into(),
|
||||
object: "object".into(),
|
||||
version_id: None,
|
||||
bucket_incarnation_id: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
},
|
||||
disposition,
|
||||
detail: None,
|
||||
});
|
||||
}
|
||||
let abort = match name {
|
||||
"cancelled" => Some(HealAbortReason::Cancelled),
|
||||
"deadline" => Some(HealAbortReason::Deadline),
|
||||
"untraversable" => Some(HealAbortReason::Untraversable),
|
||||
_ => None,
|
||||
};
|
||||
outcome.finish(abort);
|
||||
let expected = &case["response"];
|
||||
let initial_detail = abort.map(|reason| {
|
||||
match reason {
|
||||
HealAbortReason::Cancelled => "heal task cancelled",
|
||||
HealAbortReason::Deadline => "heal task timed out",
|
||||
HealAbortReason::Untraversable => "heal listing is untraversable",
|
||||
}
|
||||
.to_string()
|
||||
});
|
||||
let (bytes, detail) = encode_heal_status_response(
|
||||
if abort.is_some() { "stopped" } else { "finished" },
|
||||
Vec::new(),
|
||||
None,
|
||||
initial_detail,
|
||||
true,
|
||||
(9, 4),
|
||||
Some(&outcome),
|
||||
)
|
||||
.expect("canonical owner encoding");
|
||||
let decoded: serde_json::Value = serde_json::from_slice(&bytes).expect("wire payload");
|
||||
assert_eq!(decoded["summary"], expected["summary"], "{name}");
|
||||
assert_eq!(detail.unwrap_or_default(), expected["detail"].as_str().expect("detail"), "{name}");
|
||||
assert_eq!(decoded["outcome"], expected["outcome"], "{name}");
|
||||
assert_eq!((decoded["next_seq"].as_u64(), decoded["min_seq"].as_u64()), (Some(9), Some(4)));
|
||||
assert!(decoded["outcome"].get("retainedObjectBytes").is_none());
|
||||
assert!(decoded["outcome"].get("untraversable").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_v3_abort_cannot_be_hidden_by_a_finished_status() {
|
||||
use crate::heal::outcome::{HealAbortReason, HealTaskOutcome};
|
||||
for reason in [
|
||||
HealAbortReason::Cancelled,
|
||||
HealAbortReason::Deadline,
|
||||
HealAbortReason::Untraversable,
|
||||
] {
|
||||
let mut outcome = HealTaskOutcome::default();
|
||||
outcome.finish(Some(reason));
|
||||
let (data, detail) = encode_heal_status_response("finished", Vec::new(), None, None, false, (0, 0), Some(&outcome))
|
||||
.expect("canonical abort adapter");
|
||||
let json: serde_json::Value = serde_json::from_slice(&data).expect("public state");
|
||||
assert_eq!(json["summary"], "stopped");
|
||||
assert_eq!(json["outcome"]["execution"]["state"], "aborted");
|
||||
assert!(detail.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_v3_payload_bound_keeps_cumulative_outcome_and_cursors() {
|
||||
use crate::heal::outcome::*;
|
||||
let mut outcome = HealTaskOutcome::default();
|
||||
outcome.start();
|
||||
for index in 0..256 {
|
||||
outcome.record(HealObjectOutcome {
|
||||
identity: HealObjectIdentity {
|
||||
kind: HealObjectKind::Object,
|
||||
bucket: "bucket".into(),
|
||||
object: format!("object-{index}"),
|
||||
version_id: None,
|
||||
bucket_incarnation_id: None,
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
},
|
||||
disposition: HealObjectDisposition::Unknown,
|
||||
detail: Some("\"".repeat(1024)),
|
||||
});
|
||||
}
|
||||
let retained = outcome.objects.len();
|
||||
let items = vec![
|
||||
HealResultItem::default(),
|
||||
HealResultItem {
|
||||
detail: "x".repeat(MAX_HEAL_STATUS_PAYLOAD_SIZE + 1),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let (bytes, detail) = encode_heal_status_response("running", items, None, None, false, (9, 4), Some(&outcome))
|
||||
.expect("bounded status with cumulative outcome");
|
||||
assert!(bytes.len() <= MAX_HEAL_STATUS_PAYLOAD_SIZE);
|
||||
let wire: serde_json::Value = serde_json::from_slice(&bytes).expect("bounded payload");
|
||||
assert_eq!(wire["items"].as_array().expect("items").len(), 1);
|
||||
assert_eq!(wire["truncated"], true);
|
||||
assert_eq!((wire["next_seq"].as_u64(), wire["min_seq"].as_u64()), (Some(9), Some(4)));
|
||||
assert_eq!(wire["outcome"]["counters"]["processed"], 256);
|
||||
assert_eq!(wire["outcome"]["counters"]["healed"], 0);
|
||||
assert_eq!(wire["outcome"]["objects"].as_array().expect("outcome window").len(), retained);
|
||||
assert!(retained < 256 && outcome.objects_truncated);
|
||||
assert_eq!(detail.as_deref(), Some("heal result items were truncated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_response_preserves_all_admission_outcomes() {
|
||||
let cases = [
|
||||
|
||||
@@ -313,7 +313,6 @@ impl HealManager {
|
||||
completed_status_entry.outcome = Some(Arc::new(task.get_outcome().await));
|
||||
}
|
||||
let terminal_completion = !matches!(completed_status, HealTaskStatus::Retrying { .. });
|
||||
let successful_completion = matches!(completed_status, HealTaskStatus::Completed);
|
||||
// Keep retry ownership continuous: status snapshots acquire
|
||||
// these locks in the same active -> retrying order.
|
||||
let mut retrying_heals_guard = if let (Some((request, _, error)), Some(cancel_token)) =
|
||||
@@ -375,11 +374,11 @@ impl HealManager {
|
||||
drop(stats);
|
||||
if terminal_completion {
|
||||
let notice_targets = take_mrf_repair_notice_targets(&mrf_repair_notice_targets_clone, &task_id);
|
||||
if successful_completion {
|
||||
emit_mrf_repaired_events(notice_targets);
|
||||
} else {
|
||||
release_mrf_repair_notice_targets(notice_targets);
|
||||
}
|
||||
// Neither task status nor the diagnostic outcome
|
||||
// window supplies a storage-owned repair receipt.
|
||||
// Release only the ingress lease for rediscovery;
|
||||
// preserve the producer's existing retry hints.
|
||||
release_mrf_repair_notice_targets(notice_targets);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,20 +705,6 @@ fn move_mrf_repair_notice_targets(
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_mrf_repaired_events(targets: Vec<MrfRepairNoticeTarget>) {
|
||||
for target in targets {
|
||||
rustfs_common::mrf_channel::note_mrf_repaired(&target.bucket, &target.object, target.version_id);
|
||||
rustfs_common::mrf_channel::release_mrf_identity(
|
||||
target.kind,
|
||||
&target.bucket,
|
||||
&target.object,
|
||||
target.version_id,
|
||||
target.scope,
|
||||
target.lease,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn release_mrf_repair_notice_targets(targets: Vec<MrfRepairNoticeTarget>) {
|
||||
for target in targets {
|
||||
rustfs_common::mrf_channel::release_mrf_identity(
|
||||
|
||||
@@ -3096,7 +3096,7 @@ async fn test_cancel_task_removes_queued_request() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mrf_repaired_notice_waits_for_successful_completion() {
|
||||
async fn mrf_ownership_unverified_completion_does_not_emit_repaired() {
|
||||
let bucket = "mrf-completion-success";
|
||||
let object = "object";
|
||||
let version_id = Some([9u8; 16]);
|
||||
@@ -3126,21 +3126,81 @@ async fn test_mrf_repaired_notice_waits_for_successful_completion() {
|
||||
);
|
||||
|
||||
process_manager_queue_once(&manager).await;
|
||||
for _ in 0..100 {
|
||||
let events = rustfs_common::mrf_channel::take_mrf_repaired_events_for(bucket);
|
||||
if !events.is_empty() {
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].object.as_ref(), object);
|
||||
assert_eq!(events[0].version_id, version_id);
|
||||
return;
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let stats = manager.get_statistics().await;
|
||||
if stats.successful_tasks + stats.failed_tasks > 0 {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("successful MRF-owned heal should emit one repaired event");
|
||||
})
|
||||
.await
|
||||
.expect("scheduler completes the task");
|
||||
assert!(rustfs_common::mrf_channel::take_mrf_repaired_events_for(bucket).is_empty());
|
||||
assert!(!lock_mrf_repair_notice_targets(&manager.mrf_repair_notice_targets).contains_key(&receipt.task_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mrf_repaired_notice_removed_on_queued_cancel_without_event() {
|
||||
async fn mrf_ownership_dry_run_and_empty_window_do_not_emit_repaired() {
|
||||
for empty_window in [false, true] {
|
||||
let bucket = if empty_window {
|
||||
"mrf-empty-outcome"
|
||||
} else {
|
||||
"mrf-dry-run-outcome"
|
||||
};
|
||||
let manager = HealManager::new(Arc::new(MockStorage), None);
|
||||
let request = HealRequest::new(
|
||||
if empty_window {
|
||||
HealType::Cluster
|
||||
} else {
|
||||
HealType::Object {
|
||||
bucket: bucket.to_string(),
|
||||
object: "object".to_string(),
|
||||
version_id: None,
|
||||
}
|
||||
},
|
||||
HealOptions {
|
||||
recursive: true,
|
||||
dry_run: !empty_window,
|
||||
recreate_missing: true,
|
||||
..Default::default()
|
||||
},
|
||||
HealPriority::Normal,
|
||||
);
|
||||
let receipt = manager
|
||||
.submit_mrf_heal_request_with_receipt(request, Arc::from(bucket), Arc::from("object"), None)
|
||||
.await
|
||||
.expect("notice target registered");
|
||||
process_manager_queue_once(&manager).await;
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let stats = manager.get_statistics().await;
|
||||
if stats.successful_tasks + stats.failed_tasks > 0 {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("scheduler completed");
|
||||
let report = manager.get_task_report(&receipt.task_id).await.expect("completed report");
|
||||
assert_eq!(report.status, HealTaskStatus::Completed);
|
||||
let outcome = report.outcome.expect("canonical outcome");
|
||||
if empty_window {
|
||||
assert!(outcome.objects.is_empty());
|
||||
} else {
|
||||
assert_eq!(
|
||||
outcome.objects[0].disposition,
|
||||
crate::heal::outcome::HealObjectDisposition::DryRunObserved
|
||||
);
|
||||
}
|
||||
assert!(rustfs_common::mrf_channel::take_mrf_repaired_events_for(bucket).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mrf_ownership_queued_cancel_does_not_emit_repaired() {
|
||||
let bucket = "mrf-completion-cancel";
|
||||
let object = "object";
|
||||
let _ = rustfs_common::mrf_channel::take_mrf_repaired_events_for(bucket);
|
||||
|
||||
@@ -25,12 +25,13 @@
|
||||
//! set, rewritten on a group-commit cadence (every flush interval or flush
|
||||
//! threshold new intents). A rewrite is atomic at the record level only — a
|
||||
//! torn tail simply truncates during replay because every record carries its
|
||||
//! own CRC32. Losing the last flush window (≤500 ms) is acceptable because
|
||||
//! every producer keeps its own safety net: read-repair re-detects on the
|
||||
//! next failing read, and the scanner's corrupt-metadata branch leaves a
|
||||
//! pending-ledger entry behind even when its MRF intent is accepted
|
||||
//! (backlog#1894 axis A), so a lost intent is retried by the ledger rather
|
||||
//! than waiting for the failed-object TTL to re-scan the path.
|
||||
//! own CRC32. Neither ingress nor manager admission is a durable ownership
|
||||
//! receipt. The last flush window can be lost. Read-repair can rediscover a
|
||||
//! failed read; the scanner retains bounded, expiring retry hints. Partial
|
||||
//! writes also use a best-effort in-memory fast path, not a durable successor.
|
||||
//! These mechanisms must not be reported as verified repair completion.
|
||||
//! The partial-write caller's restart-survival requirement remains unmet by
|
||||
//! admission alone; a verified durable handoff is still required.
|
||||
|
||||
use super::{DiskStore, HealDiskExt as _, local_disk_map_read};
|
||||
use crate::heal::manager::{HealManager, MrfRepairNoticeTarget};
|
||||
@@ -585,8 +586,8 @@ impl MrfRuntime {
|
||||
self.dirty = true;
|
||||
match submit_mrf_heal_request(manager, &intent).await {
|
||||
// Accepted intents leave the pending set; the next flush persists the
|
||||
// smaller snapshot. The scanner ledger is cleared later, when the
|
||||
// canonical heal task reaches a successful terminal completion.
|
||||
// smaller snapshot. This is not a durable successor receipt and
|
||||
// does not discharge the producer's existing retry hints.
|
||||
Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => {}
|
||||
Ok(HealAdmissionResult::Full) | Ok(HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull)) => {
|
||||
intent.attempts = intent.attempts.saturating_add(1);
|
||||
|
||||
+179
-11
@@ -15,6 +15,7 @@
|
||||
//! Execution results are separate from repair responsibility. A legacy
|
||||
//! successful storage call supplies no authoritative repair receipt.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::VecDeque, time::SystemTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -22,14 +23,16 @@ const MAX_OUTCOME_ITEMS: usize = 128;
|
||||
const MAX_OUTCOME_BYTES: usize = 64 * 1024;
|
||||
const MAX_OUTCOME_DETAIL_BYTES: usize = 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HealObjectKind {
|
||||
Object,
|
||||
Metadata,
|
||||
Decode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HealObjectIdentity {
|
||||
pub kind: HealObjectKind,
|
||||
pub bucket: String,
|
||||
@@ -41,7 +44,8 @@ pub struct HealObjectIdentity {
|
||||
pub set_index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HealDeferredReason {
|
||||
DanglingDeleteGrace,
|
||||
TransientUsageCache,
|
||||
@@ -49,14 +53,21 @@ pub enum HealDeferredReason {
|
||||
Deadline,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HealFailureClass {
|
||||
Recoverable,
|
||||
RetryExhausted,
|
||||
Permanent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(
|
||||
tag = "state",
|
||||
content = "details",
|
||||
rename_all = "snake_case",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
pub enum HealObjectDisposition {
|
||||
/// The legacy storage response does not prove the requested check or commit.
|
||||
Unknown,
|
||||
@@ -72,7 +83,8 @@ pub enum HealObjectDisposition {
|
||||
DryRunObserved,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HealObjectOutcome {
|
||||
pub identity: HealObjectIdentity,
|
||||
pub disposition: HealObjectDisposition,
|
||||
@@ -89,7 +101,8 @@ impl HealObjectOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HealTraversalCoverage {
|
||||
#[default]
|
||||
Unknown,
|
||||
@@ -97,14 +110,16 @@ pub enum HealTraversalCoverage {
|
||||
Complete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HealAbortReason {
|
||||
Cancelled,
|
||||
Deadline,
|
||||
Untraversable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "state", content = "reason", rename_all = "snake_case")]
|
||||
pub enum HealExecutionOutcome {
|
||||
#[default]
|
||||
Pending,
|
||||
@@ -114,7 +129,8 @@ pub enum HealExecutionOutcome {
|
||||
Aborted(HealAbortReason),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HealOutcomeCounters {
|
||||
pub processed: u64,
|
||||
pub healed: u64,
|
||||
@@ -127,7 +143,8 @@ pub struct HealOutcomeCounters {
|
||||
pub overflowed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HealTaskOutcome {
|
||||
pub execution: HealExecutionOutcome,
|
||||
pub coverage: HealTraversalCoverage,
|
||||
@@ -135,11 +152,99 @@ pub struct HealTaskOutcome {
|
||||
/// A bounded diagnostic window, not a complete responsibility ledger.
|
||||
pub objects: VecDeque<HealObjectOutcome>,
|
||||
pub objects_truncated: bool,
|
||||
#[serde(skip)]
|
||||
retained_object_bytes: usize,
|
||||
#[serde(skip)]
|
||||
untraversable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum HealOutcomeWireError {
|
||||
#[error("heal outcome is missing execution or counters")]
|
||||
MissingFields,
|
||||
#[error("heal outcome has invalid or unsupported execution fields")]
|
||||
InvalidFields(#[from] serde_json::Error),
|
||||
#[error("finished heal summary contradicts its canonical outcome")]
|
||||
ContradictoryCompletion,
|
||||
}
|
||||
|
||||
/// Reconcile a peer's successful legacy summary using the canonical owner types.
|
||||
/// A running retry may legitimately retain the preceding attempt's outcome.
|
||||
pub fn legacy_wire_status<'a>(
|
||||
summary: &'a str,
|
||||
wire: &serde_json::Value,
|
||||
truncated: bool,
|
||||
) -> Result<(&'a str, Option<String>), HealOutcomeWireError> {
|
||||
if summary != "finished" {
|
||||
return Ok((summary, None));
|
||||
}
|
||||
let execution = HealExecutionOutcome::deserialize(wire.get("execution").ok_or(HealOutcomeWireError::MissingFields)?)?;
|
||||
let counters = HealOutcomeCounters::deserialize(wire.get("counters").ok_or(HealOutcomeWireError::MissingFields)?)?;
|
||||
if matches!(execution, HealExecutionOutcome::Pending | HealExecutionOutcome::Running)
|
||||
|| (execution == HealExecutionOutcome::Completed && counters.failed > 0)
|
||||
{
|
||||
return Err(HealOutcomeWireError::ContradictoryCompletion);
|
||||
}
|
||||
let (adapted, detail) = legacy_execution_status(summary, None, execution, &counters);
|
||||
Ok((
|
||||
adapted,
|
||||
if adapted != summary {
|
||||
heal_status_detail(detail, truncated)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn heal_status_detail(detail: Option<String>, truncated: bool) -> Option<String> {
|
||||
if !truncated {
|
||||
return detail;
|
||||
}
|
||||
let truncation = "heal result items were truncated";
|
||||
Some(detail.map_or_else(|| truncation.to_string(), |detail| format!("{detail}; {truncation}")))
|
||||
}
|
||||
|
||||
fn legacy_execution_status<'a>(
|
||||
summary: &'a str,
|
||||
detail: Option<String>,
|
||||
execution: HealExecutionOutcome,
|
||||
counters: &HealOutcomeCounters,
|
||||
) -> (&'a str, Option<String>) {
|
||||
if summary != "finished" {
|
||||
return (summary, detail);
|
||||
}
|
||||
match execution {
|
||||
HealExecutionOutcome::CompletedWithErrors => (
|
||||
"stopped",
|
||||
Some(format!("heal traversal completed with errors: {} failed objects", counters.failed)),
|
||||
),
|
||||
HealExecutionOutcome::Aborted(reason) => {
|
||||
let reason = match reason {
|
||||
HealAbortReason::Cancelled => "cancelled",
|
||||
HealAbortReason::Deadline => "timed out",
|
||||
HealAbortReason::Untraversable => "untraversable",
|
||||
};
|
||||
("stopped", Some(format!("heal task {reason}")))
|
||||
}
|
||||
HealExecutionOutcome::Completed if counters.unknown > 0 => (
|
||||
summary,
|
||||
Some(format!(
|
||||
"heal traversal completed; authoritative storage proof is unavailable for {} objects",
|
||||
counters.unknown
|
||||
)),
|
||||
),
|
||||
HealExecutionOutcome::Pending | HealExecutionOutcome::Running => {
|
||||
("running", Some("heal execution has not reached a terminal outcome".to_string()))
|
||||
}
|
||||
HealExecutionOutcome::Completed => (summary, detail),
|
||||
}
|
||||
}
|
||||
|
||||
impl HealTaskOutcome {
|
||||
pub(crate) fn legacy_status<'a>(&self, summary: &'a str, detail: Option<String>) -> (&'a str, Option<String>) {
|
||||
legacy_execution_status(summary, detail, self.execution, &self.counters)
|
||||
}
|
||||
|
||||
pub(crate) fn start(&mut self) {
|
||||
if self.execution != HealExecutionOutcome::Aborted(HealAbortReason::Cancelled) {
|
||||
self.execution = HealExecutionOutcome::Running;
|
||||
@@ -292,6 +397,69 @@ mod canonical_outcome_tests {
|
||||
assert!(outcome.objects.len() < MAX_OUTCOME_ITEMS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_v3_serialization_keeps_unverified_dispositions_and_window_bounds() {
|
||||
let mut outcome = HealTaskOutcome::default();
|
||||
for disposition in [
|
||||
HealObjectDisposition::Unknown,
|
||||
HealObjectDisposition::Deferred {
|
||||
reason: HealDeferredReason::DanglingDeleteGrace,
|
||||
retry_not_before: None,
|
||||
},
|
||||
HealObjectDisposition::DryRunObserved,
|
||||
] {
|
||||
outcome.record(item(disposition));
|
||||
}
|
||||
outcome.finish(None);
|
||||
let wire = serde_json::to_value(&outcome).expect("canonical wire view");
|
||||
assert_eq!(wire["execution"]["state"], "completed");
|
||||
assert_eq!(wire["counters"]["healed"], 0);
|
||||
assert_eq!(wire["counters"]["skipped"], 3);
|
||||
assert_eq!(wire["objects"][1]["disposition"]["details"]["reason"], "dangling_delete_grace");
|
||||
assert!(wire["objects"][1]["identity"]["bucketIncarnationId"].is_null());
|
||||
for _ in 0..MAX_OUTCOME_ITEMS + 1 {
|
||||
let mut result = item(HealObjectDisposition::Unknown);
|
||||
result.detail = Some("\"".repeat(MAX_OUTCOME_DETAIL_BYTES));
|
||||
outcome.record(result);
|
||||
}
|
||||
let bytes = serde_json::to_vec(&outcome).expect("bounded canonical samples");
|
||||
assert!(
|
||||
bytes.len() < 8 * MAX_OUTCOME_BYTES,
|
||||
"JSON escaping remains bounded independently of object count"
|
||||
);
|
||||
assert!(outcome.objects_truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_v3_wire_consistency_rejects_unknown_success_without_rejecting_extensions() {
|
||||
let mut outcome = HealTaskOutcome::default();
|
||||
outcome.finish(None);
|
||||
let mut wire = serde_json::to_value(&outcome).expect("canonical snapshot");
|
||||
wire["execution"]["futureField"] = serde_json::json!({"new": true});
|
||||
wire["counters"]["futureCounter"] = serde_json::json!(42);
|
||||
assert_eq!(
|
||||
legacy_wire_status("finished", &wire, false).expect("unknown extension fields"),
|
||||
("finished", None)
|
||||
);
|
||||
wire["execution"]["state"] = serde_json::json!("future_execution");
|
||||
assert!(legacy_wire_status("finished", &wire, false).is_err());
|
||||
assert_eq!(
|
||||
legacy_wire_status("running", &wire, false).expect("unknown nonterminal outcome"),
|
||||
("running", None)
|
||||
);
|
||||
wire["execution"] = serde_json::json!({"state":"completed"});
|
||||
wire["counters"]["failed"] = serde_json::json!(1);
|
||||
assert!(matches!(
|
||||
legacy_wire_status("finished", &wire, false),
|
||||
Err(HealOutcomeWireError::ContradictoryCompletion)
|
||||
));
|
||||
wire.as_object_mut().expect("object").remove("execution");
|
||||
assert!(matches!(
|
||||
legacy_wire_status("finished", &wire, false),
|
||||
Err(HealOutcomeWireError::MissingFields)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_outcome_counter_overflow_cannot_claim_complete_coverage() {
|
||||
let mut outcome = HealTaskOutcome::default();
|
||||
|
||||
@@ -14,8 +14,107 @@
|
||||
/// bucket/cluster/prefix heal: the recursive bucket-objects sweep and the erasure-set usage baseline
|
||||
use super::*;
|
||||
use crate::heal::progress::{add_bytes, increment_counter, stable_generation};
|
||||
use crate::heal::storage::HealListItem;
|
||||
use crate::heal::utils::format_set_disk_id;
|
||||
|
||||
const MAX_DEFERRED_OBJECTS: usize = 256;
|
||||
const MAX_DEFERRED_BYTES: usize = 256 * 1024;
|
||||
const MAX_DEFERRED_FORWARD_PAGES: u64 = 2;
|
||||
const MAX_DEFERRED_AGE: Duration = Duration::from_secs(30);
|
||||
|
||||
struct DeferredObject {
|
||||
name: String,
|
||||
version_id: Option<String>,
|
||||
attempt: u32,
|
||||
page: u64,
|
||||
first_failure: Option<tokio::time::Instant>,
|
||||
due: tokio::time::Instant,
|
||||
}
|
||||
|
||||
impl DeferredObject {
|
||||
fn new(item: HealListItem, page: u64) -> Self {
|
||||
Self {
|
||||
name: item.name,
|
||||
version_id: item.version_id,
|
||||
attempt: 0,
|
||||
page,
|
||||
first_failure: None,
|
||||
due: tokio::time::Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_bytes(&self) -> usize {
|
||||
self.name
|
||||
.capacity()
|
||||
.saturating_add(self.version_id.as_ref().map_or(0, String::capacity))
|
||||
}
|
||||
|
||||
fn expired(&self) -> bool {
|
||||
self.first_failure.is_some_and(|first| first.elapsed() >= MAX_DEFERRED_AGE)
|
||||
}
|
||||
|
||||
fn defer(&mut self, delay: Duration) {
|
||||
let now = tokio::time::Instant::now();
|
||||
let first = *self.first_failure.get_or_insert(now);
|
||||
self.attempt += 1;
|
||||
self.due = (now + delay).min(first + MAX_DEFERRED_AGE);
|
||||
}
|
||||
}
|
||||
|
||||
// Only failed identities are retained. The current listing page remains owned
|
||||
// by the caller; capacity pressure stops fetching, never discards that page.
|
||||
struct DeferredWindow {
|
||||
objects: VecDeque<DeferredObject>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for DeferredWindow {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
objects: VecDeque::new(),
|
||||
// Charge every possible slot up front, including spare capacity.
|
||||
bytes: MAX_DEFERRED_OBJECTS * size_of::<DeferredObject>(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeferredWindow {
|
||||
fn push(&mut self, item: DeferredObject) -> std::result::Result<(), DeferredObject> {
|
||||
let bytes = item.payload_bytes();
|
||||
if self.objects.len() >= MAX_DEFERRED_OBJECTS || bytes > MAX_DEFERRED_BYTES.saturating_sub(self.bytes) {
|
||||
return Err(item);
|
||||
}
|
||||
self.bytes += bytes;
|
||||
self.objects.push_back(item);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pop_due(&mut self) -> Option<DeferredObject> {
|
||||
let now = tokio::time::Instant::now();
|
||||
let index = self.objects.iter().position(|item| item.due <= now)?;
|
||||
let item = self.objects.remove(index)?;
|
||||
self.bytes -= item.payload_bytes();
|
||||
Some(item)
|
||||
}
|
||||
|
||||
fn next_due(&self) -> Option<tokio::time::Instant> {
|
||||
self.objects.iter().map(|item| item.due).min()
|
||||
}
|
||||
|
||||
fn can_advance(&self, page: u64) -> bool {
|
||||
self.objects.len() < MAX_DEFERRED_OBJECTS
|
||||
&& self.bytes < MAX_DEFERRED_BYTES
|
||||
&& self
|
||||
.objects
|
||||
.iter()
|
||||
.all(|item| page.saturating_sub(item.page) < MAX_DEFERRED_FORWARD_PAGES)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/deferred_retry_window.rs"]
|
||||
mod deferred_retry_window;
|
||||
|
||||
fn unavailable_recreate_error(result: &HealResultItem, opts: &HealOpts) -> Option<Error> {
|
||||
if opts.dry_run || !opts.recreate {
|
||||
return None;
|
||||
@@ -305,83 +404,122 @@ impl HealTask {
|
||||
|
||||
for (set_disk_id, heal_opts) in listing_scopes {
|
||||
let mut continuation_token: Option<String> = None;
|
||||
loop {
|
||||
self.check_control_flags().await?;
|
||||
let mut listing_attempt = 0;
|
||||
let (objects, next_token, is_truncated) = loop {
|
||||
let mut deferred = DeferredWindow::default();
|
||||
let mut inline_retry: Option<DeferredObject> = None;
|
||||
let mut page_number = 0_u64;
|
||||
let mut aborted_progress_unknown = false;
|
||||
let mut pending = Vec::<HealListItem>::new().into_iter();
|
||||
let mut listing_finished = false;
|
||||
let mut listing_attempt = 0;
|
||||
let mut listing_due = tokio::time::Instant::now();
|
||||
let scope_result: Result<()> = async {
|
||||
loop {
|
||||
self.check_control_flags().await?;
|
||||
if listing_finished && pending.as_slice().is_empty() && deferred.objects.is_empty() && inline_retry.is_none()
|
||||
{
|
||||
break;
|
||||
}
|
||||
self.pace_mainline().await?;
|
||||
let page = if let Some(set_disk_id) = set_disk_id.as_deref() {
|
||||
self.await_with_control(self.storage.list_versions_for_heal_page_disk_walk(
|
||||
set_disk_id,
|
||||
bucket,
|
||||
prefix,
|
||||
continuation_token.as_deref(),
|
||||
false,
|
||||
))
|
||||
.await
|
||||
} else {
|
||||
self.await_with_control(self.storage.list_objects_for_heal_page(
|
||||
bucket,
|
||||
prefix,
|
||||
continuation_token.as_deref(),
|
||||
false,
|
||||
))
|
||||
.await
|
||||
};
|
||||
match page {
|
||||
Ok(page) => break page,
|
||||
Err(error @ (Error::TaskCancelled | Error::TaskTimeout)) => return Err(error),
|
||||
Err(error) => {
|
||||
self.outcome.write().await.attempt_failed();
|
||||
if error.is_recoverable_heal() && listing_attempt < MAX_BUCKET_OBJECT_HEAL_RETRIES {
|
||||
listing_attempt += 1;
|
||||
// Listing and object retries share this safe boundary. A
|
||||
// failed listing never hides an already-due object retry.
|
||||
let item = deferred.pop_due().or_else(|| {
|
||||
if inline_retry
|
||||
.as_ref()
|
||||
.is_some_and(|item| item.due <= tokio::time::Instant::now())
|
||||
{
|
||||
inline_retry.take()
|
||||
} else if inline_retry.is_none() {
|
||||
pending.next().map(|item| DeferredObject::new(item, page_number))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let Some(mut item) = item else {
|
||||
let can_list = !listing_finished && inline_retry.is_none() && deferred.can_advance(page_number);
|
||||
if can_list && listing_due <= tokio::time::Instant::now() {
|
||||
let page = if let Some(set_disk_id) = set_disk_id.as_deref() {
|
||||
self.await_with_control(self.storage.list_versions_for_heal_page_disk_walk(
|
||||
set_disk_id,
|
||||
bucket,
|
||||
prefix,
|
||||
continuation_token.as_deref(),
|
||||
false,
|
||||
))
|
||||
.await
|
||||
} else {
|
||||
self.await_with_control(self.storage.list_objects_for_heal_page(
|
||||
bucket,
|
||||
prefix,
|
||||
continuation_token.as_deref(),
|
||||
false,
|
||||
))
|
||||
.await
|
||||
};
|
||||
match page {
|
||||
Ok((objects, next_token, is_truncated)) => {
|
||||
page_number = page_number.saturating_add(1);
|
||||
continuation_token = next_heal_listing_token(bucket, prefix, next_token, is_truncated)?;
|
||||
listing_finished = continuation_token.is_none();
|
||||
listing_attempt = 0;
|
||||
listing_due = tokio::time::Instant::now();
|
||||
pending = objects.into_iter();
|
||||
}
|
||||
Err(error @ (Error::TaskCancelled | Error::TaskTimeout)) => return Err(error),
|
||||
Err(error) => {
|
||||
self.outcome.write().await.attempt_failed();
|
||||
if error.is_recoverable_heal() && listing_attempt < MAX_BUCKET_OBJECT_HEAL_RETRIES {
|
||||
listing_attempt += 1;
|
||||
listing_due =
|
||||
tokio::time::Instant::now() + self.bucket_object_retry_delay(listing_attempt);
|
||||
continue;
|
||||
}
|
||||
self.outcome.write().await.mark_untraversable();
|
||||
return Err(Error::HealListingFailed {
|
||||
bucket: bucket.to_string(),
|
||||
source: Box::new(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
let due = deferred
|
||||
.next_due()
|
||||
.into_iter()
|
||||
.chain(inline_retry.as_ref().map(|item| item.due))
|
||||
.chain(can_list.then_some(listing_due))
|
||||
.min();
|
||||
if let Some(due) = due {
|
||||
self.await_with_control(async {
|
||||
tokio::time::sleep(self.bucket_object_retry_delay(listing_attempt)).await;
|
||||
tokio::time::sleep_until(due).await;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
continue;
|
||||
}
|
||||
self.outcome.write().await.mark_untraversable();
|
||||
return Err(Error::HealListingFailed {
|
||||
bucket: bucket.to_string(),
|
||||
source: Box::new(error),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let retry_attempt = item.attempt;
|
||||
let mut telemetry_unknown = false;
|
||||
let object = item.name.as_str();
|
||||
let identity =
|
||||
self.outcome_identity(bucket, object, item.version_id.as_deref(), heal_opts.pool, heal_opts.set);
|
||||
let mut disposition = if heal_opts.dry_run {
|
||||
HealObjectDisposition::DryRunObserved
|
||||
} else {
|
||||
HealObjectDisposition::Unknown
|
||||
};
|
||||
let mut detail = None;
|
||||
{
|
||||
let mut progress = self.progress.write().await;
|
||||
progress.set_current_object(Some(format!("{bucket}/{object}")));
|
||||
}
|
||||
};
|
||||
|
||||
let mut pending = objects;
|
||||
let mut retry_attempt = 0_u32;
|
||||
while !pending.is_empty() {
|
||||
if retry_attempt > 0 {
|
||||
self.await_with_control(async {
|
||||
tokio::time::sleep(self.bucket_object_retry_delay(retry_attempt)).await;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
let mut retry = Vec::with_capacity(pending.len());
|
||||
for item in pending {
|
||||
self.check_control_flags().await?;
|
||||
self.pace_mainline().await?;
|
||||
let mut telemetry_unknown = false;
|
||||
let object = item.name.as_str();
|
||||
let identity =
|
||||
self.outcome_identity(bucket, object, item.version_id.as_deref(), heal_opts.pool, heal_opts.set);
|
||||
let mut disposition = if heal_opts.dry_run {
|
||||
HealObjectDisposition::DryRunObserved
|
||||
} else {
|
||||
HealObjectDisposition::Unknown
|
||||
};
|
||||
let mut detail = None;
|
||||
{
|
||||
let mut progress = self.progress.write().await;
|
||||
progress.set_current_object(Some(format!("{bucket}/{object}")));
|
||||
}
|
||||
|
||||
let mut terminal_outcome = true;
|
||||
let error = match self
|
||||
let mut terminal_outcome = true;
|
||||
let age_exhausted = item.expired();
|
||||
let error = if age_exhausted {
|
||||
Some(Error::other("heal object retry age exhausted"))
|
||||
} else {
|
||||
match self
|
||||
.await_with_control(
|
||||
self.storage
|
||||
.heal_object(bucket, object, item.version_id.as_deref(), &heal_opts),
|
||||
@@ -414,152 +552,188 @@ impl HealTask {
|
||||
None
|
||||
}
|
||||
Ok((_, Some(err))) | Err(err) => Some(err),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(err) = error {
|
||||
match err {
|
||||
Error::TaskCancelled | Error::TaskTimeout => {
|
||||
let disposition = if matches!(err, Error::TaskCancelled) {
|
||||
HealObjectDisposition::Cancelled
|
||||
} else {
|
||||
HealObjectDisposition::Deferred {
|
||||
reason: HealDeferredReason::Deadline,
|
||||
retry_not_before: None,
|
||||
}
|
||||
};
|
||||
self.outcome.write().await.record(HealObjectOutcome {
|
||||
identity,
|
||||
disposition,
|
||||
detail: None,
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
_ => self.outcome.write().await.attempt_failed(),
|
||||
}
|
||||
detail = Some(err.to_string());
|
||||
if Self::is_dangling_delete_grace_error(&err) {
|
||||
disposition = HealObjectDisposition::Deferred {
|
||||
reason: HealDeferredReason::DanglingDeleteGrace,
|
||||
retry_not_before: None,
|
||||
};
|
||||
telemetry_unknown |= !increment_counter(&mut skipped);
|
||||
warn!(
|
||||
target: "rustfs::heal::task",
|
||||
event = EVENT_HEAL_BUCKET_RESULT,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_TASK,
|
||||
task_id = %self.id,
|
||||
bucket,
|
||||
object,
|
||||
result = "dangling_delete_grace_skip",
|
||||
error = %err,
|
||||
"Heal bucket object dangling cleanup deferred by grace window"
|
||||
);
|
||||
} else if Self::should_skip_data_usage_cache_heal_error(bucket, object, &err) {
|
||||
disposition = HealObjectDisposition::Deferred {
|
||||
reason: HealDeferredReason::TransientUsageCache,
|
||||
retry_not_before: None,
|
||||
};
|
||||
telemetry_unknown |= !increment_counter(&mut skipped);
|
||||
warn!(
|
||||
target: "rustfs::heal::task",
|
||||
event = EVENT_HEAL_BUCKET_RESULT,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_TASK,
|
||||
task_id = %self.id,
|
||||
bucket,
|
||||
object,
|
||||
result = "transient_skip",
|
||||
error = %err,
|
||||
"Heal bucket object repair skipped due to transient metadata error"
|
||||
);
|
||||
} else if err.is_recoverable_heal() && retry_attempt < MAX_BUCKET_OBJECT_HEAL_RETRIES {
|
||||
terminal_outcome = false;
|
||||
debug!(
|
||||
target: "rustfs::heal::task",
|
||||
event = EVENT_HEAL_BUCKET_RESULT,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_TASK,
|
||||
task_id = %self.id,
|
||||
bucket,
|
||||
object,
|
||||
retry_attempt = retry_attempt.saturating_add(1),
|
||||
error = %err,
|
||||
result = "object_retry_scheduled",
|
||||
"Heal bucket object retry scheduled"
|
||||
);
|
||||
retry.push(item);
|
||||
} else {
|
||||
disposition = HealObjectDisposition::Failed(if err.is_recoverable_heal() {
|
||||
HealFailureClass::RetryExhausted
|
||||
if let Some(err) = error {
|
||||
match err {
|
||||
Error::TaskCancelled | Error::TaskTimeout => {
|
||||
let disposition = if matches!(err, Error::TaskCancelled) {
|
||||
HealObjectDisposition::Cancelled
|
||||
} else {
|
||||
HealFailureClass::Permanent
|
||||
HealObjectDisposition::Deferred {
|
||||
reason: HealDeferredReason::Deadline,
|
||||
retry_not_before: None,
|
||||
}
|
||||
};
|
||||
self.outcome.write().await.record(HealObjectOutcome {
|
||||
identity,
|
||||
disposition,
|
||||
detail: None,
|
||||
});
|
||||
telemetry_unknown |= !increment_counter(&mut failed);
|
||||
if err.is_recoverable_heal() {
|
||||
retryable_failed = retryable_failed.saturating_add(1);
|
||||
} else {
|
||||
permanent_failed = permanent_failed.saturating_add(1);
|
||||
}
|
||||
first_failed_object.get_or_insert_with(|| object.to_string());
|
||||
first_error.get_or_insert_with(|| err.to_string());
|
||||
if take_failure_log_sample(&mut failure_samples_logged) {
|
||||
warn!(
|
||||
target: "rustfs::heal::task",
|
||||
event = EVENT_HEAL_BUCKET_RESULT,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_TASK,
|
||||
task_id = %self.id,
|
||||
bucket,
|
||||
object,
|
||||
retry_attempt,
|
||||
error = %err,
|
||||
result = "object_failed",
|
||||
"Heal bucket object repair failed"
|
||||
);
|
||||
}
|
||||
aborted_progress_unknown |= !increment_counter(&mut scanned);
|
||||
aborted_progress_unknown |= !increment_counter(&mut skipped);
|
||||
return Err(err);
|
||||
}
|
||||
_ if !age_exhausted => self.outcome.write().await.attempt_failed(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if terminal_outcome {
|
||||
telemetry_unknown |= !increment_counter(&mut scanned);
|
||||
}
|
||||
|
||||
if !terminal_outcome {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.outcome.write().await.record(HealObjectOutcome {
|
||||
identity,
|
||||
disposition,
|
||||
detail,
|
||||
});
|
||||
|
||||
let mut progress = self.progress.write().await;
|
||||
progress.update_object_progress(
|
||||
previous_progress.objects_scanned.saturating_add(scanned),
|
||||
previous_progress.objects_healed.saturating_add(healed),
|
||||
previous_progress.objects_failed.saturating_add(failed),
|
||||
previous_progress.skipped_objects.saturating_add(skipped),
|
||||
previous_progress.bytes_processed.saturating_add(bytes),
|
||||
);
|
||||
if telemetry_unknown {
|
||||
progress.mark_unknown();
|
||||
detail = Some(err.to_string());
|
||||
if Self::is_dangling_delete_grace_error(&err) {
|
||||
disposition = HealObjectDisposition::Deferred {
|
||||
reason: HealDeferredReason::DanglingDeleteGrace,
|
||||
retry_not_before: None,
|
||||
};
|
||||
telemetry_unknown |= !increment_counter(&mut skipped);
|
||||
warn!(
|
||||
target: "rustfs::heal::task",
|
||||
event = EVENT_HEAL_BUCKET_RESULT,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_TASK,
|
||||
task_id = %self.id,
|
||||
bucket,
|
||||
object,
|
||||
result = "dangling_delete_grace_skip",
|
||||
error = %err,
|
||||
"Heal bucket object dangling cleanup deferred by grace window"
|
||||
);
|
||||
} else if Self::should_skip_data_usage_cache_heal_error(bucket, object, &err) {
|
||||
disposition = HealObjectDisposition::Deferred {
|
||||
reason: HealDeferredReason::TransientUsageCache,
|
||||
retry_not_before: None,
|
||||
};
|
||||
telemetry_unknown |= !increment_counter(&mut skipped);
|
||||
warn!(
|
||||
target: "rustfs::heal::task",
|
||||
event = EVENT_HEAL_BUCKET_RESULT,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_TASK,
|
||||
task_id = %self.id,
|
||||
bucket,
|
||||
object,
|
||||
result = "transient_skip",
|
||||
error = %err,
|
||||
"Heal bucket object repair skipped due to transient metadata error"
|
||||
);
|
||||
} else if !age_exhausted && err.is_recoverable_heal() && retry_attempt < MAX_BUCKET_OBJECT_HEAL_RETRIES {
|
||||
terminal_outcome = false;
|
||||
debug!(
|
||||
target: "rustfs::heal::task",
|
||||
event = EVENT_HEAL_BUCKET_RESULT,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_TASK,
|
||||
task_id = %self.id,
|
||||
bucket,
|
||||
object,
|
||||
retry_attempt = retry_attempt.saturating_add(1),
|
||||
error = %err,
|
||||
result = "object_retry_scheduled",
|
||||
"Heal bucket object retry scheduled"
|
||||
);
|
||||
item.defer(self.bucket_object_retry_delay(retry_attempt + 1));
|
||||
if let Err(item) = deferred.push(item) {
|
||||
inline_retry = Some(item);
|
||||
}
|
||||
} else {
|
||||
disposition = HealObjectDisposition::Failed(if age_exhausted || err.is_recoverable_heal() {
|
||||
HealFailureClass::RetryExhausted
|
||||
} else {
|
||||
HealFailureClass::Permanent
|
||||
});
|
||||
telemetry_unknown |= !increment_counter(&mut failed);
|
||||
if age_exhausted || err.is_recoverable_heal() {
|
||||
retryable_failed = retryable_failed.saturating_add(1);
|
||||
} else {
|
||||
permanent_failed = permanent_failed.saturating_add(1);
|
||||
}
|
||||
first_failed_object.get_or_insert_with(|| object.to_string());
|
||||
first_error.get_or_insert_with(|| err.to_string());
|
||||
if take_failure_log_sample(&mut failure_samples_logged) {
|
||||
warn!(
|
||||
target: "rustfs::heal::task",
|
||||
event = EVENT_HEAL_BUCKET_RESULT,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_TASK,
|
||||
task_id = %self.id,
|
||||
bucket,
|
||||
object,
|
||||
retry_attempt,
|
||||
error = %err,
|
||||
result = "object_failed",
|
||||
"Heal bucket object repair failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
pending = retry;
|
||||
retry_attempt = retry_attempt.saturating_add(1);
|
||||
}
|
||||
|
||||
if !is_truncated {
|
||||
break;
|
||||
}
|
||||
if terminal_outcome {
|
||||
telemetry_unknown |= !increment_counter(&mut scanned);
|
||||
}
|
||||
|
||||
continuation_token = next_heal_listing_token(bucket, prefix, next_token, is_truncated)?;
|
||||
if continuation_token.is_none() {
|
||||
// Truncated without a continuation token is a compatibility EOF.
|
||||
break;
|
||||
if !terminal_outcome {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.outcome.write().await.record(HealObjectOutcome {
|
||||
identity,
|
||||
disposition,
|
||||
detail,
|
||||
});
|
||||
|
||||
let mut progress = self.progress.write().await;
|
||||
progress.update_object_progress(
|
||||
previous_progress.objects_scanned.saturating_add(scanned),
|
||||
previous_progress.objects_healed.saturating_add(healed),
|
||||
previous_progress.objects_failed.saturating_add(failed),
|
||||
previous_progress.skipped_objects.saturating_add(skipped),
|
||||
previous_progress.bytes_processed.saturating_add(bytes),
|
||||
);
|
||||
if telemetry_unknown {
|
||||
progress.mark_unknown();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = scope_result {
|
||||
let disposition = match error {
|
||||
Error::TaskCancelled => HealObjectDisposition::Cancelled,
|
||||
Error::TaskTimeout => HealObjectDisposition::Deferred {
|
||||
reason: HealDeferredReason::Deadline,
|
||||
retry_not_before: None,
|
||||
},
|
||||
_ => HealObjectDisposition::Unknown,
|
||||
};
|
||||
// Only attempted identities have terminal outcomes. Unstarted
|
||||
// page tails remain unprocessed under the task's partial coverage.
|
||||
// No detached sleepers survive abort.
|
||||
for item in deferred.objects.into_iter().chain(inline_retry) {
|
||||
self.outcome.write().await.record(HealObjectOutcome {
|
||||
identity: self.outcome_identity(
|
||||
bucket,
|
||||
&item.name,
|
||||
item.version_id.as_deref(),
|
||||
heal_opts.pool,
|
||||
heal_opts.set,
|
||||
),
|
||||
disposition: disposition.clone(),
|
||||
detail: None,
|
||||
});
|
||||
aborted_progress_unknown |= !increment_counter(&mut scanned);
|
||||
aborted_progress_unknown |= !increment_counter(&mut skipped);
|
||||
}
|
||||
let mut progress = self.progress.write().await;
|
||||
progress.update_object_progress(
|
||||
previous_progress.objects_scanned.saturating_add(scanned),
|
||||
previous_progress.objects_healed.saturating_add(healed),
|
||||
previous_progress.objects_failed.saturating_add(failed),
|
||||
previous_progress.skipped_objects.saturating_add(skipped),
|
||||
previous_progress.bytes_processed.saturating_add(bytes),
|
||||
);
|
||||
if aborted_progress_unknown {
|
||||
progress.mark_unknown();
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
use super::super::{DiskOption, DiskStore, Endpoint, new_disk};
|
||||
use super::*;
|
||||
|
||||
mod deferred_retry;
|
||||
|
||||
mod canonical_outcome {
|
||||
use super::*;
|
||||
use crate::heal::outcome::{HealExecutionOutcome, HealTraversalCoverage};
|
||||
@@ -939,6 +941,10 @@ async fn verified_recovery_keeps_state_when_marker_clear_fails() {
|
||||
|
||||
#[derive(Default)]
|
||||
struct MockStorage {
|
||||
retry_test_pages: Option<Vec<Vec<HealListItem>>>,
|
||||
retry_test_delays: HashMap<String, Duration>,
|
||||
retry_test_listing_delays: Mutex<VecDeque<Duration>>,
|
||||
retry_test_events: Mutex<Vec<String>>,
|
||||
listed: Mutex<bool>,
|
||||
list_each_bucket: bool,
|
||||
fail_second_listing_page: bool,
|
||||
@@ -1109,6 +1115,7 @@ fn replacement_identity(
|
||||
}
|
||||
|
||||
enum MockHealObjectOutcome {
|
||||
RetryableLock,
|
||||
OkWithOtherError(&'static str),
|
||||
ErrOther(&'static str),
|
||||
DanglingGraceDeferred,
|
||||
@@ -1213,6 +1220,10 @@ impl HealStorageAPI for MockStorage {
|
||||
opts: &HealOpts,
|
||||
) -> Result<(HealResultItem, Option<Error>)> {
|
||||
self.heal_object_calls.lock().unwrap().push(object.to_string());
|
||||
self.retry_test_events.lock().expect("events").push(format!("heal:{object}"));
|
||||
if let Some(delay) = self.retry_test_delays.get(object) {
|
||||
tokio::time::sleep(*delay).await;
|
||||
}
|
||||
self.heal_object_version_ids
|
||||
.lock()
|
||||
.unwrap()
|
||||
@@ -1241,6 +1252,13 @@ impl HealStorageAPI for MockStorage {
|
||||
bucket.to_string(),
|
||||
object.to_string(),
|
||||
))),
|
||||
MockHealObjectOutcome::RetryableLock => Ok((
|
||||
HealResultItem::default(),
|
||||
Some(Error::Storage(EcstoreError::Lock(rustfs_lock::LockError::AlreadyLocked {
|
||||
resource: object.to_string(),
|
||||
owner: "competing-writer".to_string(),
|
||||
}))),
|
||||
)),
|
||||
MockHealObjectOutcome::RetryableSlowDown => {
|
||||
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::SlowDown))))
|
||||
}
|
||||
@@ -1266,6 +1284,13 @@ impl HealStorageAPI for MockStorage {
|
||||
bucket.to_string(),
|
||||
object.to_string(),
|
||||
))),
|
||||
MockHealObjectOutcome::RetryableLock => Ok((
|
||||
HealResultItem::default(),
|
||||
Some(Error::Storage(EcstoreError::Lock(rustfs_lock::LockError::AlreadyLocked {
|
||||
resource: object.to_string(),
|
||||
owner: "competing-writer".to_string(),
|
||||
}))),
|
||||
)),
|
||||
MockHealObjectOutcome::RetryableSlowDown => {
|
||||
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::SlowDown))))
|
||||
}
|
||||
@@ -1361,6 +1386,19 @@ impl HealStorageAPI for MockStorage {
|
||||
.lock()
|
||||
.expect("listing tokens")
|
||||
.push(continuation_token.map(ToOwned::to_owned));
|
||||
self.retry_test_events
|
||||
.lock()
|
||||
.expect("events")
|
||||
.push(format!("list:{}", continuation_token.unwrap_or("first")));
|
||||
let delay = self.retry_test_listing_delays.lock().expect("listing delays").pop_front();
|
||||
if let Some(delay) = delay {
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
if let Some(pages) = &self.retry_test_pages {
|
||||
let page = continuation_token.map_or(0, |token| token.parse::<usize>().expect("test page token"));
|
||||
let next = (page + 1 < pages.len()).then(|| (page + 1).to_string());
|
||||
return Ok((pages[page].clone(), next.clone(), next.is_some()));
|
||||
}
|
||||
if let Some(remaining) = self
|
||||
.recoverable_second_page_failures
|
||||
.lock()
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn bucket_task(storage: Arc<MockStorage>) -> HealTask {
|
||||
HealTask::from_request(
|
||||
HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "bucket-a".to_string(),
|
||||
},
|
||||
HealOptions {
|
||||
recursive: true,
|
||||
timeout: None,
|
||||
..Default::default()
|
||||
},
|
||||
HealPriority::Normal,
|
||||
),
|
||||
storage,
|
||||
)
|
||||
}
|
||||
|
||||
fn pages_storage(pages: &[&[&str]]) -> MockStorage {
|
||||
MockStorage {
|
||||
retry_test_pages: Some(
|
||||
pages
|
||||
.iter()
|
||||
.map(|page| page.iter().map(|name| heal_item(name)).collect())
|
||||
.collect(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_once(storage: &MockStorage, name: &str) {
|
||||
storage
|
||||
.heal_object_outcomes
|
||||
.lock()
|
||||
.expect("outcomes")
|
||||
.insert(name.to_string(), VecDeque::from([MockHealObjectOutcome::RetryableLock]));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn slow_listing_retry_services_due_object_then_age_before_next_listing() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
recoverable_second_page_failures: Mutex::new(Some(1)),
|
||||
retry_test_listing_delays: Mutex::new(VecDeque::from([Duration::ZERO, Duration::from_secs(29)])),
|
||||
..Default::default()
|
||||
});
|
||||
storage.heal_object_outcomes.lock().expect("outcomes").insert(
|
||||
"object-a".to_string(),
|
||||
VecDeque::from([
|
||||
MockHealObjectOutcome::RetryableSlowDown,
|
||||
MockHealObjectOutcome::RetryableSlowDown,
|
||||
]),
|
||||
);
|
||||
let task = bucket_task(storage.clone());
|
||||
let execution = task.execute();
|
||||
tokio::pin!(execution);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(30_500), &mut execution)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
storage.retry_test_events.lock().expect("events").as_slice(),
|
||||
["list:first", "heal:object-a", "list:second", "heal:object-a"]
|
||||
);
|
||||
let outcome = task.get_outcome().await;
|
||||
assert_eq!(outcome.counters.processed, 1);
|
||||
assert_eq!(
|
||||
outcome.objects[0].disposition,
|
||||
HealObjectDisposition::Failed(HealFailureClass::RetryExhausted)
|
||||
);
|
||||
execution.await.expect_err("age exhausted object must remain a batch failure");
|
||||
assert_eq!(
|
||||
storage.retry_test_events.lock().expect("events").as_slice(),
|
||||
[
|
||||
"list:first",
|
||||
"heal:object-a",
|
||||
"list:second",
|
||||
"heal:object-a",
|
||||
"list:second",
|
||||
"heal:object-b"
|
||||
]
|
||||
);
|
||||
let outcome = task.get_outcome().await;
|
||||
assert_eq!((outcome.counters.processed, outcome.counters.attempt_failures), (2, 3));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn listing_return_after_age_expires_does_not_start_another_heal_attempt() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
recoverable_second_page_failures: Mutex::new(Some(1)),
|
||||
retry_test_listing_delays: Mutex::new(VecDeque::from([Duration::ZERO, Duration::from_secs(31)])),
|
||||
..Default::default()
|
||||
});
|
||||
fail_once(&storage, "object-a");
|
||||
let task = bucket_task(storage.clone());
|
||||
let execution = task.execute();
|
||||
tokio::pin!(execution);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(31_500), &mut execution)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
storage.retry_test_events.lock().expect("events").as_slice(),
|
||||
["list:first", "heal:object-a", "list:second"]
|
||||
);
|
||||
assert_eq!(task.get_outcome().await.counters.failed, 1);
|
||||
execution.await.expect_err("age exhaustion remains a failure");
|
||||
assert_eq!(
|
||||
storage.retry_test_events.lock().expect("events").as_slice(),
|
||||
["list:first", "heal:object-a", "list:second", "list:second", "heal:object-b"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn full_window_abort_accounts_inline_once_and_leaves_unstarted_tail_unprocessed() {
|
||||
for cancel in [true, false] {
|
||||
let names: Vec<String> = (0..258).map(|index| format!("blocked-{index}")).collect();
|
||||
let mut page: Vec<HealListItem> = names.iter().map(|name| heal_item(name)).collect();
|
||||
page[256].version_id = Some("inline-version".to_string());
|
||||
let storage = Arc::new(MockStorage {
|
||||
retry_test_pages: Some(vec![page, vec![heal_item("healthy")]]),
|
||||
..Default::default()
|
||||
});
|
||||
for name in &names {
|
||||
fail_once(&storage, name);
|
||||
}
|
||||
let mut task = bucket_task(storage.clone());
|
||||
if !cancel {
|
||||
task.options.timeout = Some(Duration::from_secs(1));
|
||||
}
|
||||
let execution = task.execute();
|
||||
tokio::pin!(execution);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(500), &mut execution)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(storage.heal_object_calls.lock().expect("calls").len(), 257);
|
||||
assert_eq!(storage.listing_tokens.lock().expect("tokens").len(), 1);
|
||||
if cancel {
|
||||
task.cancel().await.expect("cancel");
|
||||
}
|
||||
let result = execution.await;
|
||||
assert!(matches!(
|
||||
(&result, cancel),
|
||||
(Err(Error::TaskCancelled), true) | (Err(Error::TaskTimeout), false)
|
||||
));
|
||||
let outcome = task.get_outcome().await;
|
||||
assert_eq!(
|
||||
(
|
||||
outcome.counters.processed,
|
||||
outcome.counters.skipped,
|
||||
outcome.counters.failed,
|
||||
outcome.counters.healed
|
||||
),
|
||||
(257, 257, 0, 0)
|
||||
);
|
||||
assert_eq!(outcome.coverage, crate::heal::outcome::HealTraversalCoverage::Partial);
|
||||
assert_eq!(
|
||||
outcome.execution,
|
||||
crate::heal::outcome::HealExecutionOutcome::Aborted(if cancel {
|
||||
HealAbortReason::Cancelled
|
||||
} else {
|
||||
HealAbortReason::Deadline
|
||||
})
|
||||
);
|
||||
let inline: Vec<_> = outcome
|
||||
.objects
|
||||
.iter()
|
||||
.filter(|item| item.identity.object == "blocked-256")
|
||||
.collect();
|
||||
assert_eq!(inline.len(), 1);
|
||||
assert_eq!(inline[0].identity.version_id.as_deref(), Some("inline-version"));
|
||||
assert_eq!(
|
||||
inline[0].disposition,
|
||||
if cancel {
|
||||
HealObjectDisposition::Cancelled
|
||||
} else {
|
||||
HealObjectDisposition::Deferred {
|
||||
reason: HealDeferredReason::Deadline,
|
||||
retry_not_before: None,
|
||||
}
|
||||
}
|
||||
);
|
||||
let progress = task.get_progress().await;
|
||||
assert_eq!(
|
||||
(
|
||||
progress.objects_scanned,
|
||||
progress.skipped_objects,
|
||||
progress.objects_failed,
|
||||
progress.objects_healed
|
||||
),
|
||||
(257, 257, 0, 0)
|
||||
);
|
||||
assert!(
|
||||
!outcome
|
||||
.objects
|
||||
.iter()
|
||||
.any(|item| item.identity.object == "blocked-257" || item.identity.object == "healthy")
|
||||
);
|
||||
tokio::time::advance(Duration::from_secs(60)).await;
|
||||
assert_eq!(storage.heal_object_calls.lock().expect("calls").len(), 257);
|
||||
assert_eq!(storage.listing_tokens.lock().expect("tokens").len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn repeated_slowdown_keeps_attempts_and_forward_pages_bounded() {
|
||||
let storage = Arc::new(pages_storage(&[&["a"], &["b"], &["c"], &["d"]]));
|
||||
for name in ["a", "b", "c", "d"] {
|
||||
storage
|
||||
.heal_object_outcomes
|
||||
.lock()
|
||||
.expect("outcomes")
|
||||
.insert(name.to_string(), (0..4).map(|_| MockHealObjectOutcome::RetryableSlowDown).collect());
|
||||
}
|
||||
let task = bucket_task(storage.clone());
|
||||
let execution = task.execute();
|
||||
tokio::pin!(execution);
|
||||
assert!(tokio::time::timeout(Duration::from_secs(1), &mut execution).await.is_err());
|
||||
assert_eq!(storage.listing_tokens.lock().expect("tokens").len(), 3);
|
||||
execution.await.expect_err("all four objects exhaust retries");
|
||||
let outcome = task.get_outcome().await;
|
||||
assert_eq!(
|
||||
(outcome.counters.processed, outcome.counters.failed, outcome.counters.attempt_failures),
|
||||
(4, 4, 16)
|
||||
);
|
||||
assert_eq!(storage.heal_object_calls.lock().expect("calls").len(), 16);
|
||||
for name in ["a", "b", "c", "d"] {
|
||||
assert_eq!(outcome.objects.iter().filter(|item| item.identity.object == name).count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn listing_retry_keeps_cursor_and_does_not_replay_successful_objects() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
recoverable_second_page_failures: Mutex::new(Some(1)),
|
||||
..Default::default()
|
||||
});
|
||||
fail_once(&storage, "object-a");
|
||||
let task = bucket_task(storage.clone());
|
||||
task.execute().await.expect("both retries complete");
|
||||
assert_eq!(
|
||||
storage.listing_tokens.lock().expect("tokens").as_slice(),
|
||||
[None, Some("second".to_string()), Some("second".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
storage.heal_object_calls.lock().expect("calls").as_slice(),
|
||||
["object-a", "object-a", "object-b"]
|
||||
);
|
||||
let outcome = task.get_outcome().await;
|
||||
assert_eq!((outcome.counters.processed, outcome.counters.attempt_failures), (2, 2));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn typed_lock_contention_allows_only_two_forward_pages() {
|
||||
let storage = Arc::new(pages_storage(&[&["a"], &["b"], &["c"], &["d"]]));
|
||||
fail_once(&storage, "a");
|
||||
let task = bucket_task(storage.clone());
|
||||
let execution = task.execute();
|
||||
tokio::pin!(execution);
|
||||
assert!(tokio::time::timeout(Duration::from_secs(1), &mut execution).await.is_err());
|
||||
assert_eq!(storage.heal_object_calls.lock().expect("calls").as_slice(), ["a", "b", "c"]);
|
||||
assert_eq!(storage.listing_tokens.lock().expect("tokens").len(), 3);
|
||||
execution.await.expect("all objects complete");
|
||||
assert_eq!(storage.heal_object_calls.lock().expect("calls").as_slice(), ["a", "b", "c", "a", "d"]);
|
||||
assert_eq!(task.get_outcome().await.counters.processed, 4);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn due_retry_runs_before_next_object_in_a_slow_healthy_page() {
|
||||
let mut storage = pages_storage(&[&["a"], &["b", "c"]]);
|
||||
storage.retry_test_delays.insert("b".to_string(), Duration::from_secs(3));
|
||||
fail_once(&storage, "a");
|
||||
let storage = Arc::new(storage);
|
||||
bucket_task(storage.clone()).execute().await.expect("all objects complete");
|
||||
assert_eq!(storage.heal_object_calls.lock().expect("calls").as_slice(), ["a", "b", "a", "c"]);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn expired_retry_is_terminal_without_an_extra_storage_attempt() {
|
||||
let mut storage = pages_storage(&[&["a"], &["b"]]);
|
||||
storage.retry_test_delays.insert("b".to_string(), Duration::from_secs(31));
|
||||
fail_once(&storage, "a");
|
||||
let storage = Arc::new(storage);
|
||||
let task = bucket_task(storage.clone());
|
||||
task.execute().await.expect_err("aged pending responsibility is not success");
|
||||
assert_eq!(storage.heal_object_calls.lock().expect("calls").as_slice(), ["a", "b"]);
|
||||
let outcome = task.get_outcome().await;
|
||||
assert_eq!(outcome.counters.processed, 2);
|
||||
assert_eq!(outcome.counters.attempt_failures, 1);
|
||||
assert_eq!(outcome.counters.failed, 1);
|
||||
assert_eq!(
|
||||
outcome
|
||||
.objects
|
||||
.iter()
|
||||
.find(|item| item.identity.object == "a")
|
||||
.expect("a outcome")
|
||||
.disposition,
|
||||
HealObjectDisposition::Failed(HealFailureClass::RetryExhausted)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cancellation_drains_owned_retries_once() {
|
||||
let storage = Arc::new(pages_storage(&[&["a"], &["b"]]));
|
||||
fail_once(&storage, "a");
|
||||
let task = bucket_task(storage.clone());
|
||||
let execution = task.execute();
|
||||
tokio::pin!(execution);
|
||||
assert!(tokio::time::timeout(Duration::from_secs(1), &mut execution).await.is_err());
|
||||
task.cancel().await.expect("cancel");
|
||||
assert!(matches!(execution.await, Err(Error::TaskCancelled)));
|
||||
tokio::time::advance(Duration::from_secs(60)).await;
|
||||
assert_eq!(storage.heal_object_calls.lock().expect("calls").as_slice(), ["a", "b"]);
|
||||
let outcome = task.get_outcome().await;
|
||||
assert_eq!(outcome.counters.processed, 2);
|
||||
assert_eq!(outcome.objects.iter().filter(|item| item.identity.object == "a").count(), 1);
|
||||
assert_eq!(
|
||||
outcome
|
||||
.objects
|
||||
.iter()
|
||||
.find(|item| item.identity.object == "a")
|
||||
.expect("a")
|
||||
.disposition,
|
||||
HealObjectDisposition::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn deadline_drains_owned_retries_without_false_completion() {
|
||||
let storage = Arc::new(pages_storage(&[&["a"], &["b"]]));
|
||||
fail_once(&storage, "a");
|
||||
let mut task = bucket_task(storage.clone());
|
||||
task.options.timeout = Some(Duration::from_secs(1));
|
||||
assert!(matches!(task.execute().await, Err(Error::TaskTimeout)));
|
||||
let outcome = task.get_outcome().await;
|
||||
assert_eq!(outcome.counters.processed, 2);
|
||||
assert_eq!(
|
||||
outcome
|
||||
.objects
|
||||
.iter()
|
||||
.find(|item| item.identity.object == "a")
|
||||
.expect("a")
|
||||
.disposition,
|
||||
HealObjectDisposition::Deferred {
|
||||
reason: HealDeferredReason::Deadline,
|
||||
retry_not_before: None
|
||||
}
|
||||
);
|
||||
tokio::time::advance(Duration::from_secs(60)).await;
|
||||
assert_eq!(storage.heal_object_calls.lock().expect("calls").as_slice(), ["a", "b"]);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn full_window_backpressures_without_losing_the_current_page_tail() {
|
||||
let names: Vec<String> = (0..258).map(|index| format!("blocked-{index}")).collect();
|
||||
let mut storage = MockStorage {
|
||||
retry_test_pages: Some(vec![names.iter().map(|name| heal_item(name)).collect(), vec![heal_item("healthy")]]),
|
||||
..Default::default()
|
||||
};
|
||||
for name in &names {
|
||||
fail_once(&storage, name);
|
||||
}
|
||||
// The last item has a version, proving the current-page tail is not rebuilt
|
||||
// from names alone when the window fills.
|
||||
storage.retry_test_pages.as_mut().expect("pages")[0][257].version_id = Some("version-tail".to_string());
|
||||
let storage = Arc::new(storage);
|
||||
let task = bucket_task(storage.clone());
|
||||
let execution = task.execute();
|
||||
tokio::pin!(execution);
|
||||
assert!(tokio::time::timeout(Duration::from_secs(1), &mut execution).await.is_err());
|
||||
assert_eq!(storage.heal_object_calls.lock().expect("calls").len(), 257);
|
||||
assert_eq!(storage.listing_tokens.lock().expect("tokens").len(), 1);
|
||||
execution.await.expect("every owned item eventually completes");
|
||||
assert_eq!(task.get_outcome().await.counters.processed, 259);
|
||||
let calls = storage.heal_object_calls.lock().expect("calls");
|
||||
for name in &names {
|
||||
assert_eq!(calls.iter().filter(|called| *called == name).count(), 2);
|
||||
}
|
||||
let versions = storage.heal_object_version_ids.lock().expect("versions");
|
||||
for (name, version) in calls.iter().zip(versions.iter()) {
|
||||
if name == "blocked-257" {
|
||||
assert_eq!(version.as_deref(), Some("version-tail"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn oversized_identity_stays_inline_without_losing_version() {
|
||||
let name = "k".repeat(256 * 1024);
|
||||
let mut item = heal_item(&name);
|
||||
item.version_id = Some("v".repeat(1024));
|
||||
let storage = Arc::new(MockStorage {
|
||||
retry_test_pages: Some(vec![vec![item], vec![heal_item("healthy")]]),
|
||||
..Default::default()
|
||||
});
|
||||
fail_once(&storage, &name);
|
||||
let task = bucket_task(storage.clone());
|
||||
let execution = task.execute();
|
||||
tokio::pin!(execution);
|
||||
assert!(tokio::time::timeout(Duration::from_secs(1), &mut execution).await.is_err());
|
||||
assert_eq!(storage.listing_tokens.lock().expect("tokens").len(), 1);
|
||||
execution.await.expect("oversized identity retries inline");
|
||||
assert_eq!(task.get_outcome().await.counters.processed, 2);
|
||||
let versions = storage.heal_object_version_ids.lock().expect("versions");
|
||||
assert_eq!(versions[0], versions[1]);
|
||||
assert_eq!(versions[0].as_ref().expect("version").len(), 1024);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn terminal_listing_failure_keeps_deferred_identity_unknown() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
fail_second_listing_page: true,
|
||||
..Default::default()
|
||||
});
|
||||
fail_once(&storage, "object-a");
|
||||
let task = bucket_task(storage.clone());
|
||||
task.execute().await.expect_err("listing cannot continue");
|
||||
let outcome = task.get_outcome().await;
|
||||
assert_eq!(outcome.counters.processed, 1);
|
||||
assert_eq!(outcome.objects[0].disposition, HealObjectDisposition::Unknown);
|
||||
assert_eq!(outcome.coverage, crate::heal::outcome::HealTraversalCoverage::Partial);
|
||||
assert_eq!(storage.heal_object_calls.lock().expect("calls").as_slice(), ["object-a"]);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn healthy_second_page_advances_before_first_retry_is_due() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
recoverable_second_page_failures: Mutex::new(Some(0)),
|
||||
..Default::default()
|
||||
});
|
||||
storage
|
||||
.heal_object_outcomes
|
||||
.lock()
|
||||
.expect("outcomes")
|
||||
.insert("object-a".to_string(), VecDeque::from([MockHealObjectOutcome::RetryableSlowDown]));
|
||||
let task = HealTask::from_request(
|
||||
HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "bucket-a".to_string(),
|
||||
},
|
||||
HealOptions {
|
||||
recursive: true,
|
||||
timeout: None,
|
||||
..Default::default()
|
||||
},
|
||||
HealPriority::Normal,
|
||||
),
|
||||
storage.clone(),
|
||||
);
|
||||
let execution = task.execute();
|
||||
tokio::pin!(execution);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(1), &mut execution).await.is_err(),
|
||||
"the deferred first object must remain pending before its retry is due"
|
||||
);
|
||||
assert_eq!(
|
||||
storage.heal_object_calls.lock().expect("calls").as_slice(),
|
||||
["object-a", "object-b"],
|
||||
"a retryable page head must not hold the healthy second page behind its backoff"
|
||||
);
|
||||
execution.await.expect("retry eventually succeeds");
|
||||
let outcome = task.get_outcome().await;
|
||||
assert_eq!(outcome.counters.processed, 2);
|
||||
assert_eq!(outcome.counters.attempt_failures, 1);
|
||||
assert_eq!(
|
||||
storage.heal_object_calls.lock().expect("calls").as_slice(),
|
||||
["object-a", "object-b", "object-a"]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn item(name: String, version_id: Option<String>) -> DeferredObject {
|
||||
DeferredObject::new(
|
||||
HealListItem {
|
||||
name,
|
||||
version_id,
|
||||
mod_time_unix_nanos: None,
|
||||
lifecycle_object_info: None,
|
||||
is_delete_marker: false,
|
||||
},
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn count_cap_and_next_item_preserve_ownership() {
|
||||
let mut window = DeferredWindow::default();
|
||||
for _ in 0..MAX_DEFERRED_OBJECTS {
|
||||
assert!(window.push(item("key".to_string(), None)).is_ok());
|
||||
}
|
||||
let rejected = window
|
||||
.push(item("next".to_string(), Some("version".to_string())))
|
||||
.expect_err("count cap");
|
||||
assert_eq!(rejected.name, "next");
|
||||
assert_eq!(rejected.version_id.as_deref(), Some("version"));
|
||||
assert_eq!(window.objects.len(), MAX_DEFERRED_OBJECTS);
|
||||
assert!(window.bytes <= MAX_DEFERRED_BYTES);
|
||||
assert!(!window.can_advance(1));
|
||||
assert!(window.pop_due().is_some());
|
||||
assert!(window.push(rejected).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn byte_cap_counts_key_version_and_reserved_slots() {
|
||||
let mut window = DeferredWindow::default();
|
||||
let available = MAX_DEFERRED_BYTES - window.bytes;
|
||||
let key = "k".repeat(available / 2);
|
||||
let version = "v".repeat(available - key.capacity());
|
||||
assert_eq!(key.capacity() + version.capacity(), available);
|
||||
assert!(window.push(item(key, Some(version))).is_ok());
|
||||
assert_eq!(window.bytes, MAX_DEFERRED_BYTES);
|
||||
assert!(!window.can_advance(1));
|
||||
assert!(window.push(item("x".to_string(), None)).is_err());
|
||||
assert!(window.pop_due().is_some());
|
||||
assert_eq!(window.bytes, MAX_DEFERRED_OBJECTS * size_of::<DeferredObject>());
|
||||
assert!(window.push(item("x".to_string(), None)).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn retry_age_caps_due_time_and_is_not_reset_by_rescheduling() {
|
||||
let mut entry = item("a".to_string(), None);
|
||||
entry.defer(Duration::from_secs(2));
|
||||
let first = entry.first_failure.expect("first failure");
|
||||
tokio::time::advance(Duration::from_secs(29)).await;
|
||||
entry.defer(Duration::from_secs(8));
|
||||
assert_eq!(entry.first_failure, Some(first));
|
||||
assert_eq!(entry.due, first + MAX_DEFERRED_AGE);
|
||||
assert!(!entry.expired());
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
assert!(entry.expired());
|
||||
}
|
||||
@@ -167,6 +167,13 @@ pub struct HealTaskStatus {
|
||||
/// Live progress snapshot; the exact shape is owned by the heal runtime.
|
||||
#[serde(default)]
|
||||
pub progress: Option<serde_json::Value>,
|
||||
/// Canonical heal-owner result. Missing or future states are not repair proof.
|
||||
#[serde(default)]
|
||||
pub outcome: Option<serde_json::Value>,
|
||||
#[serde(default, alias = "next_seq")]
|
||||
pub next_seq: Option<u64>,
|
||||
#[serde(default, alias = "min_seq")]
|
||||
pub min_seq: Option<u64>,
|
||||
}
|
||||
|
||||
/// `POST /v3/background-heal/status` response. Known top-level fields are
|
||||
@@ -358,8 +365,22 @@ impl AdminClient {
|
||||
prefix: Option<&str>,
|
||||
client_token: &str,
|
||||
) -> Result<HealTaskStatus, AdminClientError> {
|
||||
self.post_json(&heal_path(bucket, prefix), &[("clientToken", client_token.to_string())], Vec::new())
|
||||
.await
|
||||
self.heal_status_since(bucket, prefix, client_token, None).await
|
||||
}
|
||||
|
||||
/// Query a retained result window. Missing cursors and outcome remain unknown.
|
||||
pub async fn heal_status_since(
|
||||
&self,
|
||||
bucket: Option<&str>,
|
||||
prefix: Option<&str>,
|
||||
client_token: &str,
|
||||
since_seq: Option<u64>,
|
||||
) -> Result<HealTaskStatus, AdminClientError> {
|
||||
let mut query = vec![("clientToken", client_token.to_string())];
|
||||
if let Some(since_seq) = since_seq {
|
||||
query.push(("sinceSeq", since_seq.to_string()));
|
||||
}
|
||||
self.post_json(&heal_path(bucket, prefix), &query, Vec::new()).await
|
||||
}
|
||||
|
||||
/// Stop a heal: with a `client_token` only that task is cancelled and its
|
||||
@@ -378,7 +399,7 @@ impl AdminClient {
|
||||
match client_token {
|
||||
Some(_) => {
|
||||
let status: HealTaskStatus = self.post_json(&heal_path(bucket, prefix), &query, Vec::new()).await?;
|
||||
Ok(HealStopOutcome::Stopped(status))
|
||||
Ok(HealStopOutcome::Stopped(Box::new(status)))
|
||||
}
|
||||
None => {
|
||||
let success: HealStartSuccess = self.post_json(&heal_path(bucket, prefix), &query, Vec::new()).await?;
|
||||
@@ -533,7 +554,7 @@ impl AdminClient {
|
||||
/// start-success-shaped receipt.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HealStopOutcome {
|
||||
Stopped(HealTaskStatus),
|
||||
Stopped(Box<HealTaskStatus>),
|
||||
PathStopped(HealStartSuccess),
|
||||
}
|
||||
|
||||
@@ -635,6 +656,24 @@ mod tests {
|
||||
assert!(status.progress.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_v3_decoder_preserves_canonical_unknown_and_future_fields() {
|
||||
let cases: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../tests/fixtures/heal-outcome-v3.json")).expect("shared fixtures");
|
||||
for case in cases.as_array().expect("cases") {
|
||||
let status: HealTaskStatus = serde_json::from_value(case["response"].clone()).expect("optional outcome response");
|
||||
assert_eq!(status.outcome.as_ref(), Some(&case["response"]["outcome"]));
|
||||
assert_eq!((status.next_seq, status.min_seq), (Some(9), Some(4)));
|
||||
assert!(status.truncated);
|
||||
}
|
||||
let old: HealTaskStatus = serde_json::from_value(json!({"summary":"finished"})).expect("legacy response");
|
||||
assert!(old.outcome.is_none() && old.next_seq.is_none() && old.min_seq.is_none());
|
||||
let future = json!({"execution":{"state":"future_state"},"newField":7});
|
||||
let status: HealTaskStatus =
|
||||
serde_json::from_value(json!({"summary":"running","outcome":future})).expect("future outcome remains opaque");
|
||||
assert_eq!(status.outcome, Some(future));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_heal_status_types_known_fields_and_passes_the_rest_through() {
|
||||
let raw = json!({
|
||||
@@ -750,6 +789,26 @@ mod tests {
|
||||
assert!(!request.query.contains("forceStop"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outcome_v3_since_query_preserves_cursor_and_never_sends_force_start() {
|
||||
let server = TestServer::spawn(
|
||||
r#"{"summary":"running","nextSeq":9,"minSeq":4,"truncated":true,"outcome":{"execution":{"state":"future_state"}}}"#,
|
||||
200,
|
||||
)
|
||||
.await;
|
||||
let client = AdminClient::new(&format!("http://{}", server.addr), "ak", "sk").expect("test client");
|
||||
let status = client
|
||||
.heal_status_since(Some("bucket"), None, "token-1", Some(3))
|
||||
.await
|
||||
.expect("window response");
|
||||
assert_eq!((status.next_seq, status.min_seq), (Some(9), Some(4)));
|
||||
assert!(status.truncated);
|
||||
assert_eq!(status.outcome.expect("future state is preserved")["execution"]["state"], "future_state");
|
||||
let request = server.recorded();
|
||||
assert!(request.query.contains("sinceSeq=3") && request.query.contains("clientToken=token-1"));
|
||||
assert!(!request.query.contains("forceStart") && !request.query.contains("forceStop"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_without_token_takes_the_path_cancel_branch() {
|
||||
let server = TestServer::spawn(r#"{"clientToken":"path","clientAddress":"c","startTime":"t"}"#, 200).await;
|
||||
@@ -762,6 +821,25 @@ mod tests {
|
||||
assert!(!request.query.contains("clientToken"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_with_token_decodes_boxed_task_status() {
|
||||
let body = r#"{"summary":"stopped","detail":"","settings":{"recursive":false},"items":[],"truncated":false}"#;
|
||||
let server = TestServer::spawn(body, 200).await;
|
||||
let client = AdminClient::new(&format!("http://{}", server.addr), "ak", "sk").unwrap();
|
||||
|
||||
let outcome = client
|
||||
.heal_stop(Some("bucket"), None, Some("token-1"))
|
||||
.await
|
||||
.expect("token stop decodes");
|
||||
let super::HealStopOutcome::Stopped(status) = outcome else {
|
||||
panic!("token stop should return task status");
|
||||
};
|
||||
assert_eq!(status.summary, "stopped");
|
||||
let request = server.recorded();
|
||||
assert!(request.query.contains("forceStop=true"));
|
||||
assert!(request.query.contains("clientToken=token-1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_heal_status_posts_to_the_registered_route() {
|
||||
let body = r#"{"state":"idle","healQueueLength":0,"healActiveTasks":0,"clusterStatusComplete":true}"#;
|
||||
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
[
|
||||
{
|
||||
"name": "completed",
|
||||
"cliExit": 0,
|
||||
"response": {
|
||||
"summary": "finished",
|
||||
"detail": "heal result items were truncated",
|
||||
"startTime": "2026-01-01T00:00:00Z",
|
||||
"settings": {
|
||||
"recursive": true,
|
||||
"scanMode": 1
|
||||
},
|
||||
"items": [],
|
||||
"truncated": true,
|
||||
"nextSeq": 9,
|
||||
"minSeq": 4,
|
||||
"progress": {
|
||||
"objectsScanned": 11,
|
||||
"objectsHealed": 7
|
||||
},
|
||||
"outcome": {
|
||||
"execution": {
|
||||
"state": "completed"
|
||||
},
|
||||
"coverage": "complete",
|
||||
"counters": {
|
||||
"processed": 0,
|
||||
"healed": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"unknown": 0,
|
||||
"attemptFailures": 0,
|
||||
"overflowed": false
|
||||
},
|
||||
"objects": [],
|
||||
"objectsTruncated": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unknown",
|
||||
"cliExit": 0,
|
||||
"response": {
|
||||
"summary": "finished",
|
||||
"detail": "heal traversal completed; authoritative storage proof is unavailable for 1 objects; heal result items were truncated",
|
||||
"startTime": "2026-01-01T00:00:00Z",
|
||||
"settings": {
|
||||
"recursive": true,
|
||||
"scanMode": 1
|
||||
},
|
||||
"items": [],
|
||||
"truncated": true,
|
||||
"nextSeq": 9,
|
||||
"minSeq": 4,
|
||||
"progress": {
|
||||
"objectsScanned": 11,
|
||||
"objectsHealed": 7
|
||||
},
|
||||
"outcome": {
|
||||
"execution": {
|
||||
"state": "completed"
|
||||
},
|
||||
"coverage": "complete",
|
||||
"counters": {
|
||||
"processed": 1,
|
||||
"healed": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 1,
|
||||
"failed": 0,
|
||||
"unknown": 1,
|
||||
"attemptFailures": 0,
|
||||
"overflowed": false
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"identity": {
|
||||
"kind": "object",
|
||||
"bucket": "bucket",
|
||||
"object": "object",
|
||||
"versionId": null,
|
||||
"bucketIncarnationId": null,
|
||||
"poolIndex": null,
|
||||
"setIndex": null
|
||||
},
|
||||
"disposition": {
|
||||
"state": "unknown"
|
||||
},
|
||||
"detail": null
|
||||
}
|
||||
],
|
||||
"objectsTruncated": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "completed_with_errors",
|
||||
"cliExit": 1,
|
||||
"response": {
|
||||
"summary": "stopped",
|
||||
"detail": "heal traversal completed with errors: 1 failed objects; heal result items were truncated",
|
||||
"startTime": "2026-01-01T00:00:00Z",
|
||||
"settings": {
|
||||
"recursive": true,
|
||||
"scanMode": 1
|
||||
},
|
||||
"items": [],
|
||||
"truncated": true,
|
||||
"nextSeq": 9,
|
||||
"minSeq": 4,
|
||||
"progress": {
|
||||
"objectsScanned": 11,
|
||||
"objectsHealed": 7
|
||||
},
|
||||
"outcome": {
|
||||
"execution": {
|
||||
"state": "completed_with_errors"
|
||||
},
|
||||
"coverage": "complete",
|
||||
"counters": {
|
||||
"processed": 1,
|
||||
"healed": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
"failed": 1,
|
||||
"unknown": 0,
|
||||
"attemptFailures": 1,
|
||||
"overflowed": false
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"identity": {
|
||||
"kind": "object",
|
||||
"bucket": "bucket",
|
||||
"object": "object",
|
||||
"versionId": null,
|
||||
"bucketIncarnationId": null,
|
||||
"poolIndex": null,
|
||||
"setIndex": null
|
||||
},
|
||||
"disposition": {
|
||||
"state": "failed",
|
||||
"details": "retry_exhausted"
|
||||
},
|
||||
"detail": null
|
||||
}
|
||||
],
|
||||
"objectsTruncated": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cancelled",
|
||||
"cliExit": 1,
|
||||
"response": {
|
||||
"summary": "stopped",
|
||||
"detail": "heal task cancelled; heal result items were truncated",
|
||||
"startTime": "2026-01-01T00:00:00Z",
|
||||
"settings": {
|
||||
"recursive": true,
|
||||
"scanMode": 1
|
||||
},
|
||||
"items": [],
|
||||
"truncated": true,
|
||||
"nextSeq": 9,
|
||||
"minSeq": 4,
|
||||
"progress": {
|
||||
"objectsScanned": 11,
|
||||
"objectsHealed": 7
|
||||
},
|
||||
"outcome": {
|
||||
"execution": {
|
||||
"state": "aborted",
|
||||
"reason": "cancelled"
|
||||
},
|
||||
"coverage": "partial",
|
||||
"counters": {
|
||||
"processed": 0,
|
||||
"healed": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"unknown": 0,
|
||||
"attemptFailures": 0,
|
||||
"overflowed": false
|
||||
},
|
||||
"objects": [],
|
||||
"objectsTruncated": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deadline",
|
||||
"cliExit": 1,
|
||||
"response": {
|
||||
"summary": "stopped",
|
||||
"detail": "heal task timed out; heal result items were truncated",
|
||||
"startTime": "2026-01-01T00:00:00Z",
|
||||
"settings": {
|
||||
"recursive": true,
|
||||
"scanMode": 1
|
||||
},
|
||||
"items": [],
|
||||
"truncated": true,
|
||||
"nextSeq": 9,
|
||||
"minSeq": 4,
|
||||
"progress": {
|
||||
"objectsScanned": 11,
|
||||
"objectsHealed": 7
|
||||
},
|
||||
"outcome": {
|
||||
"execution": {
|
||||
"state": "aborted",
|
||||
"reason": "deadline"
|
||||
},
|
||||
"coverage": "partial",
|
||||
"counters": {
|
||||
"processed": 0,
|
||||
"healed": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"unknown": 0,
|
||||
"attemptFailures": 0,
|
||||
"overflowed": false
|
||||
},
|
||||
"objects": [],
|
||||
"objectsTruncated": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "untraversable",
|
||||
"cliExit": 1,
|
||||
"response": {
|
||||
"summary": "stopped",
|
||||
"detail": "heal listing is untraversable; heal result items were truncated",
|
||||
"startTime": "2026-01-01T00:00:00Z",
|
||||
"settings": {
|
||||
"recursive": true,
|
||||
"scanMode": 1
|
||||
},
|
||||
"items": [],
|
||||
"truncated": true,
|
||||
"nextSeq": 9,
|
||||
"minSeq": 4,
|
||||
"progress": {
|
||||
"objectsScanned": 11,
|
||||
"objectsHealed": 7
|
||||
},
|
||||
"outcome": {
|
||||
"execution": {
|
||||
"state": "aborted",
|
||||
"reason": "untraversable"
|
||||
},
|
||||
"coverage": "partial",
|
||||
"counters": {
|
||||
"processed": 0,
|
||||
"healed": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"unknown": 0,
|
||||
"attemptFailures": 0,
|
||||
"overflowed": false
|
||||
},
|
||||
"objects": [],
|
||||
"objectsTruncated": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "remote_completed_with_errors",
|
||||
"cliExit": 1,
|
||||
"response": {
|
||||
"summary": "stopped",
|
||||
"detail": "heal traversal completed with errors: 1 failed objects; heal result items were truncated",
|
||||
"startTime": "2026-01-01T00:00:00Z",
|
||||
"settings": {
|
||||
"recursive": true,
|
||||
"scanMode": 1
|
||||
},
|
||||
"items": [],
|
||||
"truncated": true,
|
||||
"nextSeq": 9,
|
||||
"minSeq": 4,
|
||||
"progress": {
|
||||
"objectsScanned": 11,
|
||||
"objectsHealed": 7
|
||||
},
|
||||
"outcome": {
|
||||
"execution": {
|
||||
"state": "completed_with_errors",
|
||||
"futureExtension": {
|
||||
"value": 7
|
||||
}
|
||||
},
|
||||
"coverage": "complete",
|
||||
"counters": {
|
||||
"processed": 1,
|
||||
"healed": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
"failed": 1,
|
||||
"unknown": 0,
|
||||
"attemptFailures": 1,
|
||||
"overflowed": false,
|
||||
"futureCounter": 11
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"identity": {
|
||||
"kind": "object",
|
||||
"bucket": "bucket",
|
||||
"object": "object",
|
||||
"versionId": null,
|
||||
"bucketIncarnationId": null,
|
||||
"poolIndex": null,
|
||||
"setIndex": null
|
||||
},
|
||||
"disposition": {
|
||||
"state": "failed",
|
||||
"details": "retry_exhausted"
|
||||
},
|
||||
"detail": null
|
||||
}
|
||||
],
|
||||
"objectsTruncated": false
|
||||
}
|
||||
},
|
||||
"remoteResponse": {
|
||||
"summary": "finished",
|
||||
"detail": "",
|
||||
"startTime": "2026-01-01T00:00:00Z",
|
||||
"settings": {
|
||||
"recursive": true,
|
||||
"scanMode": 1
|
||||
},
|
||||
"items": [],
|
||||
"truncated": true,
|
||||
"nextSeq": 9,
|
||||
"minSeq": 4,
|
||||
"progress": {
|
||||
"objectsScanned": 11,
|
||||
"objectsHealed": 7
|
||||
},
|
||||
"outcome": {
|
||||
"execution": {
|
||||
"state": "completed_with_errors",
|
||||
"futureExtension": {
|
||||
"value": 7
|
||||
}
|
||||
},
|
||||
"coverage": "complete",
|
||||
"counters": {
|
||||
"processed": 1,
|
||||
"healed": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
"failed": 1,
|
||||
"unknown": 0,
|
||||
"attemptFailures": 1,
|
||||
"overflowed": false,
|
||||
"futureCounter": 11
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"identity": {
|
||||
"kind": "object",
|
||||
"bucket": "bucket",
|
||||
"object": "object",
|
||||
"versionId": null,
|
||||
"bucketIncarnationId": null,
|
||||
"poolIndex": null,
|
||||
"setIndex": null
|
||||
},
|
||||
"disposition": {
|
||||
"state": "failed",
|
||||
"details": "retry_exhausted"
|
||||
},
|
||||
"detail": null
|
||||
}
|
||||
],
|
||||
"objectsTruncated": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "remote_cancelled",
|
||||
"cliExit": 1,
|
||||
"response": {
|
||||
"summary": "stopped",
|
||||
"detail": "heal task cancelled; heal result items were truncated",
|
||||
"startTime": "2026-01-01T00:00:00Z",
|
||||
"settings": {
|
||||
"recursive": true,
|
||||
"scanMode": 1
|
||||
},
|
||||
"items": [],
|
||||
"truncated": true,
|
||||
"nextSeq": 9,
|
||||
"minSeq": 4,
|
||||
"progress": {
|
||||
"objectsScanned": 11,
|
||||
"objectsHealed": 7
|
||||
},
|
||||
"outcome": {
|
||||
"execution": {
|
||||
"state": "aborted",
|
||||
"reason": "cancelled",
|
||||
"futureExtension": {
|
||||
"value": 7
|
||||
}
|
||||
},
|
||||
"coverage": "partial",
|
||||
"counters": {
|
||||
"processed": 0,
|
||||
"healed": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"unknown": 0,
|
||||
"attemptFailures": 0,
|
||||
"overflowed": false,
|
||||
"futureCounter": 11
|
||||
},
|
||||
"objects": [],
|
||||
"objectsTruncated": false
|
||||
}
|
||||
},
|
||||
"remoteResponse": {
|
||||
"summary": "finished",
|
||||
"detail": "",
|
||||
"startTime": "2026-01-01T00:00:00Z",
|
||||
"settings": {
|
||||
"recursive": true,
|
||||
"scanMode": 1
|
||||
},
|
||||
"items": [],
|
||||
"truncated": true,
|
||||
"nextSeq": 9,
|
||||
"minSeq": 4,
|
||||
"progress": {
|
||||
"objectsScanned": 11,
|
||||
"objectsHealed": 7
|
||||
},
|
||||
"outcome": {
|
||||
"execution": {
|
||||
"state": "aborted",
|
||||
"reason": "cancelled",
|
||||
"futureExtension": {
|
||||
"value": 7
|
||||
}
|
||||
},
|
||||
"coverage": "partial",
|
||||
"counters": {
|
||||
"processed": 0,
|
||||
"healed": 0,
|
||||
"unchanged": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"unknown": 0,
|
||||
"attemptFailures": 0,
|
||||
"overflowed": false,
|
||||
"futureCounter": 11
|
||||
},
|
||||
"objects": [],
|
||||
"objectsTruncated": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -106,6 +106,7 @@ bytes.workspace = true
|
||||
hex-simd.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
rustfs-heal.workspace = true
|
||||
tracing-subscriber = { workspace = true, features = ["json", "env-filter", "time"] }
|
||||
serial_test = { workspace = true }
|
||||
temp-env = { workspace = true, features = ["async_closure"] }
|
||||
|
||||
@@ -85,8 +85,8 @@ pub use rustfs_scanner_metrics::last_minute;
|
||||
pub use scanner::{
|
||||
ScannerCycleRecoveryMarker, ScannerCycleRecoveryStatus, ScannerCycleScheduleStatus, ScannerPauseBacklogAlertReason,
|
||||
ScannerPauseBacklogPhase, ScannerPauseBacklogStatus, ScannerPauseBacklogThresholds, ScannerUsageStateResetResult,
|
||||
init_data_scanner, reset_scanner_cycle_recovery, reset_scanner_usage_state_for_full_rebuild, scanner_cycle_recovery_status,
|
||||
scanner_cycle_schedule_status, scanner_pause_backlog_status, scanner_topology_digest,
|
||||
init_data_scanner, init_scanner_with_recovery, reset_scanner_cycle_recovery, reset_scanner_usage_state_for_full_rebuild,
|
||||
scanner_cycle_recovery_status, scanner_cycle_schedule_status, scanner_pause_backlog_status, scanner_topology_digest,
|
||||
};
|
||||
pub use scanner_io::{
|
||||
ScannerDirtyUsageAckError, ScannerDirtyUsageBucket, ScannerDirtyUsageSnapshot, ScannerDirtyUsageState,
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::future::Future;
|
||||
#[cfg(test)]
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, LazyLock, RwLock};
|
||||
@@ -948,6 +947,33 @@ pub async fn init_data_scanner(ctx: CancellationToken, storeapi: Arc<ECStore>) {
|
||||
init_data_scanner_with_storage(ctx, storeapi).await;
|
||||
}
|
||||
|
||||
/// Start normal scanning when enabled, or one resume-only cleanup attempt.
|
||||
/// The disabled branch returns a finite task for the startup owner to join;
|
||||
/// it never enables ordinary namespace scanning or accepts a new reset intent.
|
||||
pub async fn init_scanner_with_recovery(
|
||||
ctx: CancellationToken,
|
||||
storeapi: Arc<ECStore>,
|
||||
enabled: bool,
|
||||
) -> Option<tokio::task::JoinHandle<()>> {
|
||||
if enabled {
|
||||
init_data_scanner(ctx, storeapi).await;
|
||||
return None;
|
||||
}
|
||||
Some(tokio::spawn(async move {
|
||||
if let Err(error) = resume_scanner_cycle_cleanup(ctx, storeapi).await {
|
||||
warn!(
|
||||
target: "rustfs::scanner",
|
||||
event = EVENT_SCANNER_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
||||
state = "disabled_cleanup_deferred",
|
||||
error = %error,
|
||||
"Disabled scanner cleanup remains pending for an operator retry"
|
||||
);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async fn init_data_scanner_with_storage<S>(ctx: CancellationToken, storeapi: Arc<S>)
|
||||
where
|
||||
S: ScannerStorage,
|
||||
@@ -1558,6 +1584,11 @@ async fn mark_scan_cycle_idle(cycle_info: &mut CurrentCycle, cycle_metrics_guard
|
||||
cycle_metrics_guard.finish(cycle_info.clone()).await;
|
||||
}
|
||||
|
||||
struct ScannerCycleScheduling {
|
||||
requires_full_scan: bool,
|
||||
service_cohort: Option<Arc<StdMutex<crate::scanner_io::ScannerServiceCohort>>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn run_data_scanner_cycle<S>(
|
||||
ctx: &CancellationToken,
|
||||
@@ -1570,7 +1601,19 @@ where
|
||||
S: ScannerStorage,
|
||||
{
|
||||
let cycle_budget = ScannerCycleBudget::new(ctx, scanner_cycle_budget_config());
|
||||
run_data_scanner_cycle_with_budget(ctx, storeapi, cycle_info, cycle_revision, leader_epoch, cycle_budget, true).await
|
||||
run_data_scanner_cycle_with_budget(
|
||||
ctx,
|
||||
storeapi,
|
||||
cycle_info,
|
||||
cycle_revision,
|
||||
leader_epoch,
|
||||
cycle_budget,
|
||||
ScannerCycleScheduling {
|
||||
requires_full_scan: true,
|
||||
service_cohort: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
@@ -1582,7 +1625,7 @@ async fn run_data_scanner_cycle_with_budget<S>(
|
||||
cycle_revision: &mut DataUsageCacheRevision,
|
||||
leader_epoch: u64,
|
||||
cycle_budget: Arc<ScannerCycleBudget>,
|
||||
requires_full_scan: bool,
|
||||
scheduling: ScannerCycleScheduling,
|
||||
) -> ScannerCycleOutcome
|
||||
where
|
||||
S: ScannerStorage,
|
||||
@@ -1721,7 +1764,8 @@ where
|
||||
scan_mode,
|
||||
scan_scope: crate::scanner_io::ScannerBucketScanScope::default(),
|
||||
persisted_usage_baseline: usage_persist_baseline.data.clone(),
|
||||
requires_full_scan,
|
||||
requires_full_scan: scheduling.requires_full_scan,
|
||||
service_cohort: scheduling.service_cohort,
|
||||
#[cfg(test)]
|
||||
resolved_scope_observer: None,
|
||||
},
|
||||
@@ -2583,6 +2627,7 @@ where
|
||||
let mut clean_idle_backoff = ScannerCleanIdleBackoff::default();
|
||||
let mut superseded_backoff = ScannerRetryBackoff::default();
|
||||
let mut deferred_backoff = ScannerRetryBackoff::default();
|
||||
let service_cohort = Arc::new(StdMutex::new(crate::scanner_io::ScannerServiceCohort::default()));
|
||||
let initial_runtime_config = resolve_scanner_runtime_config();
|
||||
if clean_idle_topology_supported && maintenance_generation_seen.is_none() {
|
||||
let Some((features, generation)) = detect_stable_scanner_maintenance_features(&ctx, &storeapi).await else {
|
||||
@@ -2792,7 +2837,10 @@ where
|
||||
&mut cycle_revision,
|
||||
leader_epoch,
|
||||
cycle_budget.clone(),
|
||||
true,
|
||||
ScannerCycleScheduling {
|
||||
requires_full_scan: true,
|
||||
service_cohort: Some(service_cohort.clone()),
|
||||
},
|
||||
),
|
||||
guard.lock_lost_notified(),
|
||||
)
|
||||
@@ -3083,11 +3131,14 @@ where
|
||||
&mut cycle_revision,
|
||||
leader_epoch,
|
||||
cycle_budget.clone(),
|
||||
maintenance_features.requires_full_scan(
|
||||
maintenance_generation_seen,
|
||||
scanner_maintenance_generation(),
|
||||
wake_reason,
|
||||
),
|
||||
ScannerCycleScheduling {
|
||||
requires_full_scan: maintenance_features.requires_full_scan(
|
||||
maintenance_generation_seen,
|
||||
scanner_maintenance_generation(),
|
||||
wake_reason,
|
||||
),
|
||||
service_cohort: Some(service_cohort.clone()),
|
||||
},
|
||||
),
|
||||
guard.lock_lost_notified(),
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::data_usage_define::{
|
||||
DATA_USAGE_BLOOM_RECOVERY_PATH, DATA_USAGE_RECOVERY_PATH, usage_floor_primary_read_error_allows_backup,
|
||||
};
|
||||
use crate::storage_api::owner::ObjectIO as _;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use tokio::io::AsyncReadExt as _;
|
||||
|
||||
const SCANNER_CYCLE_RECOVERY_SCHEMA_VERSION: u16 = 1;
|
||||
@@ -34,6 +35,86 @@ const CACHE_CYCLE_AHEAD: &str = "cache_cycle_ahead";
|
||||
|
||||
const SCANNER_USAGE_STATE_RESET_MODE_FULL_REBUILD: &str = "full-rebuild";
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) mod cleanup_io_fault {
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(in crate::scanner) enum Stage {
|
||||
PrimaryRead,
|
||||
PrimaryWrite,
|
||||
UsageFence,
|
||||
}
|
||||
|
||||
struct Injection {
|
||||
store: std::sync::Weak<ECStore>,
|
||||
stage: Stage,
|
||||
fired: AtomicBool,
|
||||
owned: AtomicBool,
|
||||
newer_completion: bool,
|
||||
}
|
||||
|
||||
static INJECTION: StdMutex<Option<Arc<Injection>>> = StdMutex::new(None);
|
||||
pub(in crate::scanner) struct Guard(Arc<Injection>);
|
||||
|
||||
impl Guard {
|
||||
pub(in crate::scanner) fn fired_while_owned(&self) -> bool {
|
||||
self.0.fired.load(Ordering::Relaxed) && self.0.owned.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
let mut slot = INJECTION.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if slot.as_ref().is_some_and(|current| Arc::ptr_eq(current, &self.0)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::scanner) fn install(store: &Arc<ECStore>, stage: Stage, newer_completion: bool) -> Guard {
|
||||
let injection = Arc::new(Injection {
|
||||
store: Arc::downgrade(store),
|
||||
stage,
|
||||
fired: AtomicBool::new(false),
|
||||
owned: AtomicBool::new(false),
|
||||
newer_completion,
|
||||
});
|
||||
let mut slot = INJECTION.lock().expect("cleanup injection slot");
|
||||
assert!(slot.is_none(), "only one cleanup I/O injection may be installed");
|
||||
*slot = Some(injection.clone());
|
||||
Guard(injection)
|
||||
}
|
||||
|
||||
pub(super) fn check(store: &Arc<ECStore>, stage: Stage, owned: bool) -> Result<(), ScannerError> {
|
||||
let injection = {
|
||||
let mut slot = INJECTION.lock().expect("cleanup injection slot");
|
||||
if slot
|
||||
.as_ref()
|
||||
.is_some_and(|injection| injection.stage == stage && injection.store.ptr_eq(&Arc::downgrade(store)))
|
||||
{
|
||||
slot.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let Some(injection) = injection else {
|
||||
return Ok(());
|
||||
};
|
||||
injection.fired.store(true, Ordering::Relaxed);
|
||||
injection.owned.store(owned, Ordering::Relaxed);
|
||||
if injection.newer_completion {
|
||||
set_scanner_cycle_recovery_status(recovery_status("healthy", None, false));
|
||||
}
|
||||
let reason = match stage {
|
||||
Stage::PrimaryRead => "injected primary read failure",
|
||||
Stage::PrimaryWrite => "injected primary write failure",
|
||||
Stage::UsageFence => "injected usage fence failure",
|
||||
};
|
||||
Err(ScannerError::Io(std::io::Error::other(reason)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ScannerCycleRecoveryStatus {
|
||||
/// The immutable primary object whose revision is being guarded.
|
||||
@@ -81,6 +162,8 @@ static SCANNER_CYCLE_RECOVERY_STATUS: LazyLock<RwLock<ScannerCycleRecoveryStatus
|
||||
..Default::default()
|
||||
})
|
||||
});
|
||||
// An old startup observation must not overwrite a newer explicit reset status.
|
||||
static SCANNER_CYCLE_RECOVERY_STATUS_VERSION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub fn scanner_cycle_recovery_status() -> ScannerCycleRecoveryStatus {
|
||||
SCANNER_CYCLE_RECOVERY_STATUS
|
||||
@@ -90,6 +173,24 @@ pub fn scanner_cycle_recovery_status() -> ScannerCycleRecoveryStatus {
|
||||
}
|
||||
|
||||
fn set_scanner_cycle_recovery_status(status: ScannerCycleRecoveryStatus) {
|
||||
let _ = publish_scanner_cleanup_status(status, None);
|
||||
}
|
||||
|
||||
pub(super) fn scanner_cleanup_status_version() -> u64 {
|
||||
let _status = SCANNER_CYCLE_RECOVERY_STATUS
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
SCANNER_CYCLE_RECOVERY_STATUS_VERSION.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub(super) fn publish_scanner_cleanup_status(status: ScannerCycleRecoveryStatus, expected: Option<u64>) -> Option<u64> {
|
||||
let mut current = SCANNER_CYCLE_RECOVERY_STATUS
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let version = SCANNER_CYCLE_RECOVERY_STATUS_VERSION.load(Ordering::Relaxed);
|
||||
if expected.is_some_and(|expected| expected == u64::MAX || expected != version) {
|
||||
return None;
|
||||
}
|
||||
let recovery_required = if matches!(
|
||||
status.state.as_str(),
|
||||
"blocked"
|
||||
@@ -106,9 +207,27 @@ fn set_scanner_cycle_recovery_status(status: ScannerCycleRecoveryStatus) {
|
||||
};
|
||||
metrics::gauge!(METRIC_SCANNER_CYCLE_RECOVERY_REQUIRED).set(recovery_required);
|
||||
metrics::gauge!(METRIC_SCANNER_CYCLE_RECOVERY_RETRY_COUNT).set(status.retry_count as f64);
|
||||
*SCANNER_CYCLE_RECOVERY_STATUS
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = status;
|
||||
*current = status;
|
||||
let next = version.saturating_add(1);
|
||||
SCANNER_CYCLE_RECOVERY_STATUS_VERSION.store(next, Ordering::Relaxed);
|
||||
Some(next)
|
||||
}
|
||||
|
||||
fn publish_scanner_cleanup_failure(reason: String, expected: u64) {
|
||||
let mut status = {
|
||||
let current = SCANNER_CYCLE_RECOVERY_STATUS
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if SCANNER_CYCLE_RECOVERY_STATUS_VERSION.load(Ordering::Relaxed) != expected {
|
||||
return;
|
||||
}
|
||||
current.clone()
|
||||
};
|
||||
// Preserve the latest core progress, including a newly written primary's
|
||||
// revision and epoch. The original marker may predate that durable write.
|
||||
status.reason = Some(reason);
|
||||
status.last_attempt_at_unix_secs = Some(unix_now_secs());
|
||||
let _ = publish_scanner_cleanup_status(status, Some(expected));
|
||||
}
|
||||
|
||||
pub(super) fn record_scanner_usage_floor_failure(reason: String) {
|
||||
@@ -932,10 +1051,81 @@ pub(crate) async fn load_scanner_cycle_state_for_startup(
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset a blocked cycle state after an operator has explicitly requested a full
|
||||
/// usage rebuild. The primary object is changed first with its observed ETag;
|
||||
/// the recovery marker is removed only when its own ETag still matches.
|
||||
/// Reset a blocked cycle state after an explicit full-rescan request. Durable
|
||||
/// cleanup state fences primary rewrites; marker removal retains its ETag and
|
||||
/// usage-epoch checks.
|
||||
pub async fn reset_scanner_cycle_recovery(ctx: CancellationToken, storeapi: Arc<ECStore>) -> Result<(), ScannerError> {
|
||||
reset_scanner_cycle_recovery_for_intent(ctx, storeapi, None, None).await
|
||||
}
|
||||
|
||||
/// Resume only an operator reset whose cleanup phase is already durable.
|
||||
/// Missing or merely blocked markers never authorize an automatic reset.
|
||||
pub(super) async fn resume_scanner_cycle_cleanup(ctx: CancellationToken, storeapi: Arc<ECStore>) -> Result<(), ScannerError> {
|
||||
let status_version = scanner_cleanup_status_version();
|
||||
let (marker, revision) = match read_scanner_cleanup_marker(storeapi.clone(), &ctx).await {
|
||||
Ok(Some(marker)) => marker,
|
||||
Ok(None) => return Ok(()),
|
||||
Err(error) => {
|
||||
let _ =
|
||||
publish_scanner_cleanup_status(recovery_status("blocked", Some(&error.to_string()), false), Some(status_version));
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let mut observation =
|
||||
publish_scanner_cleanup_status(recovery_status_from_marker(&marker, &marker.state), Some(status_version));
|
||||
if marker.state != "cleanup-pending" {
|
||||
return Ok(());
|
||||
}
|
||||
let result = reset_scanner_cycle_recovery_for_intent(ctx, storeapi, Some(revision), Some(&mut observation)).await;
|
||||
if let Err(error) = &result
|
||||
&& let Some(observation) = observation
|
||||
{
|
||||
publish_scanner_cleanup_failure(error.to_string(), observation);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) async fn read_scanner_cleanup_marker(
|
||||
storeapi: Arc<impl ScannerObjectIO>,
|
||||
ctx: &CancellationToken,
|
||||
) -> Result<Option<(ScannerCycleRecoveryMarker, DataUsageCacheRevision)>, ScannerError> {
|
||||
let (data, revision) = tokio::select! {
|
||||
biased;
|
||||
_ = ctx.cancelled() => return Err(ScannerError::Other("scanner cleanup recovery was cancelled".to_string())),
|
||||
result = tokio::time::timeout(data_usage_persist_timeout(), read_cycle_recovery_marker_bytes(storeapi)) => {
|
||||
result.map_err(|_| ScannerError::Other("scanner cleanup marker inspection timed out".to_string()))?
|
||||
.map_err(|err| ScannerError::Other(format!("failed to inspect pending scanner cleanup: {err}")))?
|
||||
}
|
||||
};
|
||||
let Some(data) = data else {
|
||||
return Ok(None);
|
||||
};
|
||||
let marker: ScannerCycleRecoveryMarker = serde_json::from_slice(&data)
|
||||
.map_err(|err| ScannerError::Other(format!("pending scanner cleanup marker is invalid: {err}")))?;
|
||||
validate_recovery_marker(&marker)
|
||||
.map_err(|err| ScannerError::Other(format!("pending scanner cleanup marker is invalid: {err}")))?;
|
||||
Ok(Some((marker, revision)))
|
||||
}
|
||||
|
||||
pub(super) async fn reset_scanner_cycle_recovery_for_intent(
|
||||
ctx: CancellationToken,
|
||||
storeapi: Arc<ECStore>,
|
||||
expected_cleanup_revision: Option<DataUsageCacheRevision>,
|
||||
mut observation: Option<&mut Option<u64>>,
|
||||
) -> Result<(), ScannerError> {
|
||||
#[cfg(test)]
|
||||
let resume_only = expected_cleanup_revision.is_some();
|
||||
// The outer Some distinguishes a tracked resume whose version may be
|
||||
// invalidated from an explicit v3 reset with no observation owner.
|
||||
let mut publish_status = |status| {
|
||||
if let Some(version) = observation.as_deref_mut() {
|
||||
if let Some(expected) = *version {
|
||||
*version = publish_scanner_cleanup_status(status, Some(expected));
|
||||
}
|
||||
} else {
|
||||
set_scanner_cycle_recovery_status(status);
|
||||
}
|
||||
};
|
||||
let lock = storeapi
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, "leader.lock")
|
||||
.await
|
||||
@@ -964,6 +1154,21 @@ pub async fn reset_scanner_cycle_recovery(ctx: CancellationToken, storeapi: Arc<
|
||||
}
|
||||
Err(err) => return Err(ScannerError::Other(format!("failed to read cycle recovery marker: {err}"))),
|
||||
};
|
||||
if let Some(expected) = expected_cleanup_revision {
|
||||
if marker_revision != expected || !owns_reset() {
|
||||
return Err(ScannerError::Other(
|
||||
"pending scanner cleanup changed before recovery acquired ownership".to_string(),
|
||||
));
|
||||
}
|
||||
let marker: ScannerCycleRecoveryMarker = marker_data
|
||||
.as_deref()
|
||||
.and_then(|data| serde_json::from_slice(data).ok())
|
||||
.filter(|marker| validate_recovery_marker(marker).is_ok() && marker.state == "cleanup-pending")
|
||||
.ok_or_else(|| {
|
||||
ScannerError::Other("scanner cleanup recovery requires an unchanged cleanup-pending marker".to_string())
|
||||
})?;
|
||||
publish_status(recovery_status_from_marker(&marker, "cleanup-pending"));
|
||||
}
|
||||
let Some(marker_data) = marker_data else {
|
||||
// A delete may commit before its reply is lost. Confirm both durable
|
||||
// fences before treating a retry without its marker as completed.
|
||||
@@ -981,7 +1186,7 @@ pub async fn reset_scanner_cycle_recovery(ctx: CancellationToken, storeapi: Arc<
|
||||
"scanner cycle recovery marker is absent without a completed reset fence".to_string(),
|
||||
));
|
||||
}
|
||||
set_scanner_cycle_recovery_status(recovery_status("healthy", None, false));
|
||||
publish_status(recovery_status("healthy", None, false));
|
||||
super::notify_scanner_cycle_recovery_wake();
|
||||
return Ok(());
|
||||
};
|
||||
@@ -997,6 +1202,10 @@ pub async fn reset_scanner_cycle_recovery(ctx: CancellationToken, storeapi: Arc<
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
if resume_only {
|
||||
cleanup_io_fault::check(&storeapi, cleanup_io_fault::Stage::PrimaryRead, owns_reset())?;
|
||||
}
|
||||
let (mut primary_reader, primary_revision) = match storeapi
|
||||
.get_object_reader(
|
||||
RUSTFS_META_BUCKET,
|
||||
@@ -1062,7 +1271,7 @@ pub async fn reset_scanner_cycle_recovery(ctx: CancellationToken, storeapi: Arc<
|
||||
let (cleanup_marker, cleanup_marker_revision) =
|
||||
mark_cycle_recovery_cleanup_pending(storeapi.clone(), marker.clone(), &marker_revision, reset_epoch, &owns_reset)
|
||||
.await?;
|
||||
set_scanner_cycle_recovery_status(recovery_status_from_marker(&cleanup_marker, "cleanup-pending"));
|
||||
publish_status(recovery_status_from_marker(&cleanup_marker, "cleanup-pending"));
|
||||
let usage_floor = persisted_usage_floor(storeapi.clone()).await?;
|
||||
let fence_epoch = primary_epoch
|
||||
.max(usage_floor.leader_epoch)
|
||||
@@ -1082,6 +1291,10 @@ pub async fn reset_scanner_cycle_recovery(ctx: CancellationToken, storeapi: Arc<
|
||||
));
|
||||
}
|
||||
verify_cycle_reset_intent(storeapi.clone(), &cleanup_marker_revision, &owns_reset).await?;
|
||||
#[cfg(test)]
|
||||
if resume_only {
|
||||
cleanup_io_fault::check(&storeapi, cleanup_io_fault::Stage::PrimaryWrite, owns_reset())?;
|
||||
}
|
||||
let preserved_info = save_reset_config(
|
||||
storeapi.clone(),
|
||||
DATA_USAGE_BLOOM_NAME_PATH.as_str(),
|
||||
@@ -1155,7 +1368,7 @@ pub async fn reset_scanner_cycle_recovery(ctx: CancellationToken, storeapi: Arc<
|
||||
ScannerError::Other(format!("failed to clear stale cycle recovery marker: {err}"))
|
||||
}
|
||||
})?;
|
||||
set_scanner_cycle_recovery_status(recovery_status("healthy", None, false));
|
||||
publish_status(recovery_status("healthy", None, false));
|
||||
super::notify_scanner_cycle_recovery_wake();
|
||||
return Ok(());
|
||||
} else if !force_full_rescan && !marker_cleanup_pending {
|
||||
@@ -1228,11 +1441,23 @@ pub async fn reset_scanner_cycle_recovery(ctx: CancellationToken, storeapi: Arc<
|
||||
));
|
||||
}
|
||||
verify_cycle_reset_intent(storeapi.clone(), &marker_revision, &owns_reset).await?;
|
||||
if let Err(err) =
|
||||
fence_scanner_usage_epoch_with_expected_epoch(&ctx, storeapi.clone(), leader_epoch, Some(reset_epoch), false, &owns_reset)
|
||||
.await
|
||||
{
|
||||
set_scanner_cycle_recovery_status(ScannerCycleRecoveryStatus {
|
||||
let usage_fence = fence_scanner_usage_epoch_with_expected_epoch(
|
||||
&ctx,
|
||||
storeapi.clone(),
|
||||
leader_epoch,
|
||||
Some(reset_epoch),
|
||||
false,
|
||||
&owns_reset,
|
||||
);
|
||||
#[cfg(test)]
|
||||
let usage_fence = async {
|
||||
if resume_only {
|
||||
cleanup_io_fault::check(&storeapi, cleanup_io_fault::Stage::UsageFence, owns_reset())?;
|
||||
}
|
||||
usage_fence.await
|
||||
};
|
||||
if let Err(err) = usage_fence.await {
|
||||
publish_status(ScannerCycleRecoveryStatus {
|
||||
path: DATA_USAGE_BLOOM_NAME_PATH.clone(),
|
||||
quarantine_path: Some(DATA_USAGE_BLOOM_RECOVERY_PATH.clone()),
|
||||
state: "cleanup-pending".to_string(),
|
||||
@@ -1260,7 +1485,7 @@ pub async fn reset_scanner_cycle_recovery(ctx: CancellationToken, storeapi: Arc<
|
||||
}
|
||||
};
|
||||
if current_revision != rebuilt_revision {
|
||||
set_scanner_cycle_recovery_status(ScannerCycleRecoveryStatus {
|
||||
publish_status(ScannerCycleRecoveryStatus {
|
||||
path: DATA_USAGE_BLOOM_NAME_PATH.clone(),
|
||||
quarantine_path: Some(DATA_USAGE_BLOOM_RECOVERY_PATH.clone()),
|
||||
state: "cleanup-pending".to_string(),
|
||||
@@ -1281,7 +1506,7 @@ pub async fn reset_scanner_cycle_recovery(ctx: CancellationToken, storeapi: Arc<
|
||||
}
|
||||
|
||||
if guard.is_lock_lost() {
|
||||
set_scanner_cycle_recovery_status(ScannerCycleRecoveryStatus {
|
||||
publish_status(ScannerCycleRecoveryStatus {
|
||||
path: DATA_USAGE_BLOOM_NAME_PATH.clone(),
|
||||
quarantine_path: Some(DATA_USAGE_BLOOM_RECOVERY_PATH.clone()),
|
||||
state: "cleanup-pending".to_string(),
|
||||
@@ -1318,7 +1543,7 @@ pub async fn reset_scanner_cycle_recovery(ctx: CancellationToken, storeapi: Arc<
|
||||
.await
|
||||
{
|
||||
if scanner_publication_epoch_changed(&err) {
|
||||
set_scanner_cycle_recovery_status(ScannerCycleRecoveryStatus {
|
||||
publish_status(ScannerCycleRecoveryStatus {
|
||||
path: DATA_USAGE_BLOOM_NAME_PATH.clone(),
|
||||
quarantine_path: Some(DATA_USAGE_BLOOM_RECOVERY_PATH.clone()),
|
||||
state: "cleanup-pending".to_string(),
|
||||
@@ -1336,7 +1561,7 @@ pub async fn reset_scanner_cycle_recovery(ctx: CancellationToken, storeapi: Arc<
|
||||
"scanner recovery reset deferred by a movement epoch change".to_string(),
|
||||
));
|
||||
}
|
||||
set_scanner_cycle_recovery_status(ScannerCycleRecoveryStatus {
|
||||
publish_status(ScannerCycleRecoveryStatus {
|
||||
path: DATA_USAGE_BLOOM_NAME_PATH.clone(),
|
||||
quarantine_path: Some(DATA_USAGE_BLOOM_RECOVERY_PATH.clone()),
|
||||
state: "cleanup-pending".to_string(),
|
||||
@@ -1352,7 +1577,7 @@ pub async fn reset_scanner_cycle_recovery(ctx: CancellationToken, storeapi: Arc<
|
||||
});
|
||||
return Err(ScannerError::Other(format!("failed to clear cycle recovery marker: {err}")));
|
||||
}
|
||||
set_scanner_cycle_recovery_status(ScannerCycleRecoveryStatus {
|
||||
publish_status(ScannerCycleRecoveryStatus {
|
||||
path: DATA_USAGE_BLOOM_NAME_PATH.clone(),
|
||||
quarantine_path: Some(DATA_USAGE_BLOOM_RECOVERY_PATH.clone()),
|
||||
state: "healthy".to_string(),
|
||||
|
||||
@@ -36,6 +36,8 @@ use tokio::time::{Duration, advance};
|
||||
|
||||
const TEST_DEFAULT_SCANNER_CYCLE_SECS: u64 = 24 * 60 * 60;
|
||||
|
||||
mod recovery_control;
|
||||
|
||||
async fn setup_scanner_cycle_store() -> (tempfile::TempDir, Arc<ECStore>) {
|
||||
setup_scanner_cycle_store_with_usage_baseline(true).await
|
||||
}
|
||||
@@ -1219,7 +1221,18 @@ async fn coordinator_walks_during_pending_put_without_persisting_or_acknowledgin
|
||||
let mut revision = DataUsageCacheRevision::Missing;
|
||||
let outcome = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
run_data_scanner_cycle_with_budget(&ctx, &store, &mut cycle_info, &mut revision, 1, Arc::clone(&budget), true),
|
||||
run_data_scanner_cycle_with_budget(
|
||||
&ctx,
|
||||
&store,
|
||||
&mut cycle_info,
|
||||
&mut revision,
|
||||
1,
|
||||
Arc::clone(&budget),
|
||||
ScannerCycleScheduling {
|
||||
requires_full_scan: true,
|
||||
service_cohort: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("the coordinator must finish its namespace walk while a PUT is pending");
|
||||
@@ -1263,7 +1276,18 @@ async fn coordinator_walks_during_pending_put_without_persisting_or_acknowledgin
|
||||
let retry_budget = ScannerCycleBudget::new_with_progress_tracking(&ctx, ScannerCycleBudgetConfig::default());
|
||||
let outcome = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
run_data_scanner_cycle_with_budget(&ctx, &store, &mut cycle_info, &mut revision, 1, Arc::clone(&retry_budget), true),
|
||||
run_data_scanner_cycle_with_budget(
|
||||
&ctx,
|
||||
&store,
|
||||
&mut cycle_info,
|
||||
&mut revision,
|
||||
1,
|
||||
Arc::clone(&retry_budget),
|
||||
ScannerCycleScheduling {
|
||||
requires_full_scan: true,
|
||||
service_cohort: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("the same cycle must converge after the pending PUT drains");
|
||||
@@ -8514,6 +8538,56 @@ async fn test_wait_for_next_scanner_cycle_wakes_for_dirty_usage() {
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial]
|
||||
async fn service_cohort_aging_preserves_explicit_cycle_wait() {
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
let config = ScannerRuntimeConfig {
|
||||
cycle_interval: Duration::from_secs(3600),
|
||||
cycle_interval_source: ScannerRuntimeConfigSource::Env,
|
||||
..Default::default()
|
||||
};
|
||||
let observed = ScannerCycleObservedGenerations::for_wait(
|
||||
&config,
|
||||
None,
|
||||
crate::scanner_io::dirty_usage_generation(),
|
||||
crate::runtime_config::scanner_runtime_config_generation(),
|
||||
crate::scanner_io::scanner_maintenance_generation(),
|
||||
);
|
||||
assert_eq!(observed.dirty_usage, None);
|
||||
let inventory = HashMap::from([(
|
||||
crate::data_usage_define::DataUsageCacheSource::new(0, 0),
|
||||
vec![crate::storage_api::scanner_io::BucketInfo {
|
||||
name: "waiting-bootstrap".to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
)]);
|
||||
let mut cohort = crate::scanner_io::ScannerServiceCohort::default();
|
||||
cohort.refresh(&inventory);
|
||||
let ctx = CancellationToken::new();
|
||||
let mut wait = Box::pin(wait_for_next_scanner_cycle(
|
||||
&ctx,
|
||||
config.cycle_interval,
|
||||
observed.dirty_usage,
|
||||
observed.runtime_config,
|
||||
observed.maintenance,
|
||||
|| false,
|
||||
));
|
||||
assert!(matches!(futures::poll!(&mut wait), Poll::Pending));
|
||||
for _ in 0..59 {
|
||||
tokio::time::advance(Duration::from_secs(60)).await;
|
||||
cohort.refresh(&inventory);
|
||||
crate::scanner_io::record_dirty_usage_bucket("hot");
|
||||
assert!(
|
||||
matches!(futures::poll!(&mut wait), Poll::Pending),
|
||||
"aging/dirty must not shorten the explicit hour"
|
||||
);
|
||||
}
|
||||
tokio::time::advance(Duration::from_secs(60)).await;
|
||||
assert_eq!(wait.await, ScannerCycleWakeReason::Timer);
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_wait_for_next_scanner_cycle_sees_unattempted_dirty_usage() {
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::super::cycle_state::cleanup_io_fault;
|
||||
use super::*;
|
||||
use crate::storage_api::owner::{EcstoreRebalStatus, EcstoreRebalanceInfo, EcstoreRebalanceMeta, EcstoreRebalanceStats};
|
||||
|
||||
async fn seed_cleanup(store: &Arc<ECStore>, state: &str) -> ScannerCycleRecoveryMarker {
|
||||
let cycle = CurrentCycle {
|
||||
current: 3,
|
||||
next: 42,
|
||||
..Default::default()
|
||||
};
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_BLOOM_NAME_PATH.as_str(),
|
||||
encode_scanner_cycle_state(&cycle, 7).expect("cycle encoding"),
|
||||
)
|
||||
.await
|
||||
.expect("persist cycle");
|
||||
let usage = DataUsageInfo {
|
||||
scanner_epoch: Some(7),
|
||||
scanner_cycle: Some(41),
|
||||
..complete_usage_with_bucket_count(Some(std::time::SystemTime::UNIX_EPOCH), 0)
|
||||
};
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
serde_json::to_vec(&usage).expect("usage encoding"),
|
||||
)
|
||||
.await
|
||||
.expect("persist usage floor");
|
||||
let marker = ScannerCycleRecoveryMarker {
|
||||
schema_version: 1,
|
||||
primary_revision: "previous-primary".to_string(),
|
||||
generation: 41,
|
||||
leader_epoch: 7,
|
||||
classification: "corrupt".to_string(),
|
||||
first_detected_at_unix_secs: 1,
|
||||
last_attempt_at_unix_secs: 2,
|
||||
retry_count: 1,
|
||||
reason: "operator reset in progress".to_string(),
|
||||
path: DATA_USAGE_BLOOM_NAME_PATH.clone(),
|
||||
quarantine_path: DATA_USAGE_BLOOM_RECOVERY_PATH.clone(),
|
||||
state: state.to_string(),
|
||||
};
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(),
|
||||
serde_json::to_vec(&marker).expect("marker encoding"),
|
||||
)
|
||||
.await
|
||||
.expect("persist operator marker");
|
||||
marker
|
||||
}
|
||||
|
||||
async fn persisted_state(store: &Arc<ECStore>) -> Vec<(Option<Vec<u8>>, DataUsageCacheRevision)> {
|
||||
let mut state = Vec::new();
|
||||
for path in [
|
||||
DATA_USAGE_BLOOM_NAME_PATH.as_str(),
|
||||
DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
] {
|
||||
state.push(
|
||||
read_config_with_revision(store.clone(), path)
|
||||
.await
|
||||
.expect("read exact metadata revision"),
|
||||
);
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
async fn run_disabled_startup(ctx: CancellationToken, store: Arc<ECStore>) {
|
||||
let initialized_before = crate::scanner_runtime_initialized();
|
||||
let cleanup = init_scanner_with_recovery(ctx, store, false).await;
|
||||
if let Some(cleanup) = cleanup {
|
||||
tokio::time::timeout(Duration::from_secs(15), cleanup)
|
||||
.await
|
||||
.expect("finite disabled cleanup attempt")
|
||||
.expect("cleanup task should not panic");
|
||||
}
|
||||
assert_eq!(
|
||||
crate::scanner_runtime_initialized(),
|
||||
initialized_before,
|
||||
"disabled recovery must not start the normal scanner runtime"
|
||||
);
|
||||
}
|
||||
|
||||
async fn assert_reset_fences(store: &Arc<ECStore>) {
|
||||
let data = read_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
|
||||
.await
|
||||
.expect("cycle remains durable");
|
||||
let (cycle, epoch) = decode_scanner_cycle_state(&data).expect("valid preserved cycle");
|
||||
assert_eq!((cycle.current, cycle.next, epoch), (3, 42, 8));
|
||||
let usage: DataUsageInfo = serde_json::from_slice(
|
||||
&read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.expect("durable usage fence"),
|
||||
)
|
||||
.expect("valid usage");
|
||||
assert_eq!(usage.scanner_epoch, Some(8));
|
||||
assert_eq!(usage.scanner_cycle, Some(41));
|
||||
assert!(matches!(
|
||||
read_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str()).await,
|
||||
Err(EcstoreError::ConfigNotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn disabled_cleanup_reopens_persisted_intent_without_starting_scanner() {
|
||||
let (_dir, store) = setup_scanner_cycle_store().await;
|
||||
seed_cleanup(&store, "cleanup-pending").await;
|
||||
let restarted = restart_scanner_cycle_store_from(&store).await;
|
||||
run_disabled_startup(CancellationToken::new(), restarted.clone()).await;
|
||||
assert_reset_fences(&restarted).await;
|
||||
let completed = persisted_state(&restarted).await;
|
||||
run_disabled_startup(CancellationToken::new(), restarted.clone()).await;
|
||||
assert_eq!(
|
||||
persisted_state(&restarted).await,
|
||||
completed,
|
||||
"a later startup without an intent must not reset again"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn disabled_cleanup_does_not_authorize_blocked_unknown_or_corrupt_markers() {
|
||||
let (_dir, store) = setup_scanner_cycle_store().await;
|
||||
for kind in ["blocked", "unknown-phase", "future-version", "unknown-field", "corrupt"] {
|
||||
let marker = seed_cleanup(&store, "blocked").await;
|
||||
let mut value = serde_json::to_value(marker).expect("marker value");
|
||||
match kind {
|
||||
"unknown-phase" => value["state"] = "future-phase".into(),
|
||||
"future-version" => value["schema_version"] = 99.into(),
|
||||
"unknown-field" => value["future_hint"] = true.into(),
|
||||
_ => {}
|
||||
}
|
||||
let bytes = if kind == "corrupt" {
|
||||
b"{broken".to_vec()
|
||||
} else {
|
||||
serde_json::to_vec(&value).expect("marker JSON")
|
||||
};
|
||||
save_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(), bytes)
|
||||
.await
|
||||
.expect("persist rejected marker");
|
||||
let before = persisted_state(&store).await;
|
||||
run_disabled_startup(CancellationToken::new(), store.clone()).await;
|
||||
assert_eq!(persisted_state(&store).await, before, "{kind} must not become an automatic full rescan");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn disabled_cleanup_rechecks_revision_after_waiting_for_leader_lock() {
|
||||
let (_dir, store) = setup_scanner_cycle_store().await;
|
||||
let mut marker = seed_cleanup(&store, "cleanup-pending").await;
|
||||
let expected = read_config_with_revision(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str())
|
||||
.await
|
||||
.expect("intent revision")
|
||||
.1;
|
||||
let lock = store
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, "leader.lock")
|
||||
.await
|
||||
.expect("leader lock");
|
||||
let guard = lock
|
||||
.get_write_lock_quiet(Duration::from_secs(1))
|
||||
.await
|
||||
.expect("hold leader ownership");
|
||||
let mut recovery = Box::pin(reset_scanner_cycle_recovery_for_intent(
|
||||
CancellationToken::new(),
|
||||
store.clone(),
|
||||
Some(expected),
|
||||
None,
|
||||
));
|
||||
assert!(matches!(futures::poll!(&mut recovery), Poll::Pending));
|
||||
marker.state = "blocked".to_string();
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(),
|
||||
serde_json::to_vec(&marker).expect("replacement marker"),
|
||||
)
|
||||
.await
|
||||
.expect("replace intent while the fixture owns leader lock");
|
||||
let replaced = persisted_state(&store).await;
|
||||
drop(guard);
|
||||
let error = recovery.await.expect_err("old preflight cannot authorize replacement marker");
|
||||
assert!(error.to_string().contains("changed before recovery acquired ownership"));
|
||||
assert_eq!(persisted_state(&store).await, replaced);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn disabled_cleanup_requires_phase_even_when_revision_matches() {
|
||||
let (_dir, store) = setup_scanner_cycle_store().await;
|
||||
seed_cleanup(&store, "blocked").await;
|
||||
let before = persisted_state(&store).await;
|
||||
let error = reset_scanner_cycle_recovery_for_intent(CancellationToken::new(), store.clone(), Some(before[1].1.clone()), None)
|
||||
.await
|
||||
.expect_err("a matching ETag alone is not operator cleanup authorization");
|
||||
assert!(error.to_string().contains("unchanged cleanup-pending"));
|
||||
assert_eq!(persisted_state(&store).await, before);
|
||||
reset_scanner_cycle_recovery(CancellationToken::new(), store.clone())
|
||||
.await
|
||||
.expect("explicit v3 core retains full reset authorization");
|
||||
assert_reset_fences(&store).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn disabled_cleanup_lock_busy_preserves_intent_without_force_unlock() {
|
||||
let (_dir, store) = setup_scanner_cycle_store().await;
|
||||
seed_cleanup(&store, "cleanup-pending").await;
|
||||
let before = persisted_state(&store).await;
|
||||
let lock = store
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, "leader.lock")
|
||||
.await
|
||||
.expect("leader lock");
|
||||
let guard = lock
|
||||
.get_write_lock_quiet(Duration::from_secs(1))
|
||||
.await
|
||||
.expect("hold live leader");
|
||||
let error = resume_scanner_cycle_cleanup(CancellationToken::new(), store.clone())
|
||||
.await
|
||||
.expect_err("busy leader must block recovery");
|
||||
assert!(error.to_string().contains("leader lock is busy"));
|
||||
let status = scanner_cycle_recovery_status();
|
||||
assert_eq!(status.state, "cleanup-pending");
|
||||
assert!(
|
||||
status
|
||||
.reason
|
||||
.as_deref()
|
||||
.is_some_and(|reason| reason.contains("leader lock is busy"))
|
||||
);
|
||||
assert!(!status.retryable, "disabled startup makes one attempt, not an automatic retry loop");
|
||||
assert!(!guard.is_lock_lost(), "recovery must not revoke the live owner");
|
||||
assert_eq!(persisted_state(&store).await, before);
|
||||
drop(guard);
|
||||
run_disabled_startup(CancellationToken::new(), store.clone()).await;
|
||||
assert_reset_fences(&store).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn disabled_cleanup_movement_pause_preserves_intent_for_later_startup() {
|
||||
let (_dir, store) = setup_scanner_cycle_store().await;
|
||||
seed_cleanup(&store, "cleanup-pending").await;
|
||||
let before = persisted_state(&store).await;
|
||||
*store.rebalance_meta.write().await = Some(EcstoreRebalanceMeta {
|
||||
id: "cleanup-movement".to_string(),
|
||||
pool_stats: vec![EcstoreRebalanceStats {
|
||||
participating: true,
|
||||
info: EcstoreRebalanceInfo {
|
||||
start_time: Some(time::OffsetDateTime::now_utc()),
|
||||
status: EcstoreRebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
let error = resume_scanner_cycle_cleanup(CancellationToken::new(), store.clone())
|
||||
.await
|
||||
.expect_err("movement must block reset publication");
|
||||
assert!(error.to_string().contains("blocked by data movement"));
|
||||
let status = scanner_cycle_recovery_status();
|
||||
assert_eq!(status.state, "cleanup-pending");
|
||||
assert!(
|
||||
status
|
||||
.reason
|
||||
.as_deref()
|
||||
.is_some_and(|reason| reason.contains("blocked by data movement"))
|
||||
);
|
||||
assert_eq!(persisted_state(&store).await, before);
|
||||
*store.rebalance_meta.write().await = None;
|
||||
run_disabled_startup(CancellationToken::new(), store.clone()).await;
|
||||
assert_reset_fences(&store).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn disabled_cleanup_cancelled_startup_preserves_persisted_work() {
|
||||
let (_dir, store) = setup_scanner_cycle_store().await;
|
||||
seed_cleanup(&store, "cleanup-pending").await;
|
||||
let before = persisted_state(&store).await;
|
||||
let ctx = CancellationToken::new();
|
||||
ctx.cancel();
|
||||
run_disabled_startup(ctx, store.clone()).await;
|
||||
assert_eq!(persisted_state(&store).await, before);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial]
|
||||
async fn disabled_cleanup_probe_obeys_cancellation_and_existing_io_deadline() {
|
||||
for cancel in [false, true] {
|
||||
let store = Arc::new(MemoryConfigStore::default());
|
||||
store.delayed_gets.lock().await.insert(
|
||||
memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str()),
|
||||
data_usage_persist_timeout().saturating_add(Duration::from_secs(60)),
|
||||
);
|
||||
let ctx = CancellationToken::new();
|
||||
let mut probe = Box::pin(read_scanner_cleanup_marker(store.clone(), &ctx));
|
||||
assert!(matches!(futures::poll!(&mut probe), Poll::Pending));
|
||||
if cancel {
|
||||
ctx.cancel();
|
||||
} else {
|
||||
advance(data_usage_persist_timeout()).await;
|
||||
}
|
||||
let error = probe.await.expect_err("pending read must be bounded");
|
||||
assert!(error.to_string().contains(if cancel { "cancelled" } else { "timed out" }));
|
||||
assert!(store.put_counts.lock().await.is_empty(), "probe must remain read-only");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn disabled_cleanup_old_observation_cannot_overwrite_a_new_completion() {
|
||||
let original = scanner_cycle_recovery_status();
|
||||
let healthy = ScannerCycleRecoveryStatus {
|
||||
state: "healthy".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
publish_scanner_cleanup_status(healthy.clone(), None).expect("first status version");
|
||||
let old = scanner_cleanup_status_version();
|
||||
publish_scanner_cleanup_status(healthy, None).expect("a newer completion may have identical fields");
|
||||
assert!(
|
||||
publish_scanner_cleanup_status(
|
||||
ScannerCycleRecoveryStatus {
|
||||
state: "cleanup-pending".to_string(),
|
||||
reason: Some("old lock wait failed".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
Some(old)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(scanner_cycle_recovery_status().state, "healthy");
|
||||
publish_scanner_cleanup_status(original, None).expect("restore prior observation");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn disabled_cleanup_owned_read_and_write_failures_keep_specific_status() {
|
||||
for (stage, newer_completion) in [
|
||||
(cleanup_io_fault::Stage::PrimaryRead, false),
|
||||
(cleanup_io_fault::Stage::PrimaryWrite, false),
|
||||
(cleanup_io_fault::Stage::PrimaryWrite, true),
|
||||
] {
|
||||
let (_dir, store) = setup_scanner_cycle_store().await;
|
||||
seed_cleanup(&store, "cleanup-pending").await;
|
||||
let before = persisted_state(&store).await;
|
||||
let injection = cleanup_io_fault::install(&store, stage, newer_completion);
|
||||
let error = resume_scanner_cycle_cleanup(CancellationToken::new(), store.clone())
|
||||
.await
|
||||
.expect_err("injected owned I/O boundary");
|
||||
assert!(
|
||||
injection.fired_while_owned(),
|
||||
"fault must occur after real leader ownership and marker validation"
|
||||
);
|
||||
let expected_error = match stage {
|
||||
cleanup_io_fault::Stage::PrimaryRead => "injected primary read failure",
|
||||
cleanup_io_fault::Stage::PrimaryWrite => "injected primary write failure",
|
||||
cleanup_io_fault::Stage::UsageFence => "injected usage fence failure",
|
||||
};
|
||||
assert!(error.to_string().contains(expected_error));
|
||||
let status = scanner_cycle_recovery_status();
|
||||
if newer_completion {
|
||||
assert_eq!(status.state, "healthy", "old error cannot overwrite a newer completion observation");
|
||||
assert!(status.reason.is_none());
|
||||
} else {
|
||||
assert_eq!(status.state, "cleanup-pending");
|
||||
assert!(
|
||||
status.reason.as_deref().is_some_and(|reason| reason.contains(expected_error)),
|
||||
"{status:?}"
|
||||
);
|
||||
}
|
||||
let after = persisted_state(&store).await;
|
||||
assert_eq!(after[0], before[0], "failed primary I/O must preserve its prior revision");
|
||||
assert_eq!(after[2], before[2], "failed primary I/O must not advance the usage fence");
|
||||
let marker: ScannerCycleRecoveryMarker =
|
||||
serde_json::from_slice(after[1].0.as_deref().expect("durable marker retained")).expect("valid cleanup marker");
|
||||
assert_eq!(marker.state, "cleanup-pending");
|
||||
drop(injection);
|
||||
run_disabled_startup(CancellationToken::new(), store.clone()).await;
|
||||
assert_reset_fences(&store).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn disabled_cleanup_invalidated_observation_never_becomes_unconditional() {
|
||||
let (_dir, store) = setup_scanner_cycle_store().await;
|
||||
seed_cleanup(&store, "cleanup-pending").await;
|
||||
let revision = read_config_with_revision(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str())
|
||||
.await
|
||||
.expect("marker revision")
|
||||
.1;
|
||||
let newer = ScannerCycleRecoveryStatus {
|
||||
state: "healthy".to_string(),
|
||||
reason: Some("newer completion owner".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
publish_scanner_cleanup_status(newer.clone(), None).expect("newer observation");
|
||||
let mut invalidated = None;
|
||||
reset_scanner_cycle_recovery_for_intent(CancellationToken::new(), store.clone(), Some(revision), Some(&mut invalidated))
|
||||
.await
|
||||
.expect("metadata cleanup may complete without owning the newest status observation");
|
||||
assert!(invalidated.is_none());
|
||||
assert_eq!(
|
||||
serde_json::to_value(scanner_cycle_recovery_status()).expect("observed status"),
|
||||
serde_json::to_value(newer).expect("newer status")
|
||||
);
|
||||
assert_reset_fences(&store).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn disabled_cleanup_later_failure_preserves_rebuilt_primary_status_identity() {
|
||||
for newer_completion in [false, true] {
|
||||
let (_dir, store) = setup_scanner_cycle_store().await;
|
||||
seed_cleanup(&store, "cleanup-pending").await;
|
||||
save_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str(), b"corrupt-cycle".to_vec())
|
||||
.await
|
||||
.expect("force the full reconstruction branch");
|
||||
let before = persisted_state(&store).await;
|
||||
let injection = cleanup_io_fault::install(&store, cleanup_io_fault::Stage::UsageFence, newer_completion);
|
||||
let error = resume_scanner_cycle_cleanup(CancellationToken::new(), store.clone())
|
||||
.await
|
||||
.expect_err("fail after primary publication");
|
||||
assert!(injection.fired_while_owned());
|
||||
assert!(error.to_string().contains("injected usage fence failure"));
|
||||
let after = persisted_state(&store).await;
|
||||
assert_ne!(after[0].1, before[0].1, "the primary write must actually commit before this failure");
|
||||
let (cycle, epoch) =
|
||||
decode_scanner_cycle_state(after[0].0.as_deref().expect("rebuilt primary")).expect("valid durable reconstruction");
|
||||
assert_eq!((cycle.current, cycle.next, epoch), (0, 42, 8));
|
||||
assert_eq!(after[2], before[2], "usage fence publication was rejected");
|
||||
let marker: ScannerCycleRecoveryMarker =
|
||||
serde_json::from_slice(after[1].0.as_deref().expect("cleanup marker retained")).expect("valid cleanup marker");
|
||||
assert_eq!(marker.state, "cleanup-pending");
|
||||
let status = scanner_cycle_recovery_status();
|
||||
if newer_completion {
|
||||
assert_eq!(status.state, "healthy");
|
||||
assert!(
|
||||
status.reason.is_none(),
|
||||
"old core and outer failure must both retain invalidated ownership"
|
||||
);
|
||||
} else {
|
||||
let DataUsageCacheRevision::Etag(etag) = &after[0].1 else {
|
||||
panic!("rebuilt primary must have a revision");
|
||||
};
|
||||
assert_eq!(status.state, "cleanup-pending");
|
||||
assert_eq!(status.primary_revision.as_deref(), Some(etag.as_str()));
|
||||
assert_eq!(status.generation, Some(cycle.next));
|
||||
assert_eq!(status.leader_epoch, Some(epoch));
|
||||
assert!(
|
||||
status
|
||||
.reason
|
||||
.as_deref()
|
||||
.is_some_and(|reason| reason.contains("injected usage fence failure"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -710,6 +710,10 @@ pub struct FolderScanner {
|
||||
coverage_frontier: Option<String>,
|
||||
resume_frontier: Option<String>,
|
||||
coverage_gap: bool,
|
||||
pending_heal_sync_deferred: bool,
|
||||
pending_heal_batch_dirty: bool,
|
||||
#[cfg(test)]
|
||||
pending_heal_sync_count: usize,
|
||||
pending_size_reconciliation_keys: HashSet<String>,
|
||||
pending_size_reconciliation_scopes: HashSet<String>,
|
||||
pending_size_reconciliation_truncated: bool,
|
||||
@@ -1080,7 +1084,7 @@ impl FolderScanner {
|
||||
scan_mode,
|
||||
result,
|
||||
);
|
||||
if result.is_admitted() {
|
||||
if result.is_admitted() || matches!(priority, HealChannelPriority::Low) {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -1828,7 +1832,12 @@ impl FolderScanner {
|
||||
}
|
||||
}
|
||||
FolderScanSource::Existing => {
|
||||
if !forward_sweep && !into.compacted && self.old_cache.is_compacted(&h) {
|
||||
// Usage sampling is not proof that a Deep check ran.
|
||||
if self.scan_mode != HealScanMode::Deep
|
||||
&& !forward_sweep
|
||||
&& !into.compacted
|
||||
&& self.old_cache.is_compacted(&h)
|
||||
{
|
||||
let next_cycle = self.old_cache.info.next_cycle as u32;
|
||||
if !h.mod_(next_cycle, data_usage_update_dir_cycles()) {
|
||||
// Transfer and add as child...
|
||||
@@ -2435,6 +2444,10 @@ pub async fn scan_data_folder(
|
||||
coverage_frontier: resume_frontier.clone(),
|
||||
resume_frontier,
|
||||
coverage_gap: false,
|
||||
pending_heal_sync_deferred: false,
|
||||
pending_heal_batch_dirty: false,
|
||||
#[cfg(test)]
|
||||
pending_heal_sync_count: 0,
|
||||
pending_size_reconciliation_keys: HashSet::new(),
|
||||
pending_size_reconciliation_scopes: HashSet::new(),
|
||||
pending_size_reconciliation_truncated: false,
|
||||
|
||||
@@ -11,13 +11,58 @@
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
/// The pending-scanner-heal ledger: durable heal intents recorded during scans and retried after MRF consumption.
|
||||
/// Persisted best-effort retry hints. Existing TTL/count pruning applies only
|
||||
/// to this hint cache, never to a committed durable repair obligation.
|
||||
use super::*;
|
||||
|
||||
const PENDING_HEAL_RETRY_BASE_SECS: u64 = 15 * 60;
|
||||
const PENDING_HEAL_RETRY_CAP_SECS: u64 = 6 * 60 * 60;
|
||||
|
||||
pub(super) struct PendingHealSyncBatch<'a> {
|
||||
pub(super) scanner: &'a mut FolderScanner,
|
||||
}
|
||||
|
||||
impl<'a> PendingHealSyncBatch<'a> {
|
||||
pub(super) fn new(scanner: &'a mut FolderScanner) -> Self {
|
||||
scanner.pending_heal_sync_deferred = true;
|
||||
scanner.pending_heal_batch_dirty = false;
|
||||
Self { scanner }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PendingHealSyncBatch<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.scanner.pending_heal_sync_deferred = false;
|
||||
if self.scanner.pending_heal_batch_dirty {
|
||||
self.scanner.pending_heal_batch_dirty = false;
|
||||
self.scanner.sync_pending_heals();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn record_pending_heal_attempt(entry: &mut PendingScannerHeal, now: u64) {
|
||||
entry.last_attempt = now;
|
||||
entry.attempts = entry.attempts.saturating_add(1);
|
||||
}
|
||||
|
||||
pub(super) fn observe_pending_heal_admission(entry: &mut PendingScannerHeal, result: HealAdmissionResult) {
|
||||
// Rediscovery and coalesced admissions must not postpone an armed retry.
|
||||
entry.last_admission_result = result.result_label().to_string();
|
||||
entry.last_admission_reason = result.reason_label().to_string();
|
||||
}
|
||||
|
||||
impl FolderScanner {
|
||||
pub(super) fn sync_pending_heals(&mut self) {
|
||||
self.update_cache.info.pending_heals = self.new_cache.info.pending_heals.clone();
|
||||
self.pending_heals_changed = true;
|
||||
if self.pending_heal_sync_deferred {
|
||||
self.pending_heal_batch_dirty = true;
|
||||
return;
|
||||
}
|
||||
self.update_cache.info.pending_heals = self.new_cache.info.pending_heals.clone();
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.pending_heal_sync_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn clear_pending_scanner_heal(
|
||||
@@ -37,32 +82,6 @@ impl FolderScanner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Batched variant of [`Self::clear_pending_scanner_heal`] for repaired
|
||||
/// notices (backlog#1894 axis B): one retain pass and one ledger sync
|
||||
/// for the whole notice set, so a mass-recovery first sweep cannot turn
|
||||
/// into thousands of full-table clones on the scan task. Only Object
|
||||
/// entries match — bucket-level heals are never the MRF consumer's work.
|
||||
pub(super) fn clear_pending_scanner_heals_for_repaired(&mut self, events: &[rustfs_common::mrf_channel::MrfRepairedEvent]) {
|
||||
// Pre-resolve the notice version strings once; each ledger entry then
|
||||
// compares against plain Option<&str>.
|
||||
let targets: Vec<(&str, &str, Option<String>)> = events
|
||||
.iter()
|
||||
.map(|event| (event.bucket.as_ref(), event.object.as_ref(), mrf_repaired_version_id(event.version_id)))
|
||||
.collect();
|
||||
let before = self.new_cache.info.pending_heals.len();
|
||||
self.new_cache.info.pending_heals.retain(|entry| {
|
||||
entry.kind != PendingScannerHealKind::Object
|
||||
|| !targets.iter().any(|(bucket, object, version)| {
|
||||
entry.bucket.as_str() == *bucket
|
||||
&& entry.object.as_deref() == Some(*object)
|
||||
&& entry.version_id.as_deref() == version.as_deref()
|
||||
})
|
||||
});
|
||||
if self.new_cache.info.pending_heals.len() != before {
|
||||
self.sync_pending_heals();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn record_pending_scanner_heal(
|
||||
&mut self,
|
||||
kind: PendingScannerHealKind,
|
||||
@@ -80,10 +99,7 @@ impl FolderScanner {
|
||||
.iter_mut()
|
||||
.find(|entry| pending_scanner_heal_matches(entry, kind, bucket, object, version_id))
|
||||
{
|
||||
entry.last_attempt = now;
|
||||
entry.attempts = entry.attempts.saturating_add(1);
|
||||
entry.last_admission_result = result.result_label().to_string();
|
||||
entry.last_admission_reason = result.reason_label().to_string();
|
||||
observe_pending_heal_admission(entry, result);
|
||||
self.sync_pending_heals();
|
||||
return;
|
||||
}
|
||||
@@ -198,47 +214,54 @@ impl FolderScanner {
|
||||
result: HealAdmissionResult,
|
||||
) {
|
||||
match result {
|
||||
HealAdmissionResult::Accepted | HealAdmissionResult::Merged => {
|
||||
self.clear_pending_scanner_heal(kind, bucket, object, version_id);
|
||||
}
|
||||
HealAdmissionResult::Full | HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull) => {
|
||||
self.record_pending_scanner_heal(kind, bucket, object, version_id, scan_mode, result);
|
||||
}
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped) => {
|
||||
self.clear_pending_scanner_heal(kind, bucket, object, version_id);
|
||||
}
|
||||
// Admin-only overlap rejections (HS-06); the scanner never sees
|
||||
// them, but if it ever does, treat them as terminal like any
|
||||
// other policy drop rather than endlessly retrying.
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
|
||||
HealAdmissionResult::Accepted
|
||||
| HealAdmissionResult::Merged
|
||||
| HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped)
|
||||
| HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
|
||||
| HealAdmissionResult::Dropped(HealAdmissionDropReason::OverlappingPaths) => {
|
||||
self.clear_pending_scanner_heal(kind, bucket, object, version_id);
|
||||
// Admission is neither repair completion nor a durable
|
||||
// successor receipt. Preserve existing responsibility without
|
||||
// turning every newly admitted hint into a persisted intent.
|
||||
if let Some(entry) = self
|
||||
.new_cache
|
||||
.info
|
||||
.pending_heals
|
||||
.iter_mut()
|
||||
.find(|entry| pending_scanner_heal_matches(entry, kind, bucket, object, version_id))
|
||||
{
|
||||
observe_pending_heal_admission(entry, result);
|
||||
self.sync_pending_heals();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn retry_pending_scanner_heals(&mut self) -> Result<(), ScannerError> {
|
||||
if !self.should_heal().await {
|
||||
let batch = PendingHealSyncBatch::new(self);
|
||||
let scanner = &mut *batch.scanner;
|
||||
if !scanner.should_heal().await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let bucket = self.new_cache.info.name.clone();
|
||||
// Backlog#1894 axis B: repairs the MRF consumer landed hand the
|
||||
// manager the heal task, so the matching pending-ledger entries are
|
||||
// retried nowhere — drop them here. Best-effort: a lost notice just
|
||||
// leaves the entry to expire through its own attempts/age limits.
|
||||
let bucket = scanner.new_cache.info.name.clone();
|
||||
// Legacy notices cannot bind a verified disposition to the current
|
||||
// incarnation, kind, set scope and responsibility generation.
|
||||
let repaired = rustfs_common::mrf_channel::take_mrf_repaired_events_for(&bucket);
|
||||
if !repaired.is_empty() {
|
||||
self.clear_pending_scanner_heals_for_repaired(&repaired);
|
||||
counter!("rustfs_scanner_unverified_repair_notices_total")
|
||||
.increment(u64::try_from(repaired.len()).unwrap_or(u64::MAX));
|
||||
}
|
||||
self.prune_pending_scanner_heals();
|
||||
for pending in pending_scanner_heal_retry_candidates(&self.new_cache.info.pending_heals, &bucket) {
|
||||
if !self.should_heal().await {
|
||||
scanner.prune_pending_scanner_heals();
|
||||
for pending in pending_scanner_heal_retry_candidates(&scanner.new_cache.info.pending_heals, &bucket) {
|
||||
if !scanner.should_heal().await {
|
||||
break;
|
||||
}
|
||||
|
||||
let Some(request) = build_pending_scanner_heal_request(&pending) else {
|
||||
self.clear_pending_scanner_heal(pending.kind, &pending.bucket, None, pending.version_id.as_deref());
|
||||
scanner.clear_pending_scanner_heal(pending.kind, &pending.bucket, None, pending.version_id.as_deref());
|
||||
counter!(
|
||||
METRIC_SCANNER_PENDING_HEAL_MALFORMED_TOTAL,
|
||||
"bucket" => pending.bucket.clone(),
|
||||
@@ -257,14 +280,27 @@ impl FolderScanner {
|
||||
continue;
|
||||
};
|
||||
|
||||
self.send_required_scanner_heal_request(
|
||||
pending.kind,
|
||||
pending.bucket.clone(),
|
||||
pending.object.clone(),
|
||||
pending.version_id.clone(),
|
||||
request,
|
||||
)
|
||||
.await?;
|
||||
if let Some(entry) = scanner.new_cache.info.pending_heals.iter_mut().find(|entry| {
|
||||
pending_scanner_heal_matches(
|
||||
entry,
|
||||
pending.kind,
|
||||
&pending.bucket,
|
||||
pending.object.as_deref(),
|
||||
pending.version_id.as_deref(),
|
||||
)
|
||||
}) {
|
||||
record_pending_heal_attempt(entry, Self::now_secs());
|
||||
scanner.sync_pending_heals();
|
||||
}
|
||||
scanner
|
||||
.send_required_scanner_heal_request(
|
||||
pending.kind,
|
||||
pending.bucket.clone(),
|
||||
pending.object.clone(),
|
||||
pending.version_id.clone(),
|
||||
request,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -295,16 +331,6 @@ pub(super) fn pending_scanner_heal_identity(entry: &PendingScannerHeal) -> (u8,
|
||||
(kind, entry.bucket.as_str(), entry.object.as_deref(), entry.version_id.as_deref())
|
||||
}
|
||||
|
||||
/// Decode an MRF repaired-notice version id for ledger matching. A nil UUID
|
||||
/// means "no value" per the repo-wide defensive-UUID invariant, so it maps
|
||||
/// to `None` and matches unversioned ledger entries only.
|
||||
pub(super) fn mrf_repaired_version_id(version_id: Option<[u8; 16]>) -> Option<String> {
|
||||
version_id
|
||||
.map(uuid::Uuid::from_bytes)
|
||||
.filter(|uuid| !uuid.is_nil())
|
||||
.map(|uuid| uuid.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn sort_pending_scanner_heals_for_retry(entries: &mut [PendingScannerHeal]) {
|
||||
entries.sort_by(|a, b| {
|
||||
a.last_attempt
|
||||
@@ -318,30 +344,56 @@ pub(super) fn pending_scanner_heal_retry_candidates(
|
||||
pending_heals: &[PendingScannerHeal],
|
||||
bucket: &str,
|
||||
) -> Vec<PendingScannerHeal> {
|
||||
let mut entries: Vec<PendingScannerHeal> = pending_heals.iter().filter(|entry| entry.bucket == bucket).cloned().collect();
|
||||
sort_pending_scanner_heals_for_retry(&mut entries);
|
||||
pending_scanner_heal_retry_candidates_at(pending_heals, bucket, FolderScanner::now_secs())
|
||||
}
|
||||
|
||||
pub(super) fn pending_scanner_heal_retry_candidates_at(
|
||||
pending_heals: &[PendingScannerHeal],
|
||||
bucket: &str,
|
||||
now: u64,
|
||||
) -> Vec<PendingScannerHeal> {
|
||||
// Schedule across scanner cycles rather than allocating a timer per hint.
|
||||
// A later Full response must not reset an already retried hint's backoff.
|
||||
let mut entries: Vec<&PendingScannerHeal> = pending_heals
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
let exponent = entry.attempts.saturating_sub(1).min(31);
|
||||
let delay = PENDING_HEAL_RETRY_BASE_SECS
|
||||
.saturating_mul(1_u64 << exponent)
|
||||
.min(PENDING_HEAL_RETRY_CAP_SECS);
|
||||
entry.bucket == bucket && now.checked_sub(entry.last_attempt).is_some_and(|age| age >= delay)
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|a, b| {
|
||||
a.last_attempt
|
||||
.cmp(&b.last_attempt)
|
||||
.then_with(|| a.attempts.cmp(&b.attempts))
|
||||
.then_with(|| pending_scanner_heal_identity(a).cmp(&pending_scanner_heal_identity(b)))
|
||||
});
|
||||
entries.truncate(MAX_PENDING_SCANNER_HEAL_RETRIES_PER_BUCKET);
|
||||
entries
|
||||
entries.into_iter().cloned().collect()
|
||||
}
|
||||
|
||||
pub(super) fn build_pending_scanner_heal_request(entry: &PendingScannerHeal) -> Option<HealChannelRequest> {
|
||||
let priority = if entry.last_admission_result == "full"
|
||||
|| (entry.last_admission_result == "dropped" && entry.last_admission_reason == "queue_full")
|
||||
{
|
||||
HealChannelPriority::High
|
||||
} else {
|
||||
HealChannelPriority::Low
|
||||
};
|
||||
match entry.kind {
|
||||
PendingScannerHealKind::Bucket => Some(build_bucket_heal_request(entry.bucket.clone(), HealChannelPriority::High)),
|
||||
PendingScannerHealKind::Bucket => Some(build_bucket_heal_request(entry.bucket.clone(), priority)),
|
||||
PendingScannerHealKind::Object => entry.object.as_ref().map(|object| {
|
||||
if entry.version_id.is_none() {
|
||||
build_non_destructive_object_heal_request(
|
||||
entry.bucket.clone(),
|
||||
object.clone(),
|
||||
entry.scan_mode,
|
||||
HealChannelPriority::High,
|
||||
)
|
||||
build_non_destructive_object_heal_request(entry.bucket.clone(), object.clone(), entry.scan_mode, priority)
|
||||
} else {
|
||||
build_object_heal_request(
|
||||
entry.bucket.clone(),
|
||||
object.clone(),
|
||||
entry.version_id.clone(),
|
||||
entry.scan_mode,
|
||||
HealChannelPriority::High,
|
||||
priority,
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
use crate::SCANNER_SLEEPER;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod mrf_ownership;
|
||||
use crate::storage_api::VersionPurgeStatusType;
|
||||
use crate::{DiskOption, Endpoint, STORAGE_FORMAT_FILE, TierStats, new_disk, storageclass};
|
||||
use rustfs_filemeta::{FileInfo, FileMeta, MetadataResolutionParams};
|
||||
@@ -350,6 +352,9 @@ async fn build_test_scanner() -> (FolderScanner, std::path::PathBuf) {
|
||||
coverage_frontier: None,
|
||||
resume_frontier: None,
|
||||
coverage_gap: false,
|
||||
pending_heal_sync_deferred: false,
|
||||
pending_heal_batch_dirty: false,
|
||||
pending_heal_sync_count: 0,
|
||||
pending_size_reconciliation_keys: HashSet::new(),
|
||||
pending_size_reconciliation_scopes: HashSet::new(),
|
||||
pending_size_reconciliation_truncated: false,
|
||||
@@ -1132,23 +1137,8 @@ fn pending_heal(
|
||||
}
|
||||
}
|
||||
|
||||
/// The nil-UUID branch of the defensive-UUID invariant: a nil version in
|
||||
/// a repaired notice means "no value" and must match unversioned ledger
|
||||
/// entries only.
|
||||
#[test]
|
||||
fn test_mrf_repaired_version_id_maps_nil_to_none() {
|
||||
assert_eq!(mrf_repaired_version_id(None), None);
|
||||
assert_eq!(mrf_repaired_version_id(Some([0u8; 16])), None);
|
||||
let uuid = Uuid::new_v4();
|
||||
assert_eq!(mrf_repaired_version_id(Some(*uuid.as_bytes())), Some(uuid.to_string()));
|
||||
}
|
||||
|
||||
/// Full wiring of backlog#1894 axis B: notes taken for the scanned bucket
|
||||
/// clear exactly the matching Object ledger entries — bucket-level
|
||||
/// entries, other buckets' entries, and version-mismatched entries
|
||||
/// survive; a real (non-nil) version matches only the same version.
|
||||
#[tokio::test]
|
||||
async fn test_mrf_repaired_notices_clear_matching_ledger_entries() {
|
||||
async fn mrf_ownership_legacy_notices_preserve_pending_entries() {
|
||||
use rustfs_common::mrf_channel::note_mrf_repaired;
|
||||
|
||||
let (mut scanner, temp_dir) = build_test_scanner().await;
|
||||
@@ -1176,8 +1166,7 @@ async fn test_mrf_repaired_notices_clear_matching_ledger_entries() {
|
||||
|
||||
note_mrf_repaired("bucket", "object-a", None);
|
||||
note_mrf_repaired("bucket", "object-b", Some(*Uuid::parse_str(&version).unwrap().as_bytes()));
|
||||
// A nil-UUID notice for object-c means "no value": it clears the
|
||||
// unversioned entry but must not touch the versioned one.
|
||||
// Neither nil nor a matching version proves incarnation, scope or owner.
|
||||
note_mrf_repaired("bucket", "object-c", Some([0u8; 16]));
|
||||
// A notice for a target the ledger does not track must be a no-op.
|
||||
note_mrf_repaired("bucket", "object-untracked", None);
|
||||
@@ -1194,12 +1183,12 @@ async fn test_mrf_repaired_notices_clear_matching_ledger_entries() {
|
||||
.iter()
|
||||
.map(|entry| (entry.kind, entry.bucket.as_str(), entry.object.as_deref(), entry.version_id.as_deref()))
|
||||
.collect();
|
||||
// Cleared: object-a (no version), object-b (exact version match), and
|
||||
// object-c's unversioned entry (the nil branch matched no-version
|
||||
// only — the versioned object-c entry survives).
|
||||
assert_eq!(
|
||||
survivors,
|
||||
vec![
|
||||
(PendingScannerHealKind::Object, "bucket", Some("object-a"), None),
|
||||
(PendingScannerHealKind::Object, "bucket", Some("object-b"), Some(version.as_str())),
|
||||
(PendingScannerHealKind::Object, "bucket", Some("object-c"), None),
|
||||
(
|
||||
PendingScannerHealKind::Object,
|
||||
"bucket",
|
||||
@@ -1338,7 +1327,7 @@ async fn test_pending_heal_update_keeps_stale_entry_until_retry_prune() {
|
||||
);
|
||||
|
||||
assert_eq!(scanner.new_cache.info.pending_heals.len(), 1);
|
||||
assert_eq!(scanner.new_cache.info.pending_heals[0].attempts, 2);
|
||||
assert_eq!(scanner.new_cache.info.pending_heals[0].attempts, 1);
|
||||
assert_eq!(scanner.new_cache.info.pending_heals[0].object.as_deref(), Some("object"));
|
||||
assert_eq!(scanner.update_cache.info.pending_heals, scanner.new_cache.info.pending_heals);
|
||||
}
|
||||
@@ -1371,7 +1360,7 @@ async fn test_pending_heal_queue_full_deduplicates_object_entry() {
|
||||
let pending = &scanner.new_cache.info.pending_heals[0];
|
||||
assert_eq!(pending.object.as_deref(), Some("object"));
|
||||
assert_eq!(pending.version_id.as_deref(), Some("version-a"));
|
||||
assert_eq!(pending.attempts, 2);
|
||||
assert_eq!(pending.attempts, 1);
|
||||
assert_eq!(pending.last_admission_result, "dropped");
|
||||
assert_eq!(pending.last_admission_reason, "queue_full");
|
||||
assert_eq!(scanner.update_cache.info.pending_heals, scanner.new_cache.info.pending_heals);
|
||||
@@ -1379,7 +1368,7 @@ async fn test_pending_heal_queue_full_deduplicates_object_entry() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pending_heal_admitted_results_clear_matching_entry() {
|
||||
async fn mrf_ownership_admission_preserves_existing_pending() {
|
||||
let (mut scanner, temp_dir) = build_test_scanner().await;
|
||||
let _guard = TestGuard::new(u64::MAX, usize::MAX, &mut scanner, temp_dir);
|
||||
|
||||
@@ -1400,7 +1389,8 @@ async fn test_pending_heal_admitted_results_clear_matching_entry() {
|
||||
HealAdmissionResult::Accepted,
|
||||
);
|
||||
|
||||
assert!(scanner.new_cache.info.pending_heals.is_empty());
|
||||
assert_eq!(scanner.new_cache.info.pending_heals.len(), 1);
|
||||
assert_eq!(scanner.new_cache.info.pending_heals[0].last_admission_result, "accepted");
|
||||
|
||||
scanner.update_pending_scanner_heal_after_admission(
|
||||
PendingScannerHealKind::Bucket,
|
||||
@@ -1419,11 +1409,12 @@ async fn test_pending_heal_admitted_results_clear_matching_entry() {
|
||||
HealAdmissionResult::Merged,
|
||||
);
|
||||
|
||||
assert!(scanner.new_cache.info.pending_heals.is_empty());
|
||||
assert_eq!(scanner.new_cache.info.pending_heals.len(), 2);
|
||||
assert_eq!(scanner.new_cache.info.pending_heals[1].last_admission_result, "merged");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pending_heal_policy_dropped_clears_without_creating_entry() {
|
||||
async fn mrf_ownership_policy_drop_does_not_discharge_existing_pending() {
|
||||
let (mut scanner, temp_dir) = build_test_scanner().await;
|
||||
let _guard = TestGuard::new(u64::MAX, usize::MAX, &mut scanner, temp_dir);
|
||||
|
||||
@@ -1454,7 +1445,7 @@ async fn test_pending_heal_policy_dropped_clears_without_creating_entry() {
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped),
|
||||
);
|
||||
|
||||
assert!(scanner.new_cache.info.pending_heals.is_empty());
|
||||
assert_eq!(scanner.new_cache.info.pending_heals.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::{DataUsageCacheSource, DataUsageScanPlanDigest};
|
||||
use std::io::Cursor;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
mod deep_compacted;
|
||||
mod segment_observation;
|
||||
|
||||
const CACHE_NAME: &str = "bucket/checkpoint-fixture.bin";
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
use super::*;
|
||||
|
||||
const PREFIX: &str = "bucket/prefix";
|
||||
const OBJECTS: u64 = 4;
|
||||
|
||||
struct CompactedFixture {
|
||||
disk: Arc<Disk>,
|
||||
root: std::path::PathBuf,
|
||||
cache: DataUsageCache,
|
||||
identity: crate::DataUsageScanIdentity,
|
||||
store: Arc<FixtureStore>,
|
||||
_cleanup: TestGuard,
|
||||
}
|
||||
|
||||
async fn scan(
|
||||
disk: &Arc<Disk>,
|
||||
cache: DataUsageCache,
|
||||
mode: HealScanMode,
|
||||
max_objects: u64,
|
||||
) -> (ScannerDiskScanOutcome, Arc<ScannerCycleBudget>) {
|
||||
let budget = ScannerCycleBudget::new_with_progress_tracking(
|
||||
&CancellationToken::new(),
|
||||
ScannerCycleBudgetConfig {
|
||||
max_objects: Some(max_objects),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let outcome = disk
|
||||
.clone()
|
||||
.nsscanner_disk(budget.token(), budget.clone(), vec![disk.clone()], cache, None, mode)
|
||||
.await
|
||||
.expect("bounded real disk scan");
|
||||
(outcome, budget)
|
||||
}
|
||||
|
||||
async fn save_reload(store: &Arc<FixtureStore>, cache: &DataUsageCache) -> DataUsageCache {
|
||||
let revisions = DataUsageCache::default()
|
||||
.load_with_revisions(store.clone(), CACHE_NAME)
|
||||
.await
|
||||
.expect("fixture save revisions");
|
||||
cache
|
||||
.save_with_revisions_for_epoch(store.clone(), CACHE_NAME, &revisions, 0)
|
||||
.await
|
||||
.expect("save compacted checkpoint through real codec and revision checks");
|
||||
let loaded = store.strict_load().await;
|
||||
assert_eq!(loaded.info.snapshot_complete, cache.info.snapshot_complete);
|
||||
assert_eq!(loaded.info.scan_checkpoint, cache.info.scan_checkpoint);
|
||||
assert_eq!(loaded.info.scan_identity, cache.info.scan_identity);
|
||||
assert_eq!(loaded.info.scan_plan_digest, cache.info.scan_plan_digest);
|
||||
assert_eq!(
|
||||
loaded.checked_flatten("bucket").expect("reloaded root").size,
|
||||
cache.checked_flatten("bucket").expect("returned root").size
|
||||
);
|
||||
loaded
|
||||
}
|
||||
|
||||
impl CompactedFixture {
|
||||
async fn new(mode: HealScanMode) -> Self {
|
||||
let (scanner, root) = build_test_scanner().await;
|
||||
let cleanup = TestGuard {
|
||||
temp_dir: Some(root.clone()),
|
||||
};
|
||||
for index in 0..OBJECTS {
|
||||
write_checkpoint_object(&root, &format!("prefix/{index:04}"), &[(None, 1)]).await;
|
||||
}
|
||||
let identity = crate::DataUsageScanIdentity {
|
||||
scan_mode: mode,
|
||||
tier_registry_generation: crate::runtime_tier_registry_for_cycle(11, 7).await.generation,
|
||||
..bound_checkpoint().1
|
||||
};
|
||||
let mut cache = DataUsageCache::default();
|
||||
// The first real scan builds coverage; the second same-plan scan takes
|
||||
// the normal compaction path. No synthetic compacted cache is injected.
|
||||
for cycle in [11, 12] {
|
||||
cache.prepare_bucket_checkpoint("bucket", cycle, 7, SOURCE, PLAN, identity);
|
||||
cache.info.skip_healing = true;
|
||||
let (outcome, budget) = scan(&scanner.local_disk, cache, mode, OBJECTS + 1).await;
|
||||
let ScannerDiskScanOutcome::Complete(completed) = outcome else {
|
||||
panic!("fixture baseline must complete")
|
||||
};
|
||||
assert_eq!(budget.progress().0, OBJECTS, "baseline must read every metadata object");
|
||||
cache = completed;
|
||||
}
|
||||
let store = FixtureStore::new();
|
||||
cache = save_reload(&store, &cache).await;
|
||||
assert!(cache.find(PREFIX).expect("baseline prefix").compacted);
|
||||
assert_eq!(cache.checked_flatten("bucket").expect("complete baseline").size, 4);
|
||||
assert!(cache.info.scan_progress.is_none());
|
||||
Self {
|
||||
disk: scanner.local_disk,
|
||||
root,
|
||||
cache,
|
||||
identity,
|
||||
store,
|
||||
_cleanup: cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
fn next_cycle(&self, sampled: bool) -> u64 {
|
||||
let first = self.cache.info.next_cycle + 1;
|
||||
(first..first + 16)
|
||||
.find(|cycle| hash_path(PREFIX).mod_(u32::try_from(*cycle).expect("bounded cycle"), 16) == sampled)
|
||||
.expect("one selected cycle and non-selected cycles exist within the fixed rotation")
|
||||
}
|
||||
|
||||
fn prepare(&mut self, cycle: u64) {
|
||||
let state = crate::scanner_io::current_cache_root_or_prepare_with_generation(
|
||||
&mut self.cache,
|
||||
"bucket",
|
||||
SOURCE,
|
||||
cycle,
|
||||
7,
|
||||
PLAN,
|
||||
crate::scanner_io::DataUsageCacheReuseOptions {
|
||||
checkpoint_identity: Some(self.identity),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(matches!(state, crate::scanner_io::DataUsageCacheScanState::Prepared { .. }));
|
||||
assert_eq!(self.cache.info.scan_identity, Some(self.identity));
|
||||
assert_eq!(self.cache.info.scan_plan_digest, Some(PLAN));
|
||||
assert!(
|
||||
self.cache.info.scan_progress.is_none(),
|
||||
"same-strength complete baseline uses the existing tree"
|
||||
);
|
||||
assert!(self.cache.find(PREFIX).expect("prepared prefix").compacted);
|
||||
}
|
||||
|
||||
async fn change_metadata_without_activity_event(&self) {
|
||||
// This models a local metadata change not announced by a segment
|
||||
// producer. Deep traversal must not depend on a usage-clean signal.
|
||||
// Healing is disabled: the oracle proves metadata re-entry, not repair.
|
||||
write_checkpoint_object(&self.root, "prefix/0000", &[(None, 7)]).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn deep_compacted_same_plan_rechecks_unsampled_prefix() {
|
||||
temp_env::async_with_vars([(ENV_DATA_USAGE_UPDATE_DIR_CYCLES, Some("16"))], async {
|
||||
let mut fixture = CompactedFixture::new(HealScanMode::Deep).await;
|
||||
fixture.change_metadata_without_activity_event().await;
|
||||
fixture.prepare(fixture.next_cycle(false));
|
||||
let (outcome, budget) = scan(&fixture.disk, fixture.cache.clone(), HealScanMode::Deep, OBJECTS + 1).await;
|
||||
assert_eq!(
|
||||
budget.progress().0,
|
||||
OBJECTS,
|
||||
"Deep must inspect compacted children even outside the usage sample cycle"
|
||||
);
|
||||
let ScannerDiskScanOutcome::Complete(cache) = outcome else {
|
||||
panic!("bounded Deep scan must complete")
|
||||
};
|
||||
let loaded = save_reload(&fixture.store, &cache).await;
|
||||
let root = loaded.checked_flatten("bucket").expect("Deep scan root");
|
||||
assert_eq!((root.objects, root.size), (4, 10), "Deep must observe the changed metadata");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn deep_compacted_normal_scan_preserves_periodic_sampling() {
|
||||
temp_env::async_with_vars([(ENV_DATA_USAGE_UPDATE_DIR_CYCLES, Some("16"))], async {
|
||||
let mut fixture = CompactedFixture::new(HealScanMode::Normal).await;
|
||||
fixture.change_metadata_without_activity_event().await;
|
||||
fixture.prepare(fixture.next_cycle(false));
|
||||
let (outcome, budget) = scan(&fixture.disk, fixture.cache.clone(), HealScanMode::Normal, OBJECTS + 1).await;
|
||||
assert_eq!(budget.progress().0, 0, "Normal retains its existing unsampled-subtree policy");
|
||||
let ScannerDiskScanOutcome::Complete(cache) = outcome else { panic!("normal sampling completes") };
|
||||
assert_eq!(cache.checked_flatten("bucket").expect("sampled root").size, 4);
|
||||
fixture.cache = save_reload(&fixture.store, &cache).await;
|
||||
fixture.prepare(fixture.next_cycle(true));
|
||||
let (outcome, budget) = scan(&fixture.disk, fixture.cache.clone(), HealScanMode::Normal, OBJECTS + 1).await;
|
||||
assert_eq!(budget.progress().0, OBJECTS);
|
||||
let ScannerDiskScanOutcome::Complete(cache) = outcome else {
|
||||
panic!("selected Normal rotation completes")
|
||||
};
|
||||
assert_eq!(cache.checked_flatten("bucket").expect("refreshed root").size, 10);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn deep_compacted_budget_preserves_partial_checkpoint() {
|
||||
temp_env::async_with_vars([(ENV_DATA_USAGE_UPDATE_DIR_CYCLES, Some("16"))], async {
|
||||
let mut fixture = CompactedFixture::new(HealScanMode::Deep).await;
|
||||
fixture.prepare(fixture.next_cycle(false));
|
||||
let (outcome, budget) = scan(&fixture.disk, fixture.cache.clone(), HealScanMode::Deep, 2).await;
|
||||
assert_eq!(budget.progress().0, 2);
|
||||
assert_eq!(budget.reason(), Some(ScannerCycleBudgetReason::Objects));
|
||||
let ScannerDiskScanOutcome::Partial(cache) = outcome else {
|
||||
panic!("Deep must retain budget interruption as partial")
|
||||
};
|
||||
assert!(!cache.info.snapshot_complete);
|
||||
let loaded = save_reload(&fixture.store, &cache).await;
|
||||
assert!(!loaded.info.snapshot_complete);
|
||||
assert!(loaded.checked_flatten("bucket").expect("partial root").objects > 0);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use crate::storage_api::EcstoreHealResultItem as HealItem;
|
||||
use crate::storage_api::scanner_io::BucketInfo;
|
||||
use rustfs_common::mrf_channel::{
|
||||
MrfIngressResult, MrfKind, MrfScope, note_mrf_repaired, take_mrf_repaired_events_for, try_send_mrf_intent_typed,
|
||||
};
|
||||
use rustfs_heal::heal::{
|
||||
manager::{HealConfig, HealManager},
|
||||
mrf_queue::spawn_mrf_consumer,
|
||||
storage::{HealListItem, HealObjectInfo, HealStorageAPI},
|
||||
};
|
||||
use rustfs_heal_contracts::heal_channel::HealOpts;
|
||||
|
||||
#[tokio::test]
|
||||
async fn mrf_ownership_admission_observation_does_not_postpone_retry() {
|
||||
let (mut scanner, temp_dir) = build_test_scanner().await;
|
||||
let _guard = TestGuard::new(u64::MAX, usize::MAX, &mut scanner, temp_dir);
|
||||
scanner.new_cache.info.pending_heals.push(pending_heal(
|
||||
PendingScannerHealKind::Object,
|
||||
"bucket",
|
||||
Some("object"),
|
||||
None,
|
||||
100,
|
||||
2,
|
||||
));
|
||||
for result in [
|
||||
HealAdmissionResult::Accepted,
|
||||
HealAdmissionResult::Merged,
|
||||
HealAdmissionResult::Full,
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull),
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped),
|
||||
] {
|
||||
scanner.update_pending_scanner_heal_after_admission(
|
||||
PendingScannerHealKind::Object,
|
||||
"bucket",
|
||||
Some("object"),
|
||||
None,
|
||||
HealScanMode::Deep,
|
||||
result,
|
||||
);
|
||||
let entry = &scanner.new_cache.info.pending_heals[0];
|
||||
assert_eq!((entry.last_attempt, entry.attempts), (100, 2));
|
||||
assert!(pending_scanner_heal_retry_candidates_at(&scanner.new_cache.info.pending_heals, "bucket", 1899).is_empty());
|
||||
assert_eq!(
|
||||
pending_scanner_heal_retry_candidates_at(&scanner.new_cache.info.pending_heals, "bucket", 1900).len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
scanner.update_pending_scanner_heal_after_admission(
|
||||
PendingScannerHealKind::Object,
|
||||
"bucket",
|
||||
Some("new-object"),
|
||||
None,
|
||||
HealScanMode::Deep,
|
||||
HealAdmissionResult::Accepted,
|
||||
);
|
||||
assert_eq!(
|
||||
scanner.new_cache.info.pending_heals.len(),
|
||||
1,
|
||||
"successful admission does not create a new ledger"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mrf_ownership_retry_due_boundaries_and_priority_are_bounded() {
|
||||
let mut entry = pending_heal(PendingScannerHealKind::Object, "bucket", Some("object"), None, 100, 1);
|
||||
entry.last_admission_result = "accepted".to_string();
|
||||
assert!(pending_scanner_heal_retry_candidates_at(std::slice::from_ref(&entry), "bucket", 999).is_empty());
|
||||
assert_eq!(
|
||||
pending_scanner_heal_retry_candidates_at(std::slice::from_ref(&entry), "bucket", 1000).len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
build_pending_scanner_heal_request(&entry).expect("request").priority,
|
||||
HealChannelPriority::Low
|
||||
);
|
||||
record_pending_heal_attempt(&mut entry, 1000);
|
||||
observe_pending_heal_admission(&mut entry, HealAdmissionResult::Full);
|
||||
assert!(pending_scanner_heal_retry_candidates_at(std::slice::from_ref(&entry), "bucket", 2799).is_empty());
|
||||
assert_eq!(
|
||||
pending_scanner_heal_retry_candidates_at(std::slice::from_ref(&entry), "bucket", 2800).len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
build_pending_scanner_heal_request(&entry).expect("request").priority,
|
||||
HealChannelPriority::High
|
||||
);
|
||||
entry.attempts = u32::MAX;
|
||||
assert!(pending_scanner_heal_retry_candidates_at(std::slice::from_ref(&entry), "bucket", 22599).is_empty());
|
||||
assert_eq!(
|
||||
pending_scanner_heal_retry_candidates_at(std::slice::from_ref(&entry), "bucket", 22600).len(),
|
||||
1
|
||||
);
|
||||
entry.last_attempt = u64::MAX;
|
||||
assert!(pending_scanner_heal_retry_candidates_at(&[entry], "bucket", 22600).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mrf_ownership_full_hint_table_has_bounded_multicycle_work_and_sync() {
|
||||
let (mut scanner, temp_dir) = build_test_scanner().await;
|
||||
let _guard = TestGuard::new(u64::MAX, usize::MAX, &mut scanner, temp_dir);
|
||||
let base = 1_700_000_000;
|
||||
scanner.new_cache.info.pending_heals = (0..MAX_PENDING_SCANNER_HEALS_PER_BUCKET)
|
||||
.map(|index| {
|
||||
let mut entry =
|
||||
pending_heal(PendingScannerHealKind::Object, "bucket", Some(&format!("object-{index}")), None, base, 1);
|
||||
entry.first_seen = base;
|
||||
entry.last_admission_result = "accepted".to_string();
|
||||
entry
|
||||
})
|
||||
.collect();
|
||||
let indices: HashMap<String, usize> = scanner
|
||||
.new_cache
|
||||
.info
|
||||
.pending_heals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, entry)| (entry.object.clone().expect("object identity"), index))
|
||||
.collect();
|
||||
scanner.sync_pending_heals();
|
||||
let initial_syncs = scanner.pending_heal_sync_count;
|
||||
let mut requests = 0usize;
|
||||
let mut nonempty_batches = 0;
|
||||
for minute in 0..24 * 60 {
|
||||
let now = base + minute * 60;
|
||||
let candidates = pending_scanner_heal_retry_candidates_at(&scanner.new_cache.info.pending_heals, "bucket", now);
|
||||
assert!(candidates.len() <= MAX_PENDING_SCANNER_HEAL_RETRIES_PER_BUCKET);
|
||||
if candidates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
nonempty_batches += 1;
|
||||
let before = scanner.pending_heal_sync_count;
|
||||
{
|
||||
let batch = PendingHealSyncBatch::new(&mut scanner);
|
||||
for candidate in candidates {
|
||||
assert_eq!(
|
||||
build_pending_scanner_heal_request(&candidate)
|
||||
.expect("retry request")
|
||||
.priority,
|
||||
HealChannelPriority::Low
|
||||
);
|
||||
let index = indices[candidate.object.as_ref().expect("object identity")];
|
||||
record_pending_heal_attempt(&mut batch.scanner.new_cache.info.pending_heals[index], now);
|
||||
observe_pending_heal_admission(
|
||||
&mut batch.scanner.new_cache.info.pending_heals[index],
|
||||
HealAdmissionResult::Accepted,
|
||||
);
|
||||
batch.scanner.sync_pending_heals();
|
||||
requests += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(scanner.pending_heal_sync_count, before + 1, "one table clone per changed retry batch");
|
||||
assert_eq!(scanner.new_cache.info.pending_heals.len(), MAX_PENDING_SCANNER_HEALS_PER_BUCKET);
|
||||
}
|
||||
assert!(requests >= MAX_PENDING_SCANNER_HEALS_PER_BUCKET, "every retained hint receives a retry");
|
||||
assert!(
|
||||
requests <= 7 * MAX_PENDING_SCANNER_HEALS_PER_BUCKET,
|
||||
"15min..6h backoff bounds repeated work within 24h"
|
||||
);
|
||||
assert!(scanner.new_cache.info.pending_heals.iter().all(|entry| entry.attempts >= 2));
|
||||
assert_eq!(scanner.pending_heal_sync_count - initial_syncs, nonempty_batches);
|
||||
assert_eq!(scanner.update_cache.info.pending_heals, scanner.new_cache.info.pending_heals);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mrf_ownership_cancelled_batch_restores_sync_without_per_item_clones() {
|
||||
let (mut scanner, temp_dir) = build_test_scanner().await;
|
||||
let _guard = TestGuard::new(u64::MAX, usize::MAX, &mut scanner, temp_dir);
|
||||
scanner
|
||||
.new_cache
|
||||
.info
|
||||
.pending_heals
|
||||
.push(pending_heal(PendingScannerHealKind::Object, "bucket", Some("object"), None, 1, 1));
|
||||
let before = scanner.pending_heal_sync_count;
|
||||
let mut work = Box::pin(async {
|
||||
let batch = PendingHealSyncBatch::new(&mut scanner);
|
||||
record_pending_heal_attempt(&mut batch.scanner.new_cache.info.pending_heals[0], 100);
|
||||
batch.scanner.sync_pending_heals();
|
||||
batch.scanner.sync_pending_heals();
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
assert!(futures::poll!(&mut work).is_pending());
|
||||
drop(work);
|
||||
assert!(!scanner.pending_heal_sync_deferred);
|
||||
assert!(!scanner.pending_heal_batch_dirty);
|
||||
assert_eq!(scanner.pending_heal_sync_count, before + 1);
|
||||
assert_eq!(scanner.new_cache.info.pending_heals[0].attempts, 2);
|
||||
assert!(pending_scanner_heal_retry_candidates_at(&scanner.new_cache.info.pending_heals, "bucket", 101).is_empty());
|
||||
assert_eq!(scanner.update_cache.info.pending_heals, scanner.new_cache.info.pending_heals);
|
||||
let result: std::result::Result<(), &'static str> = async {
|
||||
let batch = PendingHealSyncBatch::new(&mut scanner);
|
||||
record_pending_heal_attempt(&mut batch.scanner.new_cache.info.pending_heals[0], 200);
|
||||
observe_pending_heal_admission(&mut batch.scanner.new_cache.info.pending_heals[0], HealAdmissionResult::Merged);
|
||||
batch.scanner.sync_pending_heals();
|
||||
Err("injected retry batch failure")
|
||||
}
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(scanner.pending_heal_sync_count, before + 2);
|
||||
assert!(!scanner.pending_heal_sync_deferred);
|
||||
assert_eq!(scanner.update_cache.info.pending_heals, scanner.new_cache.info.pending_heals);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct NoticeStorage {
|
||||
calls: std::sync::Mutex<HashMap<String, u32>>,
|
||||
retry_started: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl HealStorageAPI for NoticeStorage {
|
||||
async fn get_object_meta(&self, _: &str, _: &str) -> rustfs_heal::Result<Option<HealObjectInfo>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn ec_decode_rebuild(&self, _: &str, _: &str) -> rustfs_heal::Result<Vec<u8>> {
|
||||
Err(rustfs_heal::Error::other("unused decode fixture"))
|
||||
}
|
||||
async fn get_bucket_info(&self, bucket: &str) -> rustfs_heal::Result<Option<BucketInfo>> {
|
||||
Ok(Some(BucketInfo {
|
||||
name: bucket.to_string(),
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
async fn list_buckets(&self) -> rustfs_heal::Result<Vec<BucketInfo>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn object_exists(&self, _: &str, _: &str) -> rustfs_heal::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn heal_object(
|
||||
&self,
|
||||
_: &str,
|
||||
object: &str,
|
||||
_: Option<&str>,
|
||||
_: &HealOpts,
|
||||
) -> rustfs_heal::Result<(HealItem, Option<rustfs_heal::Error>)> {
|
||||
let retry = {
|
||||
let mut calls = self.calls.lock().expect("fixture calls");
|
||||
let count = calls.entry(object.to_string()).or_default();
|
||||
*count += 1;
|
||||
*count > 1
|
||||
};
|
||||
if retry {
|
||||
self.retry_started.notify_one();
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
match object {
|
||||
"grace" => Ok((
|
||||
HealItem::default(),
|
||||
Some(rustfs_heal::Error::Disk(crate::DiskError::other(
|
||||
"dangling object deletion deferred by heal grace window; retry_after_secs=3599; grace_secs=3600",
|
||||
))),
|
||||
)),
|
||||
"failed" => Err(rustfs_heal::Error::other("permanent fixture failure")),
|
||||
"cancelled" => Err(rustfs_heal::Error::TaskCancelled),
|
||||
_ => Ok((HealItem::default(), None)),
|
||||
}
|
||||
}
|
||||
async fn heal_bucket(&self, _: &str, _: &HealOpts) -> rustfs_heal::Result<HealItem> {
|
||||
Ok(HealItem::default())
|
||||
}
|
||||
async fn heal_format(&self, _: bool) -> rustfs_heal::Result<(HealItem, Option<rustfs_heal::Error>)> {
|
||||
Ok((HealItem::default(), None))
|
||||
}
|
||||
async fn list_objects_for_heal_page(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: Option<&str>,
|
||||
_: bool,
|
||||
) -> rustfs_heal::Result<(Vec<HealListItem>, Option<String>, bool)> {
|
||||
Ok((Vec::new(), None, false))
|
||||
}
|
||||
async fn get_disk_for_resume(&self, _: &str) -> rustfs_heal::Result<crate::DiskStore> {
|
||||
Err(rustfs_heal::Error::other("unused resume fixture"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn mrf_ownership_manager_completion_preserves_scanner_pending() {
|
||||
const CHILD: &str = "RUSTFS_MRF_OWNERSHIP_TEST_CHILD";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
|
||||
.args([
|
||||
"--exact",
|
||||
"scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD, "1")
|
||||
.env("RUSTFS_HEAL_MRF_ENABLE", "true")
|
||||
.output()
|
||||
.expect("isolated ingress test process");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success() && stdout.contains("1 passed;"),
|
||||
"{stdout}\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The production ingress channel is a process singleton; isolation keeps
|
||||
// its receiver and lease generations independent from other scanner tests.
|
||||
let (mut scanner, temp_dir) = build_test_scanner().await;
|
||||
let _guard = TestGuard::new(u64::MAX, usize::MAX, &mut scanner, temp_dir);
|
||||
let bucket = format!("mrf-ownership-{}", Uuid::new_v4());
|
||||
scanner.new_cache.info.name = bucket.clone();
|
||||
scanner.update_cache.info.name = bucket.clone();
|
||||
scanner.heal_object_select = 1;
|
||||
let storage = Arc::new(NoticeStorage::default());
|
||||
let manager = Arc::new(HealManager::new(
|
||||
storage.clone(),
|
||||
Some(HealConfig {
|
||||
enable_auto_heal: false,
|
||||
mainline_throttle_enable: false,
|
||||
heal_interval: Duration::from_millis(10),
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
manager.start().await.expect("production manager starts");
|
||||
spawn_mrf_consumer(manager.clone());
|
||||
for (index, object) in ["grace", "unknown", "failed", "cancelled"].iter().enumerate() {
|
||||
let version = Uuid::new_v4();
|
||||
scanner.new_cache.info.pending_heals.push(pending_heal(
|
||||
PendingScannerHealKind::Object,
|
||||
&bucket,
|
||||
Some(object),
|
||||
Some(&version.to_string()),
|
||||
1,
|
||||
1,
|
||||
));
|
||||
let scope = Some(MrfScope {
|
||||
pool_index: 0,
|
||||
set_index: 0,
|
||||
});
|
||||
assert_eq!(
|
||||
try_send_mrf_intent_typed(MrfKind::PartialWrite, &bucket, object, Some(version), scope),
|
||||
MrfIngressResult::Enqueued
|
||||
);
|
||||
// Re-admission establishes that the first terminal callback released
|
||||
// its ingress lease. Statistics alone precede notice publication.
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
match try_send_mrf_intent_typed(MrfKind::PartialWrite, &bucket, object, Some(version), scope) {
|
||||
MrfIngressResult::Enqueued => break,
|
||||
MrfIngressResult::Coalesced => tokio::task::yield_now().await,
|
||||
other => panic!("unexpected retry ingress result: {other:?}"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("production terminal releases its ingress lease");
|
||||
tokio::time::timeout(Duration::from_secs(5), storage.retry_started.notified())
|
||||
.await
|
||||
.expect("the real consumer starts the second generation");
|
||||
assert!(
|
||||
take_mrf_repaired_events_for(&bucket).is_empty(),
|
||||
"{object}: task completion must not emit an unproved repair"
|
||||
);
|
||||
if *object == "unknown" {
|
||||
assert_eq!(
|
||||
manager.get_statistics().await.total_objects_healed,
|
||||
1,
|
||||
"legacy healed count is not repair proof"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
try_send_mrf_intent_typed(MrfKind::PartialWrite, &bucket, object, Some(version), scope),
|
||||
MrfIngressResult::Coalesced,
|
||||
"the in-flight retry retains its new ingress lease"
|
||||
);
|
||||
note_mrf_repaired(&bucket, object, Some(*version.as_bytes()));
|
||||
let syncs_before_retry = scanner.pending_heal_sync_count;
|
||||
scanner
|
||||
.retry_pending_scanner_heals()
|
||||
.await
|
||||
.expect("real scanner ledger retry");
|
||||
assert_eq!(scanner.pending_heal_sync_count, syncs_before_retry + 1, "the real retry batch syncs once");
|
||||
assert_eq!(
|
||||
scanner.new_cache.info.pending_heals.len(),
|
||||
index + 1,
|
||||
"{object}: pending responsibility survives"
|
||||
);
|
||||
let restored = DataUsageCache::unmarshal(&scanner.new_cache.marshal_msg().expect("serialize pending cache"))
|
||||
.expect("decode pending cache");
|
||||
assert_eq!(restored.info.pending_heals.len(), index + 1);
|
||||
assert_eq!(
|
||||
manager
|
||||
.cancel_tasks_for_path(&format!("{bucket}/{object}"))
|
||||
.await
|
||||
.expect("cancel blocked retry"),
|
||||
1
|
||||
);
|
||||
}
|
||||
manager.stop().await.expect("production manager stops");
|
||||
}
|
||||
@@ -286,6 +286,7 @@ pub struct ScannerBucketScanPlan {
|
||||
/// Includes mutation generations even when the set planner uses a structural digest.
|
||||
bucket_coverage_digest: DataUsageScanPlanDigest,
|
||||
requires_full_scan: bool,
|
||||
service_cohort: Option<Arc<StdMutex<ScannerServiceCohort>>>,
|
||||
// Cache work must invalidate on namespace completion even when its scoped baseline remains reusable.
|
||||
execution_digest: DataUsageScanPlanDigest,
|
||||
leader_epoch: u64,
|
||||
@@ -973,6 +974,7 @@ impl ScannerCycleResult {
|
||||
mod cache;
|
||||
mod dirty_usage;
|
||||
mod guards;
|
||||
pub(crate) use guards::ScannerServiceCohort;
|
||||
mod io_cache;
|
||||
mod io_cycle;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -14,6 +14,312 @@
|
||||
/// scan concurrency accounting: gauge recorders, RAII guards, and worker limits.
|
||||
use super::*;
|
||||
|
||||
const SCANNER_SERVICE_COHORT_MAX_MEMBERS: usize = 4096;
|
||||
const SCANNER_SERVICE_COHORT_MAX_NAME_BYTES: usize = 128 * 1024;
|
||||
|
||||
static SERVICE_COHORT_METRICS_OWNER: StdMutex<std::sync::Weak<()>> = StdMutex::new(std::sync::Weak::new());
|
||||
|
||||
struct ScannerCohortMetricsOwner(Arc<()>);
|
||||
|
||||
impl Default for ScannerCohortMetricsOwner {
|
||||
fn default() -> Self {
|
||||
let owner = Arc::new(());
|
||||
let mut current = SERVICE_COHORT_METRICS_OWNER
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
*current = Arc::downgrade(&owner);
|
||||
write_service_cohort_metrics(0, 0.0, false);
|
||||
Self(owner)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScannerCohortMetricsOwner {
|
||||
fn drop(&mut self) {
|
||||
let mut current = SERVICE_COHORT_METRICS_OWNER
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if current.ptr_eq(&Arc::downgrade(&self.0)) {
|
||||
*current = std::sync::Weak::new();
|
||||
write_service_cohort_metrics(0, 0.0, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_service_cohort_metrics(waiting: usize, oldest: f64, overflowed: bool) {
|
||||
metrics::gauge!("rustfs_scanner_service_cohort_waiting").set(waiting as f64);
|
||||
metrics::gauge!("rustfs_scanner_service_cohort_oldest_wait_seconds").set(oldest);
|
||||
metrics::gauge!("rustfs_scanner_service_cohort_capacity_fallback").set(if overflowed { 1.0 } else { 0.0 });
|
||||
}
|
||||
|
||||
struct ScannerCohortWait {
|
||||
order: u64,
|
||||
queued_at: Instant,
|
||||
admitted: bool,
|
||||
present: bool,
|
||||
}
|
||||
|
||||
/// Leader-local admission order, never evidence of completed scan coverage.
|
||||
/// Retains at most 4096 members and 128 KiB of name payload, including the
|
||||
/// cursor shared with its last member. Candidate selection borrows at most
|
||||
/// 4096 inventory entries; the existing full inventory is not bounded here.
|
||||
pub(crate) struct ScannerServiceCohort {
|
||||
members: HashMap<DataUsageCacheSource, HashMap<Arc<str>, ScannerCohortWait>>,
|
||||
cursor: Option<(DataUsageCacheSource, Arc<str>)>,
|
||||
next_order: u64,
|
||||
max_members: usize,
|
||||
max_name_bytes: usize,
|
||||
overflowed: bool,
|
||||
metrics_owner: ScannerCohortMetricsOwner,
|
||||
waiting: usize,
|
||||
oldest_wait_at_refresh: f64,
|
||||
#[cfg(test)]
|
||||
metric_members_examined: usize,
|
||||
}
|
||||
|
||||
impl Default for ScannerServiceCohort {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
members: HashMap::new(),
|
||||
cursor: None,
|
||||
next_order: 0,
|
||||
max_members: SCANNER_SERVICE_COHORT_MAX_MEMBERS,
|
||||
max_name_bytes: SCANNER_SERVICE_COHORT_MAX_NAME_BYTES,
|
||||
overflowed: false,
|
||||
metrics_owner: ScannerCohortMetricsOwner::default(),
|
||||
waiting: 0,
|
||||
oldest_wait_at_refresh: 0.0,
|
||||
#[cfg(test)]
|
||||
metric_members_examined: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScannerServiceCohort {
|
||||
pub(crate) fn refresh(&mut self, inventory: &HashMap<DataUsageCacheSource, Vec<BucketInfo>>) {
|
||||
let count = inventory
|
||||
.values()
|
||||
.fold(0usize, |count, buckets| count.saturating_add(buckets.len()));
|
||||
let name_bytes = inventory
|
||||
.values()
|
||||
.flatten()
|
||||
.fold(0usize, |bytes, bucket| bytes.saturating_add(bucket.name.len()));
|
||||
self.overflowed = count > self.max_members || name_bytes > self.max_name_bytes;
|
||||
for wait in self.members.values_mut().flat_map(HashMap::values_mut) {
|
||||
wait.present = false;
|
||||
}
|
||||
for (source, buckets) in inventory {
|
||||
for bucket in buckets {
|
||||
if let Some(wait) = self
|
||||
.members
|
||||
.get_mut(source)
|
||||
.and_then(|members| members.get_mut(bucket.name.as_str()))
|
||||
{
|
||||
wait.present = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.members.retain(|_, buckets| {
|
||||
buckets.retain(|_, wait| wait.present);
|
||||
!buckets.is_empty()
|
||||
});
|
||||
if self.members.values().flat_map(HashMap::values).all(|wait| wait.admitted) {
|
||||
self.members.clear();
|
||||
self.next_order = 0;
|
||||
}
|
||||
if count == 0 {
|
||||
self.cursor = None;
|
||||
}
|
||||
let mut member_count = self.members.values().map(HashMap::len).sum::<usize>();
|
||||
let mut retained_bytes = self
|
||||
.members
|
||||
.values()
|
||||
.flat_map(HashMap::keys)
|
||||
.map(|name| name.len())
|
||||
.sum::<usize>();
|
||||
let mut incoming = self.admission_candidates(inventory, true);
|
||||
if incoming.is_empty() {
|
||||
incoming = self.admission_candidates(inventory, false);
|
||||
}
|
||||
for (pool, set, bucket) in incoming {
|
||||
if member_count >= self.max_members {
|
||||
break;
|
||||
}
|
||||
if retained_bytes.saturating_add(bucket.len()) > self.max_name_bytes {
|
||||
continue;
|
||||
}
|
||||
let Some(next_order) = self.next_order.checked_add(1) else {
|
||||
self.overflowed = true;
|
||||
break;
|
||||
};
|
||||
let source = DataUsageCacheSource::new(pool, set);
|
||||
let members = self.members.entry(source).or_default();
|
||||
if members.contains_key(bucket) {
|
||||
continue;
|
||||
}
|
||||
let name: Arc<str> = bucket.into();
|
||||
retained_bytes += name.len();
|
||||
member_count += 1;
|
||||
members.insert(
|
||||
name.clone(),
|
||||
ScannerCohortWait {
|
||||
order: self.next_order,
|
||||
queued_at: Instant::now(),
|
||||
admitted: false,
|
||||
present: true,
|
||||
},
|
||||
);
|
||||
self.cursor = Some((source, name));
|
||||
self.next_order = next_order;
|
||||
}
|
||||
self.refresh_metrics();
|
||||
}
|
||||
|
||||
fn admission_candidates<'a>(
|
||||
&self,
|
||||
inventory: &'a HashMap<DataUsageCacheSource, Vec<BucketInfo>>,
|
||||
after_cursor: bool,
|
||||
) -> Vec<(usize, usize, &'a str)> {
|
||||
let mut candidates = std::collections::BinaryHeap::new();
|
||||
for (source, buckets) in inventory {
|
||||
for bucket in buckets {
|
||||
let key = (source.pool_index, source.set_index, bucket.name.as_str());
|
||||
let after = self
|
||||
.cursor
|
||||
.as_ref()
|
||||
.is_none_or(|(source, name)| key > (source.pool_index, source.set_index, name.as_ref()));
|
||||
if after != after_cursor
|
||||
|| bucket.name.len() > self.max_name_bytes
|
||||
|| self
|
||||
.members
|
||||
.get(source)
|
||||
.is_some_and(|members| members.contains_key(bucket.name.as_str()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if candidates.len() < self.max_members {
|
||||
candidates.push(key);
|
||||
} else if candidates.peek().is_some_and(|last| key < *last) {
|
||||
candidates.pop();
|
||||
candidates.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.into_sorted_vec()
|
||||
}
|
||||
|
||||
pub(crate) fn order_set_indices(&self, sets: &[Arc<SetDisks>]) -> Vec<usize> {
|
||||
let ranks = self
|
||||
.members
|
||||
.iter()
|
||||
.map(|(source, buckets)| {
|
||||
(
|
||||
*source,
|
||||
buckets
|
||||
.values()
|
||||
.filter(|wait| !wait.admitted)
|
||||
.map(|wait| wait.order)
|
||||
.min()
|
||||
.unwrap_or(u64::MAX),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut indices = (0..sets.len()).collect::<Vec<_>>();
|
||||
indices.sort_by_key(|index| {
|
||||
(
|
||||
ranks
|
||||
.get(&DataUsageCacheSource::new(sets[*index].pool_index, sets[*index].set_index))
|
||||
.copied()
|
||||
.unwrap_or(u64::MAX),
|
||||
*index,
|
||||
)
|
||||
});
|
||||
indices
|
||||
}
|
||||
|
||||
pub(crate) fn order_buckets(&self, source: DataUsageCacheSource, buckets: &mut [BucketInfo]) {
|
||||
let rank = |bucket: &str| {
|
||||
self.members
|
||||
.get(&source)
|
||||
.and_then(|members| members.get(bucket))
|
||||
.filter(|wait| !wait.admitted)
|
||||
.map_or(u64::MAX, |wait| wait.order)
|
||||
};
|
||||
// Stable sorting preserves the existing dispatch order in the tail.
|
||||
buckets.sort_by_key(|bucket| rank(&bucket.name));
|
||||
}
|
||||
|
||||
pub(crate) fn record_admitted(&mut self, source: DataUsageCacheSource, bucket: &str) {
|
||||
let Some(wait) = self.members.get_mut(&source).and_then(|members| members.get_mut(bucket)) else {
|
||||
return;
|
||||
};
|
||||
if wait.admitted {
|
||||
return;
|
||||
}
|
||||
wait.admitted = true;
|
||||
self.waiting -= 1;
|
||||
if self.waiting == 0 {
|
||||
self.oldest_wait_at_refresh = 0.0;
|
||||
}
|
||||
self.record_metrics();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn admitted_members(&self) -> Vec<(DataUsageCacheSource, String)> {
|
||||
self.members
|
||||
.iter()
|
||||
.flat_map(|(source, buckets)| {
|
||||
buckets
|
||||
.iter()
|
||||
.filter(|(_, wait)| wait.admitted)
|
||||
.map(|(bucket, _)| (*source, bucket.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn refresh_metrics(&mut self) {
|
||||
// Oldest age is an inventory-refresh snapshot, not a per-admission
|
||||
// scan of the cohort. Clear it immediately when no waiters remain.
|
||||
let (mut waiting, mut oldest) = (0usize, 0.0f64);
|
||||
for wait in self.members.values().flat_map(HashMap::values) {
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.metric_members_examined += 1;
|
||||
}
|
||||
if !wait.admitted {
|
||||
waiting += 1;
|
||||
oldest = oldest.max(wait.queued_at.elapsed().as_secs_f64());
|
||||
}
|
||||
}
|
||||
self.waiting = waiting;
|
||||
self.oldest_wait_at_refresh = oldest;
|
||||
self.record_metrics();
|
||||
}
|
||||
|
||||
fn record_metrics(&self) {
|
||||
// Serialize owner replacement, publication and retirement. A retired
|
||||
// scanner must neither publish nor clear a replacement's gauges.
|
||||
let current = SERVICE_COHORT_METRICS_OWNER
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if current.ptr_eq(&Arc::downgrade(&self.metrics_owner.0)) {
|
||||
write_service_cohort_metrics(self.waiting, self.oldest_wait_at_refresh, self.overflowed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn wait_for_bucket_scan_permit(
|
||||
semaphore: &Arc<Semaphore>,
|
||||
ctx: &CancellationToken,
|
||||
complete: &CancellationToken,
|
||||
) -> Option<tokio::sync::OwnedSemaphorePermit> {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = complete.cancelled() => None,
|
||||
_ = ctx.cancelled() => None,
|
||||
permit = semaphore.clone().acquire_owned() => permit.ok(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn bucket_usage_scan_order(
|
||||
buckets: &[BucketInfo],
|
||||
old_cache: &DataUsageCache,
|
||||
@@ -288,6 +594,305 @@ mod tests {
|
||||
use rustfs_scanner_metrics::metrics::{ScannerWorkSource, global_metrics};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordedGauge(AtomicU64);
|
||||
|
||||
impl metrics::GaugeFn for RecordedGauge {
|
||||
fn increment(&self, value: f64) {
|
||||
self.set(f64::from_bits(self.0.load(Ordering::Relaxed)) + value);
|
||||
}
|
||||
fn decrement(&self, value: f64) {
|
||||
self.increment(-value);
|
||||
}
|
||||
fn set(&self, value: f64) {
|
||||
self.0.store(value.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CohortGaugeRecorder(StdMutex<HashMap<String, Arc<RecordedGauge>>>);
|
||||
|
||||
impl metrics::Recorder for CohortGaugeRecorder {
|
||||
fn describe_counter(&self, _: metrics::KeyName, _: Option<metrics::Unit>, _: metrics::SharedString) {}
|
||||
fn describe_gauge(&self, _: metrics::KeyName, _: Option<metrics::Unit>, _: metrics::SharedString) {}
|
||||
fn describe_histogram(&self, _: metrics::KeyName, _: Option<metrics::Unit>, _: metrics::SharedString) {}
|
||||
fn register_counter(&self, _: &metrics::Key, _: &metrics::Metadata<'_>) -> metrics::Counter {
|
||||
metrics::Counter::noop()
|
||||
}
|
||||
fn register_histogram(&self, _: &metrics::Key, _: &metrics::Metadata<'_>) -> metrics::Histogram {
|
||||
metrics::Histogram::noop()
|
||||
}
|
||||
fn register_gauge(&self, key: &metrics::Key, _: &metrics::Metadata<'_>) -> metrics::Gauge {
|
||||
metrics::Gauge::from_arc(
|
||||
self.0
|
||||
.lock()
|
||||
.expect("gauge recorder")
|
||||
.entry(key.name().to_string())
|
||||
.or_default()
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl CohortGaugeRecorder {
|
||||
fn value(&self, name: &str) -> f64 {
|
||||
f64::from_bits(self.0.lock().expect("gauge recorder")[name].0.load(Ordering::Relaxed))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn service_cohort_metrics_retire_only_the_current_owner() {
|
||||
let recorder = CohortGaugeRecorder::default();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
let mut old = ScannerServiceCohort::default();
|
||||
old.refresh(&cohort_inventory(&["old"]));
|
||||
let mut current = ScannerServiceCohort {
|
||||
max_members: 1,
|
||||
..Default::default()
|
||||
};
|
||||
current.refresh(&cohort_inventory(&["a", "b"]));
|
||||
for wait in current.members.values_mut().flat_map(HashMap::values_mut) {
|
||||
wait.queued_at = Instant::now() - Duration::from_secs(60);
|
||||
}
|
||||
current.refresh_metrics();
|
||||
old.refresh(&cohort_inventory(&["old", "more"]));
|
||||
drop(old);
|
||||
assert_eq!(recorder.value("rustfs_scanner_service_cohort_waiting"), 1.0);
|
||||
assert_eq!(recorder.value("rustfs_scanner_service_cohort_capacity_fallback"), 1.0);
|
||||
assert!(recorder.value("rustfs_scanner_service_cohort_oldest_wait_seconds") >= 60.0);
|
||||
drop(current);
|
||||
for metric in [
|
||||
"rustfs_scanner_service_cohort_waiting",
|
||||
"rustfs_scanner_service_cohort_oldest_wait_seconds",
|
||||
"rustfs_scanner_service_cohort_capacity_fallback",
|
||||
] {
|
||||
assert_eq!(recorder.value(metric), 0.0, "owner retirement must clear {metric}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn service_cohort_admission_metrics_do_not_rescan_a_full_window() {
|
||||
let recorder = CohortGaugeRecorder::default();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
let source = DataUsageCacheSource::new(0, 0);
|
||||
let names = (0..SCANNER_SERVICE_COHORT_MAX_MEMBERS)
|
||||
.map(|index| format!("bucket-{index:04}"))
|
||||
.collect::<Vec<_>>();
|
||||
let inventory = cohort_inventory(&names.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
let mut cohort = ScannerServiceCohort::default();
|
||||
cohort.refresh(&inventory);
|
||||
assert_eq!(cohort.metric_members_examined, SCANNER_SERVICE_COHORT_MAX_MEMBERS);
|
||||
assert_eq!(cohort.waiting, SCANNER_SERVICE_COHORT_MAX_MEMBERS);
|
||||
for (index, name) in names.iter().enumerate() {
|
||||
cohort.record_admitted(source, name);
|
||||
for _ in 0..10 {
|
||||
cohort.record_admitted(source, name);
|
||||
cohort.record_admitted(source, "untracked-overflow-name");
|
||||
cohort.record_admitted(DataUsageCacheSource::new(99, 0), name);
|
||||
}
|
||||
assert_eq!(cohort.waiting, SCANNER_SERVICE_COHORT_MAX_MEMBERS - index - 1);
|
||||
assert_eq!(
|
||||
cohort.metric_members_examined, SCANNER_SERVICE_COHORT_MAX_MEMBERS,
|
||||
"tracked, repeated and overflow admissions must not scan cohort members"
|
||||
);
|
||||
}
|
||||
assert_eq!(recorder.value("rustfs_scanner_service_cohort_waiting"), 0.0);
|
||||
assert_eq!(recorder.value("rustfs_scanner_service_cohort_oldest_wait_seconds"), 0.0);
|
||||
cohort.refresh(&inventory);
|
||||
assert_eq!(
|
||||
cohort.metric_members_examined,
|
||||
2 * SCANNER_SERVICE_COHORT_MAX_MEMBERS,
|
||||
"one inventory refresh performs one metrics traversal"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn service_cohort_queued_permit_cancel_and_drop_return_the_same_capacity() {
|
||||
let semaphore = Arc::new(Semaphore::new(1));
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let mut cohort = ScannerServiceCohort::default();
|
||||
cohort.refresh(&cohort_inventory(&["waiting"]));
|
||||
let gauge_reset = DiskBucketScanGaugeReset::new("cohort-wait".to_string(), "0".to_string());
|
||||
record_disk_bucket_scans_queued(1, "cohort-wait", "0");
|
||||
record_disk_bucket_scans_active(0, "cohort-wait", "0");
|
||||
for cancel in [true, false] {
|
||||
let held = semaphore
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.expect("hold the sole permit as a barrier");
|
||||
let ctx = CancellationToken::new();
|
||||
let complete = CancellationToken::new();
|
||||
let mut waiter = Box::pin(wait_for_bucket_scan_permit(&semaphore, &ctx, &complete));
|
||||
assert!(
|
||||
futures::poll!(&mut waiter).is_pending(),
|
||||
"the production wait must actually enqueue behind the barrier"
|
||||
);
|
||||
assert_eq!(semaphore.available_permits(), 0);
|
||||
if cancel {
|
||||
ctx.cancel();
|
||||
assert!(waiter.as_mut().await.is_none());
|
||||
}
|
||||
drop(waiter);
|
||||
assert_eq!(semaphore.available_permits(), 0, "cancelling a waiter must not release the held permit");
|
||||
assert!(cohort.admitted_members().is_empty());
|
||||
drop(held);
|
||||
assert_eq!(semaphore.available_permits(), 1, "no queued waiter may leak or steal released capacity");
|
||||
}
|
||||
let ctx = CancellationToken::new();
|
||||
let complete = CancellationToken::new();
|
||||
let permit = wait_for_bucket_scan_permit(&semaphore, &ctx, &complete)
|
||||
.await
|
||||
.expect("same semaphore remains usable");
|
||||
let active_guard = DiskBucketScanActiveGuard::new(active.clone(), "cohort-wait".to_string(), "0".to_string());
|
||||
assert_eq!(active.load(Ordering::Relaxed), 1);
|
||||
drop(active_guard);
|
||||
drop(permit);
|
||||
drop(gauge_reset);
|
||||
assert_eq!(active.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(semaphore.available_permits(), 1);
|
||||
let state = global_metrics()
|
||||
.scanner_runtime_details_report()
|
||||
.disk_bucket_scan_states
|
||||
.into_iter()
|
||||
.find(|state| state.pool == "cohort-wait" && state.set == "0")
|
||||
.expect("fixture gauges");
|
||||
assert_eq!((state.queued, state.active), (0, 0));
|
||||
assert!(cohort.admitted_members().is_empty(), "permit ownership alone does not admit a bucket");
|
||||
}
|
||||
|
||||
fn cohort_inventory(names: &[&str]) -> HashMap<DataUsageCacheSource, Vec<BucketInfo>> {
|
||||
HashMap::from([(
|
||||
DataUsageCacheSource::new(0, 0),
|
||||
names
|
||||
.iter()
|
||||
.map(|name| BucketInfo {
|
||||
name: (*name).to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn service_cohort_visits_fixed_members_within_service_round_bound() {
|
||||
let inventory = cohort_inventory(&["a", "b", "c", "d", "e"]);
|
||||
let source = DataUsageCacheSource::new(0, 0);
|
||||
let mut cohort = ScannerServiceCohort::default();
|
||||
let mut admitted = HashSet::new();
|
||||
for _ in 0..3 {
|
||||
cohort.refresh(&inventory);
|
||||
let mut buckets = inventory[&source].clone();
|
||||
cohort.order_buckets(source, &mut buckets);
|
||||
for bucket in buckets.iter().take(2) {
|
||||
admitted.insert(bucket.name.clone());
|
||||
cohort.record_admitted(source, &bucket.name);
|
||||
}
|
||||
}
|
||||
assert_eq!(admitted.len(), 5, "ceil(5/2) service rounds must include every member");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn service_cohort_keeps_waiting_bootstrap_ahead_of_new_work() {
|
||||
let source = DataUsageCacheSource::new(0, 0);
|
||||
let mut cohort = ScannerServiceCohort::default();
|
||||
cohort.refresh(&cohort_inventory(&["a-hot", "z-bootstrap"]));
|
||||
cohort.record_admitted(source, "a-hot");
|
||||
let queued_at = cohort.members[&source]["z-bootstrap"].queued_at;
|
||||
let inventory = cohort_inventory(&["a-hot", "aaa-new-bootstrap", "z-bootstrap"]);
|
||||
for _ in 0..10 {
|
||||
cohort.refresh(&inventory);
|
||||
let mut buckets = inventory[&source].clone();
|
||||
cohort.order_buckets(source, &mut buckets);
|
||||
assert_eq!(buckets[0].name, "z-bootstrap");
|
||||
assert_eq!(buckets[1].name, "aaa-new-bootstrap");
|
||||
assert_eq!(cohort.members[&source]["z-bootstrap"].queued_at, queued_at);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn service_cohort_overflow_preserves_waiters_and_rotates_finished_windows() {
|
||||
let source = DataUsageCacheSource::new(0, 0);
|
||||
let mut cohort = ScannerServiceCohort {
|
||||
max_members: 2,
|
||||
max_name_bytes: 4,
|
||||
..Default::default()
|
||||
};
|
||||
cohort.refresh(&cohort_inventory(&["aa", "bb"]));
|
||||
cohort.record_admitted(source, "aa");
|
||||
for names in [["aa", "bb", "c"], ["aa", "bb", "d"]] {
|
||||
let inventory = cohort_inventory(&names);
|
||||
cohort.refresh(&inventory);
|
||||
assert!(cohort.overflowed);
|
||||
assert_eq!(cohort.members[&source].len(), 2);
|
||||
assert!(cohort.members[&source].contains_key("aa"));
|
||||
let mut fallback = inventory[&source].clone();
|
||||
fallback.reverse();
|
||||
cohort.order_buckets(source, &mut fallback);
|
||||
assert_eq!(fallback[0].name, "bb", "overflow must not discard a waiting member's priority");
|
||||
assert_eq!(fallback.len(), 3, "unknown tail must remain dispatchable");
|
||||
}
|
||||
cohort.record_admitted(source, "bb");
|
||||
let inventory = cohort_inventory(&["aa", "bb", "c", "d"]);
|
||||
cohort.refresh(&inventory);
|
||||
assert_eq!(
|
||||
cohort.members[&source].keys().map(AsRef::as_ref).collect::<HashSet<&str>>(),
|
||||
HashSet::from(["c", "d"])
|
||||
);
|
||||
cohort.record_admitted(source, "c");
|
||||
cohort.record_admitted(source, "d");
|
||||
cohort.refresh(&inventory);
|
||||
assert!(
|
||||
cohort.members[&source].contains_key("aa"),
|
||||
"finite inventory must wrap after the last window"
|
||||
);
|
||||
cohort.refresh(&cohort_inventory(&["bb"]));
|
||||
assert!(!cohort.overflowed);
|
||||
assert_eq!(cohort.members[&source].len(), 1);
|
||||
cohort.next_order = u64::MAX;
|
||||
cohort.refresh(&cohort_inventory(&["bb", "c"]));
|
||||
assert!(cohort.overflowed);
|
||||
assert_eq!(cohort.members[&source].len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn service_cohort_bounds_names_and_does_not_reset_duplicate_dirty_age() {
|
||||
let source = DataUsageCacheSource::new(0, 0);
|
||||
let mut cohort = ScannerServiceCohort {
|
||||
max_members: 2,
|
||||
max_name_bytes: 4,
|
||||
..Default::default()
|
||||
};
|
||||
cohort.refresh(&cohort_inventory(&["aa", "bb", "long-name"]));
|
||||
let queued_at = cohort.members[&source]["bb"].queued_at;
|
||||
for _ in 0..10 {
|
||||
cohort.refresh(&cohort_inventory(&["aa", "aa", "bb", "long-name"]));
|
||||
assert_eq!(cohort.members.values().map(HashMap::len).sum::<usize>(), 2);
|
||||
assert_eq!(
|
||||
cohort
|
||||
.members
|
||||
.values()
|
||||
.flat_map(HashMap::keys)
|
||||
.map(|name| name.len())
|
||||
.sum::<usize>(),
|
||||
4
|
||||
);
|
||||
assert_eq!(cohort.members[&source]["bb"].queued_at, queued_at);
|
||||
}
|
||||
cohort.refresh(&cohort_inventory(&[]));
|
||||
assert!(cohort.members.is_empty());
|
||||
assert!(cohort.cursor.is_none());
|
||||
}
|
||||
|
||||
fn active_bucket_drive_count(source: ScannerWorkSource, bucket: &str, drive: &str) -> u64 {
|
||||
global_metrics()
|
||||
.scanner_runtime_details_report()
|
||||
|
||||
@@ -117,6 +117,7 @@ impl ScannerIOCache for SetDisks {
|
||||
digest: scan_plan_digest,
|
||||
bucket_coverage_digest,
|
||||
requires_full_scan,
|
||||
service_cohort,
|
||||
execution_digest,
|
||||
leader_epoch,
|
||||
tier_registry_generation,
|
||||
@@ -500,7 +501,13 @@ impl ScannerIOCache for SetDisks {
|
||||
|
||||
let mut permutes = buckets.clone();
|
||||
permutes.shuffle(&mut rand::rng());
|
||||
let scan_order = bucket_usage_scan_order(&permutes, &old_cache, &dirty_usage_buckets);
|
||||
let mut scan_order = bucket_usage_scan_order(&permutes, &old_cache, &dirty_usage_buckets);
|
||||
if let Some(cohort) = &service_cohort {
|
||||
cohort
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.order_buckets(source, &mut scan_order);
|
||||
}
|
||||
|
||||
for bucket in scan_order.iter() {
|
||||
if let Some(c) = old_cache.find(&bucket.name) {
|
||||
@@ -558,6 +565,7 @@ impl ScannerIOCache for SetDisks {
|
||||
let remaining_bucket_work = Arc::new(AtomicUsize::new(buckets.len()));
|
||||
let bucket_work_complete = CancellationToken::new();
|
||||
for (disk, worker_mode) in workers {
|
||||
let service_cohort_clone = service_cohort.clone();
|
||||
let bucket_rx_mutex_clone = bucket_rx_mutex.clone();
|
||||
let bucket_tx_clone = bucket_tx.clone();
|
||||
let remaining_bucket_work_clone = remaining_bucket_work.clone();
|
||||
@@ -587,6 +595,18 @@ impl ScannerIOCache for SetDisks {
|
||||
let remote_session_id = uuid::Uuid::new_v4();
|
||||
let mut remote_session_sequence = 0_u64;
|
||||
loop {
|
||||
// Do not prefetch a FIFO member into an independently
|
||||
// scheduled permit waiter: that can reorder admissions.
|
||||
let permit_wait_start = Instant::now();
|
||||
let Some(_permit) =
|
||||
wait_for_bucket_scan_permit(&disk_scan_semaphore_clone, &ctx_clone, &bucket_work_complete_clone).await
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if ctx_clone.is_cancelled() || budget_clone.budget_elapsed() {
|
||||
break;
|
||||
}
|
||||
let permit_wait_elapsed = permit_wait_start.elapsed();
|
||||
let bucket = tokio::select! {
|
||||
_ = bucket_work_complete_clone.cancelled() => break,
|
||||
_ = ctx_clone.cancelled() => break,
|
||||
@@ -600,41 +620,27 @@ impl ScannerIOCache for SetDisks {
|
||||
let mut work_guard =
|
||||
BucketWorkGuard::new(remaining_bucket_work_clone.clone(), bucket_work_complete_clone.clone());
|
||||
|
||||
let permit_wait = ctx_clone.clone();
|
||||
let permit_wait_start = Instant::now();
|
||||
let _permit = tokio::select! {
|
||||
permit = disk_scan_semaphore_clone.clone().acquire_owned() => match permit {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => {
|
||||
decrement_disk_bucket_scans_queued(
|
||||
&queued_disk_bucket_scans_clone,
|
||||
&pool_label_clone,
|
||||
&set_label_clone,
|
||||
);
|
||||
break;
|
||||
},
|
||||
},
|
||||
_ = permit_wait.cancelled() => {
|
||||
decrement_disk_bucket_scans_queued(
|
||||
&queued_disk_bucket_scans_clone,
|
||||
&pool_label_clone,
|
||||
&set_label_clone,
|
||||
);
|
||||
break;
|
||||
},
|
||||
};
|
||||
metrics::histogram!(
|
||||
METRIC_SCANNER_DISK_SCAN_WAIT_SECONDS,
|
||||
"pool" => pool_label_clone.clone(),
|
||||
"set" => set_label_clone.clone()
|
||||
)
|
||||
.record(permit_wait_start.elapsed().as_secs_f64());
|
||||
.record(permit_wait_elapsed.as_secs_f64());
|
||||
decrement_disk_bucket_scans_queued(&queued_disk_bucket_scans_clone, &pool_label_clone, &set_label_clone);
|
||||
let _active_guard = DiskBucketScanActiveGuard::new(
|
||||
active_disk_bucket_scans_clone.clone(),
|
||||
pool_label_clone.clone(),
|
||||
set_label_clone.clone(),
|
||||
);
|
||||
if ctx_clone.is_cancelled() || budget_clone.budget_elapsed() {
|
||||
break;
|
||||
}
|
||||
if let Some(cohort) = &service_cohort_clone {
|
||||
cohort
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.record_admitted(source, &bucket.name);
|
||||
}
|
||||
|
||||
debug!(
|
||||
target: "rustfs::scanner::io",
|
||||
|
||||
@@ -73,6 +73,7 @@ where
|
||||
scan_scope: ScannerBucketScanScope::default(),
|
||||
persisted_usage_baseline: None,
|
||||
requires_full_scan: true,
|
||||
service_cohort: None,
|
||||
#[cfg(test)]
|
||||
resolved_scope_observer: None,
|
||||
};
|
||||
@@ -90,6 +91,7 @@ pub(crate) struct ScannerCycleRequest {
|
||||
pub(crate) persisted_usage_baseline: Option<Bytes>,
|
||||
/// Scheduled maintenance must visit clean buckets even with a valid dirty scope.
|
||||
pub(crate) requires_full_scan: bool,
|
||||
pub(crate) service_cohort: Option<Arc<StdMutex<ScannerServiceCohort>>>,
|
||||
#[cfg(test)]
|
||||
pub(crate) resolved_scope_observer: Option<tokio::sync::oneshot::Sender<ScannerBucketScanScope>>,
|
||||
}
|
||||
@@ -184,6 +186,7 @@ where
|
||||
scan_scope,
|
||||
persisted_usage_baseline,
|
||||
requires_full_scan,
|
||||
service_cohort,
|
||||
#[cfg(test)]
|
||||
resolved_scope_observer,
|
||||
} = request;
|
||||
@@ -275,6 +278,12 @@ where
|
||||
}
|
||||
bucket_plan_complete &= buckets_by_source.keys().copied().collect::<HashSet<_>>() == *expected_sources;
|
||||
bucket_plan_complete &= scanner_bucket_inventory_is_complete(&all_buckets, &buckets_by_source);
|
||||
if bucket_plan_complete && let Some(cohort) = &service_cohort {
|
||||
cohort
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.refresh(&buckets_by_source);
|
||||
}
|
||||
let structural_scan_plan_digest =
|
||||
scanner_bucket_plan_digest(&all_buckets, crate::scanner::scanner_activity_structural_digest(&activity_before));
|
||||
let scan_plan_digest = scanner_bucket_work_digest(structural_scan_plan_digest, scan_mode, requires_full_scan);
|
||||
@@ -399,7 +408,32 @@ where
|
||||
let first_err_mutex: Arc<Mutex<Option<Error>>> = Arc::new(Mutex::new(None));
|
||||
let mut wait_futs = Vec::new();
|
||||
|
||||
for (results_index, set) in set_disks.iter().enumerate() {
|
||||
let set_order = service_cohort.as_ref().map_or_else(
|
||||
|| (0..set_disks.len()).collect::<Vec<_>>(),
|
||||
|cohort| {
|
||||
cohort
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.order_set_indices(&set_disks)
|
||||
},
|
||||
);
|
||||
for results_index in set_order {
|
||||
let set = &set_disks[results_index];
|
||||
// Acquire in dispatch order, not in independently scheduled tasks.
|
||||
// A whole set still shares the existing parent budget; this is not
|
||||
// a per-bucket quantum or a cross-source completion guarantee.
|
||||
let permit_wait_start = Instant::now();
|
||||
let permit = tokio::select! {
|
||||
biased;
|
||||
_ = child_token.cancelled() => break,
|
||||
permit = set_scan_semaphore.clone().acquire_owned() => match permit {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => break,
|
||||
},
|
||||
};
|
||||
if child_token.is_cancelled() || budget.budget_elapsed() {
|
||||
break;
|
||||
}
|
||||
let results_index_clone = results_index;
|
||||
// Clone the Arc to move it into the spawned task
|
||||
let set_clone: Arc<SetDisks> = Arc::clone(set);
|
||||
@@ -414,7 +448,6 @@ where
|
||||
let scan_mode_clone = scan_mode;
|
||||
let results_mutex_clone = results_mutex.clone();
|
||||
let first_err_mutex_clone = first_err_mutex.clone();
|
||||
let set_scan_semaphore_clone = set_scan_semaphore.clone();
|
||||
let queued_set_scans_clone = queued_set_scans.clone();
|
||||
let active_set_scans_clone = active_set_scans.clone();
|
||||
|
||||
@@ -437,6 +470,7 @@ where
|
||||
digest: structural_scan_plan_digest,
|
||||
bucket_coverage_digest,
|
||||
requires_full_scan,
|
||||
service_cohort: service_cohort.clone(),
|
||||
execution_digest,
|
||||
leader_epoch,
|
||||
tier_registry_generation,
|
||||
@@ -448,15 +482,10 @@ where
|
||||
};
|
||||
// Spawn task to run the scanner
|
||||
let scanner_fut = tokio::spawn(async move {
|
||||
let permit_wait = child_token_clone.clone();
|
||||
let permit_wait_start = Instant::now();
|
||||
let _permit = tokio::select! {
|
||||
permit = set_scan_semaphore_clone.acquire_owned() => match permit {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => return,
|
||||
},
|
||||
_ = permit_wait.cancelled() => return,
|
||||
};
|
||||
let _permit = permit;
|
||||
if child_token_clone.is_cancelled() || budget_clone.budget_elapsed() {
|
||||
return;
|
||||
}
|
||||
metrics::histogram!(
|
||||
METRIC_SCANNER_SET_SCAN_WAIT_SECONDS,
|
||||
"pool" => pool_label.clone(),
|
||||
|
||||
@@ -40,6 +40,7 @@ use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod scoped_entry_fallback;
|
||||
mod service_cohort;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FixedWorkloadProvider {
|
||||
@@ -398,6 +399,7 @@ async fn scoped_scan_production_entry_preserves_deep_and_full_maintenance_work()
|
||||
persisted_usage_baseline: baseline,
|
||||
requires_full_scan,
|
||||
resolved_scope_observer: Some(observer),
|
||||
service_cohort: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -470,6 +472,7 @@ async fn scoped_scan_same_cycle_maintenance_rewalks_after_root_delivery_failure(
|
||||
persisted_usage_baseline: None,
|
||||
requires_full_scan: false,
|
||||
resolved_scope_observer: None,
|
||||
service_cohort: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -520,6 +523,7 @@ async fn scoped_scan_same_cycle_maintenance_rewalks_after_root_delivery_failure(
|
||||
persisted_usage_baseline: None,
|
||||
requires_full_scan,
|
||||
resolved_scope_observer: None,
|
||||
service_cohort: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1261,6 +1265,7 @@ async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers
|
||||
ctx.clone(),
|
||||
ScannerCycleBudget::new(&ctx, ScannerCycleBudgetConfig::default()),
|
||||
ScannerBucketScanPlan {
|
||||
service_cohort: None,
|
||||
buckets: Vec::new(),
|
||||
all_buckets: Arc::new(Vec::new()),
|
||||
scope: ScannerBucketScanScope::default(),
|
||||
|
||||
@@ -127,6 +127,7 @@ async fn run_entry(store: &Arc<ECStore>, cycle: u64, selected: Option<&str>, exp
|
||||
scan_scope: ScannerBucketScanScope::default(),
|
||||
persisted_usage_baseline: root_before.0.clone().map(Bytes::from),
|
||||
requires_full_scan: false,
|
||||
service_cohort: None,
|
||||
resolved_scope_observer: Some(observer),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use crate::data_usage_define::{DATA_USAGE_OBJ_NAME_PATH, read_config_with_revision};
|
||||
|
||||
async fn create_cohort_bucket(store: &ECStore, bucket: &str) {
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("fixture bucket");
|
||||
for set in store.all_set_disks() {
|
||||
let mut reader = ScannerPutObjReader::from_vec(b"cohort".to_vec());
|
||||
set.put_object(
|
||||
bucket,
|
||||
"initial",
|
||||
&mut reader,
|
||||
&ScannerObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("fixture object and all rename tails should persist");
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_cohort_cycle(
|
||||
store: &Arc<ECStore>,
|
||||
cohort: Arc<StdMutex<ScannerServiceCohort>>,
|
||||
cycle: u64,
|
||||
budget: Arc<ScannerCycleBudget>,
|
||||
) -> (ScannerCycleResult, Option<DataUsageInfo>) {
|
||||
let root_before = read_config_with_revision(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.expect("root before candidate");
|
||||
let dirty_before = dirty_usage_buckets_for_tests();
|
||||
let generation_before = dirty_usage_generation();
|
||||
let (updates, mut receiver) = mpsc::channel(1);
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
nsscanner_with_storage_status_scoped(
|
||||
store.as_ref(),
|
||||
ScannerCycleRequest {
|
||||
ctx: budget.token(),
|
||||
budget,
|
||||
updates,
|
||||
want_cycle: cycle,
|
||||
leader_epoch: 11,
|
||||
scan_mode: HealScanMode::Normal,
|
||||
scan_scope: ScannerBucketScanScope::default(),
|
||||
persisted_usage_baseline: None,
|
||||
requires_full_scan: false,
|
||||
service_cohort: Some(cohort),
|
||||
resolved_scope_observer: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("cohort cycle should finish")
|
||||
.expect("cohort cycle should return its status");
|
||||
let usage = receiver.recv().await;
|
||||
assert!(receiver.recv().await.is_none());
|
||||
assert_eq!(
|
||||
read_config_with_revision(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
.expect("root after candidate"),
|
||||
root_before
|
||||
);
|
||||
assert_eq!(
|
||||
dirty_usage_buckets_for_tests(),
|
||||
dirty_before,
|
||||
"candidate production must not ACK pending work"
|
||||
);
|
||||
assert_eq!(dirty_usage_generation(), generation_before);
|
||||
(result, usage)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn service_cohort_production_dispatch_services_waiters_across_sources() {
|
||||
let (_dir, store) = setup_two_pool_scanner_store().await;
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
let hot = format!("a-hot-{}", Uuid::new_v4().simple());
|
||||
let bootstrap = format!("z-bootstrap-{}", Uuid::new_v4().simple());
|
||||
create_cohort_bucket(&store, &hot).await;
|
||||
create_cohort_bucket(&store, &bootstrap).await;
|
||||
let cohort = Arc::new(StdMutex::new(ScannerServiceCohort::default()));
|
||||
let expected = store
|
||||
.all_set_disks()
|
||||
.iter()
|
||||
.flat_map(|set| {
|
||||
let source = DataUsageCacheSource::new(set.pool_index, set.set_index);
|
||||
[(source, hot.clone()), (source, bootstrap.clone())]
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
let mut seen = HashSet::new();
|
||||
for cycle in 1..=4 {
|
||||
// Both newly bootstrapped names and repeated dirty work sort ahead of
|
||||
// the original bootstrap in the old dirty-first policy.
|
||||
if cycle > 1 {
|
||||
create_cohort_bucket(&store, &format!("b-new-{cycle}")).await;
|
||||
}
|
||||
record_dirty_usage_bucket(&hot);
|
||||
let ctx = CancellationToken::new();
|
||||
let budget = ScannerCycleBudget::new_with_progress_tracking(
|
||||
&ctx,
|
||||
ScannerCycleBudgetConfig {
|
||||
max_objects: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
run_cohort_cycle(&store, cohort.clone(), cycle, budget.clone()).await;
|
||||
assert!(budget.budget_elapsed());
|
||||
assert_eq!(
|
||||
budget.progress().0,
|
||||
1,
|
||||
"each round must reach one real object, not just mark an admission"
|
||||
);
|
||||
let admitted = cohort
|
||||
.lock()
|
||||
.expect("cohort lock")
|
||||
.admitted_members()
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
let newly_admitted = admitted.difference(&seen).cloned().collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
newly_admitted.len(),
|
||||
1,
|
||||
"serial parent object budget must stop before another bucket admission"
|
||||
);
|
||||
seen.extend(newly_admitted);
|
||||
}
|
||||
assert!(
|
||||
expected.is_subset(&seen),
|
||||
"ongoing dirty/new bootstrap must not displace the original cohort"
|
||||
);
|
||||
|
||||
// Admission fairness is not completed coverage: prior budgeted prefixes
|
||||
// were observed under changing plans. A clean tail must not certify them.
|
||||
let ctx = CancellationToken::new();
|
||||
let (result, usage) =
|
||||
run_cohort_cycle(&store, cohort, 5, ScannerCycleBudget::new(&ctx, ScannerCycleBudgetConfig::default())).await;
|
||||
assert_eq!(result.status, ScannerCycleStatus::Incomplete);
|
||||
assert!(usage.is_none(), "neither source has a complete mixed-plan baseline to publish");
|
||||
for set in store.all_set_disks() {
|
||||
let mut cache = DataUsageCache::default();
|
||||
cache
|
||||
.load(set, &path_join_buf(&[&hot, DATA_USAGE_CACHE_NAME]))
|
||||
.await
|
||||
.expect("retained hot prefix");
|
||||
assert!(!cache.info.snapshot_complete);
|
||||
assert!(cache.info.scan_progress.is_some());
|
||||
assert!(cache.info.scan_plan_digest.is_none(), "mixed coverage must remain non-authoritative");
|
||||
}
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn service_cohort_fresh_complete_aggregate_preserves_reordered_sources() {
|
||||
let (_dir, store) = setup_two_pool_scanner_store().await;
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
for bucket in ["cohort-first", "cohort-second"] {
|
||||
create_cohort_bucket(&store, bucket).await;
|
||||
}
|
||||
let sets = store.all_set_disks();
|
||||
let listing = store
|
||||
.list_bucket_for_scanner(&BucketOptions::default())
|
||||
.await
|
||||
.expect("fresh inventory");
|
||||
let inventory = listing
|
||||
.set_buckets
|
||||
.into_iter()
|
||||
.map(|set| (DataUsageCacheSource::new(set.pool_index, set.set_index), set.buckets))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let cohort = Arc::new(StdMutex::new(ScannerServiceCohort::default()));
|
||||
{
|
||||
let mut cohort = cohort.lock().expect("cohort lock");
|
||||
cohort.refresh(&inventory);
|
||||
for bucket in &inventory[&DataUsageCacheSource::new(0, 0)] {
|
||||
cohort.record_admitted(DataUsageCacheSource::new(0, 0), &bucket.name);
|
||||
}
|
||||
assert_eq!(cohort.order_set_indices(&sets), vec![1, 0]);
|
||||
}
|
||||
let ctx = CancellationToken::new();
|
||||
let (result, usage) =
|
||||
run_cohort_cycle(&store, cohort, 1, ScannerCycleBudget::new(&ctx, ScannerCycleBudgetConfig::default())).await;
|
||||
assert_eq!(result.status, ScannerCycleStatus::Complete);
|
||||
let usage = usage.expect("fresh complete aggregate");
|
||||
assert_eq!(usage.objects_total_count, 4);
|
||||
assert!(usage.buckets_usage.values().all(|bucket| bucket.objects_count == 2));
|
||||
assert_eq!(usage.usage_snapshot_set_states.len(), 2);
|
||||
assert_eq!(
|
||||
usage
|
||||
.usage_snapshot_set_states
|
||||
.iter()
|
||||
.map(|set| (set.pool_index, set.set_index))
|
||||
.collect::<HashSet<_>>(),
|
||||
HashSet::from([(0, 0), (1, 0)])
|
||||
);
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn service_cohort_cancelled_dispatch_does_not_consume_waiters_or_leak_permits() {
|
||||
let (_dir, store) = setup_two_pool_scanner_store().await;
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
let bucket = format!("cancel-{}", Uuid::new_v4().simple());
|
||||
create_cohort_bucket(&store, &bucket).await;
|
||||
let cohort = Arc::new(StdMutex::new(ScannerServiceCohort::default()));
|
||||
let ctx = CancellationToken::new();
|
||||
let budget = ScannerCycleBudget::new(&ctx, ScannerCycleBudgetConfig::default());
|
||||
ctx.cancel();
|
||||
run_cohort_cycle(&store, cohort.clone(), 1, budget).await;
|
||||
assert!(cohort.lock().expect("cohort lock").admitted_members().is_empty());
|
||||
let report = rustfs_scanner_metrics::metrics::global_metrics().scanner_runtime_details_report();
|
||||
assert!(report.active_bucket_drive_scans.is_empty());
|
||||
|
||||
let ctx = CancellationToken::new();
|
||||
let (result, usage) =
|
||||
run_cohort_cycle(&store, cohort, 1, ScannerCycleBudget::new(&ctx, ScannerCycleBudgetConfig::default())).await;
|
||||
assert_eq!(
|
||||
result.status,
|
||||
ScannerCycleStatus::Complete,
|
||||
"cancellation must not block a subsequent scan"
|
||||
);
|
||||
assert_eq!(usage.expect("retry aggregate").objects_total_count, 2);
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
@@ -126,6 +126,9 @@ pub(crate) use rustfs_lifecycle::{
|
||||
};
|
||||
use rustfs_storage_api as storage_contracts;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) type EcstoreHealResultItem = <EcstoreStore as storage_contracts::HealOperations>::HealResultItem;
|
||||
|
||||
pub(crate) mod owner {
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::set_disk::test_util::hold_namespace_commit as ecstore_hold_namespace_commit;
|
||||
|
||||
@@ -168,6 +168,8 @@ New intents use a 15-minute expiry. A peer-only terminal tombstone is retained u
|
||||
|
||||
The coordinator creates its durable record and peer `Prepare` blocks new reference creation, drains exact tier-operation leases, and proves that edit/remove/clear will not strand authoritative references. Prepare, Commit, and Abort use all-node fanout rather than quorum: independent peer calls use a work-conserving concurrency limit of four, a 30-second per-peer deadline, and a 30-second fanout-wide deadline; Prepare is additionally capped by the intent expiry. The coordinator collects every completed outcome. A timed-out or otherwise ambiguous started Prepare is included in compensating Abort because cancellation does not prove the peer failed to persist its fence; peers not started before the fanout deadline make Prepare fail but do not require Abort. The coordinator then conditionally writes tier config, durably commits the coordinator intent, releases its exclusive guards, requires every prepared peer to commit, publishes the runtime candidate, and clears the block. Per-mutation sharded mutexes serialize local phases only; persisted intent plus tier-config ETag is authoritative.
|
||||
|
||||
Terminal recovery does not reinstall a process-local operation fence when the published in-memory manager has the exact committed candidate digest. This exception affects only ordinary tier-operation leases: recovery still replays peer `Commit`, retains and conditionally cleans the durable evidence, and blocks a new tier configuration mutation until the recovery snapshot is quiescent. A different local digest remains fenced until the committed candidate is safely published.
|
||||
|
||||
### Recovery decisions
|
||||
|
||||
| Observed durable state/input | Unique current owner | Current recovery decision | Destructive/config admission |
|
||||
|
||||
@@ -179,6 +179,14 @@ The reset does not delete metadata files by hand and does not publish an authori
|
||||
| data movement | wait for decommission or rebalance to leave the scanner metadata path, then retry |
|
||||
| invalid scanner cycle state | run `POST /v3/scanner/cycle-state/reset` with `{"mode":"full-rescan"}` first |
|
||||
|
||||
## Cleanup With The Scanner Disabled
|
||||
|
||||
With `RUSTFS_SCANNER_ENABLED=false`, startup makes one controlled attempt to finish a previously persisted cycle reset whose validated recovery marker is already `cleanup-pending`. This is metadata cleanup only: it does not start the ordinary scanner loop, scan namespaces, accept a new reset request, or automatically perform a usage-state `full-rebuild`. Missing, merely `blocked`, unknown-version, unknown-phase, or corrupt markers do not authorize an automatic reset.
|
||||
|
||||
The attempt uses the existing leader lock and revalidates the observed marker revision and phase after acquiring it. A busy leader or data-movement pause leaves the marker intact and is reported through `cycle_recovery.state` and `cycle_recovery.reason` in the existing scanner status response. There is no automatic retry loop while disabled. After resolving the blocker, explicitly retry `POST /v3/scanner/cycle-state/reset` with `{"mode":"full-rescan"}`, or restart to make another controlled attempt. The v3 reset routes remain synchronous and return their existing successful HTTP 200 responses; no asynchronous HTTP 202 acceptance is introduced.
|
||||
|
||||
The startup probe is cancellation-aware and uses the existing cache persistence I/O timeout. Shutdown waits only for the existing server shutdown timeout. If the cleanup task cannot join in that window, the `scanner_cleanup_not_joined` warning means completion is unconfirmed, not drained. The task is not force-aborted or force-unlocked while its runtime remains alive; it retains its existing namespace/admission guards, and durable marker/fence state remains authoritative. Inspect status before retrying. This does not establish a hard deadline for an unresponsive storage operation or prove that I/O has drained when the process or runtime subsequently exits. Task-ownership timeout tests are not storage fsync, commit-tail, or process-crash durability evidence.
|
||||
|
||||
## Data Movement Pauses
|
||||
|
||||
RustFS uses a `global_pause` policy while pool decommission or rebalance can hide scanner metadata: usage publication, lifecycle discovery, tier cleanup discovery, scanner-originated heal and bitrot checks, and replication discovery are deferred together. A failed or canceled decommission remains a publication barrier until an operator retries or clears it. The same pause and estimate objects are included in `GET /v3/ilm/expiry/status`.
|
||||
@@ -301,6 +309,24 @@ The pacing gate holds neither namespace locks nor I/O/page permits while sleepin
|
||||
|
||||
A missing provider, zero pause, or both class thresholds set to zero preserves unpaced execution. Missing counts follow the existing shared pressure interpreter; they are observations, not health, quorum or resource-ownership proof. The current provider exposes node-level workload classes, so this does not claim independent per-set foreground measurements or a hard global resource budget. Runtime waits increment `rustfs_heal_mainline_throttle_total` with `source=admin`, `result=delayed`, and a foreground-pressure or `recovery_window` reason. Real p99/throughput protection requires the separate W20 fixed-load ABBA measurements.
|
||||
|
||||
## Pending Heal Hints
|
||||
|
||||
The scanner's persisted pending-heal cache is a best-effort retry backstop, bounded to 10,000 hints per bucket and a 24-hour age limit. These limits do not authorize garbage collection of committed durable repair obligations. A durable owner must retain its independent replay record until verified object completion or an equivalent durable successor permits removal.
|
||||
|
||||
Accepted, merged, or policy-dropped admission does not clear an existing hint. Task completion and legacy repair notices also lack the incarnation, set scope, generation, and storage verification needed to prove repair responsibility was discharged. The legacy success path therefore retains hints conservatively; this is not a complete durable MRF handoff protocol.
|
||||
|
||||
Pending retries use their persisted attempt count and last-attempt timestamp, starting at 15 minutes and doubling up to six hours. Each bucket submits at most 128 due hints per cycle. Already admitted or policy-dropped hints retry at Low priority; queue-full hints keep High priority but obey the same due-time bound. A changed retry batch synchronizes its pending cache once, including when cancelled, rather than copying the entire table after every admission.
|
||||
|
||||
Rediscovery and admission observations update the recorded result but do not postpone an already armed retry. The actual retry loop advances the attempt count and timestamp before awaiting admission, so cancellation or repeated queue-full results cannot reset the retry budget.
|
||||
|
||||
| Producer | Current identity and compensation | Durable handoff boundary |
|
||||
|---|---|---|
|
||||
| Scanner corrupt metadata | Metadata kind with no invented version/set; an existing pending-cache hint remains available for bounded retries. | Cache publication is separate from MRF ingress. Its age/count limits mean it is not an irrevocable repair-obligation ledger. |
|
||||
| Read decode failure | Decode kind, available version and erasure-set scope; a later failing read can rediscover the repair. | Nonblocking ingress and in-memory read-repair admission do not acknowledge durable acceptance. |
|
||||
| Partial write | Partial-write kind, available version and erasure-set scope; an in-memory heal request is the fast path. | The caller's documented restart-survival requirement is not fulfilled by ignoring the ingress result or by removing the unaccepted journal record at manager admission. Verified durable ownership remains pending. |
|
||||
|
||||
Legacy notices carry only bucket/object/version, not a verified storage disposition, incarnation, scope, or durable responsibility generation. They are drained without clearing hints. Terminal callbacks release only their exact node-local ingress lease so rediscovery remains possible; lease generations are not durable successor receipts. Pending migration staging is not activated, and this change does not enable durable tombstones or garbage collection. Positive cleanup requires a storage-owner receipt with the complete responsibility identity and validated commit/fence evidence; neither task status nor the bounded diagnostic outcome window supplies it.
|
||||
|
||||
## Deliberate non-parity with MinIO
|
||||
|
||||
These differences from MinIO are design decisions, recorded so they are not re-filed as gaps.
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ license = []
|
||||
io-scheduler-debug = [] # Enable debug information in I/O scheduler
|
||||
tracing-chunk-debug = [] # Enable per-chunk tracing in data plane (high noise, for debugging only)
|
||||
full = ["metrics-gpu", "ftps", "swift", "webdav", "sftp", "pyroscope", "gcs"]
|
||||
e2e-test-hooks = ["rustfs-ecstore/e2e-test-hooks"]
|
||||
e2e-test-hooks = []
|
||||
# Shortens Connect credentials only in debug E2E builds.
|
||||
connect-e2e-short-credentials = []
|
||||
# Builds the dedicated rustfs-cli-e2e target with a build-time public enrollment root.
|
||||
|
||||
@@ -237,18 +237,13 @@ struct HealStartSuccess {
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HealTaskStatus {
|
||||
summary: String,
|
||||
#[serde(flatten)]
|
||||
payload: HealTaskStatusPayload,
|
||||
#[serde(rename = "detail")]
|
||||
failure_detail: String,
|
||||
start_time: String,
|
||||
#[serde(rename = "settings")]
|
||||
heal_settings: HealOpts,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
items: Vec<rustfs_madmin::heal_commands::HealResultItem>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
truncated: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
progress: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -1055,15 +1050,23 @@ async fn submit_cluster_heal_channel_command(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct HealTaskStatusPayload {
|
||||
#[serde(skip)]
|
||||
adapted_detail: Option<String>,
|
||||
summary: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
items: Vec<rustfs_madmin::heal_commands::HealResultItem>,
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
truncated: bool,
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
progress: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
outcome: Option<serde_json::Value>,
|
||||
#[serde(default, rename = "nextSeq", alias = "next_seq", skip_serializing_if = "Option::is_none")]
|
||||
next_seq: Option<u64>,
|
||||
#[serde(default, rename = "minSeq", alias = "min_seq", skip_serializing_if = "Option::is_none")]
|
||||
min_seq: Option<u64>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1103,21 +1106,16 @@ fn encode_heal_start_success(client_token: String, client_address: String) -> S3
|
||||
}
|
||||
|
||||
fn encode_heal_task_status(
|
||||
summary: String,
|
||||
mut payload: HealTaskStatusPayload,
|
||||
failure_detail: String,
|
||||
heal_settings: HealOpts,
|
||||
items: Vec<rustfs_madmin::heal_commands::HealResultItem>,
|
||||
truncated: bool,
|
||||
progress: Option<serde_json::Value>,
|
||||
) -> S3Result<Vec<u8>> {
|
||||
let failure_detail = payload.adapted_detail.take().unwrap_or(failure_detail);
|
||||
encode_json(&HealTaskStatus {
|
||||
summary,
|
||||
payload,
|
||||
failure_detail,
|
||||
start_time: current_rfc3339_time()?,
|
||||
heal_settings,
|
||||
items,
|
||||
truncated,
|
||||
progress,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1162,42 +1160,63 @@ fn build_heal_channel_request(hip: &HealInitParams) -> HealChannelRequest {
|
||||
|
||||
fn heal_channel_response_status(
|
||||
response: &rustfs_heal_contracts::heal_channel::HealChannelResponse,
|
||||
) -> (String, Vec<rustfs_madmin::heal_commands::HealResultItem>, bool, Option<serde_json::Value>) {
|
||||
) -> S3Result<HealTaskStatusPayload> {
|
||||
let Some(data) = response.data.as_deref() else {
|
||||
return ("running".to_string(), Vec::new(), false, None);
|
||||
return Ok(HealTaskStatusPayload {
|
||||
summary: "running".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
};
|
||||
|
||||
if let Ok(payload) = serde_json::from_slice::<HealTaskStatusPayload>(data)
|
||||
&& !payload.summary.is_empty()
|
||||
if let Ok(mut payload) = serde_json::from_slice::<HealTaskStatusPayload>(data)
|
||||
&& matches!(payload.summary.as_str(), "running" | "finished" | "stopped" | "notFound")
|
||||
{
|
||||
return (payload.summary, payload.items, payload.truncated, payload.progress);
|
||||
let adapted = payload
|
||||
.outcome
|
||||
.as_ref()
|
||||
.map(|outcome| {
|
||||
rustfs_heal::heal::outcome::legacy_wire_status(&payload.summary, outcome, payload.truncated)
|
||||
.map(|(summary, detail)| (summary.to_string(), detail))
|
||||
})
|
||||
.transpose();
|
||||
if let Ok(adapted) = adapted {
|
||||
if let Some((summary, detail)) = adapted {
|
||||
payload.summary = summary;
|
||||
payload.adapted_detail = detail;
|
||||
}
|
||||
return Ok(payload);
|
||||
}
|
||||
}
|
||||
|
||||
let summary = std::str::from_utf8(data)
|
||||
.ok()
|
||||
.filter(|summary| !summary.is_empty())
|
||||
.unwrap_or("running")
|
||||
.to_string();
|
||||
(summary, Vec::new(), false, None)
|
||||
if let Ok(summary @ ("running" | "finished" | "stopped" | "notFound")) = std::str::from_utf8(data) {
|
||||
return Ok(HealTaskStatusPayload {
|
||||
summary: summary.to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Err(s3s::S3Error::with_message(
|
||||
s3s::S3ErrorCode::InternalError,
|
||||
"invalid heal status payload or unsupported summary",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn heal_channel_response_summary(response: &rustfs_heal_contracts::heal_channel::HealChannelResponse) -> String {
|
||||
heal_channel_response_status(response).0
|
||||
heal_channel_response_status(response).expect("valid status fixture").summary
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn heal_channel_response_items(
|
||||
response: &rustfs_heal_contracts::heal_channel::HealChannelResponse,
|
||||
) -> Vec<rustfs_madmin::heal_commands::HealResultItem> {
|
||||
heal_channel_response_status(response).1
|
||||
heal_channel_response_status(response).expect("valid status fixture").items
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn heal_channel_response_progress(
|
||||
response: &rustfs_heal_contracts::heal_channel::HealChannelResponse,
|
||||
) -> Option<serde_json::Value> {
|
||||
heal_channel_response_status(response).3
|
||||
heal_channel_response_status(response).expect("valid status fixture").progress
|
||||
}
|
||||
|
||||
fn encode_background_heal_status(
|
||||
@@ -1385,15 +1404,8 @@ impl Operation for HealHandler {
|
||||
response.error.unwrap_or_else(|| "query heal status failed".to_string())
|
||||
));
|
||||
}
|
||||
let (summary, items, truncated, progress) = heal_channel_response_status(&response);
|
||||
let body = encode_heal_task_status(
|
||||
summary,
|
||||
response.error.unwrap_or_default(),
|
||||
HealOpts::default(),
|
||||
items,
|
||||
truncated,
|
||||
progress,
|
||||
)?;
|
||||
let payload = heal_channel_response_status(&response)?;
|
||||
let body = encode_heal_task_status(payload, response.error.unwrap_or_default(), HealOpts::default())?;
|
||||
info!(
|
||||
event = EVENT_ADMIN_RESPONSE_EMITTED,
|
||||
component = LOG_COMPONENT_ADMIN_API,
|
||||
@@ -1430,8 +1442,8 @@ impl Operation for HealHandler {
|
||||
let body = if client_token.is_empty() {
|
||||
encode_heal_start_success(response.request_id, client_address)?
|
||||
} else {
|
||||
let (summary, items, truncated, progress) = heal_channel_response_status(&response);
|
||||
encode_heal_task_status(summary, response.error.unwrap_or_default(), hip.hs, items, truncated, progress)?
|
||||
let payload = heal_channel_response_status(&response)?;
|
||||
encode_heal_task_status(payload, response.error.unwrap_or_default(), hip.hs)?
|
||||
};
|
||||
info!(
|
||||
event = EVENT_ADMIN_RESPONSE_EMITTED,
|
||||
@@ -2761,12 +2773,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_encode_heal_task_status_uses_client_wire_shape() {
|
||||
let encoded = encode_heal_task_status(
|
||||
"Heal status query accepted".to_string(),
|
||||
super::HealTaskStatusPayload {
|
||||
summary: "Heal status query accepted".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
String::new(),
|
||||
HealOpts::default(),
|
||||
Vec::new(),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("status response should serialize");
|
||||
let json: serde_json::Value = serde_json::from_slice(&encoded).expect("json should deserialize");
|
||||
@@ -2783,12 +2795,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_encode_heal_task_status_reports_truncated_items() {
|
||||
let encoded = encode_heal_task_status(
|
||||
"running".to_string(),
|
||||
super::HealTaskStatusPayload {
|
||||
summary: "running".to_string(),
|
||||
truncated: true,
|
||||
..Default::default()
|
||||
},
|
||||
"heal result items were truncated".to_string(),
|
||||
HealOpts::default(),
|
||||
Vec::new(),
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.expect("truncated status response should serialize");
|
||||
let json: serde_json::Value = serde_json::from_slice(&encoded).expect("json should deserialize");
|
||||
@@ -2804,12 +2817,13 @@ mod tests {
|
||||
"currentObject": "bucket-a/object-a"
|
||||
});
|
||||
let encoded = encode_heal_task_status(
|
||||
"running".to_string(),
|
||||
super::HealTaskStatusPayload {
|
||||
summary: "running".to_string(),
|
||||
progress: Some(progress.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
String::new(),
|
||||
HealOpts::default(),
|
||||
Vec::new(),
|
||||
false,
|
||||
Some(progress.clone()),
|
||||
)
|
||||
.expect("status response should serialize");
|
||||
let json: serde_json::Value = serde_json::from_slice(&encoded).expect("json should deserialize");
|
||||
@@ -2817,6 +2831,80 @@ mod tests {
|
||||
assert_eq!(json["progress"], progress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_v3_admin_forwards_outcome_cursors_and_progress_without_recounting() {
|
||||
let cases: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../../crates/madmin/tests/fixtures/heal-outcome-v3.json"))
|
||||
.expect("shared fixtures");
|
||||
for case in cases.as_array().expect("cases") {
|
||||
let expected = &case["response"];
|
||||
let mut channel_payload = case.get("remoteResponse").unwrap_or(expected).clone();
|
||||
let payload = channel_payload.as_object_mut().expect("payload");
|
||||
let next_seq = payload.remove("nextSeq").expect("cursor");
|
||||
let min_seq = payload.remove("minSeq").expect("cursor");
|
||||
payload.insert("next_seq".to_string(), next_seq);
|
||||
payload.insert("min_seq".to_string(), min_seq);
|
||||
let response = rustfs_heal_contracts::heal_channel::HealChannelResponse {
|
||||
request_id: "token".into(),
|
||||
success: true,
|
||||
data: Some(serde_json::to_vec(&channel_payload).expect("channel bytes")),
|
||||
error: None,
|
||||
};
|
||||
let payload = super::heal_channel_response_status(&response).expect("valid owner payload");
|
||||
let encoded =
|
||||
encode_heal_task_status(payload, expected["detail"].as_str().expect("detail").into(), HealOpts::default())
|
||||
.expect("public response");
|
||||
let actual: serde_json::Value = serde_json::from_slice(&encoded).expect("public JSON");
|
||||
for key in ["summary", "detail", "outcome", "progress", "truncated", "nextSeq", "minSeq"] {
|
||||
assert_eq!(actual[key], expected[key], "{}: {key}", case["name"]);
|
||||
}
|
||||
assert_eq!(actual["progress"]["objectsHealed"], 7);
|
||||
assert_eq!(actual["outcome"]["counters"]["healed"], 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_v3_rejects_corrupt_status_without_inventing_a_terminal() {
|
||||
for data in [
|
||||
br#"{"summary":"future_state"}"#.as_slice(),
|
||||
br#"{"summary":"finished","nextSeq":9,"next_seq":8}"#.as_slice(),
|
||||
br#"{"outcome":{"execution":{"state":"completed"}}}"#.as_slice(),
|
||||
b"future_state".as_slice(),
|
||||
] {
|
||||
let response = rustfs_heal_contracts::heal_channel::HealChannelResponse {
|
||||
request_id: "token".into(),
|
||||
success: true,
|
||||
data: Some(data.to_vec()),
|
||||
error: None,
|
||||
};
|
||||
assert!(super::heal_channel_response_status(&response).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_v3_admin_preserves_future_nonterminal_without_validating_success() {
|
||||
let outcome = serde_json::json!({
|
||||
"execution": {"state": "future_execution", "extension": {"value": 7}},
|
||||
"futureCounter": 9
|
||||
});
|
||||
let mut wire = serde_json::json!({"summary": "running", "outcome": outcome, "next_seq": 9, "min_seq": 4});
|
||||
let mut response = rustfs_heal_contracts::heal_channel::HealChannelResponse {
|
||||
request_id: "token".into(),
|
||||
success: true,
|
||||
data: Some(serde_json::to_vec(&wire).expect("future running wire")),
|
||||
error: None,
|
||||
};
|
||||
let payload = super::heal_channel_response_status(&response).expect("future nonterminal is opaque");
|
||||
let bytes = encode_heal_task_status(payload, String::new(), HealOpts::default()).expect("public nonterminal");
|
||||
let public: serde_json::Value = serde_json::from_slice(&bytes).expect("public JSON");
|
||||
assert_eq!(public["summary"], "running");
|
||||
assert_eq!(public["outcome"], outcome);
|
||||
assert_eq!((public["nextSeq"].as_u64(), public["minSeq"].as_u64()), (Some(9), Some(4)));
|
||||
wire["summary"] = serde_json::json!("finished");
|
||||
response.data = Some(serde_json::to_vec(&wire).expect("unprovable success wire"));
|
||||
assert!(super::heal_channel_response_status(&response).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_heal_channel_request_preserves_safe_client_options() {
|
||||
let hip = HealInitParams {
|
||||
|
||||
@@ -63,6 +63,7 @@ const EVENT_ADMIN_REQUEST_STATE: &str = "admin_request_state";
|
||||
const EVENT_ADMIN_REQUEST_REJECTED: &str = "admin_request_rejected";
|
||||
const EVENT_ADMIN_REQUEST_FAILED: &str = "admin_request_failed";
|
||||
const EVENT_ADMIN_RESPONSE_EMITTED: &str = "admin_response_emitted";
|
||||
const POOL_ACTIVATION_FLEET_PROOF_REQUIRED: &str = "pool activation requires a live fleet capability proof";
|
||||
|
||||
fn admin_request_id(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
@@ -321,6 +322,17 @@ fn contextualize_admin_pool_api_error(
|
||||
}
|
||||
}
|
||||
|
||||
fn decommission_start_api_error(err: crate::storage_api::error::StorageError) -> ApiError {
|
||||
if crate::storage_api::capacity::is_pool_activation_fleet_proof_error(&err) {
|
||||
return ApiError {
|
||||
code: S3ErrorCode::InternalError,
|
||||
message: POOL_ACTIVATION_FLEET_PROOF_REQUIRED.to_string(),
|
||||
source: Some(Box::new(err)),
|
||||
};
|
||||
}
|
||||
ApiError::from(err)
|
||||
}
|
||||
|
||||
fn decommission_admin_not_initialized_error_with_audit(operation: &str, audit: PoolAuditContext<'_>) -> S3Error {
|
||||
error!(
|
||||
event = EVENT_ADMIN_REQUEST_FAILED,
|
||||
@@ -790,7 +802,24 @@ impl Operation for StartDecommission {
|
||||
store
|
||||
.decommission(ctx.clone(), pools_indices.clone())
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
.map_err(|err| {
|
||||
error!(
|
||||
event = EVENT_ADMIN_REQUEST_FAILED,
|
||||
component = LOG_COMPONENT_ADMIN_API,
|
||||
subsystem = LOG_SUBSYSTEM_POOL_ADMIN,
|
||||
operation = "start_decommission",
|
||||
action = "start_decommission",
|
||||
result = "failed",
|
||||
reason = "storage_decommission_failed",
|
||||
request_id = %request_id,
|
||||
actor = %actor,
|
||||
remote_addr = %remote_addr,
|
||||
pool_indices = ?pools_indices,
|
||||
error = %err,
|
||||
"admin request failed"
|
||||
);
|
||||
decommission_start_api_error(err)
|
||||
})
|
||||
.map_err(|err| contextualize_admin_pool_api_error(err, "start decommission", &pool_context))?;
|
||||
}
|
||||
}
|
||||
@@ -1018,9 +1047,10 @@ impl Operation for ClearDecommission {
|
||||
#[cfg(test)]
|
||||
mod pools_handler_tests {
|
||||
use super::{
|
||||
AdminPoolStatus, Body, CancelDecommission, ClearDecommission, HeaderMap, ListPools, Method, Operation, Params,
|
||||
PoolAuditContext, S3ErrorCode, S3Request, StartDecommission, StatusDecommission, StatusPool, Uri,
|
||||
contextualize_admin_pool_api_error, decommission_admin_not_initialized_error_with_audit, decommission_peer_target,
|
||||
AdminPoolStatus, Body, CancelDecommission, ClearDecommission, HeaderMap, ListPools, Method, Operation,
|
||||
POOL_ACTIVATION_FLEET_PROOF_REQUIRED, Params, PoolAuditContext, S3ErrorCode, S3Request, StartDecommission,
|
||||
StatusDecommission, StatusPool, Uri, contextualize_admin_pool_api_error,
|
||||
decommission_admin_not_initialized_error_with_audit, decommission_peer_target, decommission_start_api_error,
|
||||
has_duplicate_indices, parse_mutation_pool_query, parse_pool_idx_by_id, parse_status_pool_query,
|
||||
pool_admin_missing_credentials_error, pool_admin_missing_credentials_error_with_request,
|
||||
pool_admin_pool_index_error_with_audit, pool_admin_pool_not_found_error_with_audit,
|
||||
@@ -1209,6 +1239,21 @@ mod pools_handler_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decommission_start_api_error_preserves_fleet_proof_retry_marker() {
|
||||
let err = crate::storage_api::error::StorageError::other(POOL_ACTIVATION_FLEET_PROOF_REQUIRED);
|
||||
|
||||
let err = decommission_start_api_error(err);
|
||||
|
||||
assert_eq!(err.code, s3s::S3ErrorCode::InternalError);
|
||||
assert_eq!(err.message, POOL_ACTIVATION_FLEET_PROOF_REQUIRED);
|
||||
assert!(err.source.is_some());
|
||||
|
||||
let unrelated = decommission_start_api_error(crate::storage_api::error::StorageError::other("disk read failed"));
|
||||
assert_eq!(unrelated.code, s3s::S3ErrorCode::InternalError);
|
||||
assert_eq!(unrelated.message, "We encountered an internal error, please try again.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contextualize_admin_pool_api_error_preserves_source() {
|
||||
let err = contextualize_admin_pool_api_error(
|
||||
|
||||
@@ -26,14 +26,13 @@
|
||||
//! server is not ready rather than that another server's global context applies.
|
||||
|
||||
use super::global::{AppContext, get_global_app_context};
|
||||
use crate::app::storage_api::context::{BootstrapLocalTarget, ECStore, InstanceContext};
|
||||
use crate::app::storage_api::context::ECStore;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// Late-bound, per-server handle to the application context.
|
||||
#[derive(Default)]
|
||||
pub struct ServerContextSlot {
|
||||
app_context: OnceLock<Arc<AppContext>>,
|
||||
bootstrap_target: Option<BootstrapLocalTarget>,
|
||||
heal_topology_fingerprint: Arc<tokio::sync::OnceCell<String>>,
|
||||
}
|
||||
|
||||
@@ -51,47 +50,15 @@ impl ServerContextSlot {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
app_context: OnceLock::new(),
|
||||
bootstrap_target: None,
|
||||
heal_topology_fingerprint: Arc::new(tokio::sync::OnceCell::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Bind the listener to its foundation before it can accept requests.
|
||||
pub fn with_instance_context(ctx: Arc<InstanceContext>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
bootstrap_target: Some(BootstrapLocalTarget::new(ctx)),
|
||||
..Self::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Install this server's application context (once). Returns `false` if
|
||||
/// the slot was already installed; the first installation wins, matching
|
||||
/// the process-global singleton's `get_or_init` semantics.
|
||||
pub fn install(&self, context: Arc<AppContext>) -> bool {
|
||||
self.try_install(context).is_ok()
|
||||
}
|
||||
|
||||
/// Claim the slot before any process-global application publication.
|
||||
/// Repeated installation, even of the same Arc, is an explicit conflict.
|
||||
pub fn try_install(&self, context: Arc<AppContext>) -> std::io::Result<()> {
|
||||
if self
|
||||
.bootstrap_target
|
||||
.as_ref()
|
||||
.is_some_and(|target| !target.is_for_store(&context.object_store()))
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"application context does not belong to this server foundation",
|
||||
));
|
||||
}
|
||||
self.app_context.set(context).map_err(|_| {
|
||||
std::io::Error::new(std::io::ErrorKind::AlreadyExists, "server application context is already installed")
|
||||
})
|
||||
}
|
||||
|
||||
/// Immutable, restricted startup capability; never resolves an ambient store.
|
||||
pub fn bootstrap_target(&self) -> Option<BootstrapLocalTarget> {
|
||||
self.bootstrap_target.clone()
|
||||
self.app_context.set(context).is_ok()
|
||||
}
|
||||
|
||||
/// This server's installed application context, if startup has completed.
|
||||
|
||||
@@ -37,8 +37,8 @@ impl AppContext {
|
||||
// also publishes to the process default (first server wins) so legacy
|
||||
// free-function readers keep resolving the first server's context.
|
||||
let context = Arc::new(AppContext::with_default_interfaces(store, iam, kms_interface));
|
||||
server_ctx.try_install(context.clone())?;
|
||||
publish_global_app_context(context);
|
||||
publish_global_app_context(context.clone());
|
||||
let _ = server_ctx.install(context);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1261,7 +1261,7 @@ pub(crate) mod context {
|
||||
pub(crate) use super::EndpointServerPools;
|
||||
pub(crate) use super::bucket;
|
||||
pub(crate) use super::runtime;
|
||||
pub(crate) use crate::storage::storage_api::{BootstrapLocalTarget, ECStore, EndpointServerPools, InstanceContext};
|
||||
pub(crate) use crate::storage::storage_api::{ECStore, EndpointServerPools};
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::storage::storage_api::{Endpoint, Endpoints, PoolEndpoints};
|
||||
}
|
||||
|
||||
@@ -36,9 +36,7 @@ use crate::server::{
|
||||
};
|
||||
use crate::storage_api::server::http as storage;
|
||||
use crate::storage_api::server::http::rpc::InternodeRpcService;
|
||||
#[cfg(test)]
|
||||
use crate::storage_api::server::http::tonic_service::make_server;
|
||||
use crate::storage_api::server::http::tonic_service::make_server_for_slot;
|
||||
use crate::storage_api::server::http::{
|
||||
ServerContextSlot, TONIC_RPC_PREFIX, normalize_tonic_rpc_audience, tonic_boot_epoch_challenge,
|
||||
tonic_boot_epoch_response_headers, verify_tonic_rpc_signature_with_bootstrap,
|
||||
@@ -1836,7 +1834,7 @@ fn process_connection(
|
||||
// each service in the auth interceptor.
|
||||
let rpc_max_message_size = rustfs_protos::internode_rpc_max_message_size();
|
||||
let node_service = InterceptedService::new(
|
||||
NodeServiceServer::new(make_server_for_slot(Arc::clone(&server_ctx)))
|
||||
NodeServiceServer::new(make_server())
|
||||
.max_decoding_message_size(rpc_max_message_size)
|
||||
.max_encoding_message_size(rpc_max_message_size),
|
||||
check_auth,
|
||||
|
||||
@@ -124,6 +124,9 @@ pub(crate) async fn run_embedded_startup(args: EmbeddedStartupArgs) -> Result<Em
|
||||
} else {
|
||||
bootstrap_instance_ctx()
|
||||
};
|
||||
// This server's request-path context slot (backlog#1052 S2).
|
||||
let server_ctx = ServerContextSlot::new();
|
||||
|
||||
let EmbeddedStartupConfig {
|
||||
config,
|
||||
identity,
|
||||
@@ -148,7 +151,6 @@ pub(crate) async fn run_embedded_startup(args: EmbeddedStartupArgs) -> Result<Em
|
||||
.await
|
||||
.map_err(init_error)?;
|
||||
|
||||
let server_ctx = ServerContextSlot::with_instance_context(instance_ctx.clone());
|
||||
let http_server = start_embedded_http_server(&config, listen_context.readiness.clone(), server_ctx.clone()).await?;
|
||||
let shutdown_handle = http_server.shutdown_handle;
|
||||
let bound_addr = http_server.bound_addr;
|
||||
|
||||
@@ -62,29 +62,6 @@ fn emit_fatal_stderr(context: &str, error: impl std::fmt::Display) {
|
||||
}
|
||||
|
||||
async fn async_main() -> Result<()> {
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if let Ok(nonce) = std::env::var("RUSTFS_E2E_STARTUP_CAS_PROBE") {
|
||||
let nonce = uuid::Uuid::parse_str(&nonce).map_err(Error::other)?;
|
||||
// This precedes CLI parsing and observability, including `--help`.
|
||||
println!(
|
||||
"RUSTFS_E2E_STARTUP_CAS {}",
|
||||
serde_json::json!({
|
||||
"kind": "capability", "schema": "fresh-startup-cas/v1", "nonce": nonce,
|
||||
})
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if let Ok(nonce) = std::env::var("RUSTFS_E2E_STARTUP_CAS_NONCE") {
|
||||
let nonce = uuid::Uuid::parse_str(&nonce).map_err(Error::other)?;
|
||||
let line = format!(
|
||||
"RUSTFS_E2E_STARTUP_CAS {}\n",
|
||||
serde_json::json!({
|
||||
"kind": "observer-ready", "nonce": nonce, "pid": std::process::id(),
|
||||
})
|
||||
);
|
||||
let _ = std::io::Write::write_all(&mut std::io::stderr().lock(), line.as_bytes());
|
||||
}
|
||||
hotpath::tokio_runtime!();
|
||||
|
||||
// Log container resource detection early in startup
|
||||
@@ -164,6 +141,10 @@ async fn run(config: Config) -> Result<()> {
|
||||
// the storage path explicitly (Phase 5 follow-up, backlog#1052); a future
|
||||
// multi-instance server constructs its own context here instead.
|
||||
let instance_ctx = bootstrap_instance_ctx();
|
||||
// This server's request-path context slot (backlog#1052 S2): handed to the
|
||||
// HTTP service now, installed once IAM bootstrap completes.
|
||||
let server_ctx = ServerContextSlot::new();
|
||||
|
||||
let StartupListenContext {
|
||||
readiness,
|
||||
server_addr,
|
||||
@@ -171,7 +152,6 @@ async fn run(config: Config) -> Result<()> {
|
||||
} = init_startup_listen_context(&config, &instance_ctx).await?;
|
||||
|
||||
let endpoint_pools = init_startup_storage_foundation(&server_address, &config.volumes, &instance_ctx).await?;
|
||||
let server_ctx = ServerContextSlot::with_instance_context(instance_ctx.clone());
|
||||
let StartupHttpServers {
|
||||
state_manager,
|
||||
s3_shutdown_tx,
|
||||
@@ -183,33 +163,6 @@ async fn run(config: Config) -> Result<()> {
|
||||
shutdown_token: ctx,
|
||||
} = init_startup_storage_runtime(server_addr, &endpoint_pools, readiness.clone(), instance_ctx).await?;
|
||||
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if let Ok(nonce) = std::env::var("RUSTFS_E2E_STARTUP_CAS_NONCE") {
|
||||
let nonce = uuid::Uuid::parse_str(&nonce).map_err(Error::other)?;
|
||||
let release = std::path::PathBuf::from(
|
||||
std::env::var_os("RUSTFS_E2E_STARTUP_CAS_RELEASE")
|
||||
.ok_or_else(|| Error::other("startup CAS fixture requires a release path"))?,
|
||||
);
|
||||
if server_ctx.installed_object_store().is_some() {
|
||||
return Err(Error::other("startup CAS gate reached an installed slot"));
|
||||
}
|
||||
let line = format!(
|
||||
"RUSTFS_E2E_STARTUP_CAS {}\n",
|
||||
serde_json::json!({
|
||||
"kind": "gate", "nonce": nonce, "pid": std::process::id(), "slot_installed": false,
|
||||
})
|
||||
);
|
||||
let _ = std::io::Write::write_all(&mut std::io::stderr().lock(), line.as_bytes());
|
||||
tokio::time::timeout(std::time::Duration::from_secs(180), async {
|
||||
while !tokio::fs::try_exists(&release).await? {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||
}
|
||||
Ok::<_, Error>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::other("startup CAS gate release timed out"))??;
|
||||
}
|
||||
|
||||
let capacity_tasks = crate::capacity::capacity_integration::init_capacity_management_managed().await;
|
||||
|
||||
let service_runtime = init_startup_runtime_services(
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
use crate::storage_api::startup::lifecycle::ECStore;
|
||||
use crate::{
|
||||
connect::runtime::shutdown_connect_runtimes,
|
||||
server::{ServiceStateManager, ShutdownHandle, start_persisted_event_notifier_reconciler, wait_for_shutdown},
|
||||
server::{
|
||||
SHUTDOWN_TIMEOUT, ServiceStateManager, ShutdownHandle, start_persisted_event_notifier_reconciler, wait_for_shutdown,
|
||||
},
|
||||
startup_iam::{IamBootstrapDisposition, publish_ready_for_iam_bootstrap},
|
||||
startup_runtime_sources,
|
||||
startup_services::StartupServiceRuntime,
|
||||
@@ -23,7 +25,7 @@ use crate::{
|
||||
};
|
||||
use rustfs_common::GlobalReadiness;
|
||||
use rustfs_object_capacity::capacity_manager::CapacityBackgroundTasks;
|
||||
use rustfs_scanner::init_data_scanner;
|
||||
use rustfs_scanner::init_scanner_with_recovery;
|
||||
use std::{
|
||||
io::{Error, Result},
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
@@ -150,9 +152,7 @@ pub(crate) async fn run_startup_runtime_lifecycle(lifecycle: StartupRuntimeLifec
|
||||
startup_runtime_sources::publish_init_time_now().await;
|
||||
let event_notifier_reconciler = start_persisted_event_notifier_reconciler(store.clone(), shutdown_token.clone());
|
||||
|
||||
if enable_scanner {
|
||||
init_data_scanner(shutdown_token.clone(), store).await;
|
||||
}
|
||||
let scanner_cleanup = init_scanner_with_recovery(shutdown_token.clone(), store, enable_scanner).await;
|
||||
|
||||
let shutdown_signal = wait_for_shutdown().await;
|
||||
run_startup_shutdown_sequence(
|
||||
@@ -166,6 +166,9 @@ pub(crate) async fn run_startup_runtime_lifecycle(lifecycle: StartupRuntimeLifec
|
||||
)
|
||||
.await;
|
||||
shutdown_connect_runtimes(heartbeat, inventory).await;
|
||||
if let Some(cleanup) = scanner_cleanup {
|
||||
let _ = wait_for_scanner_cleanup(cleanup).await;
|
||||
}
|
||||
if let Err(err) = event_notifier_reconciler.await {
|
||||
tracing::warn!(
|
||||
target: "rustfs::main::run",
|
||||
@@ -192,6 +195,40 @@ pub(crate) async fn run_startup_runtime_lifecycle(lifecycle: StartupRuntimeLifec
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_scanner_cleanup(cleanup: tokio::task::JoinHandle<()>) -> bool {
|
||||
// Dropping a JoinHandle detaches rather than aborts the task. Keep an
|
||||
// in-flight reset's guards owned while this runtime remains alive. This
|
||||
// bounded wait does not prove I/O drain at a later runtime/process exit.
|
||||
match tokio::time::timeout(SHUTDOWN_TIMEOUT, cleanup).await {
|
||||
Ok(Ok(())) => true,
|
||||
Ok(Err(error)) => {
|
||||
tracing::warn!(
|
||||
target: "rustfs::main::run",
|
||||
event = EVENT_SERVER_SHUTDOWN_STATE,
|
||||
component = LOG_COMPONENT_MAIN,
|
||||
subsystem = LOG_SUBSYSTEM_STARTUP,
|
||||
state = "scanner_cleanup_join_failed",
|
||||
reason = if error.is_cancelled() { "task_cancelled" } else { "task_panicked" },
|
||||
"Scanner cleanup task failed to join"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
target: "rustfs::main::run",
|
||||
event = EVENT_SERVER_SHUTDOWN_STATE,
|
||||
component = LOG_COMPONENT_MAIN,
|
||||
subsystem = LOG_SUBSYSTEM_STARTUP,
|
||||
state = "scanner_cleanup_not_joined",
|
||||
reason = "timeout",
|
||||
timeout_secs = SHUTDOWN_TIMEOUT.as_secs(),
|
||||
"Scanner cleanup completion is unconfirmed; task was not aborted"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn publish_embedded_startup_ready(
|
||||
iam_bootstrap: IamBootstrapDisposition,
|
||||
readiness: &GlobalReadiness,
|
||||
@@ -226,12 +263,50 @@ pub(crate) fn log_embedded_server_ready(endpoint_address: SocketAddr) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{embedded_endpoint_address, mark_embedded_global_init_started};
|
||||
use super::{embedded_endpoint_address, mark_embedded_global_init_started, wait_for_scanner_cleanup};
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_cleanup_shutdown_joins_a_finished_task() {
|
||||
assert!(wait_for_scanner_cleanup(tokio::spawn(async {})).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_cleanup_shutdown_reports_task_failure() {
|
||||
assert!(!wait_for_scanner_cleanup(tokio::spawn(async { panic!("fixture cleanup failure") })).await);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn disabled_cleanup_shutdown_timeout_does_not_abort_owned_work() {
|
||||
struct OwnedWork(std::sync::Arc<AtomicBool>);
|
||||
impl Drop for OwnedWork {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let dropped = std::sync::Arc::new(AtomicBool::new(false));
|
||||
let owned = OwnedWork(dropped.clone());
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let (release, resume) = tokio::sync::oneshot::channel();
|
||||
let (finished, done) = tokio::sync::oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let owned = owned;
|
||||
started.send(()).expect("work started");
|
||||
resume.await.expect("fixture releases owned work");
|
||||
drop(owned);
|
||||
finished.send(()).expect("work completed");
|
||||
});
|
||||
ready.await.expect("task must actually be in flight");
|
||||
assert!(!wait_for_scanner_cleanup(task).await, "timeout is not a completed join");
|
||||
assert!(!dropped.load(Ordering::SeqCst), "timeout must not abort the reset's owned guards");
|
||||
release.send(()).expect("detached task remains alive");
|
||||
done.await.expect("owned work should finish after explicit release");
|
||||
assert!(dropped.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_global_init_guard_allows_local_retry_before_mark() {
|
||||
let server_started = AtomicBool::new(false);
|
||||
|
||||
@@ -28,9 +28,9 @@ use crate::storage::storage_api::rpc_consumer::node_service::{
|
||||
SCANNER_PUBLICATION_LEASE_TTL_MS, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC, StorageDiskRpcExt as _,
|
||||
StorageResult, all_local_disk_path, find_local_disk_by_ref, reload_transition_tier_config,
|
||||
};
|
||||
use crate::storage::storage_api::runtime_sources_consumer::{EndpointServerPools, ServerContextSlot, runtime_sources};
|
||||
use crate::storage::storage_api::runtime_sources_consumer::{EndpointServerPools, runtime_sources};
|
||||
use crate::storage::storage_api::{
|
||||
BootstrapLocalTarget, sign_tonic_rpc_response_proof, verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest,
|
||||
sign_tonic_rpc_response_proof, verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest,
|
||||
verify_tonic_mutation_body_digest_reject_unsigned,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
@@ -482,97 +482,6 @@ mod metrics;
|
||||
pub struct NodeService {
|
||||
local_peer: LocalPeerS3Client,
|
||||
context: Option<Arc<runtime_sources::AppContext>>,
|
||||
server_ctx: Option<Arc<ServerContextSlot>>,
|
||||
}
|
||||
|
||||
enum LocalMutationTarget {
|
||||
Ready(Arc<ECStore>),
|
||||
Bootstrap(BootstrapLocalTarget),
|
||||
Unbound,
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
pub(crate) mod rename_target_capture_test_hook {
|
||||
use super::LocalMutationTarget;
|
||||
use rustfs_protos::proto_gen::node_service::RenameDataRequest;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use tokio::sync::oneshot;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct Hook {
|
||||
id: Uuid,
|
||||
disk: String,
|
||||
volume: String,
|
||||
path: String,
|
||||
captured: oneshot::Sender<bool>,
|
||||
release: oneshot::Receiver<()>,
|
||||
}
|
||||
|
||||
static HOOK: LazyLock<Mutex<Option<Hook>>> = LazyLock::new(|| Mutex::new(None));
|
||||
|
||||
/// One exact signed rename paused after its listener target was captured.
|
||||
/// Dropping the handle removes an unused hook and releases an entered one.
|
||||
pub struct RenameTargetCapturePause {
|
||||
id: Uuid,
|
||||
captured: oneshot::Receiver<bool>,
|
||||
release: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl RenameTargetCapturePause {
|
||||
pub async fn wait_until_captured(&mut self) -> bool {
|
||||
(&mut self.captured)
|
||||
.await
|
||||
.expect("matching rename must report its actual captured target")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RenameTargetCapturePause {
|
||||
fn drop(&mut self) {
|
||||
let unused = HOOK
|
||||
.lock()
|
||||
.expect("rename capture hook lock")
|
||||
.take_if(|hook| hook.id == self.id);
|
||||
drop(unused);
|
||||
if let Some(release) = self.release.take() {
|
||||
let _ = release.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pause_rename_after_target_capture(disk: &str, volume: &str, path: &str) -> RenameTargetCapturePause {
|
||||
let id = Uuid::new_v4();
|
||||
let (captured_tx, captured) = oneshot::channel();
|
||||
let (release, release_rx) = oneshot::channel();
|
||||
let mut active = HOOK.lock().expect("rename capture hook lock");
|
||||
if active.is_some() {
|
||||
drop(active);
|
||||
panic!("only one rename capture hook may be active");
|
||||
}
|
||||
*active = Some(Hook {
|
||||
id,
|
||||
disk: disk.to_owned(),
|
||||
volume: volume.to_owned(),
|
||||
path: path.to_owned(),
|
||||
captured: captured_tx,
|
||||
release: release_rx,
|
||||
});
|
||||
RenameTargetCapturePause {
|
||||
id,
|
||||
captured,
|
||||
release: Some(release),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn wait(target: &LocalMutationTarget, request: &RenameDataRequest) {
|
||||
let hook = {
|
||||
let mut active = HOOK.lock().expect("rename capture hook lock");
|
||||
active.take_if(|hook| hook.disk == request.disk && hook.volume == request.dst_volume && hook.path == request.dst_path)
|
||||
};
|
||||
if let Some(hook) = hook {
|
||||
let _ = hook.captured.send(matches!(target, LocalMutationTarget::Bootstrap(_)));
|
||||
let _ = hook.release.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NodeService {
|
||||
@@ -598,19 +507,7 @@ pub fn make_server() -> NodeService {
|
||||
|
||||
pub fn make_server_for_context(context: Option<Arc<runtime_sources::AppContext>>) -> NodeService {
|
||||
let local_peer = LocalPeerS3Client::new(None, None);
|
||||
NodeService {
|
||||
local_peer,
|
||||
context,
|
||||
server_ctx: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn make_server_for_slot(server_ctx: Arc<ServerContextSlot>) -> NodeService {
|
||||
// Unrelated RPCs retain their existing context policy. Target mutations
|
||||
// resolve exclusively through this listener slot on each request.
|
||||
let mut service = make_server();
|
||||
service.server_ctx = Some(server_ctx);
|
||||
service
|
||||
NodeService { local_peer, context }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -1177,24 +1074,6 @@ impl heal_control_service_server::HealControlService for HealControlRpcService {
|
||||
}
|
||||
|
||||
impl NodeService {
|
||||
fn local_mutation_target(&self) -> LocalMutationTarget {
|
||||
if let Some(slot) = &self.server_ctx {
|
||||
// Capture exactly once per request, not at connection acceptance.
|
||||
// A captured Bootstrap request cannot upgrade across a later await.
|
||||
if let Some(store) = slot.installed_object_store() {
|
||||
LocalMutationTarget::Ready(store)
|
||||
} else if let Some(target) = slot.bootstrap_target() {
|
||||
LocalMutationTarget::Bootstrap(target)
|
||||
} else {
|
||||
LocalMutationTarget::Unbound
|
||||
}
|
||||
} else if let Some(context) = &self.context {
|
||||
LocalMutationTarget::Ready(context.object_store())
|
||||
} else {
|
||||
LocalMutationTarget::Unbound
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_object_store(&self) -> Option<Arc<ECStore>> {
|
||||
let context = self.context.clone().or_else(runtime_sources::current_app_context);
|
||||
runtime_sources::current_object_store_handle_for_context(context.as_deref())
|
||||
@@ -2801,7 +2680,6 @@ mod tests {
|
||||
validate_admin_heal_control_start,
|
||||
};
|
||||
use crate::storage::rpc::node_service::heal::heal_topology_fingerprint;
|
||||
use crate::storage::storage_api::ecstore_disk::DiskAPI as _;
|
||||
use crate::storage::storage_api::rpc_consumer::node_service::{DiskError, HealBucketInfo};
|
||||
use crate::storage::storage_api::set_tonic_canonical_body_digest;
|
||||
use crate::storage::storage_api::{
|
||||
@@ -4782,687 +4660,6 @@ mod tests {
|
||||
assert!(rename_response.error.is_some());
|
||||
}
|
||||
|
||||
struct TargetRpcFixture {
|
||||
_root: tempfile::TempDir,
|
||||
env: rustfs_test_utils::TestECStoreEnv,
|
||||
instance: Arc<crate::storage::storage_api::InstanceContext>,
|
||||
context: Arc<crate::runtime_sources::AppContext>,
|
||||
iam: Arc<rustfs_iam::sys::IamSys<ObjectStore>>,
|
||||
}
|
||||
|
||||
async fn target_rpc_fixture() -> TargetRpcFixture {
|
||||
super::timeout(Duration::from_secs(90), async {
|
||||
let root = tempfile::tempdir().expect("target RPC root");
|
||||
let env = rustfs_test_utils::TestECStoreEnv::builder()
|
||||
.base_dir(root.path())
|
||||
.init_bucket_metadata(false)
|
||||
.build()
|
||||
.await;
|
||||
ObjectStore::new(env.ecstore.clone())
|
||||
.save_iam_config(serde_json::json!({"version": 1}), format!("{}/format.json", *IAM_CONFIG_PREFIX))
|
||||
.await
|
||||
.expect("seed real IAM format");
|
||||
let iam = rustfs_iam::build_iam_sys(env.ecstore.clone())
|
||||
.await
|
||||
.expect("build fixture IAM");
|
||||
let context = Arc::new(crate::runtime_sources::AppContext::with_default_interfaces(
|
||||
env.ecstore.clone(),
|
||||
iam.clone(),
|
||||
Arc::new(KmsServiceManager::new()),
|
||||
));
|
||||
let instance = crate::storage::storage_api::bootstrap_instance_ctx();
|
||||
assert!(
|
||||
super::BootstrapLocalTarget::new(instance.clone()).is_for_store(&env.ecstore),
|
||||
"the standard builder must use this exact instance context"
|
||||
);
|
||||
super::timeout(Duration::from_secs(10), async {
|
||||
while env.ecstore.scanner_data_usage_publication_blocked().await {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("startup namespace commits drain before test");
|
||||
TargetRpcFixture {
|
||||
_root: root,
|
||||
env,
|
||||
instance,
|
||||
context,
|
||||
iam,
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("bounded real fixture initialization")
|
||||
}
|
||||
|
||||
async fn stage_target_rpc(fixture: &TargetRpcFixture) -> (super::DiskStore, rustfs_filemeta::FileInfo, Vec<u8>) {
|
||||
use crate::storage::storage_api::ecstore_disk::{DiskAPI, ReadOptions};
|
||||
let set = fixture
|
||||
.env
|
||||
.ecstore
|
||||
.all_set_disks()
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("target erasure set");
|
||||
let disk = set.disks.read().await.iter().find_map(Clone::clone).expect("local target");
|
||||
let mut fi = rustfs_filemeta::FileInfo::new("destination", 1, 0);
|
||||
fi.erasure.index = 1;
|
||||
fi.version_id = Some(Uuid::new_v4());
|
||||
fi.mod_time = Some(OffsetDateTime::now_utc());
|
||||
fi.size = 17;
|
||||
fi.parts = vec![rustfs_filemeta::ObjectPartInfo {
|
||||
number: 1,
|
||||
size: 17,
|
||||
actual_size: 17,
|
||||
..Default::default()
|
||||
}];
|
||||
fi.data = Some(Bytes::from_static(b"target-rpc-inline"));
|
||||
fi.set_inline_data();
|
||||
disk.make_volume("target-rpc").await.expect("target volume");
|
||||
disk.write_metadata("target-rpc", "target-rpc", "staged", fi.clone())
|
||||
.await
|
||||
.expect("stage real inline body");
|
||||
let read = disk
|
||||
.read_version(
|
||||
"target-rpc",
|
||||
"target-rpc",
|
||||
"staged",
|
||||
&fi.version_id.expect("version").to_string(),
|
||||
&ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read staged body before mutation");
|
||||
assert_eq!(read.data, fi.data);
|
||||
let before = tokio::fs::read(disk.path().join("target-rpc/staged/xl.meta"))
|
||||
.await
|
||||
.expect("staged bytes");
|
||||
(disk, fi, before)
|
||||
}
|
||||
|
||||
fn target_rename_request(disk: &super::DiskStore, fi: &rustfs_filemeta::FileInfo) -> Request<RenameDataRequest> {
|
||||
let mut request = Request::new(RenameDataRequest {
|
||||
disk: disk.endpoint().to_string(),
|
||||
src_volume: "target-rpc".to_string(),
|
||||
src_path: "staged".to_string(),
|
||||
dst_volume: "target-rpc".to_string(),
|
||||
dst_path: "destination".to_string(),
|
||||
file_info: serde_json::to_string(fi).expect("real FileInfo JSON"),
|
||||
..Default::default()
|
||||
});
|
||||
let body = rustfs_protos::canonical_rename_data_request_body(request.get_ref()).expect("canonical target body");
|
||||
set_tonic_canonical_body_digest(&mut request, &body).expect("body digest");
|
||||
// Direct-handler precondition only; this does not stand in for wire authentication.
|
||||
mark_v2_authenticated(&mut request);
|
||||
request
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_slot_rejects_mismatched_and_repeated_install_before_global_publication() {
|
||||
let fixture = target_rpc_fixture().await;
|
||||
assert!(
|
||||
crate::runtime_sources::current_app_context().is_none(),
|
||||
"requires a separate nextest process"
|
||||
);
|
||||
let wrong = super::ServerContextSlot::with_instance_context(crate::storage::storage_api::new_instance_ctx());
|
||||
let error = crate::runtime_sources::AppContext::ensure_startup_after_iam(
|
||||
fixture.env.ecstore.clone(),
|
||||
Arc::new(KmsServiceManager::new()),
|
||||
&wrong,
|
||||
fixture.iam.clone(),
|
||||
)
|
||||
.expect_err("mismatched startup must fail");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert!(wrong.installed_app_context().is_none());
|
||||
assert!(
|
||||
crate::runtime_sources::current_app_context().is_none(),
|
||||
"failed install must not publish globally"
|
||||
);
|
||||
assert!(!wrong.install(fixture.context.clone()), "bool adapter cannot bypass identity checks");
|
||||
let slot = super::ServerContextSlot::with_instance_context(fixture.instance.clone());
|
||||
crate::runtime_sources::AppContext::ensure_startup_after_iam(
|
||||
fixture.env.ecstore.clone(),
|
||||
Arc::new(KmsServiceManager::new()),
|
||||
&slot,
|
||||
fixture.iam.clone(),
|
||||
)
|
||||
.expect("matching startup installation");
|
||||
let installed = slot.installed_app_context().expect("installed A");
|
||||
assert!(Arc::ptr_eq(
|
||||
&crate::runtime_sources::current_app_context().expect("published A"),
|
||||
&installed
|
||||
));
|
||||
assert_eq!(
|
||||
slot.try_install(installed.clone())
|
||||
.expect_err("same Arc is still a duplicate")
|
||||
.kind(),
|
||||
std::io::ErrorKind::AlreadyExists
|
||||
);
|
||||
assert!(!slot.install(installed.clone()));
|
||||
assert!(Arc::ptr_eq(&slot.installed_app_context().expect("first winner retained"), &installed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_slot_captures_bootstrap_once_and_next_request_observes_ready() {
|
||||
let fixture = target_rpc_fixture().await;
|
||||
let (disk, fi, before) = stage_target_rpc(&fixture).await;
|
||||
let slot = super::ServerContextSlot::with_instance_context(fixture.instance.clone());
|
||||
let service = super::make_server_for_slot(slot.clone());
|
||||
let captured = service.local_mutation_target();
|
||||
slot.try_install(fixture.context.clone())
|
||||
.expect("install after the request captures bootstrap");
|
||||
let super::LocalMutationTarget::Bootstrap(target) = captured else {
|
||||
panic!("pre-install request must capture bootstrap");
|
||||
};
|
||||
assert!(
|
||||
target
|
||||
.rename_local_data(
|
||||
&disk.endpoint().to_string(),
|
||||
("target-rpc", "staged"),
|
||||
&fi,
|
||||
("target-rpc", "destination"),
|
||||
None
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"captured request cannot acquire Ready privileges"
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(disk.path().join("target-rpc/staged/xl.meta"))
|
||||
.await
|
||||
.expect("original source"),
|
||||
before
|
||||
);
|
||||
assert!(!disk.path().join("target-rpc/destination").exists());
|
||||
assert!(
|
||||
matches!(service.local_mutation_target(), super::LocalMutationTarget::Ready(_)),
|
||||
"the same service must read the installed slot for its next request"
|
||||
);
|
||||
let result = service
|
||||
.rename_data(target_rename_request(&disk, &fi))
|
||||
.await
|
||||
.expect("ready handler")
|
||||
.into_inner();
|
||||
assert!(result.success, "{:?}", result.error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_unbound_slot_never_mutates_a_published_global_store() {
|
||||
let fixture = target_rpc_fixture().await;
|
||||
let (disk, fi, before) = stage_target_rpc(&fixture).await;
|
||||
let published = crate::runtime_sources::publish_test_app_context(fixture.context.clone());
|
||||
assert!(Arc::ptr_eq(&published, &fixture.context));
|
||||
let service = super::make_server_for_slot(super::ServerContextSlot::new());
|
||||
let result = service
|
||||
.rename_data(target_rename_request(&disk, &fi))
|
||||
.await
|
||||
.expect("handler reply")
|
||||
.into_inner();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.is_some());
|
||||
assert_eq!(
|
||||
tokio::fs::read(disk.path().join("target-rpc/staged/xl.meta"))
|
||||
.await
|
||||
.expect("source remains"),
|
||||
before
|
||||
);
|
||||
assert!(!disk.path().join("target-rpc/destination").exists());
|
||||
assert!(!fixture.env.ecstore.scanner_data_usage_publication_blocked().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_undo_rejects_force_delete_marker_before_mutation() {
|
||||
let fixture = target_rpc_fixture().await;
|
||||
let (disk, fi, before) = stage_target_rpc(&fixture).await;
|
||||
let service = make_server_for_context(Some(fixture.context.clone()));
|
||||
let opts = crate::storage::storage_api::ecstore_disk::DeleteOptions {
|
||||
undo_write: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut request = Request::new(DeleteVersionRequest {
|
||||
disk: disk.endpoint().to_string(),
|
||||
volume: "target-rpc".to_string(),
|
||||
path: "staged".to_string(),
|
||||
file_info: serde_json::to_string(&fi).expect("FileInfo"),
|
||||
opts: serde_json::to_string(&opts).expect("opts"),
|
||||
force_del_marker: true,
|
||||
..Default::default()
|
||||
});
|
||||
let body = rustfs_protos::canonical_delete_version_request_body(request.get_ref()).expect("canonical undo body");
|
||||
set_tonic_canonical_body_digest(&mut request, &body).expect("body digest");
|
||||
mark_v2_authenticated(&mut request);
|
||||
let result = service.delete_version(request).await.expect("handler reply").into_inner();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.is_some());
|
||||
assert_eq!(
|
||||
tokio::fs::read(disk.path().join("target-rpc/staged/xl.meta"))
|
||||
.await
|
||||
.expect("source remains"),
|
||||
before
|
||||
);
|
||||
assert!(!fixture.env.ecstore.scanner_data_usage_publication_blocked().await);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn target_handler_cancellation_retains_namespace_through_physical_rename() {
|
||||
use crate::storage::storage_api::{
|
||||
LocalPublicationPause, LocalPublicationStage,
|
||||
ecstore_disk::{DiskAPI, ReadOptions},
|
||||
};
|
||||
let fixture = target_rpc_fixture().await;
|
||||
let (disk, fi, _) = stage_target_rpc(&fixture).await;
|
||||
let slot = super::ServerContextSlot::with_instance_context(fixture.instance.clone());
|
||||
slot.try_install(fixture.context.clone()).expect("ready target");
|
||||
let service = super::make_server_for_slot(slot);
|
||||
let mut pause =
|
||||
LocalPublicationPause::install(&disk, "target-rpc", "destination/xl.meta", LocalPublicationStage::PreparedRename)
|
||||
.expect("install scoped physical pause");
|
||||
let mut handler = Box::pin(service.rename_data(target_rename_request(&disk, &fi)));
|
||||
super::timeout(Duration::from_secs(10), async {
|
||||
tokio::select! {
|
||||
result = &mut handler => panic!("handler completed before physical entry: {result:?}"),
|
||||
entered = pause.entered() => entered.expect("physical executor entered"),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("bounded physical entry");
|
||||
drop(handler);
|
||||
assert!(
|
||||
fixture.env.ecstore.scanner_data_usage_publication_blocked().await,
|
||||
"dropping the actual target handler must not release its physical owner"
|
||||
);
|
||||
drop(pause);
|
||||
super::timeout(Duration::from_secs(10), async {
|
||||
while fixture.env.ecstore.scanner_data_usage_publication_blocked().await {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("physical owner must drain");
|
||||
let read = disk
|
||||
.read_version(
|
||||
"target-rpc",
|
||||
"target-rpc",
|
||||
"destination",
|
||||
&fi.version_id.expect("version").to_string(),
|
||||
&ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read real late commit");
|
||||
assert_eq!(read.data, fi.data);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn target_undo_handler_cancellation_retains_owner_until_backup_restoration() {
|
||||
use crate::storage::storage_api::{
|
||||
LocalPublicationPause, LocalPublicationStage,
|
||||
ecstore_disk::{DeleteOptions, DiskAPI, ReadOptions},
|
||||
};
|
||||
let fixture = target_rpc_fixture().await;
|
||||
let (disk, fi, _) = stage_target_rpc(&fixture).await;
|
||||
let mut old = fi.clone();
|
||||
old.data = Some(Bytes::from_static(b"previous-rpc-body"));
|
||||
assert_eq!(old.data.as_ref().expect("old body").len(), 17);
|
||||
disk.write_metadata("target-rpc", "target-rpc", "destination", old.clone())
|
||||
.await
|
||||
.expect("old actual version");
|
||||
let old_bytes = tokio::fs::read(disk.path().join("target-rpc/destination/xl.meta"))
|
||||
.await
|
||||
.expect("old metadata bytes");
|
||||
let committed = fixture
|
||||
.env
|
||||
.ecstore
|
||||
.rename_local_data(
|
||||
&disk.endpoint().to_string(),
|
||||
("target-rpc", "staged"),
|
||||
&fi,
|
||||
("target-rpc", "destination"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("real overwrite creates rollback backup");
|
||||
let opts = DeleteOptions {
|
||||
undo_write: true,
|
||||
old_data_dir: Some(committed.rollback_data_dir.expect("real rollback backup")),
|
||||
..Default::default()
|
||||
};
|
||||
let service = make_server_for_context(Some(fixture.context.clone()));
|
||||
let mut request = Request::new(DeleteVersionRequest {
|
||||
disk: disk.endpoint().to_string(),
|
||||
volume: "target-rpc".to_string(),
|
||||
path: "destination".to_string(),
|
||||
file_info: serde_json::to_string(&fi).expect("FileInfo"),
|
||||
opts: serde_json::to_string(&opts).expect("undo options"),
|
||||
..Default::default()
|
||||
});
|
||||
let body = rustfs_protos::canonical_delete_version_request_body(request.get_ref()).expect("canonical undo body");
|
||||
set_tonic_canonical_body_digest(&mut request, &body).expect("body digest");
|
||||
mark_v2_authenticated(&mut request);
|
||||
let mut pause = LocalPublicationPause::install(&disk, "target-rpc", "destination/xl.meta", LocalPublicationStage::Rename)
|
||||
.expect("pause actual backup restoration");
|
||||
let mut handler = Box::pin(service.delete_version(request));
|
||||
super::timeout(Duration::from_secs(10), async {
|
||||
tokio::select! {
|
||||
result = &mut handler => panic!("undo completed before physical entry: {result:?}"),
|
||||
entered = pause.entered() => entered.expect("physical restore entered"),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("bounded physical restore entry");
|
||||
drop(handler);
|
||||
assert!(fixture.env.ecstore.scanner_data_usage_publication_blocked().await);
|
||||
drop(pause);
|
||||
super::timeout(Duration::from_secs(10), async {
|
||||
while fixture.env.ecstore.scanner_data_usage_publication_blocked().await {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("restore owner drains");
|
||||
assert_eq!(
|
||||
tokio::fs::read(disk.path().join("target-rpc/destination/xl.meta"))
|
||||
.await
|
||||
.expect("restored bytes"),
|
||||
old_bytes
|
||||
);
|
||||
let read = disk
|
||||
.read_version(
|
||||
"target-rpc",
|
||||
"target-rpc",
|
||||
"destination",
|
||||
&fi.version_id.expect("version").to_string(),
|
||||
&ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("restored readable version");
|
||||
assert_eq!(read.data, old.data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_data_same_uuid_uses_captured_instance_instead_of_global_disk() {
|
||||
use crate::storage::storage_api::{
|
||||
ECStore,
|
||||
ecstore_disk::{DiskAPI, RUSTFS_META_BUCKET, ReadOptions},
|
||||
init_local_disks_with_instance_ctx, new_instance_ctx, read_config_no_lock,
|
||||
};
|
||||
use rustfs_filemeta::{FileInfo, ObjectPartInfo};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
async fn build_store(root: &std::path::Path) -> Arc<ECStore> {
|
||||
let mut endpoints = Vec::new();
|
||||
for index in 0..4 {
|
||||
let path = root.join(format!("disk{index}"));
|
||||
tokio::fs::create_dir_all(&path).await.expect("create instance disk");
|
||||
let mut endpoint = Endpoint::try_from(path.to_str().expect("UTF-8 disk path")).expect("local endpoint");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(index);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
let pools = EndpointServerPools(vec![PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 4,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: "namespace-target-context".to_string(),
|
||||
platform: "test".to_string(),
|
||||
}]);
|
||||
let instance = new_instance_ctx();
|
||||
init_local_disks_with_instance_ctx(&instance, pools.clone())
|
||||
.await
|
||||
.expect("register this instance's real disks");
|
||||
// Match the isolated ECStore fixtures: startup still runs, while
|
||||
// unrelated background recovery is cancelled for this process.
|
||||
let shutdown = CancellationToken::new();
|
||||
shutdown.cancel();
|
||||
ECStore::new_with_instance_ctx("127.0.0.1:0".parse().expect("local address"), pools, shutdown, instance)
|
||||
.await
|
||||
.expect("initialize isolated ECStore")
|
||||
}
|
||||
|
||||
async fn context(store: &Arc<ECStore>) -> Arc<crate::runtime_sources::AppContext> {
|
||||
ObjectStore::new(store.clone())
|
||||
.save_iam_config(serde_json::json!({"version": 1}), format!("{}/format.json", *IAM_CONFIG_PREFIX))
|
||||
.await
|
||||
.expect("seed isolated IAM format");
|
||||
let iam = rustfs_iam::build_iam_sys(store.clone()).await.expect("build isolated IAM");
|
||||
Arc::new(crate::runtime_sources::AppContext::with_default_interfaces(
|
||||
store.clone(),
|
||||
iam,
|
||||
Arc::new(KmsServiceManager::new()),
|
||||
))
|
||||
}
|
||||
|
||||
async fn internal_snapshot(root: &std::path::Path) -> std::collections::BTreeMap<std::path::PathBuf, Option<Vec<u8>>> {
|
||||
let mut snapshot = std::collections::BTreeMap::new();
|
||||
let mut directories = (0..4)
|
||||
.map(|index| std::path::PathBuf::from(format!("disk{index}/{RUSTFS_META_BUCKET}")))
|
||||
.collect::<Vec<_>>();
|
||||
while let Some(relative) = directories.pop() {
|
||||
let mut entries = tokio::fs::read_dir(root.join(&relative))
|
||||
.await
|
||||
.expect("read internal snapshot directory");
|
||||
snapshot.insert(relative.clone(), None);
|
||||
while let Some(entry) = entries.next_entry().await.expect("read internal snapshot entry") {
|
||||
let path = relative.join(entry.file_name());
|
||||
let file_type = entry.file_type().await.expect("read internal snapshot entry type");
|
||||
if file_type.is_dir() {
|
||||
directories.push(path);
|
||||
} else {
|
||||
assert!(file_type.is_file(), "fixture snapshot must contain only directories and regular files");
|
||||
snapshot.insert(path, Some(tokio::fs::read(entry.path()).await.expect("read snapshot file bytes")));
|
||||
}
|
||||
}
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn file_info(object: &str, version: Uuid, body: Bytes) -> FileInfo {
|
||||
let mut fi = FileInfo::new(object, 1, 0);
|
||||
fi.erasure.index = 1;
|
||||
fi.name = object.to_string();
|
||||
fi.version_id = Some(version);
|
||||
fi.size = i64::try_from(body.len()).expect("small fixture body");
|
||||
fi.parts = vec![ObjectPartInfo {
|
||||
number: 1,
|
||||
size: body.len(),
|
||||
actual_size: fi.size,
|
||||
..Default::default()
|
||||
}];
|
||||
fi.data = Some(body);
|
||||
fi.set_inline_data();
|
||||
fi.mod_time = Some(OffsetDateTime::now_utc());
|
||||
fi
|
||||
}
|
||||
|
||||
// Both global publications are first-writer-wins. Run this fixture in
|
||||
// its own nextest process; do not reset or replace another test's state.
|
||||
assert!(
|
||||
crate::runtime_sources::current_app_context().is_none(),
|
||||
"requires an unpublished AppContext"
|
||||
);
|
||||
super::timeout(Duration::from_secs(90), async {
|
||||
let root_b = tempfile::tempdir().expect("instance B directory");
|
||||
let root_a = tempfile::tempdir().expect("instance A directory");
|
||||
let store_b = build_store(root_b.path()).await;
|
||||
let context_b = context(&store_b).await;
|
||||
let published = crate::runtime_sources::publish_test_app_context(context_b.clone());
|
||||
assert!(Arc::ptr_eq(&published, &context_b), "B must win the process AppContext publication");
|
||||
|
||||
// Existing formats require their committed pool metadata on restart.
|
||||
// Copy the complete internal trees, including erasure part data,
|
||||
// without editing disk IDs, cluster identity, epochs or pool topology.
|
||||
super::timeout(Duration::from_secs(10), async {
|
||||
while store_b.scanner_data_usage_publication_blocked().await {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("B startup namespace commits must drain before its snapshot");
|
||||
let snapshot_generation = store_b.scanner_namespace_mutation_generation();
|
||||
let pool_config = read_config_no_lock(store_b.clone(), "pool.bin")
|
||||
.await
|
||||
.expect("read B's actually committed pool metadata");
|
||||
let pool_identity = read_config_no_lock(store_b.clone(), "pool.bin.identity")
|
||||
.await
|
||||
.expect("read B's actually committed pool identity");
|
||||
let snapshot = internal_snapshot(root_b.path()).await;
|
||||
for (relative, contents) in &snapshot {
|
||||
let target = root_a.path().join(relative);
|
||||
match contents {
|
||||
None => tokio::fs::create_dir_all(target).await.expect("copy internal directory"),
|
||||
Some(bytes) => tokio::fs::write(target, bytes).await.expect("copy complete internal file"),
|
||||
}
|
||||
}
|
||||
assert_eq!(internal_snapshot(root_a.path()).await, snapshot, "A must receive the complete physical snapshot");
|
||||
assert_eq!(internal_snapshot(root_b.path()).await, snapshot, "B's source snapshot must remain unchanged");
|
||||
assert!(!store_b.scanner_data_usage_publication_blocked().await);
|
||||
assert_eq!(store_b.scanner_namespace_mutation_generation(), snapshot_generation);
|
||||
let store_a = build_store(root_a.path()).await;
|
||||
assert_eq!(
|
||||
read_config_no_lock(store_a.clone(), "pool.bin").await.expect("read A's restarted pool metadata"),
|
||||
pool_config,
|
||||
"A must load the same committed topology without a bootstrap rewrite"
|
||||
);
|
||||
assert_eq!(
|
||||
read_config_no_lock(store_a.clone(), "pool.bin.identity")
|
||||
.await
|
||||
.expect("read A's restarted pool identity"),
|
||||
pool_identity,
|
||||
"A must preserve the initialized cluster identity and epoch"
|
||||
);
|
||||
let service = make_server_for_context(Some(context(&store_a).await));
|
||||
assert!(Arc::ptr_eq(&service.resolve_object_store().expect("captured store"), &store_a));
|
||||
assert!(Arc::ptr_eq(
|
||||
&crate::runtime_sources::current_object_store_handle().expect("global store"),
|
||||
&store_b
|
||||
));
|
||||
let disk_a = store_a.disk_map[&0][0].as_ref().expect("A disk zero").clone();
|
||||
let disk_b = store_b.disk_map[&0][0].as_ref().expect("B disk zero").clone();
|
||||
assert!(disk_a.is_local() && disk_b.is_local());
|
||||
assert!(!Arc::ptr_eq(&disk_a, &disk_b));
|
||||
let disk_id = disk_a.get_disk_id().await.expect("A disk ID").expect("formatted A disk");
|
||||
assert!(!disk_id.is_nil());
|
||||
assert_eq!(disk_b.get_disk_id().await.expect("B disk ID"), Some(disk_id));
|
||||
let global_disk = super::find_local_disk_by_ref(&disk_id.to_string())
|
||||
.await
|
||||
.expect("global UUID lookup must resolve B before the request");
|
||||
assert!(Arc::ptr_eq(&global_disk, &disk_b));
|
||||
|
||||
let volume = "namespace-target-context";
|
||||
let object = "destination";
|
||||
let staging = "staged";
|
||||
let version = Uuid::new_v4();
|
||||
let new_body = Bytes::from_static(b"committed-through-captured-A");
|
||||
let new_fi = file_info(object, version, new_body.clone());
|
||||
let opts = ReadOptions { read_data: true, ..Default::default() };
|
||||
for (disk, old_body) in [
|
||||
(&disk_a, Bytes::from_static(b"old-body-A")),
|
||||
(&disk_b, Bytes::from_static(b"old-body-B")),
|
||||
] {
|
||||
disk.make_volume(volume).await.expect("create destination volume");
|
||||
disk.write_metadata(volume, volume, object, file_info(object, version, old_body.clone()))
|
||||
.await
|
||||
.expect("write real old object metadata and inline body");
|
||||
disk.write_metadata(volume, volume, staging, new_fi.clone())
|
||||
.await
|
||||
.expect("stage identical valid metadata on both physical disks");
|
||||
let seeded = disk
|
||||
.read_version(volume, volume, object, &version.to_string(), &opts)
|
||||
.await
|
||||
.expect("decode seeded inline object before invoking the handler");
|
||||
assert_eq!(seeded.data, Some(old_body), "the real reader must return the seeded body");
|
||||
}
|
||||
let a_meta = disk_a.path().join(volume).join(object).join("xl.meta");
|
||||
let b_meta = disk_b.path().join(volume).join(object).join("xl.meta");
|
||||
let a_staging = disk_a.path().join(volume).join(staging).join("xl.meta");
|
||||
let b_staging = disk_b.path().join(volume).join(staging).join("xl.meta");
|
||||
let a_before = tokio::fs::read(&a_meta).await.expect("A old metadata bytes");
|
||||
let b_before = tokio::fs::read(&b_meta).await.expect("B old metadata bytes");
|
||||
let b_staging_before = tokio::fs::read(&b_staging).await.expect("B staged metadata bytes");
|
||||
assert!(tokio::fs::try_exists(&a_staging).await.expect("A staging exists"));
|
||||
assert_ne!(a_before, b_before, "the old on-disk bodies must distinguish A from B");
|
||||
super::timeout(Duration::from_secs(10), async {
|
||||
while store_a.scanner_data_usage_publication_blocked().await
|
||||
|| store_b.scanner_data_usage_publication_blocked().await
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("startup namespace commits must drain before measuring the handler");
|
||||
let generation_before = (
|
||||
store_a.scanner_namespace_mutation_generation(),
|
||||
store_b.scanner_namespace_mutation_generation(),
|
||||
);
|
||||
|
||||
let mut request = Request::new(RenameDataRequest {
|
||||
disk: disk_id.to_string(),
|
||||
src_volume: volume.to_string(),
|
||||
src_path: staging.to_string(),
|
||||
dst_volume: volume.to_string(),
|
||||
dst_path: object.to_string(),
|
||||
file_info: serde_json::to_string(&new_fi).expect("encode real FileInfo"),
|
||||
file_info_bin: Vec::new().into(),
|
||||
scanner_publication_lease_token: Vec::new().into(),
|
||||
});
|
||||
let body = rustfs_protos::canonical_rename_data_request_body(request.get_ref()).expect("canonical rename body");
|
||||
set_tonic_canonical_body_digest(&mut request, &body).expect("body-bound handler request");
|
||||
mark_v2_authenticated(&mut request);
|
||||
let response = super::timeout(Duration::from_secs(10), service.rename_data(request))
|
||||
.await
|
||||
.expect("real rename handler must finish within ten seconds")
|
||||
.expect("rename handler response")
|
||||
.into_inner();
|
||||
assert!(response.success, "the valid staged rename must execute: {:?}", response.error);
|
||||
|
||||
let a_after = disk_a
|
||||
.read_version(volume, volume, object, &version.to_string(), &opts)
|
||||
.await
|
||||
.expect("read A's physical object after rename");
|
||||
let b_after = disk_b
|
||||
.read_version(volume, volume, object, &version.to_string(), &opts)
|
||||
.await
|
||||
.expect("read B's physical object after rename");
|
||||
let a_bytes_after = tokio::fs::read(&a_meta).await.expect("A metadata after rename");
|
||||
let b_bytes_after = tokio::fs::read(&b_meta).await.expect("B metadata after rename");
|
||||
let b_staging_after = tokio::fs::read(&b_staging).await.ok();
|
||||
let generation_after = (
|
||||
store_a.scanner_namespace_mutation_generation(),
|
||||
store_b.scanner_namespace_mutation_generation(),
|
||||
);
|
||||
let pending_after = (
|
||||
store_a.scanner_data_usage_publication_blocked().await,
|
||||
store_b.scanner_data_usage_publication_blocked().await,
|
||||
);
|
||||
for disk in store_a.disk_map.values().chain(store_b.disk_map.values()).flatten().flatten() {
|
||||
disk.close().await.expect("close real fixture disk before assertions and directory cleanup");
|
||||
}
|
||||
assert_eq!(
|
||||
a_after.data,
|
||||
Some(new_body),
|
||||
"RenameData must commit to captured A, even when global B owns the same UUID; B body={:?}, generations={generation_before:?}->{generation_after:?}, pending={pending_after:?}",
|
||||
b_after.data
|
||||
);
|
||||
assert_ne!(a_bytes_after, a_before, "A metadata must actually be replaced");
|
||||
assert_eq!(b_after.data, Some(Bytes::from_static(b"old-body-B")), "B body must remain unchanged");
|
||||
assert_eq!(b_bytes_after, b_before, "B metadata must remain byte-for-byte unchanged");
|
||||
assert_eq!(b_staging_after, Some(b_staging_before), "B staging must not be consumed");
|
||||
assert_eq!(pending_after, (false, false), "both stores must reach a stable terminal state");
|
||||
})
|
||||
.await
|
||||
.expect("two-instance handler fixture must finish within ninety seconds");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_make_volumes_invalid_disk() {
|
||||
let service = create_test_node_service();
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::{LocalMutationTarget, NodeService};
|
||||
use super::NodeService;
|
||||
use crate::storage::storage_api::rpc_consumer::node_service::{
|
||||
BatchReadVersionReq, BatchReadVersionResp, DeleteOptions, DiskError, DiskInfoOptions, FileInfoVersions, ReadMultipleReq,
|
||||
ReadMultipleResp, ReadOptions, StorageDiskRpcExt as _, UpdateMetadataOpts, validate_batch_read_version_item_count,
|
||||
@@ -39,69 +39,6 @@ use tonic::{Request, Response, Status};
|
||||
use tracing::debug;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
fn startup_cas_rename_observation(
|
||||
target: &LocalMutationTarget,
|
||||
request: &RenameDataRequest,
|
||||
file_info: &FileInfo,
|
||||
) -> Option<serde_json::Value> {
|
||||
use sha2::{Digest, Sha256};
|
||||
if request.dst_volume != ".rustfs.sys" || !matches!(request.dst_path.as_str(), "pool.bin" | "pool.bin.identity") {
|
||||
return None;
|
||||
}
|
||||
let nonce = uuid::Uuid::parse_str(&std::env::var("RUSTFS_E2E_STARTUP_CAS_NONCE").ok()?).ok()?;
|
||||
let body = rustfs_protos::canonical_rename_data_request_body(request).ok()?;
|
||||
Some(serde_json::json!({
|
||||
"kind": "receiver", "nonce": nonce, "pid": std::process::id(),
|
||||
"target": match target { LocalMutationTarget::Ready(_) => "ready", LocalMutationTarget::Bootstrap(_) => "bootstrap", LocalMutationTarget::Unbound => "unbound" },
|
||||
"disk": request.disk, "src_volume": request.src_volume, "src_path": request.src_path,
|
||||
"dst_volume": request.dst_volume, "dst_path": request.dst_path,
|
||||
"body_sha256": rustfs_utils::crypto::hex(Sha256::digest(body)),
|
||||
"etag": file_info.metadata.get("etag"),
|
||||
"mod_time": file_info.mod_time.map(|time| time.unix_timestamp_nanos().to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
impl LocalMutationTarget {
|
||||
async fn rename_local_data(
|
||||
&self,
|
||||
disk_ref: &str,
|
||||
source: (&str, &str),
|
||||
fi: &FileInfo,
|
||||
destination: (&str, &str),
|
||||
scanner_token: Option<Uuid>,
|
||||
) -> Result<RenameDataResp, DiskError> {
|
||||
match self {
|
||||
Self::Ready(store) => {
|
||||
store
|
||||
.rename_local_data(disk_ref, source, fi, destination, scanner_token)
|
||||
.await
|
||||
}
|
||||
Self::Bootstrap(target) => {
|
||||
target
|
||||
.rename_local_data(disk_ref, source, fi, destination, scanner_token)
|
||||
.await
|
||||
}
|
||||
Self::Unbound => Err(DiskError::other("target disk instance is unavailable")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn undo_local_write(
|
||||
&self,
|
||||
disk_ref: &str,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
fi: FileInfo,
|
||||
opts: DeleteOptions,
|
||||
) -> Result<(), DiskError> {
|
||||
match self {
|
||||
Self::Ready(store) => store.undo_local_write(disk_ref, volume, path, fi, opts).await,
|
||||
Self::Bootstrap(target) => target.undo_local_write(disk_ref, volume, path, fi, opts).await,
|
||||
Self::Unbound => Err(DiskError::other("target disk instance is unavailable")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initial capacity hint (bytes) for typical small msgpack requests and responses.
|
||||
const MSGPACK_ENCODE_CAPACITY_HINT: usize = 512;
|
||||
const FILE_INFO_MSGPACK_ENCODE_CAPACITY_HINT: usize = 1024;
|
||||
@@ -733,59 +670,55 @@ impl NodeService {
|
||||
"delete_version",
|
||||
)?;
|
||||
let request = request.into_inner();
|
||||
let file_info = match decode_msgpack_or_json::<FileInfo>(&request.file_info_bin, &request.file_info, "FileInfo") {
|
||||
Ok(file_info) => file_info,
|
||||
Err(err) => {
|
||||
return Ok(Response::new(DeleteVersionResponse {
|
||||
success: false,
|
||||
raw_file_info: "".to_string(),
|
||||
error: Some(DiskError::other(format!("decode FileInfo failed: {err}")).into()),
|
||||
}));
|
||||
}
|
||||
};
|
||||
let opts = match decode_msgpack_or_json::<DeleteOptions>(&request.opts_bin, &request.opts, "DeleteOptions") {
|
||||
Ok(opts) => opts,
|
||||
Err(err) => {
|
||||
return Ok(Response::new(DeleteVersionResponse {
|
||||
success: false,
|
||||
raw_file_info: "".to_string(),
|
||||
error: Some(DiskError::other(format!("decode DeleteOptions failed: {err}")).into()),
|
||||
}));
|
||||
}
|
||||
};
|
||||
let result = if opts.undo_write {
|
||||
if request.force_del_marker {
|
||||
Err(DiskError::other("undo_write cannot force a delete marker"))
|
||||
} else {
|
||||
let target = self.local_mutation_target();
|
||||
target
|
||||
.undo_local_write(&request.disk, &request.volume, &request.path, file_info, opts)
|
||||
.await
|
||||
}
|
||||
} else if let Some(disk) = self.find_disk(&request.disk).await {
|
||||
disk.delete_version(&request.volume, &request.path, file_info, request.force_del_marker, opts)
|
||||
if let Some(disk) = self.find_disk(&request.disk).await {
|
||||
let file_info = match decode_msgpack_or_json::<FileInfo>(&request.file_info_bin, &request.file_info, "FileInfo") {
|
||||
Ok(file_info) => file_info,
|
||||
Err(err) => {
|
||||
return Ok(Response::new(DeleteVersionResponse {
|
||||
success: false,
|
||||
raw_file_info: "".to_string(),
|
||||
error: Some(DiskError::other(format!("decode FileInfo failed: {err}")).into()),
|
||||
}));
|
||||
}
|
||||
};
|
||||
let opts = match decode_msgpack_or_json::<DeleteOptions>(&request.opts_bin, &request.opts, "DeleteOptions") {
|
||||
Ok(opts) => opts,
|
||||
Err(err) => {
|
||||
return Ok(Response::new(DeleteVersionResponse {
|
||||
success: false,
|
||||
raw_file_info: "".to_string(),
|
||||
error: Some(DiskError::other(format!("decode DeleteOptions failed: {err}")).into()),
|
||||
}));
|
||||
}
|
||||
};
|
||||
match disk
|
||||
.delete_version(&request.volume, &request.path, file_info, request.force_del_marker, opts)
|
||||
.await
|
||||
} else {
|
||||
Err(DiskError::other("cannot find disk"))
|
||||
};
|
||||
match result {
|
||||
Ok(raw_file_info) => match serde_json::to_string(&raw_file_info) {
|
||||
Ok(raw_file_info) => Ok(Response::new(DeleteVersionResponse {
|
||||
success: true,
|
||||
raw_file_info,
|
||||
error: None,
|
||||
})),
|
||||
{
|
||||
Ok(raw_file_info) => match serde_json::to_string(&raw_file_info) {
|
||||
Ok(raw_file_info) => Ok(Response::new(DeleteVersionResponse {
|
||||
success: true,
|
||||
raw_file_info,
|
||||
error: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(DeleteVersionResponse {
|
||||
success: false,
|
||||
raw_file_info: "".to_string(),
|
||||
error: Some(DiskError::other(format!("encode data failed: {err}")).into()),
|
||||
})),
|
||||
},
|
||||
Err(err) => Ok(Response::new(DeleteVersionResponse {
|
||||
success: false,
|
||||
raw_file_info: "".to_string(),
|
||||
error: Some(DiskError::other(format!("encode data failed: {err}")).into()),
|
||||
error: Some(err.into()),
|
||||
})),
|
||||
},
|
||||
Err(err) => Ok(Response::new(DeleteVersionResponse {
|
||||
}
|
||||
} else {
|
||||
Ok(Response::new(DeleteVersionResponse {
|
||||
success: false,
|
||||
raw_file_info: "".to_string(),
|
||||
error: Some(err.into()),
|
||||
})),
|
||||
error: Some(DiskError::other("cannot find disk".to_string()).into()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1273,70 +1206,98 @@ impl NodeService {
|
||||
"rename_data",
|
||||
)?;
|
||||
let request = request.into_inner();
|
||||
let target = self.local_mutation_target();
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
super::rename_target_capture_test_hook::wait(&target, &request).await;
|
||||
let decoded_file_info = match decode_rename_data_request_file_info(&request.file_info_bin, &request.file_info) {
|
||||
Ok(file_info) => file_info,
|
||||
Err(err) => {
|
||||
return Ok(Response::new(RenameDataResponse {
|
||||
success: false,
|
||||
rename_data_resp: String::new(),
|
||||
rename_data_resp_bin: Vec::new().into(),
|
||||
error: Some(DiskError::other(format!("decode FileInfo failed: {err}")).into()),
|
||||
}));
|
||||
}
|
||||
};
|
||||
let scanner_publication_lease_token = if request.scanner_publication_lease_token.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let token = Uuid::from_slice(&request.scanner_publication_lease_token)
|
||||
.map_err(|_| Status::invalid_argument("scanner publication lease token must be a UUID"))?;
|
||||
if token.is_nil() {
|
||||
return Err(Status::invalid_argument("scanner publication lease token must not be nil"));
|
||||
}
|
||||
Some(token)
|
||||
};
|
||||
let request_decoded_from_msgpack = decoded_file_info.from_msgpack;
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
let observation = startup_cas_rename_observation(&target, &request, &decoded_file_info.value);
|
||||
let result = target
|
||||
.rename_local_data(
|
||||
&request.disk,
|
||||
(&request.src_volume, &request.src_path),
|
||||
&decoded_file_info.value,
|
||||
(&request.dst_volume, &request.dst_path),
|
||||
scanner_publication_lease_token,
|
||||
)
|
||||
.await;
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if let Some(mut observation) = observation {
|
||||
observation["ok"] = serde_json::json!(result.is_ok());
|
||||
observation["error"] = serde_json::json!(result.as_ref().err().map(ToString::to_string));
|
||||
let line = format!("RUSTFS_E2E_STARTUP_CAS {observation}\n");
|
||||
let _ = std::io::Write::write_all(&mut std::io::stderr().lock(), line.as_bytes());
|
||||
}
|
||||
match result {
|
||||
Ok(rename_data_resp) => match encode_rename_data_response_payloads(&rename_data_resp, request_decoded_from_msgpack) {
|
||||
Ok((rename_data_resp, rename_data_resp_bin)) => Ok(Response::new(RenameDataResponse {
|
||||
success: true,
|
||||
rename_data_resp,
|
||||
rename_data_resp_bin: rename_data_resp_bin.into(),
|
||||
error: None,
|
||||
})),
|
||||
if let Some(disk) = self.find_disk(&request.disk).await {
|
||||
let decoded_file_info = match decode_rename_data_request_file_info(&request.file_info_bin, &request.file_info) {
|
||||
Ok(file_info) => file_info,
|
||||
Err(err) => {
|
||||
return Ok(Response::new(RenameDataResponse {
|
||||
success: false,
|
||||
rename_data_resp: String::new(),
|
||||
rename_data_resp_bin: Vec::new().into(),
|
||||
error: Some(DiskError::other(format!("decode FileInfo failed: {err}")).into()),
|
||||
}));
|
||||
}
|
||||
};
|
||||
let scanner_publication_lease_token = if request.scanner_publication_lease_token.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let token = Uuid::from_slice(&request.scanner_publication_lease_token)
|
||||
.map_err(|_| Status::invalid_argument("scanner publication lease token must be a UUID"))?;
|
||||
if token.is_nil() {
|
||||
return Err(Status::invalid_argument("scanner publication lease token must not be nil"));
|
||||
}
|
||||
Some(token)
|
||||
};
|
||||
// The target owns this read guard. It must span the complete
|
||||
// disk rename, not merely the preflight, so a movement transition
|
||||
// cannot restart after validation and before rename linearization.
|
||||
let scanner_publication_lease_guard: Option<Arc<dyn Send + Sync>> =
|
||||
if let Some(token) = scanner_publication_lease_token {
|
||||
let Some(store) = self.resolve_object_store() else {
|
||||
return Ok(Response::new(RenameDataResponse {
|
||||
success: false,
|
||||
rename_data_resp: String::new(),
|
||||
rename_data_resp_bin: Vec::new().into(),
|
||||
error: Some(DiskError::other("scanner publication lease owner is unavailable").into()),
|
||||
}));
|
||||
};
|
||||
match store.acquire_scanner_publication_lease_guard(token).await {
|
||||
Ok(guard) => Some(Arc::new(guard)),
|
||||
Err(err) => {
|
||||
return Ok(Response::new(RenameDataResponse {
|
||||
success: false,
|
||||
rename_data_resp: String::new(),
|
||||
rename_data_resp_bin: Vec::new().into(),
|
||||
error: Some(DiskError::other(err.to_string()).into()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let request_decoded_from_msgpack = decoded_file_info.from_msgpack;
|
||||
match disk
|
||||
.rename_data_borrowed_with_fence_and_guard(
|
||||
&request.src_volume,
|
||||
&request.src_path,
|
||||
&decoded_file_info.value,
|
||||
&request.dst_volume,
|
||||
&request.dst_path,
|
||||
scanner_publication_lease_token,
|
||||
scanner_publication_lease_guard,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(rename_data_resp) => {
|
||||
match encode_rename_data_response_payloads(&rename_data_resp, request_decoded_from_msgpack) {
|
||||
Ok((rename_data_resp, rename_data_resp_bin)) => Ok(Response::new(RenameDataResponse {
|
||||
success: true,
|
||||
rename_data_resp,
|
||||
rename_data_resp_bin: rename_data_resp_bin.into(),
|
||||
error: None,
|
||||
})),
|
||||
Err(err) => Ok(Response::new(RenameDataResponse {
|
||||
success: false,
|
||||
rename_data_resp: String::new(),
|
||||
rename_data_resp_bin: Vec::new().into(),
|
||||
error: Some(err.into()),
|
||||
})),
|
||||
}
|
||||
}
|
||||
Err(err) => Ok(Response::new(RenameDataResponse {
|
||||
success: false,
|
||||
rename_data_resp: String::new(),
|
||||
rename_data_resp_bin: Vec::new().into(),
|
||||
error: Some(err.into()),
|
||||
})),
|
||||
},
|
||||
Err(err) => Ok(Response::new(RenameDataResponse {
|
||||
}
|
||||
} else {
|
||||
Ok(Response::new(RenameDataResponse {
|
||||
success: false,
|
||||
rename_data_resp: String::new(),
|
||||
rename_data_resp_bin: Vec::new().into(),
|
||||
error: Some(err.into()),
|
||||
})),
|
||||
error: Some(DiskError::other("cannot find disk".to_string()).into()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -377,12 +377,10 @@ pub(crate) mod timeout_wrapper_consumer {
|
||||
}
|
||||
|
||||
pub(crate) mod tonic_service_consumer {
|
||||
#[cfg(test)]
|
||||
pub(crate) use super::super::tonic_service::make_server;
|
||||
#[cfg(test)]
|
||||
pub(crate) use super::super::tonic_service::{heal_topology_fingerprint, make_heal_control_server_for_source};
|
||||
pub(crate) use super::super::tonic_service::{
|
||||
make_heal_control_server_with_cache, make_scanner_control_server, make_server_for_slot, make_tier_mutation_control_server,
|
||||
make_heal_control_server_with_cache, make_scanner_control_server, make_server, make_tier_mutation_control_server,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -419,7 +417,7 @@ pub(crate) mod ecstore_bucket {
|
||||
pub(crate) mod ecstore_capacity {
|
||||
pub(crate) use rustfs_ecstore::api::capacity::{
|
||||
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolStatus, get_total_usable_capacity, get_total_usable_capacity_free,
|
||||
is_reserved_or_invalid_bucket,
|
||||
is_pool_activation_fleet_proof_error, is_reserved_or_invalid_bucket,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -602,8 +600,8 @@ pub(crate) mod ecstore_storage {
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::storage::init_local_disks;
|
||||
pub(crate) use rustfs_ecstore::api::storage::{
|
||||
BootstrapLocalTarget, ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk,
|
||||
all_local_disk_path, find_local_disk_by_ref, init_local_disks_with_instance_ctx, init_lock_clients,
|
||||
ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk, all_local_disk_path,
|
||||
find_local_disk_by_ref, init_local_disks_with_instance_ctx, init_lock_clients,
|
||||
prewarm_local_disk_id_map_with_instance_ctx,
|
||||
};
|
||||
}
|
||||
@@ -679,9 +677,6 @@ type EcstoreReplicationStats = ecstore_bucket::replication::ReplicationStats;
|
||||
pub(crate) type DynReplicationPool = StorageReplicationPoolHandle;
|
||||
pub(crate) type DynReader = ecstore_rio::DynReader;
|
||||
pub(crate) type ECStore = ecstore_storage::ECStore;
|
||||
pub(crate) type BootstrapLocalTarget = ecstore_storage::BootstrapLocalTarget;
|
||||
#[cfg(all(test, not(windows)))]
|
||||
pub(crate) use rustfs_ecstore::api::disk::{LocalPublicationPause, LocalPublicationStage};
|
||||
pub(crate) type Endpoint = ecstore_disk::endpoint::Endpoint;
|
||||
#[cfg(test)]
|
||||
pub(crate) type Endpoints = ecstore_layout::Endpoints;
|
||||
|
||||
@@ -13,14 +13,8 @@
|
||||
// limitations under the License.
|
||||
|
||||
pub(crate) use crate::storage::rpc::node_service::make_heal_control_server_with_cache;
|
||||
pub(crate) use crate::storage::rpc::node_service::make_scanner_control_server;
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::storage::rpc::node_service::{heal::heal_topology_fingerprint, make_heal_control_server_for_source};
|
||||
pub(crate) use crate::storage::rpc::node_service::{make_scanner_control_server, make_server_for_slot};
|
||||
pub use crate::storage::rpc::{make_heal_control_server, make_server, make_tier_mutation_control_server};
|
||||
pub type NodeService = crate::storage::rpc::NodeService;
|
||||
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
#[doc(hidden)]
|
||||
pub use crate::storage::rpc::node_service::rename_target_capture_test_hook::{
|
||||
RenameTargetCapturePause, pause_rename_after_target_capture,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
use rustfs_storage_api as storage_contracts;
|
||||
|
||||
pub(crate) mod capacity {
|
||||
pub(crate) use crate::storage::storage_api::ecstore_capacity::is_pool_activation_fleet_proof_error;
|
||||
|
||||
pub(crate) mod service {
|
||||
pub(crate) use crate::storage::storage_api::{all_local_disk, disk_drive_path, disk_endpoint};
|
||||
}
|
||||
@@ -171,15 +173,12 @@ pub(crate) mod server {
|
||||
}
|
||||
|
||||
pub(crate) mod tonic_service {
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::storage::storage_api::tonic_service_consumer::make_server;
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::storage::storage_api::tonic_service_consumer::{
|
||||
heal_topology_fingerprint, make_heal_control_server_for_source,
|
||||
};
|
||||
pub(crate) use crate::storage::storage_api::tonic_service_consumer::{
|
||||
make_heal_control_server_with_cache, make_scanner_control_server, make_server_for_slot,
|
||||
make_tier_mutation_control_server,
|
||||
make_heal_control_server_with_cache, make_scanner_control_server, make_server, make_tier_mutation_control_server,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,530 +540,3 @@ async fn second_embedded_server_fails_closed_until_its_context_slot_is_installed
|
||||
server_b.shutdown().await;
|
||||
server_a.shutdown().await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
mod signed_target_rpc {
|
||||
use super::{common, find_available_port, pause_embedded_startup_after_http_bind, sha256_hex};
|
||||
use bytes::Bytes;
|
||||
use futures::FutureExt;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use rustfs::app::context::resolve_object_store_handle;
|
||||
use rustfs::embedded::RustFSServerBuilder;
|
||||
use rustfs_ecstore::api::disk::{DiskAPI, DiskError, DiskOption, DiskStore, Endpoint, ReadOptions, new_disk};
|
||||
use rustfs_ecstore::api::rpc::{gen_tonic_signature_headers, normalize_tonic_rpc_audience};
|
||||
use rustfs_filemeta::{FileInfo, ObjectPartInfo};
|
||||
use rustfs_protos::proto_gen::node_service::{RenameDataRequest, RenameDataResponse, node_service_client::NodeServiceClient};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::timeout;
|
||||
use tonic::transport::Channel;
|
||||
use uuid::Uuid;
|
||||
|
||||
const WAIT: Duration = Duration::from_secs(30);
|
||||
const INTERNAL_VOLUME: &str = ".rustfs.sys/tmp";
|
||||
const USER_VOLUME: &str = "target-transport";
|
||||
|
||||
struct SingleConnection {
|
||||
client: NodeServiceClient<Channel>,
|
||||
local: SocketAddr,
|
||||
peer: SocketAddr,
|
||||
attempts: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl SingleConnection {
|
||||
async fn connect(address: SocketAddr) -> Self {
|
||||
let socket = timeout(WAIT, TcpStream::connect(address))
|
||||
.await
|
||||
.expect("bounded real TCP connection")
|
||||
.expect("connect to the production listener");
|
||||
let local = socket.local_addr().expect("client socket identity");
|
||||
let peer = socket.peer_addr().expect("listener socket identity");
|
||||
let socket = Arc::new(Mutex::new(Some(socket)));
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let connector_attempts = attempts.clone();
|
||||
let channel = timeout(
|
||||
WAIT,
|
||||
tonic::transport::Endpoint::from_shared(format!("http://{address}"))
|
||||
.expect("local endpoint")
|
||||
.timeout(WAIT)
|
||||
.connect_with_connector(tower::service_fn(move |_: http::Uri| {
|
||||
connector_attempts.fetch_add(1, Ordering::SeqCst);
|
||||
// A channel may reconnect implicitly. This fixture has exactly one
|
||||
// already-connected socket and fails every subsequent dial attempt.
|
||||
let socket = socket.lock().expect("single socket lock").take();
|
||||
async move {
|
||||
socket.map(TokioIo::new).ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "implicit reconnect forbidden")
|
||||
})
|
||||
}
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("bounded HTTP/2 handshake")
|
||||
.expect("HTTP/2 over the original TCP connection");
|
||||
Self {
|
||||
client: NodeServiceClient::new(channel),
|
||||
local,
|
||||
peer,
|
||||
attempts,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_original_connection(&self) {
|
||||
assert_eq!(self.attempts.load(Ordering::SeqCst), 1, "the channel must not redial");
|
||||
}
|
||||
|
||||
async fn rename(&mut self, request: tonic::Request<RenameDataRequest>) -> RenameDataResponse {
|
||||
let response = timeout(WAIT, self.client.rename_data(request))
|
||||
.await
|
||||
.expect("bounded signed RenameData")
|
||||
.expect("production authentication and RPC routing")
|
||||
.into_inner();
|
||||
self.assert_original_connection();
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
async fn local_fixture_disk(root: &Path) -> DiskStore {
|
||||
let mut endpoint = Endpoint::try_from(root.to_str().expect("UTF-8 fixture root")).expect("local disk endpoint");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(0);
|
||||
new_disk(&endpoint, &DiskOption::default())
|
||||
.await
|
||||
.expect("open real fixture disk")
|
||||
}
|
||||
|
||||
async fn stage(disk: &DiskStore, volume: &str, path: &str, body: &'static [u8]) -> FileInfo {
|
||||
match disk.make_volume(volume).await {
|
||||
Ok(()) | Err(DiskError::VolumeExists) => {}
|
||||
Err(err) => panic!("create fixture volume: {err}"),
|
||||
}
|
||||
let mut fi = FileInfo::new(path, 1, 0);
|
||||
fi.erasure.index = 1;
|
||||
fi.version_id = Some(Uuid::new_v4());
|
||||
fi.mod_time = Some(OffsetDateTime::now_utc());
|
||||
fi.size = i64::try_from(body.len()).expect("small fixture");
|
||||
fi.parts = vec![ObjectPartInfo {
|
||||
number: 1,
|
||||
size: body.len(),
|
||||
actual_size: fi.size,
|
||||
..Default::default()
|
||||
}];
|
||||
fi.data = Some(Bytes::from_static(body));
|
||||
fi.set_inline_data();
|
||||
disk.write_metadata(volume, volume, path, fi.clone())
|
||||
.await
|
||||
.expect("stage real xl.meta");
|
||||
assert_body(disk, volume, path, &fi).await;
|
||||
fi
|
||||
}
|
||||
|
||||
async fn assert_body(disk: &DiskStore, volume: &str, path: &str, fi: &FileInfo) {
|
||||
let read = disk
|
||||
.read_version(
|
||||
volume,
|
||||
volume,
|
||||
path,
|
||||
&fi.version_id.expect("version").to_string(),
|
||||
&ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("decode actual inline object bytes");
|
||||
assert_eq!(read.data, fi.data);
|
||||
}
|
||||
|
||||
fn signed_rename(
|
||||
disk: &DiskStore,
|
||||
volume: &str,
|
||||
source: &str,
|
||||
destination: &str,
|
||||
fi: &FileInfo,
|
||||
) -> tonic::Request<RenameDataRequest> {
|
||||
let payload = RenameDataRequest {
|
||||
disk: disk.endpoint().to_string(),
|
||||
src_volume: volume.to_owned(),
|
||||
src_path: source.to_owned(),
|
||||
dst_volume: volume.to_owned(),
|
||||
dst_path: destination.to_owned(),
|
||||
file_info: serde_json::to_string(fi).expect("real FileInfo JSON"),
|
||||
..Default::default()
|
||||
};
|
||||
let canonical = rustfs_protos::canonical_rename_data_request_body(&payload).expect("canonical mutation body");
|
||||
// The current production interceptor uses the process RPC identity. Keep
|
||||
// that authentication contract while testing listener-local disk routing.
|
||||
let identity = rustfs_common::try_get_global_local_node_name().expect("startup published the RPC identity");
|
||||
let audience = normalize_tonic_rpc_audience(&identity).expect("RPC audience");
|
||||
let headers =
|
||||
gen_tonic_signature_headers(&audience, "node_service.NodeService", "RenameData", Some(&sha256_hex(&canonical)))
|
||||
.expect("production v2 signing with the configured shared secret");
|
||||
assert_eq!(headers.get("x-rustfs-rpc-auth-version").expect("v2 metadata"), "2");
|
||||
let mut request = tonic::Request::new(payload);
|
||||
*request.metadata_mut() = tonic::metadata::MetadataMap::from_headers(headers);
|
||||
request
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_target_rpc_uses_listener_instance_across_install_and_reconnect() {
|
||||
common::run_embedded_test(|| async {
|
||||
timeout(WAIT * 6, signed_target_rpc_body())
|
||||
.await
|
||||
.expect("bounded listener/startup/transport fixture");
|
||||
});
|
||||
}
|
||||
|
||||
async fn signed_target_rpc_body() {
|
||||
// B installs the process default first; A must remain a different target
|
||||
// both before and after its own application context is installed.
|
||||
let root_b = tempfile::tempdir().expect("B root");
|
||||
let server_b = timeout(
|
||||
WAIT,
|
||||
RustFSServerBuilder::new()
|
||||
.address(format!("127.0.0.1:{}", find_available_port().expect("B port")))
|
||||
.volume(root_b.path().to_str().expect("B path"))
|
||||
.access_key("target-transport-access")
|
||||
.secret_key("target-transport-secret")
|
||||
.build(),
|
||||
)
|
||||
.await
|
||||
.expect("bounded B startup")
|
||||
.expect("start global B");
|
||||
let global_b = resolve_object_store_handle().expect("B installed process AppContext");
|
||||
let disk_b = local_fixture_disk(root_b.path()).await;
|
||||
let global_endpoints = global_b.instance_endpoints().expect("B instance topology");
|
||||
let global_paths: Vec<_> = global_endpoints
|
||||
.0
|
||||
.iter()
|
||||
.flat_map(|pool| pool.endpoints.as_ref().iter())
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
global_paths,
|
||||
vec![disk_b.endpoint().to_string()],
|
||||
"the ambient store must really own B's disk"
|
||||
);
|
||||
let sentinel = stage(&disk_b, USER_VOLUME, "sentinel", b"global-B-must-survive").await;
|
||||
let sentinel_path = root_b.path().join(USER_VOLUME).join("sentinel/xl.meta");
|
||||
let sentinel_bytes = tokio::fs::read(&sentinel_path).await.expect("B's committed bytes");
|
||||
|
||||
let root_a = tempfile::tempdir().expect("A root");
|
||||
let port_a = find_available_port().expect("A port");
|
||||
let address_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().expect("A address");
|
||||
let mut barrier = pause_embedded_startup_after_http_bind(port_a);
|
||||
let startup_a = RustFSServerBuilder::new()
|
||||
.address(address_a.to_string())
|
||||
.volume(root_a.path().to_str().expect("A path"))
|
||||
.access_key("target-transport-access")
|
||||
.secret_key("target-transport-secret")
|
||||
.build();
|
||||
tokio::pin!(startup_a);
|
||||
timeout(WAIT, async {
|
||||
tokio::select! {
|
||||
() = barrier.wait_until_http_bound() => {}
|
||||
result = startup_a.as_mut() => {
|
||||
let _unexpected_server = result.expect("A startup before barrier");
|
||||
panic!("A must pause after bind and before ECStore/AppContext");
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("bounded A HTTP-bind barrier");
|
||||
|
||||
// Catch assertion failures only to release the real startup barrier and
|
||||
// obtain a shutdown-capable server handle before resuming the failure.
|
||||
let pre_ready = std::panic::AssertUnwindSafe(async {
|
||||
assert!(Arc::ptr_eq(&global_b, &resolve_object_store_handle().expect("global B remains live")));
|
||||
let disk_a = local_fixture_disk(root_a.path()).await;
|
||||
let internal = stage(&disk_a, INTERNAL_VOLUME, "transport-staged", b"pre-ready-internal-body").await;
|
||||
let user = stage(&disk_a, USER_VOLUME, "staged", b"listener-A-user-body").await;
|
||||
let user_before = tokio::fs::read(root_a.path().join(USER_VOLUME).join("staged/xl.meta"))
|
||||
.await
|
||||
.expect("A staged user bytes");
|
||||
let mut connection = SingleConnection::connect(address_a).await;
|
||||
let mut invalid_signature = signed_rename(&disk_a, INTERNAL_VOLUME, "transport-staged", "bad-signature", &internal);
|
||||
invalid_signature
|
||||
.metadata_mut()
|
||||
.insert("x-rustfs-rpc-signature-v2", "00".parse().expect("invalid MAC header"));
|
||||
let status = timeout(WAIT, connection.client.rename_data(invalid_signature))
|
||||
.await
|
||||
.expect("bounded invalid-signature response")
|
||||
.expect_err("production interceptor must reject a bad signature");
|
||||
assert_eq!(status.code(), tonic::Code::Unauthenticated);
|
||||
assert!(!root_a.path().join(INTERNAL_VOLUME).join("bad-signature/xl.meta").exists());
|
||||
assert_body(&disk_a, INTERNAL_VOLUME, "transport-staged", &internal).await;
|
||||
|
||||
let committed = connection
|
||||
.rename(signed_rename(
|
||||
&disk_a,
|
||||
INTERNAL_VOLUME,
|
||||
"transport-staged",
|
||||
"transport-published",
|
||||
&internal,
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
committed.success,
|
||||
"Bootstrap must commit internal metadata through the bound A registry: {:?}",
|
||||
committed.error
|
||||
);
|
||||
assert_body(&disk_a, INTERNAL_VOLUME, "transport-published", &internal).await;
|
||||
|
||||
let denied = connection
|
||||
.rename(signed_rename(&disk_a, USER_VOLUME, "staged", "destination", &user))
|
||||
.await;
|
||||
assert!(!denied.success, "Bootstrap must reject a real user mutation");
|
||||
let error: DiskError = denied.error.expect("typed bootstrap rejection").into();
|
||||
assert_eq!(error, DiskError::FileAccessDenied);
|
||||
assert_eq!(
|
||||
tokio::fs::read(root_a.path().join(USER_VOLUME).join("staged/xl.meta"))
|
||||
.await
|
||||
.expect("unchanged A source"),
|
||||
user_before
|
||||
);
|
||||
assert!(!root_a.path().join(USER_VOLUME).join("destination/xl.meta").exists());
|
||||
|
||||
assert_eq!(tokio::fs::read(&sentinel_path).await.expect("unchanged B bytes"), sentinel_bytes);
|
||||
assert_body(&disk_b, USER_VOLUME, "sentinel", &sentinel).await;
|
||||
connection.assert_original_connection();
|
||||
(connection, disk_a, user)
|
||||
})
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
barrier.release();
|
||||
let server_a = timeout(WAIT, startup_a.as_mut())
|
||||
.await
|
||||
.expect("bounded A context installation")
|
||||
.expect("A startup after real internal metadata commit");
|
||||
let (mut connection, disk_a, user) = match pre_ready {
|
||||
Ok(fixture) => fixture,
|
||||
Err(panic) => {
|
||||
timeout(WAIT, server_a.shutdown()).await.expect("bounded A failure cleanup");
|
||||
timeout(WAIT, server_b.shutdown()).await.expect("bounded B failure cleanup");
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
};
|
||||
assert!(Arc::ptr_eq(
|
||||
&global_b,
|
||||
&resolve_object_store_handle().expect("A install preserves global B")
|
||||
));
|
||||
assert_eq!(connection.peer, server_a.address());
|
||||
assert_body(&disk_a, USER_VOLUME, "staged", &user).await;
|
||||
let committed = connection
|
||||
.rename(signed_rename(&disk_a, USER_VOLUME, "staged", "destination", &user))
|
||||
.await;
|
||||
assert!(
|
||||
committed.success,
|
||||
"the same accepted connection must observe Ready for its next request: {:?}",
|
||||
committed.error
|
||||
);
|
||||
assert_body(&disk_a, USER_VOLUME, "destination", &user).await;
|
||||
|
||||
// Keep the first connection open so the OS cannot recycle its 4-tuple.
|
||||
let mut reconnected = SingleConnection::connect(address_a).await;
|
||||
assert_ne!(reconnected.local, connection.local);
|
||||
assert_eq!(reconnected.peer, connection.peer);
|
||||
let committed = reconnected
|
||||
.rename(signed_rename(&disk_a, USER_VOLUME, "destination", "reconnected", &user))
|
||||
.await;
|
||||
assert!(committed.success, "new connections must retain listener A: {:?}", committed.error);
|
||||
assert_body(&disk_a, USER_VOLUME, "reconnected", &user).await;
|
||||
assert_eq!(tokio::fs::read(&sentinel_path).await.expect("B remains unchanged"), sentinel_bytes);
|
||||
assert_body(&disk_b, USER_VOLUME, "sentinel", &sentinel).await;
|
||||
assert!(!root_b.path().join(USER_VOLUME).join("reconnected/xl.meta").exists());
|
||||
connection.assert_original_connection();
|
||||
reconnected.assert_original_connection();
|
||||
drop(reconnected);
|
||||
drop(connection);
|
||||
drop(disk_a);
|
||||
drop(disk_b);
|
||||
timeout(WAIT, server_a.shutdown()).await.expect("bounded A shutdown");
|
||||
timeout(WAIT, server_b.shutdown()).await.expect("bounded B shutdown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_bootstrap_request_does_not_upgrade_after_context_installation() {
|
||||
common::run_embedded_test(|| async {
|
||||
timeout(WAIT * 6, signed_delayed_bootstrap_body())
|
||||
.await
|
||||
.expect("bounded delayed Bootstrap fixture");
|
||||
});
|
||||
}
|
||||
|
||||
async fn signed_delayed_bootstrap_body() {
|
||||
use rustfs::storage::tonic_service::pause_rename_after_target_capture;
|
||||
|
||||
let root_b = tempfile::tempdir().expect("B root");
|
||||
let server_b = timeout(
|
||||
WAIT,
|
||||
RustFSServerBuilder::new()
|
||||
.address(format!("127.0.0.1:{}", find_available_port().expect("B port")))
|
||||
.volume(root_b.path().to_str().expect("B path"))
|
||||
.access_key("delayed-bootstrap-access")
|
||||
.secret_key("delayed-bootstrap-secret")
|
||||
.build(),
|
||||
)
|
||||
.await
|
||||
.expect("bounded B startup")
|
||||
.expect("start global B");
|
||||
let global_b = resolve_object_store_handle().expect("B's published context");
|
||||
let disk_b = local_fixture_disk(root_b.path()).await;
|
||||
let endpoints = global_b.instance_endpoints().expect("B instance topology");
|
||||
let paths: Vec<_> = endpoints
|
||||
.0
|
||||
.iter()
|
||||
.flat_map(|pool| pool.endpoints.as_ref().iter())
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
assert_eq!(paths, [disk_b.endpoint().to_string()], "the ambient store owns B");
|
||||
stage(&disk_b, USER_VOLUME, "delayed-sentinel", b"B-is-not-the-listener-target").await;
|
||||
let sentinel_path = root_b.path().join(USER_VOLUME).join("delayed-sentinel/xl.meta");
|
||||
let sentinel_before = tokio::fs::read(&sentinel_path).await.expect("B sentinel bytes");
|
||||
|
||||
let root_a = tempfile::tempdir().expect("A root");
|
||||
let port_a = find_available_port().expect("A port");
|
||||
let address_a = format!("127.0.0.1:{port_a}").parse().expect("A address");
|
||||
let mut startup_barrier = Some(pause_embedded_startup_after_http_bind(port_a));
|
||||
let startup_a = RustFSServerBuilder::new()
|
||||
.address(format!("127.0.0.1:{port_a}"))
|
||||
.volume(root_a.path().to_str().expect("A path"))
|
||||
.access_key("delayed-bootstrap-access")
|
||||
.secret_key("delayed-bootstrap-secret")
|
||||
.build();
|
||||
tokio::pin!(startup_a);
|
||||
timeout(WAIT, async {
|
||||
tokio::select! {
|
||||
() = startup_barrier.as_mut().expect("startup barrier").wait_until_http_bound() => {}
|
||||
startup = startup_a.as_mut() => {
|
||||
let _unexpected_server = startup.expect("A initial startup");
|
||||
panic!("A must reach its pre-AppContext barrier");
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("bounded A listener startup");
|
||||
|
||||
let disk_a = local_fixture_disk(root_a.path()).await;
|
||||
let delayed_info = stage(&disk_a, USER_VOLUME, "delayed-source", b"captured-Bootstrap-must-not-publish").await;
|
||||
let control_info = stage(&disk_a, USER_VOLUME, "control-source", b"new-Ready-request-can-publish").await;
|
||||
let source_path = root_a.path().join(USER_VOLUME).join("delayed-source/xl.meta");
|
||||
let destination_path = root_a.path().join(USER_VOLUME).join("delayed-destination/xl.meta");
|
||||
let source_before = tokio::fs::read(&source_path).await.expect("delayed source bytes");
|
||||
let mut connection = SingleConnection::connect(address_a).await;
|
||||
let mut server_a = None;
|
||||
let mut startup_finished = false;
|
||||
|
||||
let (observations, delayed_result, source_after, destination_exists, sentinel_after) = {
|
||||
let mut capture =
|
||||
pause_rename_after_target_capture(&disk_a.endpoint().to_string(), USER_VOLUME, "delayed-destination");
|
||||
let mut delayed_client = connection.client.clone();
|
||||
let delayed = delayed_client.rename_data(signed_rename(
|
||||
&disk_a,
|
||||
USER_VOLUME,
|
||||
"delayed-source",
|
||||
"delayed-destination",
|
||||
&delayed_info,
|
||||
));
|
||||
tokio::pin!(delayed);
|
||||
let mut early_response = None;
|
||||
|
||||
// Bound all work while the request is parked to less than the
|
||||
// existing channel's 30-second deadline; no timeout is disabled.
|
||||
let observations = std::panic::AssertUnwindSafe(timeout(Duration::from_secs(20), async {
|
||||
let was_bootstrap = tokio::select! {
|
||||
observed = capture.wait_until_captured() => observed,
|
||||
response = delayed.as_mut() => {
|
||||
early_response = Some(response);
|
||||
panic!("signed request finished before the capture pause: {early_response:?}");
|
||||
},
|
||||
};
|
||||
assert!(was_bootstrap, "the actual authenticated handler captured Bootstrap");
|
||||
assert!(Arc::ptr_eq(&global_b, &resolve_object_store_handle().expect("global B")));
|
||||
assert_eq!(tokio::fs::read(&source_path).await.expect("source before install"), source_before);
|
||||
assert!(!destination_path.exists());
|
||||
|
||||
startup_barrier.take().expect("unreleased startup barrier").release();
|
||||
let started = startup_a.as_mut().await;
|
||||
startup_finished = true;
|
||||
server_a = Some(started.expect("normal A context installation"));
|
||||
assert!(Arc::ptr_eq(&global_b, &resolve_object_store_handle().expect("global remains B")));
|
||||
assert_eq!(connection.peer, server_a.as_ref().expect("A handle").address());
|
||||
|
||||
// A separate source prevents this control from consuming the
|
||||
// delayed request's data and masking an erroneous second lookup.
|
||||
let ready = connection
|
||||
.rename(signed_rename(&disk_a, USER_VOLUME, "control-source", "ready-control", &control_info))
|
||||
.await;
|
||||
assert!(ready.success, "a fresh signed user request must actually use Ready: {:?}", ready.error);
|
||||
assert_body(&disk_a, USER_VOLUME, "ready-control", &control_info).await;
|
||||
assert_body(&disk_a, USER_VOLUME, "delayed-source", &delayed_info).await;
|
||||
assert!(!destination_path.exists(), "the original request remains parked");
|
||||
connection.assert_original_connection();
|
||||
}))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
// Release on every assertion/timeout path, then drain the original
|
||||
// RPC before shutting down the server and its connection.
|
||||
drop(capture);
|
||||
if let Some(barrier) = startup_barrier.take() {
|
||||
barrier.release();
|
||||
}
|
||||
if !startup_finished {
|
||||
let started = timeout(WAIT, startup_a.as_mut()).await;
|
||||
if let Ok(Ok(started)) = started {
|
||||
server_a = Some(started);
|
||||
}
|
||||
}
|
||||
let delayed_result = match early_response {
|
||||
Some(response) => Ok(response),
|
||||
None => timeout(WAIT, delayed.as_mut()).await,
|
||||
};
|
||||
let source_after = tokio::fs::read(&source_path).await;
|
||||
let destination_exists = tokio::fs::try_exists(&destination_path).await;
|
||||
let sentinel_after = tokio::fs::read(&sentinel_path).await;
|
||||
(observations, delayed_result, source_after, destination_exists, sentinel_after)
|
||||
};
|
||||
let connection_attempts = connection.attempts.load(Ordering::SeqCst);
|
||||
drop(connection);
|
||||
let shutdown_a = if let Some(server) = server_a {
|
||||
Some(timeout(WAIT, server.shutdown()).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let shutdown_b = timeout(WAIT, server_b.shutdown()).await;
|
||||
if let Some(result) = shutdown_a {
|
||||
result.expect("bounded A shutdown");
|
||||
}
|
||||
shutdown_b.expect("bounded B shutdown");
|
||||
|
||||
assert_eq!(connection_attempts, 1, "the original channel must not redial");
|
||||
match observations {
|
||||
Err(panic) => std::panic::resume_unwind(panic),
|
||||
Ok(result) => result.expect("complete capture/install/Ready-control within the parked request deadline"),
|
||||
}
|
||||
let response = delayed_result
|
||||
.expect("bounded original request drain")
|
||||
.expect("the original signed request must return an application result")
|
||||
.into_inner();
|
||||
assert!(
|
||||
!response.success,
|
||||
"a captured Bootstrap request must not upgrade to Ready after its await"
|
||||
);
|
||||
let error: DiskError = response.error.expect("typed Bootstrap rejection").into();
|
||||
assert_eq!(error, DiskError::FileAccessDenied);
|
||||
assert_eq!(source_after.expect("original source remains readable"), source_before);
|
||||
assert!(!destination_exists.expect("read original destination state"));
|
||||
assert_eq!(sentinel_after.expect("global B sentinel survives"), sentinel_before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Legacy Heal Outcome Compatibility
|
||||
|
||||
This fixture executes the real `madmin-go` HTTP decoder and a pinned `mc` binary against synthetic v3 responses. Rust owner, admin adapter, and SDK tests validate the same JSON cases in `crates/madmin/tests/fixtures/heal-outcome-v3.json`. It does not run a storage repair or prove distributed recovery.
|
||||
|
||||
Pinned primary sources:
|
||||
|
||||
- `mc` release `RELEASE.2025-08-13T08-35-41Z`, commit `7394ce0dd2a80935aded936b09fa12cbb3cb8096`: [polling implementation](https://github.com/minio/mc/blob/7394ce0dd2a80935aded936b09fa12cbb3cb8096/cmd/admin-heal-ui.go#L414).
|
||||
- Its `madmin-go/v3` dependency is `v3.0.107-0.20250415152934-4b504b82db63`: [decoder and response type](https://github.com/minio/madmin-go/blob/4b504b82db633e978a57d49443b2be75824244c3/heal-commands.go#L101).
|
||||
|
||||
The old decoder ignores additional JSON fields. The old poller returns success for `finished` without examining `detail`; only `stopped` returns a terminal error. Consequently `completed_with_errors` retains its canonical outcome and complete traversal coverage, but uses legacy summary `stopped`. `completed` describes execution only: unknown storage receipts remain `unknown`, not `repaired`.
|
||||
|
||||
Run from the repository root with an isolated tool cache and binary directory:
|
||||
|
||||
```sh
|
||||
(
|
||||
set -eu
|
||||
compat_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$compat_dir"' EXIT
|
||||
export GOPATH="$compat_dir/gopath" GOMODCACHE="$compat_dir/mod" GOCACHE="$compat_dir/cache" GOBIN="$compat_dir/bin"
|
||||
export CGO_ENABLED=0 GOTOOLCHAIN=local GOMAXPROCS=2
|
||||
go install github.com/minio/mc@v0.0.0-20250813083541-7394ce0dd2a8
|
||||
cd scripts/compat/heal-outcome
|
||||
MC_BINARY="$compat_dir/bin/mc" NO_PROXY=127.0.0.1,localhost go test -mod=readonly -p 2 -count=1 -v ./...
|
||||
)
|
||||
```
|
||||
|
||||
The subshell keeps the calling shell unchanged. `MC_BINARY` is mandatory and its Go build metadata must identify the pinned commit. Each subprocess gets a temporary mc configuration directory and synthetic credentials; it never edits the user's mc configuration. Loopback socket permission is required. The Go tests do not skip unavailable prerequisites.
|
||||
|
||||
Six cases cover completed traversal, unknown repair proof, completed traversal with failures, cancellation, deadline, and untraversable listing. Two receiver cases carry a remote `finished` summary that contradicts an aborted or completed-with-errors outcome. The admin test applies the heal owner's wire validator to each `remoteResponse` and must produce the corresponding public `response`; the old CLI must then exit with an error. Unknown extension fields remain intact. Unknown or missing execution fields cannot validate a successful summary.
|
||||
|
||||
New counters do not replace or reinterpret legacy progress. Outcome is a cumulative snapshot, not a page delta; `sinceSeq` only pages legacy result items. Result cursors and truncation markers remain separate from execution and traversal coverage.
|
||||
|
||||
Two existing CLI limitations remain explicit: this mc does not terminate on `notFound`, and `-f` polling sends `forceStart` together with `clientToken`, a combination the RustFS v3 request contract rejects. The fixture uses the standard non-force polling flow. Neither limitation is hidden by emitting a new summary string or reporting a missing task as completed.
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
package compat_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"debug/buildinfo"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
madmin "github.com/minio/madmin-go/v3"
|
||||
)
|
||||
|
||||
type fixture struct {
|
||||
Name string `json:"name"`
|
||||
CLIExit int `json:"cliExit"`
|
||||
Response json.RawMessage `json:"response"`
|
||||
}
|
||||
|
||||
func fixtures(t *testing.T) []fixture {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("../../../crates/madmin/tests/fixtures/heal-outcome-v3.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cases []fixture
|
||||
if err := json.Unmarshal(data, &cases); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cases) != 8 {
|
||||
t.Fatalf("expected eight owner/receiver-validated fixtures, got %d", len(cases))
|
||||
}
|
||||
return cases
|
||||
}
|
||||
|
||||
func fixtureServer(t *testing.T, response []byte, polls *atomic.Int32) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || !strings.HasPrefix(r.URL.Path, "/minio/admin/v3/heal/") {
|
||||
t.Errorf("unexpected client request %s %s", r.Method, r.URL.Path)
|
||||
http.Error(w, "unexpected request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Query().Get("clientToken") == "" {
|
||||
fmt.Fprint(w, `{"clientToken":"fixture-token","clientAddress":"","startTime":"2026-01-01T00:00:00Z"}`)
|
||||
return
|
||||
}
|
||||
polls.Add(1)
|
||||
w.Write(response)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func TestLegacyMadminDecoder(t *testing.T) {
|
||||
for _, f := range fixtures(t) {
|
||||
t.Run(f.Name, func(t *testing.T) {
|
||||
var polls atomic.Int32
|
||||
server := fixtureServer(t, f.Response, &polls)
|
||||
client, err := madmin.New(strings.TrimPrefix(server.URL, "http://"), "fixture-access", "fixture-secret", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, status, err := client.Heal(ctx, "bucket", "", madmin.HealOpts{}, "fixture-token", false, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var expected struct {
|
||||
Summary string `json:"summary"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
if err := json.Unmarshal(f.Response, &expected); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.Summary != expected.Summary || status.FailureDetail != expected.Detail || polls.Load() != 1 {
|
||||
t.Fatalf("decoder changed legacy fields: %+v, polls=%d", status, polls.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyMCPoll(t *testing.T) {
|
||||
binary := os.Getenv("MC_BINARY")
|
||||
if binary == "" {
|
||||
t.Fatal("MC_BINARY must point to the pinned mc release; this check cannot be skipped")
|
||||
}
|
||||
info, err := buildinfo.ReadFile(binary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Main.Path != "github.com/minio/mc" || !strings.Contains(info.Main.Version, "7394ce0dd2a8") {
|
||||
t.Fatalf("expected mc RELEASE.2025-08-13T08-35-41Z (7394ce0dd2a8), got %+v", info.Main)
|
||||
}
|
||||
for _, f := range fixtures(t) {
|
||||
t.Run(f.Name, func(t *testing.T) {
|
||||
var polls atomic.Int32
|
||||
server := fixtureServer(t, f.Response, &polls)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, binary, "--config-dir", filepath.Join(t.TempDir(), "mc"), "--json", "admin", "heal", "--recursive", "w23/bucket")
|
||||
cmd.Env = append(os.Environ(), "MC_HOST_w23="+strings.Replace(server.URL, "http://", "http://fixture-access:fixture-secret@", 1), "MC_NO_COLOR=1")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("legacy poll did not terminate: %s", output)
|
||||
}
|
||||
exit := 0
|
||||
if err != nil {
|
||||
var ok bool
|
||||
var status *exec.ExitError
|
||||
status, ok = err.(*exec.ExitError)
|
||||
if !ok {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exit = status.ExitCode()
|
||||
}
|
||||
if exit != f.CLIExit || polls.Load() != 1 {
|
||||
t.Fatalf("exit=%d expected=%d polls=%d output=%s", exit, f.CLIExit, polls.Load(), output)
|
||||
}
|
||||
if f.CLIExit != 0 && !strings.Contains(string(output), "Heal had an error") {
|
||||
t.Fatalf("failure was not the expected legacy terminal result: %s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
module rustfs.local/heal-outcome-compat
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require github.com/minio/madmin-go/v3 v3.0.107-0.20250415152934-4b504b82db63
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/minio-go/v7 v7.0.90 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.63.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.0 // indirect
|
||||
github.com/prometheus/prom2json v1.4.2 // indirect
|
||||
github.com/prometheus/prometheus v0.303.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/secure-io/sio-go v0.3.1 // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/tinylib/msgp v1.2.5 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||
github.com/minio/madmin-go/v3 v3.0.107-0.20250415152934-4b504b82db63 h1:ktN/FrMuM9sjvjIbPZYRKeHEzBDOXQdpYUDiNO0CutE=
|
||||
github.com/minio/madmin-go/v3 v3.0.107-0.20250415152934-4b504b82db63/go.mod h1:U0bL6ip4yKFwvo0keonUcWFQp0Hd462tOLLeVyPzWmE=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.90 h1:TmSj1083wtAD0kEYTx7a5pFsv3iRYMsOJ6A4crjA1lE=
|
||||
github.com/minio/minio-go/v7 v7.0.90/go.mod h1:uvMUcGrpgeSAAI6+sD3818508nUyMULw94j2Nxku/Go=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
|
||||
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
|
||||
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||
github.com/prometheus/prom2json v1.4.2 h1:PxCTM+Whqi/eykO1MKsEL0p/zMpxp9ybpsmdFamw6po=
|
||||
github.com/prometheus/prom2json v1.4.2/go.mod h1:zuvPm7u3epZSbXPWHny6G+o8ETgu6eAK3oPr6yFkRWE=
|
||||
github.com/prometheus/prometheus v0.303.0 h1:wsNNsbd4EycMCphYnTmNY9JASBVbp7NWwJna857cGpA=
|
||||
github.com/prometheus/prometheus v0.303.0/go.mod h1:8PMRi+Fk1WzopMDeb0/6hbNs9nV6zgySkU/zds5Lu3o=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/secure-io/sio-go v0.3.1 h1:dNvY9awjabXTYGsTF1PiCySl9Ltofk9GA3VdWlo7rRc=
|
||||
github.com/secure-io/sio-go v0.3.1/go.mod h1:+xbkjDzPjwh4Axd07pRKSNriS9SCiYksWnZqdnfpQxs=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
|
||||
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
Reference in New Issue
Block a user