mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-07 20:46:11 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0486ca9877 | |||
| 54716aa61c | |||
| 1a5e2b6256 | |||
| 474fcf78fb | |||
| 752d4a81ab | |||
| 0686277ee4 | |||
| 7373a5902e |
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=a953d2e05cdb5169a051a12eef1c2390066dc3fb211fab28a05e4700ae450cee
|
||||
sha256-linux=e6a93961b581dc40fe90dd7d1975ab548e80bff806f5e923304432d5ecf37347
|
||||
sha256-darwin=53b05ac745905809d3828c6994bdd8ecf9d20b2b61a8a9d80fe15eb62f932193
|
||||
sha256-linux=7c892afa4b9d1591b46bd79c976b647109a277284fddb3b98edced4b0297eda2
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -958,36 +912,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
|
||||
@@ -1000,10 +924,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
|
||||
@@ -1016,17 +936,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
@@ -4057,7 +4057,6 @@ dependencies = [
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"suppaftp",
|
||||
"tempfile",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
|
||||
@@ -66,6 +66,11 @@ Current guidance:
|
||||
|
||||
- `RUSTFS_BROWSER_REDIRECT_URL` sets the externally reachable browser origin used for OIDC callback, console success redirect, and logout fallback URLs. Configure it to the public scheme and authority without a path, for example `https://console.example.com`. In load-balancer deployments, keep OIDC authorize and callback requests on the same backend node because the in-flight OIDC `state` is local to the RustFS node.
|
||||
|
||||
## S3 API environment variables
|
||||
|
||||
- `RUSTFS_API_OBJECT_MAX_VERSIONS` caps the number of retained versions for a single object. It defaults to `9223372036854775807`, matching MinIO's practical-unlimited default. Set a positive integer to enforce a lower per-object metadata bound.
|
||||
- `MINIO_API_OBJECT_MAX_VERSIONS` is accepted as a compatibility alias when the canonical RustFS variable is not set.
|
||||
|
||||
## Distributed endpoint locality
|
||||
|
||||
- `RUSTFS_LOCAL_ENDPOINT_HOST` identifies this server's host in a distributed `RUSTFS_VOLUMES` topology without resolving every peer during startup. Set it to exactly one host, without a scheme, port, or path. It is accepted only for orchestrated URL topologies and must match at least one endpoint on the RustFS server port; invalid or unmatched values fail startup. Leave it unset to retain DNS-based locality discovery.
|
||||
|
||||
@@ -90,3 +90,15 @@ pub const ENV_API_MAX_CONNECTIONS: &str = "RUSTFS_API_MAX_CONNECTIONS";
|
||||
|
||||
/// Default for `RUSTFS_API_MAX_CONNECTIONS` (`0` = unlimited).
|
||||
pub const DEFAULT_API_MAX_CONNECTIONS: usize = 0;
|
||||
|
||||
/// Maximum retained versions per object.
|
||||
///
|
||||
/// The default follows MinIO and is effectively unlimited for practical
|
||||
/// deployments. Operators can lower it to bound per-object metadata growth.
|
||||
/// Environment variable: RUSTFS_API_OBJECT_MAX_VERSIONS
|
||||
/// MinIO-compatible alias: MINIO_API_OBJECT_MAX_VERSIONS
|
||||
/// Example: RUSTFS_API_OBJECT_MAX_VERSIONS=50000
|
||||
pub const ENV_API_OBJECT_MAX_VERSIONS: &str = "RUSTFS_API_OBJECT_MAX_VERSIONS";
|
||||
|
||||
/// Default for `RUSTFS_API_OBJECT_MAX_VERSIONS`.
|
||||
pub const DEFAULT_API_OBJECT_MAX_VERSIONS: u64 = 9_223_372_036_854_775_807;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -384,8 +384,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,
|
||||
@@ -566,8 +564,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5283,49 +5283,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>
|
||||
@@ -5766,60 +5724,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,
|
||||
@@ -5841,20 +5745,6 @@ 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,
|
||||
})
|
||||
});
|
||||
// Cancellation can happen at the very first poll of the storage future.
|
||||
// Arm before dispatch, but not during read/encode/fence preflight.
|
||||
let previous_phase = transaction_arm.phase;
|
||||
@@ -5864,37 +5754,23 @@ where
|
||||
record_pool_meta_stale_write_rejection(phase);
|
||||
transaction_arm.phase = previous_phase;
|
||||
}
|
||||
let result = match result {
|
||||
Ok(object_info) => fence.ensure_held().map(|()| object_info),
|
||||
let object_info = match result {
|
||||
Ok(info) => info,
|
||||
Err(err) => {
|
||||
let source = Arc::new(err);
|
||||
transaction_arm.source = Some(Arc::clone(&source));
|
||||
if matches!(source.as_ref(), Error::PreconditionFailed) {
|
||||
Err(Error::PreconditionFailed)
|
||||
} else {
|
||||
Err(Error::other(pool_metadata_error(
|
||||
crate::error::PoolMetadataFailure::TransactionUnknown,
|
||||
phase,
|
||||
Some(source),
|
||||
)))
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
return Err(Error::other(pool_metadata_error(
|
||||
crate::error::PoolMetadataFailure::TransactionUnknown,
|
||||
phase,
|
||||
Some(source),
|
||||
)));
|
||||
}
|
||||
};
|
||||
#[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
|
||||
fence.ensure_held()?;
|
||||
Ok(object_info)
|
||||
}
|
||||
|
||||
async fn persist_pool_meta_identity<S>(
|
||||
@@ -7426,13 +7302,6 @@ impl PoolMeta {
|
||||
};
|
||||
if confirmed.revision == revision && confirmed.canonical.as_ref() == Some(&durable) {
|
||||
persist_pool_meta_identity(pools, write_state, true, fence, transaction_arm).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 {
|
||||
|
||||
@@ -425,6 +425,7 @@ impl From<rustfs_filemeta::Error> for DiskError {
|
||||
rustfs_filemeta::Error::FileVersionNotFound => DiskError::FileVersionNotFound,
|
||||
rustfs_filemeta::Error::FileCorrupt => DiskError::FileCorrupt,
|
||||
rustfs_filemeta::Error::MethodNotAllowed => DiskError::MethodNotAllowed,
|
||||
rustfs_filemeta::Error::MaxVersionsExceeded => DiskError::MaxVersionsExceeded,
|
||||
e => DiskError::other(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +263,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::*;
|
||||
|
||||
@@ -272,9 +272,7 @@ pub(crate) mod prepared_publication_test_hooks {
|
||||
PreparedRename,
|
||||
Rename,
|
||||
Remove,
|
||||
#[cfg(test)]
|
||||
Rollback,
|
||||
#[cfg(test)]
|
||||
DirFsync,
|
||||
}
|
||||
|
||||
@@ -290,7 +288,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)
|
||||
}
|
||||
@@ -349,51 +346,6 @@ pub(crate) mod prepared_publication_test_hooks {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(all(feature = "test-util", not(windows)))]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum LocalPublicationStage {
|
||||
PreparedRename,
|
||||
Rename,
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[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,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn entered(&mut self) -> std::result::Result<(), oneshot::error::RecvError> {
|
||||
(&mut self.entered).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
pub(crate) mod windows_rename_test_hooks {
|
||||
use super::*;
|
||||
@@ -2024,7 +1976,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)
|
||||
})
|
||||
@@ -2284,7 +2236,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)
|
||||
}
|
||||
@@ -2417,7 +2369,7 @@ async fn reliable_rename_inner_with_lease(
|
||||
let preparation = prepare_rename_with_retry(&src_file_path, &dst_file_path, &base_dir, &publication_root)?;
|
||||
#[cfg(all(test, not(windows)))]
|
||||
prepared_publication_test_hooks::run_rename_destination(&src_file_path, &dst_file_path);
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
#[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);
|
||||
|
||||
@@ -588,6 +588,7 @@ impl From<rustfs_filemeta::Error> for StorageError {
|
||||
rustfs_filemeta::Error::FileVersionNotFound => StorageError::FileVersionNotFound,
|
||||
rustfs_filemeta::Error::FileCorrupt => StorageError::FileCorrupt,
|
||||
rustfs_filemeta::Error::Unexpected => StorageError::Unexpected,
|
||||
rustfs_filemeta::Error::MaxVersionsExceeded => StorageError::MaxVersionsExceeded,
|
||||
rustfs_filemeta::Error::Io(io_error) => io_error.into(),
|
||||
_ => StorageError::Io(std::io::Error::other(e)),
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ pub(crate) const SCANNER_PUBLICATION_LEASE_TTL: std::time::Duration = std::time:
|
||||
pub(crate) struct ScannerPublicationLeaseEntry {
|
||||
pub(crate) expires_at: Instant,
|
||||
pub(crate) movement_generation: u64,
|
||||
pub(crate) namespace_generation: u64,
|
||||
pub(crate) _operation_guard: OwnedRwLockReadGuard<()>,
|
||||
}
|
||||
|
||||
@@ -306,7 +305,6 @@ impl InstanceContext {
|
||||
token: Uuid,
|
||||
expires_at: Instant,
|
||||
movement_generation: u64,
|
||||
namespace_generation: u64,
|
||||
operation_guard: OwnedRwLockReadGuard<()>,
|
||||
) -> bool {
|
||||
let mut leases = self.scanner_publication_leases.lock().await;
|
||||
@@ -318,7 +316,6 @@ impl InstanceContext {
|
||||
ScannerPublicationLeaseEntry {
|
||||
expires_at,
|
||||
movement_generation,
|
||||
namespace_generation,
|
||||
_operation_guard: operation_guard,
|
||||
},
|
||||
);
|
||||
@@ -329,21 +326,39 @@ impl InstanceContext {
|
||||
self.scanner_publication_leases.lock().await.remove(&token).is_some()
|
||||
}
|
||||
|
||||
/// Return both generations from the same live lease while the caller holds
|
||||
/// the movement read guard. Namespace commits do not take that guard, so
|
||||
/// the caller must compare the saved namespace generation after this await.
|
||||
/// The process-owned table rejects tokens from a prior instance or expiry.
|
||||
pub(crate) async fn scanner_publication_lease_generations(&self, token: Uuid) -> Option<(u64, u64)> {
|
||||
/// Check a lease token while the caller holds the movement read guard.
|
||||
///
|
||||
/// The token table is deliberately process-owned and non-persistent: a
|
||||
/// restarted instance has no entries from the previous process, so an old
|
||||
/// coordinator proof cannot become valid again merely because the
|
||||
/// movement generation counter restarted at zero.
|
||||
pub(crate) async fn scanner_publication_lease_is_active(&self, token: Uuid) -> bool {
|
||||
let mut leases = self.scanner_publication_leases.lock().await;
|
||||
let now = Instant::now();
|
||||
let (expires_at, movement_generation, namespace_generation) = leases
|
||||
let Some(expires_at) = leases.get(&token).map(|entry| entry.expires_at) else {
|
||||
return false;
|
||||
};
|
||||
if expires_at <= now {
|
||||
leases.remove(&token);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Return the generation bound to a live lease. The lease entry owns the
|
||||
/// movement read guard, so a successful lookup remains valid for the
|
||||
/// caller's guard-protected operation; expiry is still fail-closed.
|
||||
pub(crate) async fn scanner_publication_lease_generation(&self, token: Uuid) -> Option<u64> {
|
||||
let mut leases = self.scanner_publication_leases.lock().await;
|
||||
let now = Instant::now();
|
||||
let (expires_at, movement_generation) = leases
|
||||
.get(&token)
|
||||
.map(|entry| (entry.expires_at, entry.movement_generation, entry.namespace_generation))?;
|
||||
.map(|entry| (entry.expires_at, entry.movement_generation))?;
|
||||
if expires_at <= now {
|
||||
leases.remove(&token);
|
||||
return None;
|
||||
}
|
||||
Some((movement_generation, namespace_generation))
|
||||
Some(movement_generation)
|
||||
}
|
||||
|
||||
pub(crate) async fn expire_scanner_publication_lease(&self, token: Uuid, expires_at: Instant) {
|
||||
@@ -501,11 +516,6 @@ impl InstanceContext {
|
||||
.store(SCANNER_PUBLICATION_STATE_UNKNOWN, Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_namespace_commit_generation_for_test(&self, generation: u64) {
|
||||
self.namespace_commit_generation.store(generation, Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_data_movement_generation_for_test(&self, generation: u64) {
|
||||
self.data_movement_generation.store(generation, Ordering::Release);
|
||||
@@ -852,26 +862,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn scanner_lease_generations_remain_bound_until_expiry() {
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let token = Uuid::new_v4();
|
||||
let gate = ctx.data_movement_operation_gate();
|
||||
let permit = gate.clone().read_owned().await;
|
||||
assert!(
|
||||
ctx.install_scanner_publication_lease(token, Instant::now() + SCANNER_PUBLICATION_LEASE_TTL, 7, 11, permit)
|
||||
.await
|
||||
);
|
||||
drop(ctx.begin_namespace_commit());
|
||||
assert_eq!(ctx.namespace_commit_generation(), 2);
|
||||
assert_eq!(ctx.scanner_publication_lease_generations(token).await, Some((7, 11)));
|
||||
assert!(gate.clone().try_write_owned().is_err(), "lookup must retain the stored permit");
|
||||
tokio::time::advance(SCANNER_PUBLICATION_LEASE_TTL).await;
|
||||
assert_eq!(ctx.scanner_publication_lease_generations(token).await, None);
|
||||
assert!(!ctx.remove_scanner_publication_lease(token).await);
|
||||
assert!(gate.try_write_owned().is_ok(), "expiry releases the stored permit");
|
||||
}
|
||||
|
||||
// The SetupType inputs must derive the exact (is_erasure,
|
||||
// is_dist_erasure, is_erasure_sd) triples that the original three
|
||||
// process-global erasure bools produced via update_erasure_type().
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1089,15 +1089,11 @@ impl ECStore {
|
||||
return Err(Error::other("scanner publication lease TTL is not supported"));
|
||||
}
|
||||
|
||||
// Bind the original activity generation across the asynchronous checks;
|
||||
// a completed namespace commit must never refresh an existing proof.
|
||||
let namespace_generation = self.scanner_namespace_mutation_generation();
|
||||
let operation_gate = self.ctx.data_movement_operation_gate();
|
||||
let operation_guard = operation_gate.read_owned().await;
|
||||
if self.ctx.data_movement_generation_exhausted()
|
||||
|| self.ctx.data_movement_operation_epoch_exhausted()
|
||||
|| self.ctx.data_movement_generation() != expected_generation
|
||||
|| namespace_generation == u64::MAX
|
||||
{
|
||||
return Err(Error::other("scanner publication lease generation is stale"));
|
||||
}
|
||||
@@ -1105,15 +1101,11 @@ impl ECStore {
|
||||
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
||||
}
|
||||
|
||||
if self.scanner_namespace_mutation_generation() != namespace_generation {
|
||||
return Err(Error::other("scanner publication lease generation is stale"));
|
||||
}
|
||||
|
||||
let token = Uuid::new_v4();
|
||||
let expires_at = tokio::time::Instant::now() + ttl;
|
||||
if !self
|
||||
.ctx
|
||||
.install_scanner_publication_lease(token, expires_at, expected_generation, namespace_generation, operation_guard)
|
||||
.install_scanner_publication_lease(token, expires_at, expected_generation, operation_guard)
|
||||
.await
|
||||
{
|
||||
return Err(Error::other("scanner publication lease capacity is exhausted"));
|
||||
@@ -1147,16 +1139,8 @@ impl ECStore {
|
||||
if self.scanner_data_movement_snapshot_locked().await.1 || self.ctx.namespace_commits_pending() {
|
||||
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
||||
}
|
||||
let Some((lease_generation, lease_namespace_generation)) = self.ctx.scanner_publication_lease_generations(token).await
|
||||
else {
|
||||
if !self.ctx.scanner_publication_lease_is_active(token).await {
|
||||
return Err(Error::other("scanner publication lease is unknown or expired"));
|
||||
};
|
||||
let namespace_generation = self.scanner_namespace_mutation_generation();
|
||||
if lease_generation != self.ctx.data_movement_generation()
|
||||
|| lease_namespace_generation != namespace_generation
|
||||
|| namespace_generation == u64::MAX
|
||||
{
|
||||
return Err(Error::other("scanner publication lease generation is stale"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1175,15 +1159,10 @@ impl ECStore {
|
||||
if self.scanner_data_movement_snapshot_locked().await.1 || self.ctx.namespace_commits_pending() {
|
||||
return Err(Error::other("scanner publication lease is blocked by data movement"));
|
||||
}
|
||||
let Some((lease_generation, lease_namespace_generation)) = self.ctx.scanner_publication_lease_generations(token).await
|
||||
else {
|
||||
let Some(lease_generation) = self.ctx.scanner_publication_lease_generation(token).await else {
|
||||
return Err(Error::other("scanner publication lease is unknown or expired"));
|
||||
};
|
||||
let namespace_generation = self.scanner_namespace_mutation_generation();
|
||||
if lease_generation != self.ctx.data_movement_generation()
|
||||
|| lease_namespace_generation != namespace_generation
|
||||
|| namespace_generation == u64::MAX
|
||||
{
|
||||
if lease_generation != self.ctx.data_movement_generation() {
|
||||
return Err(Error::other("scanner publication lease generation is stale"));
|
||||
}
|
||||
Ok(operation_guard)
|
||||
@@ -1833,7 +1812,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(),
|
||||
@@ -2577,88 +2556,6 @@ mod tests {
|
||||
assert!(error.to_string().contains("generation is stale"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_publication_lease_rejects_namespace_change_during_admission() {
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let store = build_store_with_ctx(ctx.clone());
|
||||
let snapshot_blocker = store.rebalance_meta.write().await;
|
||||
let mut acquire =
|
||||
Box::pin(store.acquire_scanner_publication_lease(0, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL));
|
||||
assert!(futures::poll!(&mut acquire).is_pending(), "acquisition reaches the blocked snapshot");
|
||||
assert!(ctx.data_movement_operation_gate().try_write_owned().is_err());
|
||||
drop(ctx.begin_namespace_commit());
|
||||
assert_eq!(ctx.namespace_commit_generation(), 2);
|
||||
assert!(!ctx.namespace_commits_pending());
|
||||
drop(snapshot_blocker);
|
||||
let error = tokio::time::timeout(Duration::from_secs(1), acquire)
|
||||
.await
|
||||
.expect("snapshot admission must finish")
|
||||
.expect_err("acquisition must preserve its original namespace generation");
|
||||
assert_eq!(error.to_string(), "Io error: scanner publication lease generation is stale");
|
||||
assert!(ctx.data_movement_operation_gate().try_write_owned().is_ok(), "no lease was installed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_publication_lease_rechecks_namespace_after_validate_snapshot() {
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let store = build_store_with_ctx(ctx.clone());
|
||||
let (token, generation) = store
|
||||
.acquire_scanner_publication_lease(0, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
|
||||
.await
|
||||
.expect("current lease");
|
||||
let snapshot_blocker = store.rebalance_meta.write().await;
|
||||
let mut validate = Box::pin(store.validate_scanner_publication_lease(token, generation));
|
||||
assert!(futures::poll!(&mut validate).is_pending(), "target guard queues its first snapshot read");
|
||||
let mut next_writer = Box::pin(store.rebalance_meta.write());
|
||||
assert!(futures::poll!(&mut next_writer).is_pending());
|
||||
drop(snapshot_blocker);
|
||||
// Fair lock order admits the first read, then this queued writer, then
|
||||
// validate's second snapshot. The first target check has already passed.
|
||||
assert!(futures::poll!(&mut validate).is_pending(), "validate reaches its second snapshot");
|
||||
let next_writer = next_writer.await;
|
||||
drop(ctx.begin_namespace_commit());
|
||||
assert_eq!(ctx.namespace_commit_generation(), 2);
|
||||
assert!(!ctx.namespace_commits_pending());
|
||||
drop(next_writer);
|
||||
let error = tokio::time::timeout(Duration::from_secs(1), validate)
|
||||
.await
|
||||
.expect("validation must finish without nesting movement read locks")
|
||||
.expect_err("the final snapshot must reject a completed namespace commit");
|
||||
assert_eq!(error.to_string(), "Io error: scanner publication lease generation is stale");
|
||||
assert!(
|
||||
ctx.data_movement_operation_gate().try_write_owned().is_err(),
|
||||
"stale lookup retains the lease permit"
|
||||
);
|
||||
assert!(store.release_scanner_publication_lease(token).await);
|
||||
assert!(ctx.data_movement_operation_gate().try_write_owned().is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_publication_lease_rejects_namespace_generation_exhaustion() {
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let store = build_store_with_ctx(ctx.clone());
|
||||
let (token, generation) = store
|
||||
.acquire_scanner_publication_lease(0, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
|
||||
.await
|
||||
.expect("current lease");
|
||||
ctx.set_namespace_commit_generation_for_test(u64::MAX);
|
||||
assert_eq!(store.scanner_namespace_mutation_generation(), u64::MAX);
|
||||
let acquire = store
|
||||
.acquire_scanner_publication_lease(generation, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
|
||||
.await
|
||||
.expect_err("exhausted namespace cannot become a new lease baseline");
|
||||
assert_eq!(acquire.to_string(), "Io error: scanner publication lease generation is stale");
|
||||
let validate = store.validate_scanner_publication_lease(token, generation).await;
|
||||
let target = store.acquire_scanner_publication_lease_guard(token).await;
|
||||
assert!(validate.is_err() && target.is_err(), "exhaustion rejects both old-token entrances");
|
||||
assert!(
|
||||
ctx.data_movement_operation_gate().try_write_owned().is_err(),
|
||||
"rejection must retain the old permit"
|
||||
);
|
||||
assert!(store.release_scanner_publication_lease(token).await);
|
||||
assert!(ctx.data_movement_operation_gate().try_write_owned().is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn scanner_publication_commit_scope_owns_permit_until_terminal_drain() {
|
||||
let store = build_store_with_ctx(Arc::new(InstanceContext::new()));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,9 @@ pub enum Error {
|
||||
#[error("Method not allowed")]
|
||||
MethodNotAllowed,
|
||||
|
||||
#[error("You've exceeded the limit on the number of versions you can create on this object")]
|
||||
MaxVersionsExceeded,
|
||||
|
||||
#[error("Unexpected error")]
|
||||
Unexpected,
|
||||
|
||||
@@ -86,6 +89,7 @@ impl PartialEq for Error {
|
||||
(Error::FileCorrupt, Error::FileCorrupt) => true,
|
||||
(Error::DoneForNow, Error::DoneForNow) => true,
|
||||
(Error::MethodNotAllowed, Error::MethodNotAllowed) => true,
|
||||
(Error::MaxVersionsExceeded, Error::MaxVersionsExceeded) => true,
|
||||
(Error::FileNotFound, Error::FileNotFound) => true,
|
||||
(Error::FileVersionNotFound, Error::FileVersionNotFound) => true,
|
||||
(Error::VolumeNotFound, Error::VolumeNotFound) => true,
|
||||
@@ -111,6 +115,7 @@ impl Clone for Error {
|
||||
Error::FileCorrupt => Error::FileCorrupt,
|
||||
Error::DoneForNow => Error::DoneForNow,
|
||||
Error::MethodNotAllowed => Error::MethodNotAllowed,
|
||||
Error::MaxVersionsExceeded => Error::MaxVersionsExceeded,
|
||||
Error::VolumeNotFound => Error::VolumeNotFound,
|
||||
Error::Io(e) => Error::Io(std::io::Error::new(e.kind(), e.to_string())),
|
||||
Error::RmpSerdeDecode(s) => Error::RmpSerdeDecode(s.clone()),
|
||||
|
||||
+134
-14
@@ -34,11 +34,14 @@ use rustfs_utils::http::{
|
||||
};
|
||||
use s3s::header::X_AMZ_RESTORE;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(test)]
|
||||
use std::cell::Cell;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::hash::Hasher;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
|
||||
use std::{collections::HashMap, io::Cursor};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
@@ -67,8 +70,46 @@ const _XL_FLAG_INLINE_DATA: u8 = 1 << 2;
|
||||
const META_DATA_READ_DEFAULT: usize = 4 << 10;
|
||||
const MSGP_UINT32_SIZE: usize = 5;
|
||||
|
||||
/// Max object versions per object, default is 10000
|
||||
const DEFAULT_OBJECT_MAX_VERSIONS: usize = 10000;
|
||||
/// Default max object versions per object, aligned with MinIO's default.
|
||||
pub const DEFAULT_OBJECT_MAX_VERSIONS: usize = if usize::BITS >= 64 {
|
||||
9_223_372_036_854_775_807
|
||||
} else {
|
||||
usize::MAX
|
||||
};
|
||||
|
||||
static OBJECT_MAX_VERSIONS: AtomicUsize = AtomicUsize::new(DEFAULT_OBJECT_MAX_VERSIONS);
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static OBJECT_MAX_VERSIONS_OVERRIDE: Cell<Option<usize>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn object_max_versions() -> usize {
|
||||
#[cfg(test)]
|
||||
if let Some(limit) = OBJECT_MAX_VERSIONS_OVERRIDE.with(Cell::get) {
|
||||
return limit;
|
||||
}
|
||||
|
||||
OBJECT_MAX_VERSIONS.load(AtomicOrdering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn set_object_max_versions(limit: usize) -> Result<()> {
|
||||
if limit == 0 {
|
||||
return Err(Error::other("object max versions must be greater than 0"));
|
||||
}
|
||||
OBJECT_MAX_VERSIONS.store(limit, AtomicOrdering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_object_max_versions_override_for_test(limit: Option<usize>) -> Option<usize> {
|
||||
OBJECT_MAX_VERSIONS_OVERRIDE.with(|override_limit| {
|
||||
let previous = override_limit.get();
|
||||
override_limit.set(limit);
|
||||
previous
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the inline data map key for a version_id. "null" for null version.
|
||||
pub(crate) fn data_key_for_version(version_id: Option<Uuid>) -> String {
|
||||
@@ -460,18 +501,6 @@ impl FileMeta {
|
||||
return Err(Error::other("file meta version invalid"));
|
||||
}
|
||||
|
||||
// check max versions limit
|
||||
if self.versions.len() + 1 > DEFAULT_OBJECT_MAX_VERSIONS {
|
||||
return Err(Error::other(
|
||||
"You've exceeded the limit on the number of versions you can create on this object",
|
||||
));
|
||||
}
|
||||
|
||||
if self.versions.is_empty() {
|
||||
self.versions.push(FileMetaShallowVersion::try_from(version)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let vid = version.get_version_id();
|
||||
let vid_is_null = vid.is_none() || vid == Some(Uuid::nil());
|
||||
let existing_idx = if vid_is_null {
|
||||
@@ -490,6 +519,15 @@ impl FileMeta {
|
||||
return self.set_idx(fidx, version);
|
||||
}
|
||||
|
||||
if self.versions.len() >= object_max_versions() {
|
||||
return Err(Error::MaxVersionsExceeded);
|
||||
}
|
||||
|
||||
if self.versions.is_empty() {
|
||||
self.versions.push(FileMetaShallowVersion::try_from(version)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let new_shallow = FileMetaShallowVersion::try_from(version)?;
|
||||
let insert_pos = self
|
||||
.versions
|
||||
@@ -1330,6 +1368,88 @@ mod test {
|
||||
}
|
||||
}
|
||||
|
||||
struct ObjectMaxVersionsRestore {
|
||||
previous: Option<usize>,
|
||||
}
|
||||
|
||||
impl Drop for ObjectMaxVersionsRestore {
|
||||
fn drop(&mut self) {
|
||||
set_object_max_versions_override_for_test(self.previous);
|
||||
}
|
||||
}
|
||||
|
||||
fn with_object_max_versions_for_test<R>(limit: usize, test: impl FnOnce() -> R) -> R {
|
||||
let previous = set_object_max_versions_override_for_test(Some(limit));
|
||||
let _restore = ObjectMaxVersionsRestore { previous };
|
||||
test()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_version_filemata_rejects_new_version_above_configured_limit() {
|
||||
with_object_max_versions_for_test(2, || {
|
||||
let mut fm = FileMeta::new();
|
||||
fm.add_version_filemata(valid_object_version(Uuid::from_u128(1), vec![10, 20]))
|
||||
.expect("add first version within limit");
|
||||
fm.add_version_filemata(valid_object_version(Uuid::from_u128(2), vec![10, 20]))
|
||||
.expect("add second version at limit");
|
||||
|
||||
let err = fm
|
||||
.add_version_filemata(valid_object_version(Uuid::from_u128(3), vec![10, 20]))
|
||||
.expect_err("new version above limit must fail");
|
||||
|
||||
assert_eq!(err, Error::MaxVersionsExceeded);
|
||||
assert_eq!(fm.versions.len(), 2, "failed insert must not mutate version list");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_version_filemata_allows_same_version_replacement_at_limit() {
|
||||
with_object_max_versions_for_test(2, || {
|
||||
let mut fm = FileMeta::new();
|
||||
let target = Uuid::from_u128(10);
|
||||
fm.add_version_filemata(valid_object_version(target, vec![10, 20]))
|
||||
.expect("add target version");
|
||||
fm.add_version_filemata(valid_object_version(Uuid::from_u128(20), vec![10, 20]))
|
||||
.expect("add peer version at limit");
|
||||
|
||||
fm.add_version_filemata(valid_object_version(target, vec![30, 40]))
|
||||
.expect("same version replacement at limit must succeed");
|
||||
|
||||
assert_eq!(fm.versions.len(), 2);
|
||||
let replaced = fm
|
||||
.versions
|
||||
.iter()
|
||||
.find(|version| version.header.version_id == Some(target))
|
||||
.expect("target version must remain present")
|
||||
.parse_version_meta()
|
||||
.expect("parse replaced version");
|
||||
assert_eq!(replaced.object.expect("object version").part_sizes, vec![30, 40]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_version_allows_null_version_replacement_at_limit() {
|
||||
with_object_max_versions_for_test(1, || {
|
||||
let mut fm = FileMeta::new();
|
||||
let mut first = FileInfo::new("object", 2, 2);
|
||||
first.mod_time = Some(OffsetDateTime::now_utc());
|
||||
first.version_id = None;
|
||||
fm.add_version(first).expect("add initial null version");
|
||||
|
||||
let mut replacement = FileInfo::new("object", 2, 2);
|
||||
replacement.mod_time = Some(OffsetDateTime::now_utc());
|
||||
replacement.version_id = None;
|
||||
replacement.size = 42;
|
||||
fm.add_version(replacement)
|
||||
.expect("null version replacement at limit must succeed");
|
||||
|
||||
assert_eq!(fm.versions.len(), 1);
|
||||
assert_eq!(fm.versions[0].header.version_id, Some(Uuid::nil()));
|
||||
let replaced = fm.versions[0].parse_version_meta().expect("parse null replacement");
|
||||
assert_eq!(replaced.object.expect("object version").size, 42);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_version_filemata_uses_canonical_equal_time_order() {
|
||||
let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid test timestamp");
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::heal::{
|
||||
task::{HealOptions, HealPriority, HealRequest, HealTask, HealTaskStatus, HealType, demote_to_debug_when},
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
use metrics::{counter, gauge};
|
||||
use metrics::{counter, gauge, histogram};
|
||||
use rustfs_concurrency::WorkloadAdmissionSnapshotProvider;
|
||||
use rustfs_concurrency::workload::{ForegroundPressure, foreground_pressure};
|
||||
#[cfg(test)]
|
||||
@@ -34,7 +34,7 @@ use std::sync::LazyLock;
|
||||
use std::{
|
||||
collections::{BinaryHeap, HashMap, HashSet},
|
||||
sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard},
|
||||
time::{Duration, SystemTime},
|
||||
time::{Duration, Instant, SystemTime},
|
||||
};
|
||||
use tokio::{
|
||||
sync::{Mutex, Notify, RwLock},
|
||||
@@ -181,6 +181,13 @@ fn lock_displaced_terminals(
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_admission_telemetry(registry: &StdMutex<HealAdmissionTelemetry>) -> StdMutexGuard<'_, HealAdmissionTelemetry> {
|
||||
match registry.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_displaced_terminal(
|
||||
registry: &StdMutex<HashMap<String, Arc<CompletedHealStatus>>>,
|
||||
request: &HealRequest,
|
||||
@@ -384,6 +391,61 @@ impl HealSourceCounts {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct HealAdmissionTelemetry {
|
||||
pub accepted: u64,
|
||||
pub merged: u64,
|
||||
pub full: u64,
|
||||
pub dropped: u64,
|
||||
pub duplicate: u64,
|
||||
pub overlap_rejected: u64,
|
||||
pub displaced: u64,
|
||||
pub force_start: u64,
|
||||
pub max_start_duration_micros: u64,
|
||||
pub max_lock_phase_micros: u64,
|
||||
}
|
||||
|
||||
impl HealAdmissionTelemetry {
|
||||
fn record(&mut self, observation: HealAdmissionObservation) {
|
||||
match observation.result {
|
||||
HealAdmissionResult::Accepted => self.accepted = self.accepted.saturating_add(1),
|
||||
HealAdmissionResult::Merged => self.merged = self.merged.saturating_add(1),
|
||||
HealAdmissionResult::Full => self.full = self.full.saturating_add(1),
|
||||
HealAdmissionResult::Dropped(_) => self.dropped = self.dropped.saturating_add(1),
|
||||
}
|
||||
if observation.context == "duplicate" {
|
||||
self.duplicate = self.duplicate.saturating_add(1);
|
||||
}
|
||||
if observation.context == "overlap_rejected" {
|
||||
self.overlap_rejected = self.overlap_rejected.saturating_add(1);
|
||||
}
|
||||
if observation.displaced {
|
||||
self.displaced = self.displaced.saturating_add(1);
|
||||
}
|
||||
if observation.force_start {
|
||||
self.force_start = self.force_start.saturating_add(1);
|
||||
}
|
||||
self.max_start_duration_micros = self
|
||||
.max_start_duration_micros
|
||||
.max(duration_micros_saturated(observation.start_duration));
|
||||
self.max_lock_phase_micros = self
|
||||
.max_lock_phase_micros
|
||||
.max(duration_micros_saturated(observation.lock_phase));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct HealAdmissionObservation {
|
||||
source: HealRequestSource,
|
||||
result: HealAdmissionResult,
|
||||
context: &'static str,
|
||||
force_start: bool,
|
||||
displaced: bool,
|
||||
start_duration: Duration,
|
||||
lock_phase: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct HealOperationsSnapshot {
|
||||
@@ -396,12 +458,18 @@ pub struct HealOperationsSnapshot {
|
||||
pub queued_by_source: HealSourceCounts,
|
||||
pub active_by_source: HealSourceCounts,
|
||||
pub retrying_by_source: HealSourceCounts,
|
||||
#[serde(default)]
|
||||
pub admission: HealAdmissionTelemetry,
|
||||
}
|
||||
|
||||
fn usize_to_u64_saturated(value: usize) -> u64 {
|
||||
u64::try_from(value).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
fn duration_micros_saturated(duration: Duration) -> u64 {
|
||||
u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
fn heal_type_matches_path(heal_type: &HealType, heal_path: &str) -> bool {
|
||||
let heal_path = heal_path.trim_matches('/');
|
||||
if heal_path.is_empty() || heal_path == LEGACY_ROOT_HEAL_PATH {
|
||||
@@ -764,6 +832,9 @@ pub struct HealManager {
|
||||
notify: Arc<Notify>,
|
||||
/// Optional runtime workload snapshot provider used to protect foreground data-plane work.
|
||||
workload_provider: Option<WorkloadSnapshotProviderRef>,
|
||||
/// Bounded, low-cardinality admission telemetry exposed through the
|
||||
/// existing operations snapshot for cluster E2E assertions.
|
||||
admission_telemetry: Arc<StdMutex<HealAdmissionTelemetry>>,
|
||||
}
|
||||
|
||||
/// Where a task-id lookup resolved. The variants carry the resolved state
|
||||
@@ -919,6 +990,33 @@ impl HealManager {
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
fn record_admission_observation(&self, observation: HealAdmissionObservation) {
|
||||
let result = observation.result.result_label().to_string();
|
||||
let reason = observation.result.reason_label().to_string();
|
||||
let source = observation.source.as_str().to_string();
|
||||
let context = observation.context.to_string();
|
||||
let force_start = observation.force_start.to_string();
|
||||
histogram!(
|
||||
"rustfs_heal_admission_start_duration_seconds",
|
||||
"source" => source.clone(),
|
||||
"result" => result.clone(),
|
||||
"reason" => reason.clone(),
|
||||
"context" => context.clone(),
|
||||
"force_start" => force_start.clone()
|
||||
)
|
||||
.record(observation.start_duration.as_secs_f64());
|
||||
histogram!(
|
||||
"rustfs_heal_admission_lock_phase_seconds",
|
||||
"source" => source,
|
||||
"result" => result,
|
||||
"reason" => reason,
|
||||
"context" => context,
|
||||
"force_start" => force_start
|
||||
)
|
||||
.record(observation.lock_phase.as_secs_f64());
|
||||
lock_admission_telemetry(&self.admission_telemetry).record(observation);
|
||||
}
|
||||
|
||||
fn remove_mrf_repair_notice_targets_for_task(&self, task_id: &str) {
|
||||
let targets = lock_mrf_repair_notice_targets(&self.mrf_repair_notice_targets).remove(task_id);
|
||||
if let Some(targets) = targets {
|
||||
@@ -1265,6 +1363,7 @@ impl HealManager {
|
||||
statistics: Arc::new(RwLock::new(HealStatistics::new())),
|
||||
notify: Arc::new(Notify::new()),
|
||||
workload_provider,
|
||||
admission_telemetry: Arc::new(StdMutex::new(HealAdmissionTelemetry::default())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1455,6 +1554,9 @@ impl HealManager {
|
||||
preserve_alias: bool,
|
||||
mrf_notice_target: Option<MrfRepairNoticeTarget>,
|
||||
) -> Result<HealAdmissionReceipt> {
|
||||
let admission_start = Instant::now();
|
||||
let source = request.source;
|
||||
let force_start = request.force_start;
|
||||
// HS-06 forceStart semantics (admin only): MinIO stops the old task
|
||||
// first and then starts the new one. Cancel any active admin task
|
||||
// overlapping this request's path before entering admission, so the
|
||||
@@ -1505,6 +1607,7 @@ impl HealManager {
|
||||
// Match the scheduler's active -> queue order and keep retry ownership
|
||||
// in the same atomic view. Otherwise queue -> active and
|
||||
// active -> retrying transitions can slip between duplicate checks.
|
||||
let lock_phase_start = Instant::now();
|
||||
let active_heals = self.active_heals.lock().await;
|
||||
#[cfg(test)]
|
||||
pause_duplicate_admission_after_active_lock(&request.id).await;
|
||||
@@ -1539,7 +1642,17 @@ impl HealManager {
|
||||
drop(retrying_heals);
|
||||
drop(queue);
|
||||
drop(active_heals);
|
||||
let lock_phase = lock_phase_start.elapsed();
|
||||
Self::record_admission_metric(request.source, admission, "duplicate");
|
||||
self.record_admission_observation(HealAdmissionObservation {
|
||||
source,
|
||||
result: admission,
|
||||
context: "duplicate",
|
||||
force_start,
|
||||
displaced: false,
|
||||
start_duration: admission_start.elapsed(),
|
||||
lock_phase,
|
||||
});
|
||||
|
||||
match admission {
|
||||
HealAdmissionResult::Merged => {
|
||||
@@ -1618,7 +1731,17 @@ impl HealManager {
|
||||
drop(retrying_heals);
|
||||
drop(queue);
|
||||
drop(active_heals);
|
||||
let lock_phase = lock_phase_start.elapsed();
|
||||
Self::record_admission_metric(request.source, HealAdmissionResult::Dropped(reason), "overlap_rejected");
|
||||
self.record_admission_observation(HealAdmissionObservation {
|
||||
source,
|
||||
result: HealAdmissionResult::Dropped(reason),
|
||||
context: "overlap_rejected",
|
||||
force_start,
|
||||
displaced: false,
|
||||
start_duration: admission_start.elapsed(),
|
||||
lock_phase,
|
||||
});
|
||||
warn!(
|
||||
target: "rustfs::heal::manager",
|
||||
event = EVENT_HEAL_QUEUE_ADMISSION,
|
||||
@@ -1663,6 +1786,8 @@ impl HealManager {
|
||||
drop(retrying_heals);
|
||||
drop(queue);
|
||||
drop(active_heals);
|
||||
let lock_phase = lock_phase_start.elapsed();
|
||||
let displaced = displaced_terminal.is_some();
|
||||
|
||||
if let (Some(displaced_task_id), Some(displaced_terminal)) = (displaced_task_id, displaced_terminal) {
|
||||
// The queue has already removed the displaced request, so the
|
||||
@@ -1676,6 +1801,16 @@ impl HealManager {
|
||||
self.notify.notify_one();
|
||||
}
|
||||
|
||||
self.record_admission_observation(HealAdmissionObservation {
|
||||
source,
|
||||
result: admission,
|
||||
context: "submit",
|
||||
force_start,
|
||||
displaced,
|
||||
start_duration: admission_start.elapsed(),
|
||||
lock_phase,
|
||||
});
|
||||
|
||||
Ok(HealAdmissionReceipt {
|
||||
result: admission,
|
||||
task_id,
|
||||
@@ -2111,17 +2246,25 @@ impl HealManager {
|
||||
}
|
||||
publish_active_heal_count(&active_heals);
|
||||
publish_heal_queue_length(&queue);
|
||||
let queue_length = usize_to_u64_saturated(queue.len());
|
||||
let active_tasks = usize_to_u64_saturated(active_heals.len());
|
||||
let retrying_tasks = usize_to_u64_saturated(retrying_heals.len());
|
||||
drop(retrying_heals);
|
||||
drop(queue);
|
||||
drop(active_heals);
|
||||
let admission = *lock_admission_telemetry(&self.admission_telemetry);
|
||||
|
||||
HealOperationsSnapshot {
|
||||
queue_length: usize_to_u64_saturated(queue.len()),
|
||||
active_tasks: usize_to_u64_saturated(active_heals.len()),
|
||||
retrying_tasks: usize_to_u64_saturated(retrying_heals.len()),
|
||||
queue_length,
|
||||
active_tasks,
|
||||
retrying_tasks,
|
||||
queued_by_priority,
|
||||
active_by_priority,
|
||||
retrying_by_priority,
|
||||
queued_by_source,
|
||||
active_by_source,
|
||||
retrying_by_source,
|
||||
admission,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2792,6 +2792,88 @@ async fn admin_force_start_cancels_overlapping_active_task_first() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admission_snapshot_tracks_start_duplicate_force_start_and_displacement() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
let manager = Arc::new(HealManager::new(
|
||||
storage,
|
||||
Some(HealConfig {
|
||||
queue_size: 1,
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
|
||||
let mut paused = admin_prefix_request("bucket-a", "logs/");
|
||||
paused.priority = HealPriority::Low;
|
||||
let hook = Arc::new(DuplicateAdmissionTestHook {
|
||||
request_id: paused.id.clone(),
|
||||
active_lock_reached: Notify::new(),
|
||||
active_lock_release: Notify::new(),
|
||||
});
|
||||
*DUPLICATE_ADMISSION_TEST_HOOK.lock().await = Some(hook.clone());
|
||||
|
||||
let submit_manager = Arc::clone(&manager);
|
||||
let mut paused_submission = tokio::spawn(async move { submit_manager.submit_heal_request(paused).await });
|
||||
tokio::time::timeout(Duration::from_secs(1), hook.active_lock_reached.notified())
|
||||
.await
|
||||
.expect("admission should reach the test-only lock phase hook");
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(10), &mut paused_submission)
|
||||
.await
|
||||
.is_err(),
|
||||
"admission must wait while the lock-phase hook is held"
|
||||
);
|
||||
hook.active_lock_release.notify_one();
|
||||
assert_eq!(
|
||||
paused_submission
|
||||
.await
|
||||
.expect("paused admission task should join")
|
||||
.expect("paused admission should succeed"),
|
||||
HealAdmissionResult::Accepted
|
||||
);
|
||||
*DUPLICATE_ADMISSION_TEST_HOOK.lock().await = None;
|
||||
|
||||
let duplicate = admin_prefix_request("bucket-a", "logs/");
|
||||
let duplicate_receipt = manager
|
||||
.submit_heal_request_with_receipt(duplicate)
|
||||
.await
|
||||
.expect("duplicate admission should return a canonical receipt");
|
||||
assert_eq!(duplicate_receipt.result, HealAdmissionResult::Merged);
|
||||
|
||||
let mut high = admin_prefix_request("bucket-b", "logs/");
|
||||
high.priority = HealPriority::High;
|
||||
assert_eq!(
|
||||
manager
|
||||
.submit_heal_request(high)
|
||||
.await
|
||||
.expect("higher priority admin request should displace queued low-priority work"),
|
||||
HealAdmissionResult::Accepted
|
||||
);
|
||||
|
||||
let mut forced = admin_prefix_request("bucket-c", "logs/");
|
||||
forced.force_start = true;
|
||||
assert_eq!(
|
||||
manager
|
||||
.submit_heal_request(forced)
|
||||
.await
|
||||
.expect("forceStart should keep explicit admission semantics"),
|
||||
HealAdmissionResult::Accepted
|
||||
);
|
||||
|
||||
let admission = manager.operations_snapshot().await.admission;
|
||||
assert_eq!(admission.accepted, 3);
|
||||
assert_eq!(admission.merged, 1);
|
||||
assert_eq!(admission.full, 0);
|
||||
assert_eq!(admission.dropped, 0);
|
||||
assert_eq!(admission.duplicate, 1);
|
||||
assert_eq!(admission.displaced, 1);
|
||||
assert_eq!(admission.force_start, 1);
|
||||
assert!(
|
||||
admission.max_lock_phase_micros > 0,
|
||||
"snapshot should expose a measurable queue/admission lock phase for p95-style external aggregation"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_operations_snapshot_counts_active_by_source_and_priority() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
|
||||
@@ -34,7 +34,7 @@ use storage_api::owner::{
|
||||
};
|
||||
|
||||
pub use erasure_healer::ErasureSetHealer;
|
||||
pub use manager::{HealManager, HealOperationsSnapshot, HealPriorityCounts, HealSourceCounts};
|
||||
pub use manager::{HealAdmissionTelemetry, HealManager, HealOperationsSnapshot, HealPriorityCounts, HealSourceCounts};
|
||||
pub use resume::{CheckpointManager, ResumeCheckpoint, ResumeManager, ResumeState, ResumeUtils};
|
||||
pub use task::{HealOptions, HealPriority, HealRequest, HealTask, HealType};
|
||||
|
||||
|
||||
@@ -516,6 +516,7 @@ async fn submit_mrf_heal_request(manager: &HealManager, intent: &MrfIntent) -> c
|
||||
|
||||
struct MrfRuntime {
|
||||
queue: MrfQueue,
|
||||
retained_replay_intents: Vec<MrfIntent>,
|
||||
config: MrfConsumerConfig,
|
||||
new_since_flush: usize,
|
||||
/// True while the in-memory pending set has changed since the last
|
||||
@@ -524,9 +525,8 @@ struct MrfRuntime {
|
||||
/// waiting out an admission backoff must not re-fsync every local disk
|
||||
/// twice a second.
|
||||
dirty: bool,
|
||||
/// True while a journal snapshot exists on disk that no longer reflects
|
||||
/// an all-consumed pending set; the next idle tick removes it (MinIO
|
||||
/// deletes its `list.bin` after replay for the same reason).
|
||||
/// True while a journal snapshot exists on disk that may still be needed
|
||||
/// for replay or cleanup.
|
||||
journal_on_disk: bool,
|
||||
/// Earliest instant a full-admission retry may proceed.
|
||||
backoff_until: Option<tokio::time::Instant>,
|
||||
@@ -536,7 +536,7 @@ impl MrfRuntime {
|
||||
fn snapshot(&self) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut authoritative = Vec::new();
|
||||
let mut legacy = Vec::new();
|
||||
for intent in self.queue.intents() {
|
||||
for intent in self.retained_replay_intents.iter().chain(self.queue.intents()) {
|
||||
let scoped_identity =
|
||||
!matches!(intent.kind, rustfs_common::mrf_channel::MrfKind::MetadataCorruption) && intent.scope.is_some();
|
||||
if !encode_intent(intent, &mut authoritative) {
|
||||
@@ -674,10 +674,11 @@ pub async fn replay_journal_once(manager: &Arc<HealManager>) -> usize {
|
||||
struct ReplayOutcome {
|
||||
replayed: usize,
|
||||
journal_on_disk: bool,
|
||||
retained_replay_intents: Vec<MrfIntent>,
|
||||
}
|
||||
|
||||
fn replay_must_retain_journal(rearm_incomplete: bool, pending_depth: usize) -> bool {
|
||||
rearm_incomplete || pending_depth > 0
|
||||
fn replay_must_retain_journal(rearm_incomplete: bool, pending_depth: usize, retained_replay_depth: usize) -> bool {
|
||||
rearm_incomplete || pending_depth > 0 || retained_replay_depth > 0
|
||||
}
|
||||
|
||||
/// Shared replay core: read + decode + re-arm, then drain what fits. The
|
||||
@@ -699,6 +700,7 @@ async fn replay_into(
|
||||
return ReplayOutcome {
|
||||
replayed: 0,
|
||||
journal_on_disk: false,
|
||||
retained_replay_intents: Vec::new(),
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -738,23 +740,42 @@ async fn replay_into(
|
||||
|
||||
// Drain the replayed intents immediately; whatever the manager refuses
|
||||
// stays armed in `queue` for the consumer's retry loop.
|
||||
let mut retained_replay_intents = Vec::new();
|
||||
if backoff_until.is_none() {
|
||||
while let Some(mut intent) = queue.pop_front() {
|
||||
match submit_mrf_heal_request(manager, &intent).await {
|
||||
Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => {}
|
||||
Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => {
|
||||
retained_replay_intents.push(intent);
|
||||
}
|
||||
Ok(HealAdmissionResult::Full) | Ok(HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull)) => {
|
||||
intent.attempts = intent.attempts.saturating_add(1);
|
||||
if intent.attempts < MRF_MAX_ATTEMPTS {
|
||||
queue.push_back(intent);
|
||||
*backoff_until = Some(tokio::time::Instant::now());
|
||||
} else {
|
||||
rearm_incomplete = true;
|
||||
counter!("rustfs_heal_mrf_dropped_total", "reason" => "attempts_exhausted").increment(1);
|
||||
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(HealAdmissionResult::Dropped(_)) => {}
|
||||
Err(_) => {
|
||||
intent.attempts = intent.attempts.saturating_add(1);
|
||||
if intent.attempts < MRF_MAX_ATTEMPTS {
|
||||
queue.push_back(intent);
|
||||
*backoff_until = Some(tokio::time::Instant::now());
|
||||
} else {
|
||||
rearm_incomplete = true;
|
||||
counter!("rustfs_heal_mrf_dropped_total", "reason" => "attempts_exhausted").increment(1);
|
||||
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(HealAdmissionResult::Dropped(_)) | Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let journal_on_disk = if replay_must_retain_journal(rearm_incomplete, queue.depth()) {
|
||||
let journal_on_disk = if replay_must_retain_journal(rearm_incomplete, queue.depth(), retained_replay_intents.len()) {
|
||||
true
|
||||
} else {
|
||||
!delete_journals().await
|
||||
@@ -762,6 +783,7 @@ async fn replay_into(
|
||||
ReplayOutcome {
|
||||
replayed,
|
||||
journal_on_disk,
|
||||
retained_replay_intents,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,6 +793,7 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
let config = MrfConsumerConfig::default();
|
||||
let mut runtime = MrfRuntime {
|
||||
queue: MrfQueue::new(config.queue_capacity, config.journal_max_bytes),
|
||||
retained_replay_intents: Vec::new(),
|
||||
config: config.clone(),
|
||||
new_since_flush: 0,
|
||||
dirty: false,
|
||||
@@ -782,6 +805,7 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
// on disk whenever any replayed intent still needs a successor snapshot.
|
||||
let replay = replay_into(&manager, &mut runtime.queue, &mut runtime.backoff_until).await;
|
||||
runtime.journal_on_disk = replay.journal_on_disk;
|
||||
runtime.retained_replay_intents = replay.retained_replay_intents;
|
||||
// Anything still pending (e.g. the manager was full and backoff armed)
|
||||
// must be re-persisted by the next flush before replay can delete the
|
||||
// startup anchor.
|
||||
@@ -799,7 +823,7 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
// provably current AND idle (a dirty or pending state
|
||||
// gets one last persist attempt, matching the shutdown
|
||||
// retry the unconditional flush used to provide).
|
||||
if runtime.dirty || runtime.queue.depth() > 0 {
|
||||
if runtime.dirty || runtime.queue.depth() > 0 || !runtime.retained_replay_intents.is_empty() {
|
||||
runtime.flush().await;
|
||||
}
|
||||
tracing::info!(
|
||||
@@ -825,7 +849,12 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
}
|
||||
}
|
||||
_ = flush_tick.tick() => {
|
||||
match tick_action(runtime.dirty, runtime.queue.depth(), runtime.journal_on_disk) {
|
||||
match tick_action(
|
||||
runtime.dirty,
|
||||
runtime.queue.depth(),
|
||||
runtime.retained_replay_intents.len(),
|
||||
runtime.journal_on_disk,
|
||||
) {
|
||||
TickAction::Flush => {
|
||||
runtime.flush().await;
|
||||
runtime.dispatch(manager.as_ref()).await;
|
||||
@@ -838,8 +867,8 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
runtime.dispatch(manager.as_ref()).await;
|
||||
}
|
||||
TickAction::DeleteJournal => {
|
||||
// All intents consumed: remove the journal so a restart
|
||||
// replays nothing (mirrors MinIO's post-replay unlink).
|
||||
// Only remove a stale journal after every replayed
|
||||
// intent has a durable successor proof.
|
||||
if delete_journals().await {
|
||||
runtime.journal_on_disk = false;
|
||||
gauge!("rustfs_heal_mrf_journal_bytes").set(0.0);
|
||||
@@ -868,11 +897,13 @@ enum TickAction {
|
||||
Idle,
|
||||
}
|
||||
|
||||
fn tick_action(dirty: bool, depth: usize, journal_on_disk: bool) -> TickAction {
|
||||
fn tick_action(dirty: bool, depth: usize, retained_replay_depth: usize, journal_on_disk: bool) -> TickAction {
|
||||
if dirty {
|
||||
TickAction::Flush
|
||||
} else if depth > 0 {
|
||||
TickAction::Retry
|
||||
} else if retained_replay_depth > 0 {
|
||||
TickAction::Idle
|
||||
} else if journal_on_disk {
|
||||
TickAction::DeleteJournal
|
||||
} else {
|
||||
@@ -905,34 +936,73 @@ mod tests {
|
||||
|
||||
// Dirty dominates: a changed pending set flushes even when idle
|
||||
// otherwise.
|
||||
assert!(matches!(tick_action(true, 0, false), Flush));
|
||||
assert!(matches!(tick_action(true, 3, true), Flush));
|
||||
assert!(matches!(tick_action(true, 0, 0, false), Flush));
|
||||
assert!(matches!(tick_action(true, 3, 0, true), Flush));
|
||||
|
||||
// Clean backlog: no rewrite, but keep draining so an expired
|
||||
// admission backoff retries on time.
|
||||
assert!(matches!(tick_action(false, 1, false), Retry));
|
||||
assert!(matches!(tick_action(false, 2, true), Retry));
|
||||
assert!(matches!(tick_action(false, 1, 0, false), Retry));
|
||||
assert!(matches!(tick_action(false, 2, 0, true), Retry));
|
||||
|
||||
// Replayed records accepted by the manager are still restart anchors
|
||||
// until a durable successor proof can tombstone them.
|
||||
assert!(matches!(tick_action(false, 0, 1, true), Idle));
|
||||
|
||||
// Quiescent with a stale journal file on disk: remove it.
|
||||
assert!(matches!(tick_action(false, 0, true), DeleteJournal));
|
||||
assert!(matches!(tick_action(false, 0, 0, true), DeleteJournal));
|
||||
|
||||
// Fully quiescent: nothing to do.
|
||||
assert!(matches!(tick_action(false, 0, false), Idle));
|
||||
assert!(matches!(tick_action(false, 0, 0, false), Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_cleanup_retains_journal_for_unarmed_or_refused_records() {
|
||||
assert!(
|
||||
replay_must_retain_journal(true, 0),
|
||||
replay_must_retain_journal(true, 0, 0),
|
||||
"a rejected replay record still needs its disk anchor"
|
||||
);
|
||||
assert!(
|
||||
replay_must_retain_journal(false, 1),
|
||||
replay_must_retain_journal(false, 1, 0),
|
||||
"a Full admission retry must keep the startup journal until the next snapshot"
|
||||
);
|
||||
assert!(
|
||||
!replay_must_retain_journal(false, 0),
|
||||
"only a fully consumed replay snapshot may be deleted"
|
||||
replay_must_retain_journal(false, 0, 1),
|
||||
"an accepted replay record still needs a durable successor before cleanup"
|
||||
);
|
||||
assert!(
|
||||
!replay_must_retain_journal(false, 0, 0),
|
||||
"only a fully consumed replay snapshot with no retained anchors may be deleted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_replay_anchor_remains_in_successor_snapshot() {
|
||||
let retained = intent("accepted-replay", "object", 0);
|
||||
let mut runtime = MrfRuntime {
|
||||
queue: MrfQueue::new(8, 8192),
|
||||
retained_replay_intents: vec![retained.clone()],
|
||||
config: MrfConsumerConfig::default(),
|
||||
new_since_flush: 0,
|
||||
dirty: false,
|
||||
journal_on_disk: true,
|
||||
backoff_until: None,
|
||||
};
|
||||
assert_eq!(
|
||||
runtime.queue.try_push_typed(intent("new-pending", "object", 0)),
|
||||
MrfQueuePushResult::Enqueued
|
||||
);
|
||||
|
||||
let (authoritative, legacy) = runtime.snapshot();
|
||||
let (decoded, truncated) = decode_journal(&authoritative);
|
||||
let (legacy_decoded, legacy_truncated) = decode_journal(&legacy);
|
||||
|
||||
assert_eq!(truncated, 0);
|
||||
assert_eq!(legacy_truncated, 0);
|
||||
assert_eq!(decoded.len(), 2);
|
||||
assert_eq!(legacy_decoded.len(), 2);
|
||||
assert!(
|
||||
decoded.iter().any(|intent| intent.bucket == retained.bucket),
|
||||
"accepted replay anchor must remain crash-replayable"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ pub mod heal;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use heal::{
|
||||
HealManager, HealOperationsSnapshot, HealOptions, HealPriority, HealPriorityCounts, HealRequest, HealSourceCounts, HealType,
|
||||
HealAdmissionTelemetry, HealManager, HealOperationsSnapshot, HealOptions, HealPriority, HealPriorityCounts, HealRequest,
|
||||
HealSourceCounts, HealType,
|
||||
channel::HealChannelProcessor,
|
||||
progress::{HealProgress, aggregate_heal_progress},
|
||||
resume::{ReplacementRecoveryRecord, ReplacementRecoveryState, ResumeUtils},
|
||||
|
||||
@@ -68,17 +68,14 @@ pub(super) fn rules() -> Vec<Rule> {
|
||||
)
|
||||
},
|
||||
Rule {
|
||||
anchors: strings(["Storage inventory probe failed; current drive health is unknown"]),
|
||||
anchors: strings(["reporting peer disks offline after consecutive storage_info failures"]),
|
||||
..base(
|
||||
"peer-disks-offline",
|
||||
P2Degraded,
|
||||
"disk",
|
||||
"peer 存储清单探测失败",
|
||||
any([
|
||||
contains("Storage inventory probe failed; current drive health is unknown"),
|
||||
contains("reporting peer disks offline after consecutive storage_info failures"),
|
||||
]),
|
||||
"某 peer 的 storage_info 探测失败,当前磁盘健康状态未知。",
|
||||
"peer 磁盘被整体判定离线",
|
||||
contains("reporting peer disks offline after consecutive storage_info failures"),
|
||||
"对某 peer 连续 storage_info 失败,判定其磁盘整体离线。",
|
||||
"检查该 peer 节点存活与 RPC 端口可达。",
|
||||
)
|
||||
},
|
||||
|
||||
@@ -110,7 +110,7 @@ fn every_rule_has_a_positive_sample() {
|
||||
("remote-peer-faulty", msg("Remote peer health check failed for node2: marking as faulty")),
|
||||
(
|
||||
"peer-disks-offline",
|
||||
msg("Storage inventory probe failed; current drive health is unknown"),
|
||||
msg("reporting peer disks offline after consecutive storage_info failures"),
|
||||
),
|
||||
("drive-faulty-error", msg("remote drive is faulty")),
|
||||
(
|
||||
@@ -318,10 +318,6 @@ fn smoke_samples_hit_exact_rule_sets() {
|
||||
&["disk-marked-faulty"],
|
||||
);
|
||||
exact(&msg("erasure write quorum (required=8, achieved=5)"), &["ec-write-quorum"]);
|
||||
exact(
|
||||
&msg("reporting peer disks offline after consecutive storage_info failures"),
|
||||
&["peer-disks-offline"],
|
||||
);
|
||||
exact(
|
||||
&Sample {
|
||||
message: "Metacache listing quorum failed",
|
||||
|
||||
@@ -37,6 +37,8 @@ fn segment_proof() -> SegmentInvalidationProof {
|
||||
key_format: envelope.key_format,
|
||||
baseline_scan_plan_digest: envelope.baseline_scan_plan_digest,
|
||||
process_epoch: envelope.process_epoch,
|
||||
generation_start: envelope.generation_start,
|
||||
generation_end: envelope.generation_end,
|
||||
durable_producer_identity: true,
|
||||
invalidation_domain: SegmentInvalidationDomain::LocalSingleSet,
|
||||
distributed_ec_invalidation: false,
|
||||
@@ -126,6 +128,20 @@ fn segment_observation_trusted_proposal_requires_identity_and_complete_producer_
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_generation_start = proof.clone();
|
||||
wrong_generation_start.generation_start = wrong_generation_start.generation_start.saturating_sub(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &wrong_generation_start, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_generation_end = proof.clone();
|
||||
wrong_generation_end.generation_end = wrong_generation_end.generation_end.saturating_add(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &wrong_generation_end, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut no_durable_identity = proof.clone();
|
||||
no_durable_identity.durable_producer_identity = false;
|
||||
assert_eq!(
|
||||
|
||||
@@ -166,16 +166,22 @@ async fn round(request: &Request) -> serde_json::Value {
|
||||
let reloaded = DataUsageCache::unmarshal(&read_bounded(&cache_path).await).expect("reload returned cache codec");
|
||||
let retained = reloaded.checked_flatten("bucket").expect("reloaded bucket root");
|
||||
let scanned = returned.checked_flatten("bucket").expect("returned bucket root");
|
||||
let raw_page_index_committed_entries = reloaded
|
||||
.validated_raw_enumeration_page_index()
|
||||
let raw_page_index = reloaded.validated_raw_enumeration_page_index();
|
||||
let raw_page_index_committed_entries = raw_page_index
|
||||
.and_then(|index| index.committed_entries().ok())
|
||||
.map(|entries| entries.len())
|
||||
.unwrap_or(0);
|
||||
let raw_page_index_indexed_entries = reloaded
|
||||
.validated_raw_enumeration_page_index()
|
||||
let raw_page_index_indexed_entries = raw_page_index
|
||||
.and_then(|index| index.indexed_entries().ok())
|
||||
.map(|entries| entries.len())
|
||||
.unwrap_or(0);
|
||||
let (raw_page_index_parent, raw_page_index_complete) = raw_page_index
|
||||
.map(|index| match index.status() {
|
||||
crate::raw_page_index::RawEnumerationPageOwnerStatus::Building { parent, .. } => (Some(parent), false),
|
||||
crate::raw_page_index::RawEnumerationPageOwnerStatus::Ready { parent, complete, .. } => (Some(parent), complete),
|
||||
crate::raw_page_index::RawEnumerationPageOwnerStatus::Unsupported => (None, false),
|
||||
})
|
||||
.unwrap_or((None, false));
|
||||
assert_eq!(
|
||||
(retained.objects, retained.versions, retained.size),
|
||||
(scanned.objects, scanned.versions, scanned.size)
|
||||
@@ -188,6 +194,8 @@ async fn round(request: &Request) -> serde_json::Value {
|
||||
"objects_expected": request.objects, "raw_entry_budget": request.raw_entry_budget,
|
||||
"raw_entries": observation.entries, "raw_name_bytes": observation.name_bytes,
|
||||
"raw_first_entry": observation.first_entry, "raw_last_entry": observation.last_entry,
|
||||
"raw_page_index_parent": raw_page_index_parent,
|
||||
"raw_page_index_complete": raw_page_index_complete,
|
||||
"raw_page_index_committed_entries": raw_page_index_committed_entries,
|
||||
"raw_page_index_indexed_entries": raw_page_index_indexed_entries,
|
||||
"objects_processed": budget.progress().0,
|
||||
|
||||
@@ -77,6 +77,8 @@ pub struct SegmentInvalidationProof {
|
||||
pub key_format: u16,
|
||||
pub baseline_scan_plan_digest: DataUsageScanPlanDigest,
|
||||
pub process_epoch: String,
|
||||
pub generation_start: u64,
|
||||
pub generation_end: u64,
|
||||
pub durable_producer_identity: bool,
|
||||
pub invalidation_domain: SegmentInvalidationDomain,
|
||||
pub distributed_ec_invalidation: bool,
|
||||
@@ -108,11 +110,15 @@ fn validate_segment_invalidation_proof(
|
||||
|| envelope.baseline_scan_plan_digest != proof.baseline_scan_plan_digest
|
||||
|| envelope.process_epoch.is_empty()
|
||||
|| envelope.process_epoch != proof.process_epoch
|
||||
|| envelope.generation_start != proof.generation_start
|
||||
|| envelope.generation_end != proof.generation_end
|
||||
|| !proof.durable_producer_identity
|
||||
|| !proof.cold_zero_walk_oracle
|
||||
|| (proof.invalidation_domain == SegmentInvalidationDomain::DistributedEc && !proof.distributed_ec_invalidation)
|
||||
|| envelope.generation_start == 0
|
||||
|| envelope.generation_end < envelope.generation_start
|
||||
|| proof.generation_start == 0
|
||||
|| proof.generation_end < proof.generation_start
|
||||
|| envelope.restart_gap
|
||||
|| envelope.overflow
|
||||
|| !SegmentInvalidationProducer::REQUIRED
|
||||
@@ -190,6 +196,8 @@ mod tests {
|
||||
key_format: envelope.key_format,
|
||||
baseline_scan_plan_digest: envelope.baseline_scan_plan_digest,
|
||||
process_epoch: envelope.process_epoch,
|
||||
generation_start: envelope.generation_start,
|
||||
generation_end: envelope.generation_end,
|
||||
durable_producer_identity: true,
|
||||
invalidation_domain: SegmentInvalidationDomain::LocalSingleSet,
|
||||
distributed_ec_invalidation: false,
|
||||
@@ -241,6 +249,20 @@ mod tests {
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_generation_start = proof.clone();
|
||||
wrong_generation_start.generation_start = wrong_generation_start.generation_start.saturating_sub(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &wrong_generation_start, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_generation_end = proof.clone();
|
||||
wrong_generation_end.generation_end = wrong_generation_end.generation_end.saturating_add(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &wrong_generation_end, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut no_durable_identity = proof.clone();
|
||||
no_durable_identity.durable_producer_identity = false;
|
||||
assert_eq!(
|
||||
|
||||
@@ -125,6 +125,7 @@ const EXTERNAL_COMPATIBLE_SUFFIXES: &[&str] = &[
|
||||
"ACCESS_KEY",
|
||||
"ACCESS_KEY_FILE",
|
||||
"ADDRESS",
|
||||
"API_OBJECT_MAX_VERSIONS",
|
||||
"API_XFF_HEADER",
|
||||
"AUDIT_WEBHOOK_AUTH_TOKEN",
|
||||
"AUDIT_WEBHOOK_CLIENT_CERT",
|
||||
@@ -900,4 +901,15 @@ mod tests {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_env_compat_includes_api_object_max_versions() {
|
||||
let report =
|
||||
build_external_env_compat_report_from_entries([("MINIO_API_OBJECT_MAX_VERSIONS".to_string(), "50000".to_string())]);
|
||||
|
||||
assert_eq!(
|
||||
report.mapped_pairs,
|
||||
vec![("MINIO_API_OBJECT_MAX_VERSIONS".to_string(), "RUSTFS_API_OBJECT_MAX_VERSIONS".to_string())]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@ python3 scripts/diagnose_scanner_enumeration_restart.py \
|
||||
--objects 128 --raw-entry-budget 8 --rounds 8
|
||||
```
|
||||
|
||||
The output directory must not exist. Each round starts a new OS test-worker process, opens the same synthetic disk, decodes the preceding cache, invokes the real scanner, encodes the returned cache, and decodes it again. When cancellation returns no useful partial cache, it preserves the previous cache. Reports identify the actual child PID, round, raw entries and name bytes observed, processed objects, retained object/version/byte counts, and completeness. No observed-name set, `readdir` offset, or assumed stable ordering is used as durable progress. Namespace creation happens only during fixture setup, before scan accounting.
|
||||
The output directory must not exist. Each round starts a new OS test-worker process, opens the same synthetic disk, decodes the preceding cache, invokes the real scanner, encodes the returned cache, and decodes it again. When cancellation returns no useful partial cache, it preserves the previous cache. Reports identify the actual child PID, round, raw entries and name bytes observed, raw page-index parent/entries, processed/classified objects, retained object/version/byte counts, and completeness. The driver rejects retained coverage that advances beyond classified object work, root raw page indexes that outrun the fixture namespace, committed page coverage that exceeds indexed coverage, same-parent committed coverage regressions before completion, and any process-restart regression in retained coverage. No observed-name set, `readdir` offset, or assumed stable ordering is used as durable progress. Namespace creation happens only during fixture setup, before scan accounting.
|
||||
|
||||
The `cfg(test)` hook observes actual entries delivered by `read_dir` and cancels the existing cycle token at the fixed entry limit. This is a deterministic injected **raw-entry work budget**, not a wall-clock performance measurement or a claim that kernel prefetch, probes, allocations, name bytes, or cache I/O are independently budgeted. The watchdog timeout only bounds worker lifetime. The hook does not replace enumeration, classification, or recursion, and does not exist in production builds. In particular, `xl.meta` object-boundary classification is unchanged.
|
||||
|
||||
Exit 0 requires exact complete object/version/byte coverage within the same fixed budget on every executed round. Exit 1 means the strict convergence oracle remains unmet, including the current flat-directory enumeration starvation case. Exit 2 means invalid input, worker failure, or invalid evidence; it is not a successful reproduction. There is no final unbudgeted sweep. Small fixtures can pass; that does not establish the general R-E gate from [the scanner review comment](https://github.com/rustfs/backlog/issues/2240#issuecomment-5549222480). Raw entries observed are not a retained enumeration watermark. This is scanner-worker process restart plus codec evidence, **not** whole-daemon restart, EC quorum persistence, crash/fsync durability, remote RPC, or a throughput benchmark. The caller owns the bounded evidence directory and may remove it after inspection.
|
||||
Exit 0 requires exact complete object/version/byte coverage within the same fixed budget on every executed round and positive evidence for all three stages: raw enumeration/indexing, object classification/processing, and durable cache retention after a fresh worker process reloads the previous report. Exit 1 means the strict convergence oracle remains unmet, including the current flat-directory enumeration starvation case. Exit 2 means invalid input, worker failure, or invalid evidence; it is not a successful reproduction. There is no final unbudgeted sweep. Small fixtures can pass; that does not establish the general R-E gate from [the scanner review comment](https://github.com/rustfs/backlog/issues/2240#issuecomment-5549222480). Raw entries observed are not a retained enumeration watermark. This is scanner-worker process restart plus codec evidence, **not** whole-daemon restart, EC quorum persistence, crash/fsync durability, remote RPC, or a throughput benchmark. The caller owns the bounded evidence directory and may remove it after inspection.
|
||||
|
||||
### Missing Storage Capability
|
||||
|
||||
@@ -64,7 +64,7 @@ The nested `segment_observation` fixture compares diagnostic on/off runs of the
|
||||
|
||||
Entry/byte overflow and malformed keys reject the fixture proposal. Missing producers, process restarts, event gaps, and compacted child coverage remain **unverified production capabilities**, not simulated success cases in this fixture. Mainline bucket dirty generations and hashed metadata-cache invalidation stripes are not an exact, replayable object-key stream. The open [prefix reuse proposal #7208](https://github.com/rustfs/rustfs/pull/7208) is a separate candidate implementation; these tests neither import its hint map nor activate its skip path.
|
||||
|
||||
The ECStore `segment_observation_equal_size_mutations_retire_metadata_generation` test uses the existing exact-key, test-only invalidation probe and actual owner operations. A same-length PUT must change the returned body and ETag while retiring the old generation; metadata-only PUT must change returned metadata and retire the old generation while size and ETag remain equal. Setup uses the existing full-fanout cache-priming helper; the observed mutations use normal owner locking. This is focused producer evidence, not an end-to-end connection between the owner probe and scanner range selection. The existing semantic mutation matrix covers additional owner entry points separately.
|
||||
The ECStore `segment_observation_equal_size_mutations_retire_metadata_generation` test uses the existing exact-key, test-only invalidation probe and actual owner operations. A same-length PUT must change the returned body and ETag while retiring the old generation; metadata-only PUT must change returned metadata and retire the old generation while size and ETag remain equal. Setup uses the existing full-fanout cache-priming helper; the observed mutations use normal owner locking. This is focused producer evidence, not an end-to-end connection between the owner probe and scanner range selection. The segment invalidation proof is bound to the same generation window as the observed envelope, so an old distributed or cold-walk proof cannot authorize a later mutation range. The existing semantic mutation matrix covers additional owner entry points separately.
|
||||
|
||||
```sh
|
||||
cargo test -p rustfs-scanner --lib segment_observation -- --list
|
||||
|
||||
+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.
|
||||
|
||||
@@ -339,6 +339,19 @@ fn add_source_counts(total: &mut rustfs_heal::HealSourceCounts, next: rustfs_hea
|
||||
total.mrf = total.mrf.saturating_add(next.mrf);
|
||||
}
|
||||
|
||||
fn add_admission_telemetry(total: &mut rustfs_heal::HealAdmissionTelemetry, next: rustfs_heal::HealAdmissionTelemetry) {
|
||||
total.accepted = total.accepted.saturating_add(next.accepted);
|
||||
total.merged = total.merged.saturating_add(next.merged);
|
||||
total.full = total.full.saturating_add(next.full);
|
||||
total.dropped = total.dropped.saturating_add(next.dropped);
|
||||
total.duplicate = total.duplicate.saturating_add(next.duplicate);
|
||||
total.overlap_rejected = total.overlap_rejected.saturating_add(next.overlap_rejected);
|
||||
total.displaced = total.displaced.saturating_add(next.displaced);
|
||||
total.force_start = total.force_start.saturating_add(next.force_start);
|
||||
total.max_start_duration_micros = total.max_start_duration_micros.max(next.max_start_duration_micros);
|
||||
total.max_lock_phase_micros = total.max_lock_phase_micros.max(next.max_lock_phase_micros);
|
||||
}
|
||||
|
||||
fn add_operations(total: &mut rustfs_heal::HealOperationsSnapshot, next: rustfs_heal::HealOperationsSnapshot) {
|
||||
total.queue_length = total.queue_length.saturating_add(next.queue_length);
|
||||
total.active_tasks = total.active_tasks.saturating_add(next.active_tasks);
|
||||
@@ -349,6 +362,7 @@ fn add_operations(total: &mut rustfs_heal::HealOperationsSnapshot, next: rustfs_
|
||||
add_source_counts(&mut total.queued_by_source, next.queued_by_source);
|
||||
add_source_counts(&mut total.active_by_source, next.active_by_source);
|
||||
add_source_counts(&mut total.retrying_by_source, next.retrying_by_source);
|
||||
add_admission_telemetry(&mut total.admission, next.admission);
|
||||
}
|
||||
|
||||
fn aggregate_cluster_heal_status(snapshots: Vec<NodeHealStatusSnapshot>) -> ClusterHealStatusSnapshot {
|
||||
@@ -2307,6 +2321,10 @@ mod tests {
|
||||
assert!(json["healOperations"]["queuedBySource"]["admin"].is_u64());
|
||||
assert!(json["healOperations"]["queuedByPriority"]["low"].is_u64());
|
||||
assert!(json["healOperations"]["queuedByPriority"]["high"].is_u64());
|
||||
assert!(json["healOperations"]["admission"]["accepted"].is_u64());
|
||||
assert!(json["healOperations"]["admission"]["duplicate"].is_u64());
|
||||
assert!(json["healOperations"]["admission"]["forceStart"].is_u64());
|
||||
assert!(json["healOperations"]["admission"]["maxLockPhaseMicros"].is_u64());
|
||||
assert_eq!(json["state"], "active");
|
||||
assert_eq!(json["clusterStatusComplete"], true);
|
||||
assert!(json["progress"].is_null());
|
||||
@@ -2486,6 +2504,18 @@ mod tests {
|
||||
queued_by_source: sources(value),
|
||||
active_by_source: sources(value),
|
||||
retrying_by_source: sources(value),
|
||||
admission: rustfs_heal::HealAdmissionTelemetry {
|
||||
accepted: value,
|
||||
merged: value,
|
||||
full: value,
|
||||
dropped: value,
|
||||
duplicate: value,
|
||||
overlap_rejected: value,
|
||||
displaced: value,
|
||||
force_start: value,
|
||||
max_start_duration_micros: value,
|
||||
max_lock_phase_micros: value,
|
||||
},
|
||||
};
|
||||
let progress = |value| NodeHealProgress {
|
||||
objects_scanned: value,
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
+27
-1
@@ -14,9 +14,13 @@
|
||||
|
||||
use crate::storage_api::error::contract::{StorageErrorCode, range::HTTPRangeError};
|
||||
use crate::storage_api::error::{QuotaError, StorageError};
|
||||
use http::StatusCode;
|
||||
use rustfs_kms::KmsUnavailableError;
|
||||
use s3s::{S3Error, S3ErrorCode};
|
||||
|
||||
const MAX_VERSIONS_EXCEEDED_CODE: &str = "MaxVersionsExceeded";
|
||||
const MAX_VERSIONS_EXCEEDED_MESSAGE: &str = "You've exceeded the limit on the number of versions you can create on this object";
|
||||
|
||||
/// Marks a request body that exceeded a presigned upload size capability.
|
||||
///
|
||||
/// This marker must survive the body-reader and storage layers so the client
|
||||
@@ -284,6 +288,9 @@ impl ApiError {
|
||||
S3ErrorCode::EvaluatorBindingDoesNotExist => "A column name or a path provided does not exist in the SQL expression".to_string(),
|
||||
S3ErrorCode::InvalidColumnIndex => "The column index is invalid. Please check the service documentation and try again.".to_string(),
|
||||
S3ErrorCode::UnsupportedFunction => "Encountered an unsupported SQL function.".to_string(),
|
||||
S3ErrorCode::Custom(code) if &**code == MAX_VERSIONS_EXCEEDED_CODE => {
|
||||
MAX_VERSIONS_EXCEEDED_MESSAGE.to_string()
|
||||
}
|
||||
_ => code.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
@@ -362,6 +369,9 @@ fn error_chain_s3s_body_stream_error(err: &(dyn std::error::Error + 'static)) ->
|
||||
impl From<ApiError> for S3Error {
|
||||
fn from(err: ApiError) -> Self {
|
||||
let mut s3e = S3Error::with_message(err.code, err.message);
|
||||
if matches!(s3e.code(), S3ErrorCode::Custom(code) if &**code == MAX_VERSIONS_EXCEEDED_CODE) {
|
||||
s3e.set_status_code(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
if let Some(source) = err.source {
|
||||
s3e.set_source(source);
|
||||
}
|
||||
@@ -455,6 +465,7 @@ impl From<StorageError> for ApiError {
|
||||
| StorageError::InsufficientWriteQuorum(_, _) => S3ErrorCode::ServiceUnavailable,
|
||||
StorageError::NamespaceLockQuorumUnavailable { .. } => S3ErrorCode::ServiceUnavailable,
|
||||
StorageError::QuotaExceeded { .. } => S3ErrorCode::InvalidRequest,
|
||||
StorageError::MaxVersionsExceeded => S3ErrorCode::Custom(MAX_VERSIONS_EXCEEDED_CODE.into()),
|
||||
StorageError::Lock(_) => S3ErrorCode::ServiceUnavailable,
|
||||
StorageError::DecommissionNotStarted => S3ErrorCode::InvalidRequest,
|
||||
StorageError::DecommissionAlreadyRunning => S3ErrorCode::InvalidRequest,
|
||||
@@ -485,7 +496,9 @@ impl From<StorageError> for ApiError {
|
||||
|
||||
let message = if matches!(&err, StorageError::QuotaExceeded { .. }) {
|
||||
err.to_string()
|
||||
} else if code == S3ErrorCode::InternalError && matches!(&err, StorageError::Io(_)) {
|
||||
} else if matches!(&err, StorageError::MaxVersionsExceeded)
|
||||
|| (code == S3ErrorCode::InternalError && matches!(&err, StorageError::Io(_)))
|
||||
{
|
||||
ApiError::error_code_to_message(&code)
|
||||
} else if code == S3ErrorCode::InternalError {
|
||||
err.to_string()
|
||||
@@ -1189,6 +1202,19 @@ mod tests {
|
||||
assert_eq!(api_error.message, "Bucket quota exceeded. Current usage: 5 bytes, limit: 10 bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_versions_exceeded_maps_to_minio_compatible_s3_error() {
|
||||
let api_error: ApiError = StorageError::MaxVersionsExceeded.into();
|
||||
|
||||
assert_eq!(api_error.code, S3ErrorCode::Custom(MAX_VERSIONS_EXCEEDED_CODE.into()));
|
||||
assert_eq!(api_error.message, MAX_VERSIONS_EXCEEDED_MESSAGE);
|
||||
|
||||
let s3_error: S3Error = api_error.into();
|
||||
assert_eq!(s3_error.code(), &S3ErrorCode::Custom(MAX_VERSIONS_EXCEEDED_CODE.into()));
|
||||
assert_eq!(s3_error.message(), Some(MAX_VERSIONS_EXCEEDED_MESSAGE));
|
||||
assert_eq!(s3_error.status_code(), Some(StatusCode::BAD_REQUEST));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_error_to_s3_error_without_source() {
|
||||
let api_error = ApiError {
|
||||
|
||||
@@ -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,
|
||||
@@ -1857,7 +1855,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(
|
||||
|
||||
@@ -17,12 +17,108 @@ use crate::{
|
||||
startup_runtime_hooks::{init_profiling_runtime, install_default_crypto_provider, log_startup_runtime_diagnostics},
|
||||
startup_tls_material::init_outbound_tls_material,
|
||||
};
|
||||
use std::io::Result;
|
||||
use rustfs_config::ENV_API_OBJECT_MAX_VERSIONS;
|
||||
use rustfs_utils::EnvParseOutcome;
|
||||
use std::io::{Error, Result};
|
||||
|
||||
pub(crate) async fn init_startup_runtime_foundation(config: &Config) -> Result<()> {
|
||||
log_startup_runtime_diagnostics();
|
||||
init_profiling_runtime().await;
|
||||
rustfs_trusted_proxies::init();
|
||||
install_default_crypto_provider();
|
||||
init_object_max_versions_config()?;
|
||||
init_outbound_tls_material(config).await
|
||||
}
|
||||
|
||||
fn init_object_max_versions_config() -> Result<()> {
|
||||
let limit = match rustfs_utils::get_env_parse_outcome::<u64>(ENV_API_OBJECT_MAX_VERSIONS) {
|
||||
EnvParseOutcome::Absent => rustfs_filemeta::DEFAULT_OBJECT_MAX_VERSIONS,
|
||||
EnvParseOutcome::Invalid => {
|
||||
return Err(Error::other(format!(
|
||||
"{ENV_API_OBJECT_MAX_VERSIONS} must be a positive integer no greater than {}",
|
||||
usize::MAX
|
||||
)));
|
||||
}
|
||||
EnvParseOutcome::Parsed(value) => object_max_versions_limit_from_u64(value)?,
|
||||
};
|
||||
|
||||
rustfs_filemeta::set_object_max_versions(limit).map_err(Error::other)
|
||||
}
|
||||
|
||||
fn object_max_versions_limit_from_u64(value: u64) -> Result<usize> {
|
||||
if value == 0 {
|
||||
return Err(Error::other(format!(
|
||||
"{ENV_API_OBJECT_MAX_VERSIONS} must be a positive integer no greater than {}",
|
||||
usize::MAX
|
||||
)));
|
||||
}
|
||||
|
||||
usize::try_from(value).map_err(|_| {
|
||||
Error::other(format!(
|
||||
"{ENV_API_OBJECT_MAX_VERSIONS} must be a positive integer no greater than {}",
|
||||
usize::MAX
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct ObjectMaxVersionsRestore {
|
||||
previous: usize,
|
||||
}
|
||||
|
||||
impl Drop for ObjectMaxVersionsRestore {
|
||||
fn drop(&mut self) {
|
||||
rustfs_filemeta::set_object_max_versions(self.previous).expect("restore object max versions limit after test");
|
||||
}
|
||||
}
|
||||
|
||||
fn with_object_max_versions_env<R>(rustfs_value: Option<&str>, minio_value: Option<&str>, test: impl FnOnce() -> R) -> R {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
let _serial = LOCK.lock().expect("serialize object max versions env tests");
|
||||
let previous = rustfs_filemeta::object_max_versions();
|
||||
let _restore = ObjectMaxVersionsRestore { previous };
|
||||
|
||||
temp_env::with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_API_OBJECT_MAX_VERSIONS, rustfs_value),
|
||||
("MINIO_API_OBJECT_MAX_VERSIONS", minio_value),
|
||||
],
|
||||
test,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_max_versions_env_sets_filemeta_limit() {
|
||||
with_object_max_versions_env(Some("3"), None, || {
|
||||
init_object_max_versions_config().expect("valid object max versions env must initialize");
|
||||
assert_eq!(rustfs_filemeta::object_max_versions(), 3);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minio_object_max_versions_env_alias_sets_filemeta_limit() {
|
||||
with_object_max_versions_env(None, Some("4"), || {
|
||||
init_object_max_versions_config().expect("valid MinIO alias must initialize");
|
||||
assert_eq!(rustfs_filemeta::object_max_versions(), 4);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_max_versions_env_rejects_zero() {
|
||||
with_object_max_versions_env(Some("0"), None, || {
|
||||
let err = init_object_max_versions_config().expect_err("zero object max versions must fail startup config");
|
||||
assert!(err.to_string().contains(rustfs_config::ENV_API_OBJECT_MAX_VERSIONS));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_max_versions_env_rejects_malformed_value() {
|
||||
with_object_max_versions_env(Some("not-a-number"), None, || {
|
||||
let err = init_object_max_versions_config().expect_err("malformed object max versions must fail startup config");
|
||||
assert!(err.to_string().contains(rustfs_config::ENV_API_OBJECT_MAX_VERSIONS));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -520,97 +520,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 {
|
||||
@@ -636,19 +545,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)]
|
||||
@@ -1217,24 +1114,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())
|
||||
@@ -2844,7 +2723,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::{
|
||||
@@ -5325,687 +5203,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,
|
||||
)
|
||||
.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()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -726,6 +726,7 @@ mod tests {
|
||||
assert_eq!(decoded.info().bitrot_start_cycle, 9);
|
||||
assert_eq!(decoded.operations.queue_length, 2);
|
||||
assert_eq!(decoded.operations.queued_by_source.mrf, 0);
|
||||
assert_eq!(decoded.operations.admission, rustfs_heal::HealAdmissionTelemetry::default());
|
||||
let progress = decoded.progress.expect("legacy progress should decode");
|
||||
assert_eq!(progress.objects_scanned, 7);
|
||||
assert!(!progress.baseline_known);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -604,8 +602,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,
|
||||
};
|
||||
}
|
||||
@@ -681,9 +679,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,
|
||||
};
|
||||
|
||||
@@ -177,15 +177,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +47,24 @@ def validate_report(report, *, round_number, pid, objects, budget):
|
||||
continue
|
||||
if type(value) is not str or not 0 < len(value.encode("utf-8")) <= 512:
|
||||
raise ValueError(f"invalid raw entry marker: {key}")
|
||||
if "raw_page_index_parent" not in report:
|
||||
raise ValueError("missing raw page index parent")
|
||||
raw_page_index_parent = report.get("raw_page_index_parent")
|
||||
if raw_page_index_parent is not None and (type(raw_page_index_parent) is not str
|
||||
or not 0 < len(raw_page_index_parent.encode("utf-8")) <= 512):
|
||||
raise ValueError("invalid raw page index parent")
|
||||
if type(report.get("raw_page_index_complete")) is not bool:
|
||||
raise ValueError("missing raw page index completeness")
|
||||
if type(report.get("snapshot_complete")) is not bool:
|
||||
raise ValueError("missing explicit completeness")
|
||||
if report.get("outcome") not in ("complete", "partial", "cancelled_without_cache"):
|
||||
raise ValueError("unexpected scanner outcome")
|
||||
if report["raw_page_index_committed_entries"] > report["raw_page_index_indexed_entries"]:
|
||||
raise ValueError("raw page index committed entries exceed indexed entries")
|
||||
if report["raw_page_index_parent"] == "bucket" and report["raw_page_index_indexed_entries"] > objects:
|
||||
raise ValueError("raw page index exceeds fixture object count")
|
||||
if report["objects_retained"] > report["objects_before"] + report["objects_processed"]:
|
||||
raise ValueError("retained coverage advanced beyond classified object work")
|
||||
|
||||
|
||||
def converged(report, objects):
|
||||
@@ -66,6 +80,38 @@ def replays_raw_window(previous, current):
|
||||
and current["objects_retained"] == previous["objects_retained"])
|
||||
|
||||
|
||||
def validate_recoverable_quantum(reports, *, objects, budget, require_converged):
|
||||
if not reports:
|
||||
raise ValueError("no scanner restart reports were produced")
|
||||
previous = None
|
||||
made_enumeration_progress = False
|
||||
made_classification_progress = False
|
||||
made_durable_progress = False
|
||||
for index, report in enumerate(reports):
|
||||
validate_report(report, round_number=index, pid=report["pid"], objects=objects, budget=budget)
|
||||
if previous is not None:
|
||||
if report["objects_before"] != previous["objects_retained"]:
|
||||
raise ValueError("durable retained coverage did not survive process restart")
|
||||
if report["objects_retained"] < previous["objects_retained"]:
|
||||
raise ValueError("durable retained coverage regressed across restart")
|
||||
if (report["raw_page_index_parent"] == previous["raw_page_index_parent"]
|
||||
and report["raw_page_index_committed_entries"] < previous["raw_page_index_committed_entries"]
|
||||
and not previous["raw_page_index_complete"]):
|
||||
raise ValueError("committed raw enumeration page coverage regressed before completion")
|
||||
made_enumeration_progress |= report["raw_entries"] > 0 or report["raw_page_index_indexed_entries"] > 0
|
||||
made_classification_progress |= report["objects_processed"] > 0
|
||||
made_durable_progress |= report["objects_retained"] > report["objects_before"]
|
||||
previous = report
|
||||
if not made_enumeration_progress:
|
||||
raise ValueError("restart proof did not exercise raw enumeration")
|
||||
if not made_classification_progress:
|
||||
raise ValueError("restart proof did not exercise object classification")
|
||||
if not made_durable_progress:
|
||||
raise ValueError("restart proof did not persist processed object coverage")
|
||||
if require_converged and not converged(reports[-1], objects):
|
||||
raise ValueError("fixed-budget restart convergence was not established")
|
||||
|
||||
|
||||
def run(args):
|
||||
binary = args.test_binary.resolve(strict=True)
|
||||
listed = subprocess.run([str(binary), WORKER, "--exact", "--list"],
|
||||
@@ -103,15 +149,15 @@ def run(args):
|
||||
report = json.loads(raw)
|
||||
validate_report(report, round_number=round_number, pid=worker.pid,
|
||||
objects=args.objects, budget=args.raw_entry_budget)
|
||||
if reports and report["objects_before"] != reports[-1]["objects_retained"]:
|
||||
raise ValueError("cache coverage did not survive the process boundary")
|
||||
if reports and replays_raw_window(reports[-1], report):
|
||||
replayed_raw_window = True
|
||||
reports.append(report)
|
||||
print(json.dumps(report, sort_keys=True), flush=True)
|
||||
if converged(report, args.objects):
|
||||
print("PASS: bounded scanner-worker restart convergence for this fixture only")
|
||||
validate_recoverable_quantum(reports, objects=args.objects, budget=args.raw_entry_budget, require_converged=True)
|
||||
print("PASS: bounded scanner-worker restart convergence with enumeration/classification/processing evidence")
|
||||
return 0
|
||||
validate_recoverable_quantum(reports, objects=args.objects, budget=args.raw_entry_budget, require_converged=False)
|
||||
reason = "replayed raw enumeration window" if replayed_raw_window else "no bounded restart convergence"
|
||||
print(f"FAIL: fixed-budget restart convergence not established ({reason}); R-E gate remains unmet",
|
||||
file=sys.stderr)
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import unittest
|
||||
|
||||
from diagnose_scanner_enumeration_restart import converged, replays_raw_window, validate_report
|
||||
from diagnose_scanner_enumeration_restart import (
|
||||
converged,
|
||||
replays_raw_window,
|
||||
validate_recoverable_quantum,
|
||||
validate_report,
|
||||
)
|
||||
|
||||
|
||||
class ReportTests(unittest.TestCase):
|
||||
@@ -10,6 +15,7 @@ class ReportTests(unittest.TestCase):
|
||||
return dict(schema=1, round=0, pid=123, objects_expected=4, raw_entry_budget=16,
|
||||
raw_entries=8, raw_name_bytes=64, objects_before=0, objects_retained=4,
|
||||
versions_retained=4, bytes_retained=4, objects_processed=4,
|
||||
raw_page_index_parent="bucket", raw_page_index_complete=True,
|
||||
raw_page_index_committed_entries=4,
|
||||
raw_page_index_indexed_entries=4,
|
||||
raw_first_entry="bucket/object-0000",
|
||||
@@ -46,6 +52,26 @@ class ReportTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
self.validate(report)
|
||||
|
||||
def test_raw_page_index_ordering_and_bounds_are_checked(self):
|
||||
report = self.report()
|
||||
report["raw_page_index_committed_entries"] = 5
|
||||
report["raw_page_index_indexed_entries"] = 4
|
||||
with self.assertRaisesRegex(ValueError, "committed entries exceed indexed entries"):
|
||||
self.validate(report)
|
||||
|
||||
report = self.report()
|
||||
report["raw_page_index_indexed_entries"] = 5
|
||||
with self.assertRaisesRegex(ValueError, "exceeds fixture object count"):
|
||||
self.validate(report)
|
||||
|
||||
def test_retained_coverage_cannot_advance_without_classified_work(self):
|
||||
report = self.report()
|
||||
report["objects_before"] = 1
|
||||
report["objects_processed"] = 1
|
||||
report["objects_retained"] = 3
|
||||
with self.assertRaisesRegex(ValueError, "advanced beyond classified object work"):
|
||||
self.validate(report)
|
||||
|
||||
report = self.report()
|
||||
report["objects_processed"] = 17
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -73,6 +99,11 @@ class ReportTests(unittest.TestCase):
|
||||
report["raw_first_entry"] = None
|
||||
report["raw_last_entry"] = None
|
||||
report["objects_processed"] = 1
|
||||
report["objects_retained"] = 1
|
||||
report["versions_retained"] = 1
|
||||
report["bytes_retained"] = 1
|
||||
report["snapshot_complete"] = False
|
||||
report["outcome"] = "partial"
|
||||
self.validate(report)
|
||||
|
||||
def test_missing_wrong_type_and_negative_counter_rejected(self):
|
||||
@@ -84,7 +115,7 @@ class ReportTests(unittest.TestCase):
|
||||
self.validate(report)
|
||||
|
||||
def test_missing_completeness_or_unknown_outcome_rejected(self):
|
||||
for key in ("snapshot_complete", "outcome"):
|
||||
for key in ("raw_page_index_parent", "raw_page_index_complete", "snapshot_complete", "outcome"):
|
||||
report = self.report()
|
||||
del report[key]
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -109,6 +140,59 @@ class ReportTests(unittest.TestCase):
|
||||
advanced = dict(current, objects_retained=1)
|
||||
self.assertFalse(replays_raw_window(previous, advanced))
|
||||
|
||||
def test_recoverable_quantum_requires_three_stage_progress_and_convergence(self):
|
||||
first = self.report()
|
||||
first.update(round=0, pid=123, raw_entries=2, raw_page_index_committed_entries=2,
|
||||
raw_page_index_indexed_entries=2, objects_processed=2, objects_before=0,
|
||||
objects_retained=2, versions_retained=2, bytes_retained=2,
|
||||
snapshot_complete=False, outcome="partial")
|
||||
second = self.report()
|
||||
second.update(round=1, pid=124, raw_entries=2, raw_page_index_committed_entries=4,
|
||||
raw_page_index_indexed_entries=4, objects_processed=2, objects_before=2,
|
||||
objects_retained=4, snapshot_complete=True, outcome="complete")
|
||||
validate_recoverable_quantum([first, second], objects=4, budget=16, require_converged=True)
|
||||
|
||||
def test_recoverable_quantum_rejects_restart_regression(self):
|
||||
first = self.report()
|
||||
first.update(snapshot_complete=False, outcome="partial", objects_retained=2,
|
||||
versions_retained=2, bytes_retained=2)
|
||||
second = self.report()
|
||||
second.update(round=1, pid=124, objects_before=1, objects_retained=1,
|
||||
versions_retained=1, bytes_retained=1, snapshot_complete=False,
|
||||
outcome="partial")
|
||||
with self.assertRaisesRegex(ValueError, "did not survive process restart"):
|
||||
validate_recoverable_quantum([first, second], objects=4, budget=16, require_converged=False)
|
||||
|
||||
def test_recoverable_quantum_allows_new_raw_page_parent_after_processing(self):
|
||||
first = self.report()
|
||||
first.update(snapshot_complete=False, outcome="partial", objects_before=0,
|
||||
objects_processed=0, objects_retained=0, versions_retained=0,
|
||||
bytes_retained=0, raw_page_index_parent="bucket",
|
||||
raw_page_index_complete=True, raw_page_index_committed_entries=4,
|
||||
raw_page_index_indexed_entries=4)
|
||||
second = self.report()
|
||||
second.update(round=1, pid=124, raw_entries=0, raw_first_entry=None,
|
||||
raw_last_entry=None, raw_name_bytes=0, objects_before=0,
|
||||
objects_processed=2, objects_retained=2,
|
||||
versions_retained=2, bytes_retained=2,
|
||||
snapshot_complete=False, outcome="partial",
|
||||
raw_page_index_parent="bucket/object-0000",
|
||||
raw_page_index_complete=False,
|
||||
raw_page_index_committed_entries=1,
|
||||
raw_page_index_indexed_entries=1)
|
||||
validate_recoverable_quantum([first, second], objects=4, budget=16, require_converged=False)
|
||||
|
||||
def test_recoverable_quantum_rejects_missing_processing_stage(self):
|
||||
report = self.report()
|
||||
report["objects_processed"] = 0
|
||||
report["objects_retained"] = 0
|
||||
report["versions_retained"] = 0
|
||||
report["bytes_retained"] = 0
|
||||
report["snapshot_complete"] = False
|
||||
report["outcome"] = "partial"
|
||||
with self.assertRaisesRegex(ValueError, "object classification"):
|
||||
validate_recoverable_quantum([report], objects=4, budget=16, require_converged=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user