mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-09 13:46:05 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81038fc4e2 | |||
| 6920abfe29 | |||
| f9913bbe72 | |||
| d0a6260002 | |||
| 4b4360a1ca | |||
| 9ae11bd3b5 | |||
| 3e64697d03 | |||
| 9fa1d3f58f | |||
| 73957d0faf | |||
| 7e0c67111b | |||
| 0008cbcb29 | |||
| c03d3cdd59 | |||
| 0db77be5c6 | |||
| 590fab5c7e | |||
| c02967baf6 | |||
| 72e85210f1 | |||
| 0a9f0f59a7 | |||
| e65788f2e8 |
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=f0c78fdb93471575d9a64c5c46eae6c806bdd0bc10a6e33d7fb574aabd8db5a3
|
sha256-darwin=f0d15f2d1183be319d9d977d20b48bb05a29a424c266eed910c67aaa6f1ff955
|
||||||
sha256-linux=03ed7016cab672de9320e31375a0358eceacb4408b0e79cf063614fa7c878b87
|
sha256-linux=340aa702576ebed5266b7c47e591f267a11178fa04bf3828d64f53fe92eb0907
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=a5665318c9bdc0947514fb7008ba1b83b114b739fac775c3c446f207058b7c7a
|
sha256-darwin=12d30fff5ed48fe95bbfb310dd507048f81782b2954e00eb72435708b1133f9c
|
||||||
sha256-linux=45d80e1723de5d25bb5b81f3ef5c82f583efc3e4f036a8cd2bb99e4f1eca9e51
|
sha256-linux=e917b2fdb303d01e6008ac4f1836c7b268698afe8383bd2aa4e697061df85cf6
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
sha256=0fe8408874ccec3620262a9812d67920ddd72dc9edf0e36e0d0aed3f8bad026e
|
sha256=0e338d305260229e17ccfb2adc48a6212dbdfea36a9ebfb5a4e0d38658e6cc45
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ name: Security Audit
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main, release ]
|
||||||
paths:
|
paths:
|
||||||
- '**/Cargo.toml'
|
- '**/Cargo.toml'
|
||||||
- '**/Cargo.lock'
|
- '**/Cargo.lock'
|
||||||
@@ -32,7 +32,7 @@ on:
|
|||||||
- 'scripts/security/check_workflow_pins.sh'
|
- 'scripts/security/check_workflow_pins.sh'
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [ opened, synchronize, reopened, closed ]
|
types: [ opened, synchronize, reopened, closed ]
|
||||||
branches: [ main ]
|
branches: [ main, release ]
|
||||||
paths:
|
paths:
|
||||||
- '**/Cargo.toml'
|
- '**/Cargo.toml'
|
||||||
- '**/Cargo.lock'
|
- '**/Cargo.lock'
|
||||||
|
|||||||
@@ -1002,11 +1002,22 @@ jobs:
|
|||||||
# debug binary; each test spawns its own rustfs server on a random port.
|
# debug binary; each test spawns its own rustfs server on a random port.
|
||||||
- name: Run e2e full suite
|
- name: Run e2e full suite
|
||||||
env:
|
env:
|
||||||
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-full-logs
|
||||||
|
RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG: rustfs::heal=debug,rustfs_heal=debug,rustfs_ecstore::disk=debug,rustfs_lock=debug,rustfs=error
|
||||||
RUSTFS_E2E_STARTUP_CAS_BINARY: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs
|
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_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
|
RUSTFS_E2E_STARTUP_CAS_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence
|
||||||
run: cargo nextest run --profile e2e-full -p e2e_test
|
run: cargo nextest run --profile e2e-full -p e2e_test
|
||||||
|
|
||||||
|
- name: Upload e2e full diagnostics
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: e2e-full-diagnostics-${{ github.run_number }}
|
||||||
|
path: ${{ runner.temp }}/rustfs-e2e-full-logs/
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload junit
|
- name: Upload junit
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is
|
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is
|
||||||
# `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs,
|
# `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs,
|
||||||
# nightly runs, and manual dispatches all execute the same fail-closed suite.
|
# nightly runs, and manual dispatches all execute the same fail-closed suite.
|
||||||
# Upgrade cases download the same pinned previous release as e2e-upgrade.yml.
|
# Upgrade cases use an independent 1.0.0-rc.2 pin defined below.
|
||||||
#
|
#
|
||||||
# Isolated pool filesystems: expand/decommission/rebalance cases require
|
# Isolated pool filesystems: expand/decommission/rebalance cases require
|
||||||
# independent `statfs` capacity. This job runs on GitHub-hosted
|
# independent `statfs` capacity. This job runs on GitHub-hosted
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
NO_PROXY: 127.0.0.1,localhost
|
NO_PROXY: 127.0.0.1,localhost
|
||||||
HTTP_PROXY: ""
|
HTTP_PROXY: ""
|
||||||
HTTPS_PROXY: ""
|
HTTPS_PROXY: ""
|
||||||
# Pinned previous release used by distributed::upgrade_test (same pin as e2e-upgrade.yml).
|
# Independent 1.0.0-rc.2 source pin for distributed::upgrade_test.
|
||||||
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
||||||
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
||||||
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
|
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Fresh multi-pool bootstrap with distinct format creators**: a new deployment whose pools have their first endpoint on different nodes (for example two single-node pools) could never publish its initial `pool.bin`: each node held fresh-bootstrap proof only for the pool it formatted, the deployment-wide proof collapsed to none, and every node died with `pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available` after the startup retry budget. The first pool's creator now mints the pending cluster identity on its own pool, every other creator copies that nonce-bound identity onto the pool it formatted first-hand, and the elected writer publishes `pool.bin` once every pool replica carries the same pending identity. Corrupt or disagreeing replicas, pools that merely have a format, expansion pools joining an initialized deployment, and restarts without first-hand proof still fail closed. Non-elected nodes that start before `pool.bin` exists, and the elected writer while it waits for the other creators, no longer latch their pool-metadata write gate for the life of the process. Refs rustfs/backlog#2338, rustfs/backlog#2375.
|
||||||
|
- **Lock RPC timeout storms** (#7363): the remote lock client no longer evicts and re-dials the shared internode HTTP/2 channel on every request deadline. A timeout evicts only when the peer has not completed any lock RPC for two deadlines, evictions and transport-failure re-dials are rate limited per peer (`RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS`, default 5 s), and a timed-out request is left running instead of being reset (bounded per peer by `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT`, default 256), so a slow lock endpoint can no longer drive the `RST_STREAM`/`GOAWAY too_many_resets`/reconnect loop. A lock granted after its caller timed out is released immediately, and unlocks that fail the quick retries continue on a deferred 1/2/4/8/16 s schedule before the server lease reclaims them. New `rustfs_remote_lock_*` metrics cover timeouts, evictions, suppressed evictions, detached streams, late completions and late releases per peer. Operator guide at `docs/operations/lock-rpc-storm-protection.md`.
|
||||||
- **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385.
|
- **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385.
|
||||||
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
|
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
|
||||||
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
|
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
|
||||||
|
|||||||
@@ -609,6 +609,36 @@ pub const ENV_OBJECT_LOCK_RPC_TIMEOUT_MS: &str = "RUSTFS_OBJECT_LOCK_RPC_TIMEOUT
|
|||||||
/// Default remote lock RPC transport timeout: 3000 milliseconds.
|
/// Default remote lock RPC transport timeout: 3000 milliseconds.
|
||||||
pub const DEFAULT_OBJECT_LOCK_RPC_TIMEOUT_MS: u64 = 3000;
|
pub const DEFAULT_OBJECT_LOCK_RPC_TIMEOUT_MS: u64 = 3000;
|
||||||
|
|
||||||
|
/// Environment variable for the minimum interval between evictions of the
|
||||||
|
/// cached lock RPC channel to one peer, in milliseconds.
|
||||||
|
///
|
||||||
|
/// A lock RPC that fails on transport, or that times out while the peer has
|
||||||
|
/// not completed any lock RPC for two deadlines, evicts the shared HTTP/2
|
||||||
|
/// channel so the next request re-dials. Evictions are rate limited per peer
|
||||||
|
/// so one slow lock endpoint cannot drive a reset/GOAWAY/reconnect loop
|
||||||
|
/// (issue #7363). `0` disables the cooldown.
|
||||||
|
///
|
||||||
|
/// Default: 5000 milliseconds.
|
||||||
|
pub const ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS: &str = "RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS";
|
||||||
|
|
||||||
|
/// Default minimum interval between lock RPC channel evictions per peer: 5000 milliseconds.
|
||||||
|
pub const DEFAULT_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS: u64 = 5000;
|
||||||
|
|
||||||
|
/// Environment variable for how many timed-out lock RPCs per peer may keep
|
||||||
|
/// running in the background instead of being cancelled.
|
||||||
|
///
|
||||||
|
/// Cancelling a timed-out stream sends `RST_STREAM`; enough of them make the
|
||||||
|
/// peer answer `GOAWAY too_many_resets` and drop every stream on the
|
||||||
|
/// connection. A detached RPC ends on its own within the internode RPC
|
||||||
|
/// timeout, and a lock it acquires after its caller gave up is released
|
||||||
|
/// immediately. Beyond this budget timed-out RPCs are cancelled as before.
|
||||||
|
///
|
||||||
|
/// Default: 256.
|
||||||
|
pub const ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT: &str = "RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT";
|
||||||
|
|
||||||
|
/// Default per-peer budget of detached (timed-out but still running) lock RPCs: 256.
|
||||||
|
pub const DEFAULT_OBJECT_LOCK_RPC_DETACHED_LIMIT: usize = 256;
|
||||||
|
|
||||||
/// Environment variable to enable object namespace lock diagnostics.
|
/// Environment variable to enable object namespace lock diagnostics.
|
||||||
///
|
///
|
||||||
/// When enabled, RustFS emits slow lock acquisition and long lock hold
|
/// When enabled, RustFS emits slow lock acquisition and long lock hold
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ use sha2::{Digest, Sha256};
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::time::{Duration, Instant, sleep};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
@@ -374,6 +375,33 @@ pub(crate) fn census_object_version_on_disk(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wait for the background PUT tail to commit every physical part on one disk.
|
||||||
|
/// Invalid metadata remains an immediate error instead of a retryable absence.
|
||||||
|
pub(crate) async fn wait_for_complete_physical_shard_on_disk(
|
||||||
|
disk: &Path,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
version_id: Option<&str>,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> ChaosResult<VersionShardCensus> {
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
let census = census_object_version_on_disk(disk, bucket, key, version_id)?;
|
||||||
|
if census.is_complete() && !census.expected_part_numbers.is_empty() {
|
||||||
|
return Ok(census);
|
||||||
|
}
|
||||||
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||||
|
if remaining.is_zero() {
|
||||||
|
return Err(format!(
|
||||||
|
"physical shard for {bucket}/{key}@{version_id:?} on {} did not become complete within {timeout:?}: {census:?}",
|
||||||
|
disk.display()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
sleep(remaining.min(Duration::from_millis(50))).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `POST` a signed (SigV4, service `s3`) admin request without relying on the
|
/// `POST` a signed (SigV4, service `s3`) admin request without relying on the
|
||||||
/// external `awscurl` binary. Mirrors the admin heal calls used by the heal
|
/// external `awscurl` binary. Mirrors the admin heal calls used by the heal
|
||||||
/// regression suite.
|
/// regression suite.
|
||||||
@@ -451,4 +479,30 @@ mod tests {
|
|||||||
assert!(expected.matches_manifest(&expected));
|
assert!(expected.matches_manifest(&expected));
|
||||||
assert!(!changed.matches_manifest(&expected));
|
assert!(!changed.matches_manifest(&expected));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn physical_shard_readiness_fails_closed_with_last_census() {
|
||||||
|
let disk = tempfile::tempdir().expect("temporary disk");
|
||||||
|
let error = wait_for_complete_physical_shard_on_disk(disk.path(), "bucket", "missing", None, Duration::ZERO)
|
||||||
|
.await
|
||||||
|
.expect_err("missing physical shards must fail the baseline gate");
|
||||||
|
assert!(error.to_string().contains("has_xl_meta: false"));
|
||||||
|
assert!(error.to_string().contains("bucket/missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn physical_shard_readiness_does_not_retry_invalid_metadata() {
|
||||||
|
let disk = tempfile::tempdir().expect("temporary disk");
|
||||||
|
let object = disk.path().join("bucket").join("corrupt");
|
||||||
|
std::fs::create_dir_all(&object).expect("object directory");
|
||||||
|
std::fs::write(object.join("xl.meta"), b"invalid metadata").expect("corrupt metadata fixture");
|
||||||
|
let error = tokio::time::timeout(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
wait_for_complete_physical_shard_on_disk(disk.path(), "bucket", "corrupt", None, Duration::from_secs(30)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("corrupt metadata must fail immediately")
|
||||||
|
.expect_err("invalid metadata must not be accepted as a complete baseline");
|
||||||
|
assert!(!error.to_string().contains("did not become complete"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ use serde_json;
|
|||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fs as stdfs;
|
use std::fs as stdfs;
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
use std::net::SocketAddr;
|
use std::net::{Ipv6Addr, SocketAddr};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::Once;
|
use std::sync::Once;
|
||||||
@@ -1468,7 +1468,9 @@ impl RustFSTestClusterEnvironment {
|
|||||||
return Err(format!("a volume proxy is already configured for node {node_idx}").into());
|
return Err(format!("a volume proxy is already configured for node {node_idx}").into());
|
||||||
}
|
}
|
||||||
let target = self.nodes[node_idx].address.parse::<SocketAddr>()?;
|
let target = self.nodes[node_idx].address.parse::<SocketAddr>()?;
|
||||||
let proxy = crate::fault_proxy::FaultProxy::start(target).await?;
|
// Endpoint locality requires the server port to match. IPv6 loopback
|
||||||
|
// keeps that port while leaving the direct IPv4 S3 listener available.
|
||||||
|
let proxy = crate::fault_proxy::FaultProxy::start_on((Ipv6Addr::LOCALHOST, target.port()).into(), target).await?;
|
||||||
self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr());
|
self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr());
|
||||||
Ok(proxy)
|
Ok(proxy)
|
||||||
}
|
}
|
||||||
@@ -2373,7 +2375,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn volume_proxy_rewrites_cluster_volume_endpoint() {
|
async fn volume_proxy_rewrites_cluster_volume_endpoint() {
|
||||||
let mut env = RustFSTestClusterEnvironment::new(1)
|
let mut env = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(2, 2))
|
||||||
.await
|
.await
|
||||||
.expect("cluster environment should allocate a node");
|
.expect("cluster environment should allocate a node");
|
||||||
let direct = env.nodes[0].address.clone();
|
let direct = env.nodes[0].address.clone();
|
||||||
@@ -2388,6 +2390,24 @@ mod tests {
|
|||||||
assert!(!volumes.contains(&direct), "volumes must not retain the direct address: {volumes}");
|
assert!(!volumes.contains(&direct), "volumes must not retain the direct address: {volumes}");
|
||||||
|
|
||||||
proxy.shutdown().await;
|
proxy.shutdown().await;
|
||||||
|
|
||||||
|
for node in &env.nodes {
|
||||||
|
let local_port = node.address.parse::<SocketAddr>().expect("node address").port();
|
||||||
|
let local_paths = volumes
|
||||||
|
.split_whitespace()
|
||||||
|
.filter_map(|volume| {
|
||||||
|
let endpoint = reqwest::Url::parse(volume).expect("volume endpoint");
|
||||||
|
rustfs_utils::is_local_host(
|
||||||
|
endpoint.host().expect("volume endpoint host"),
|
||||||
|
endpoint.port().expect("volume endpoint port"),
|
||||||
|
local_port,
|
||||||
|
)
|
||||||
|
.expect("endpoint locality")
|
||||||
|
.then(|| endpoint.path().to_string())
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(local_paths, node.data_dirs, "the proxy must preserve local disk ownership");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -12,18 +12,15 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use super::harness::{
|
use super::harness::{DistCluster, DistLayout, TestResult, assert_inventory, payload_for, put_object, unique_bucket, wait_until};
|
||||||
DistCluster, DistLayout, TestResult, assert_inventory, get_object_bytes, payload_for, put_object, sha256_hex, unique_bucket,
|
use crate::chaos::{
|
||||||
wait_until,
|
VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post, wait_for_complete_physical_shard_on_disk,
|
||||||
};
|
};
|
||||||
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post};
|
use crate::common::{init_logging, rustfs_binary_path};
|
||||||
use crate::common::init_logging;
|
use crate::scanner_heal_evidence::{EvidenceTopology, RestartObservation, ScannerHealEvidenceCase, restart_evidence_run};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use serde_json::Value;
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, HashSet};
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -31,8 +28,6 @@ const EC84_NODE_COUNT: usize = 3;
|
|||||||
const EC84_DRIVES_PER_NODE: usize = 4;
|
const EC84_DRIVES_PER_NODE: usize = 4;
|
||||||
const EC84_DATA_BLOCKS: usize = 8;
|
const EC84_DATA_BLOCKS: usize = 8;
|
||||||
const EC84_PARITY_BLOCKS: usize = 4;
|
const EC84_PARITY_BLOCKS: usize = 4;
|
||||||
const EC84_TARGET_DRIVE_RESTART_CASE: &str = "ec84-target-drive-restart";
|
|
||||||
const EC84_TARGET_DRIVE_RESTART_ORACLE: &str = "ec84-target-drive-restart.json";
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ExpectedShard {
|
struct ExpectedShard {
|
||||||
@@ -41,79 +36,6 @@ struct ExpectedShard {
|
|||||||
baseline: VersionShardCensus,
|
baseline: VersionShardCensus,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ScannerHealEvidenceContext {
|
|
||||||
directory: PathBuf,
|
|
||||||
run: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn file_sha256(path: &Path) -> TestResult<String> {
|
|
||||||
let mut file = std::fs::File::open(path)?;
|
|
||||||
let mut digest = Sha256::new();
|
|
||||||
let mut buffer = [0_u8; 64 * 1024];
|
|
||||||
loop {
|
|
||||||
let read = file.read(&mut buffer)?;
|
|
||||||
if read == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
digest.update(&buffer[..read]);
|
|
||||||
}
|
|
||||||
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compiled_test_identity() -> Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
|
|
||||||
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
|
|
||||||
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
|
|
||||||
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
|
|
||||||
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
|
|
||||||
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
|
|
||||||
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn string_field<'a>(value: &'a Value, path: &str) -> TestResult<&'a str> {
|
|
||||||
let mut current = value;
|
|
||||||
for segment in path.split('.') {
|
|
||||||
current = current
|
|
||||||
.get(segment)
|
|
||||||
.ok_or_else(|| format!("scanner/heal run receipt missing {path}"))?;
|
|
||||||
}
|
|
||||||
current
|
|
||||||
.as_str()
|
|
||||||
.filter(|text| !text.is_empty())
|
|
||||||
.ok_or_else(|| format!("scanner/heal run receipt has invalid {path}").into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scanner_heal_evidence_context() -> TestResult<Option<ScannerHealEvidenceContext>> {
|
|
||||||
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let directory = PathBuf::from(directory);
|
|
||||||
let receipt = directory.join("run.json");
|
|
||||||
if receipt.metadata()?.len() > 1024 * 1024 {
|
|
||||||
return Err("oversized scanner/heal execution receipt".into());
|
|
||||||
}
|
|
||||||
let run: Value = serde_json::from_slice(&std::fs::read(receipt)?)?;
|
|
||||||
let built = compiled_test_identity();
|
|
||||||
for key in ["source_revision", "dirty", "lock_blob", "features"] {
|
|
||||||
if built[key] != run["test_build"][key] {
|
|
||||||
return Err(format!("compiled test identity differs for {key}").into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let binary_path = PathBuf::from(string_field(&run, "binary.path")?);
|
|
||||||
if file_sha256(&binary_path)? != string_field(&run, "binary.sha256")? {
|
|
||||||
return Err("server binary must match the run receipt".into());
|
|
||||||
}
|
|
||||||
if file_sha256(&std::env::current_exe()?)? != string_field(&run, "test_binary.sha256")? {
|
|
||||||
return Err("test executable must match the run receipt".into());
|
|
||||||
}
|
|
||||||
if directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE).exists() {
|
|
||||||
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
|
|
||||||
}
|
|
||||||
Ok(Some(ScannerHealEvidenceContext { directory, run }))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
|
fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
|
||||||
if census.data_blocks != Some(EC84_DATA_BLOCKS) || census.parity_blocks != Some(EC84_PARITY_BLOCKS) {
|
if census.data_blocks != Some(EC84_DATA_BLOCKS) || census.parity_blocks != Some(EC84_PARITY_BLOCKS) {
|
||||||
return Err(format!("object {key} did not use EC8+4 geometry: {census:?}").into());
|
return Err(format!("object {key} did not use EC8+4 geometry: {census:?}").into());
|
||||||
@@ -130,76 +52,6 @@ fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_scanner_heal_evidence(
|
|
||||||
context: ScannerHealEvidenceContext,
|
|
||||||
dist: &DistCluster,
|
|
||||||
bucket: &str,
|
|
||||||
expected: &[ExpectedShard],
|
|
||||||
outage_key: &str,
|
|
||||||
outage_body: &[u8],
|
|
||||||
replaced_drive: &Path,
|
|
||||||
pid_before: u32,
|
|
||||||
pid_after: u32,
|
|
||||||
node_listings: Vec<Vec<String>>,
|
|
||||||
) -> TestResult {
|
|
||||||
let verifier = dist.client(0)?;
|
|
||||||
let mut objects = Vec::new();
|
|
||||||
for item in expected {
|
|
||||||
let actual = get_object_bytes(&verifier, bucket, &item.key).await?;
|
|
||||||
let physical = census_object_version_on_disk(replaced_drive, bucket, &item.key, None)?;
|
|
||||||
objects.push(serde_json::json!({
|
|
||||||
"key": item.key,
|
|
||||||
"version_id": null,
|
|
||||||
"expected_bytes": item.body.len(),
|
|
||||||
"actual_bytes": actual.len(),
|
|
||||||
"expected_sha256": sha256_hex(&item.body),
|
|
||||||
"actual_sha256": sha256_hex(&actual),
|
|
||||||
"expected_physical": item.baseline,
|
|
||||||
"physical": physical,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
let actual = get_object_bytes(&verifier, bucket, outage_key).await?;
|
|
||||||
let physical = census_object_version_on_disk(replaced_drive, bucket, outage_key, None)?;
|
|
||||||
objects.push(serde_json::json!({
|
|
||||||
"key": outage_key,
|
|
||||||
"version_id": null,
|
|
||||||
"expected_bytes": outage_body.len(),
|
|
||||||
"actual_bytes": actual.len(),
|
|
||||||
"expected_sha256": sha256_hex(outage_body),
|
|
||||||
"actual_sha256": sha256_hex(&actual),
|
|
||||||
"expected_physical": null,
|
|
||||||
"physical": physical,
|
|
||||||
}));
|
|
||||||
|
|
||||||
let evidence = serde_json::json!({
|
|
||||||
"schema": 1,
|
|
||||||
"case": EC84_TARGET_DRIVE_RESTART_CASE,
|
|
||||||
"evidence": "process-restart",
|
|
||||||
"run_id": string_field(&context.run, "run_id")?,
|
|
||||||
"source_revision": string_field(&context.run, "source_revision")?,
|
|
||||||
"test_build": compiled_test_identity(),
|
|
||||||
"binary_sha256": string_field(&context.run, "binary.sha256")?,
|
|
||||||
"test_binary_sha256": string_field(&context.run, "test_binary.sha256")?,
|
|
||||||
"topology": {"nodes": EC84_NODE_COUNT, "drives_per_node": EC84_DRIVES_PER_NODE},
|
|
||||||
"pid_before": pid_before,
|
|
||||||
"pid_after": pid_after,
|
|
||||||
"unclean_shutdown_marker": false,
|
|
||||||
"objects": objects,
|
|
||||||
"node_listings": node_listings,
|
|
||||||
});
|
|
||||||
let data = serde_json::to_vec(&evidence)?;
|
|
||||||
if data.len() > 1024 * 1024 {
|
|
||||||
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
|
|
||||||
}
|
|
||||||
let mut output = std::fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.open(context.directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE))?;
|
|
||||||
output.write_all(&data)?;
|
|
||||||
output.sync_all()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_replaced_drive_empty(drive: &Path, bucket: &str, keys: &[String]) -> TestResult {
|
fn assert_replaced_drive_empty(drive: &Path, bucket: &str, keys: &[String]) -> TestResult {
|
||||||
for key in keys {
|
for key in keys {
|
||||||
let census = census_object_version_on_disk(drive, bucket, key, None)?;
|
let census = census_object_version_on_disk(drive, bucket, key, None)?;
|
||||||
@@ -238,7 +90,19 @@ async fn put_large_inventory(client: &Client, bucket: &str) -> TestResult<Vec<Ex
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_restart() -> TestResult {
|
async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_restart() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
let evidence_context = scanner_heal_evidence_context()?;
|
let server_binary = rustfs_binary_path();
|
||||||
|
let evidence_run = restart_evidence_run(
|
||||||
|
&server_binary,
|
||||||
|
ScannerHealEvidenceCase {
|
||||||
|
id: "ec84-target-drive-restart",
|
||||||
|
oracle: "ec84-target-drive-restart.json",
|
||||||
|
evidence: "process-restart",
|
||||||
|
unclean_shutdown_marker: false,
|
||||||
|
topology: EvidenceTopology::new(3, 4),
|
||||||
|
storage_class_standard: Some("EC:4"),
|
||||||
|
erasure_set_drive_count: Some("12"),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
let mut dist = DistCluster::start_with_env(
|
let mut dist = DistCluster::start_with_env(
|
||||||
DistLayout::ThreeByFourEc84,
|
DistLayout::ThreeByFourEc84,
|
||||||
&[
|
&[
|
||||||
@@ -262,18 +126,24 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
|
|||||||
let replaced_drive = PathBuf::from(&dist.cluster.nodes[replaced_node].data_dirs[replaced_drive_index]);
|
let replaced_drive = PathBuf::from(&dist.cluster.nodes[replaced_node].data_dirs[replaced_drive_index]);
|
||||||
|
|
||||||
for item in &mut expected {
|
for item in &mut expected {
|
||||||
item.baseline = census_object_version_on_disk(&replaced_drive, &bucket, &item.key, None)?;
|
item.baseline =
|
||||||
|
wait_for_complete_physical_shard_on_disk(&replaced_drive, &bucket, &item.key, None, Duration::from_secs(10)).await?;
|
||||||
assert_ec84_geometry(&item.baseline, &item.key)?;
|
assert_ec84_geometry(&item.baseline, &item.key)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let format_path = replaced_drive.join(".rustfs.sys").join("format.json");
|
let format_path = replaced_drive.join(".rustfs.sys").join("format.json");
|
||||||
let format_json = std::fs::read(&format_path)?;
|
let format_json = std::fs::read(&format_path)?;
|
||||||
let target_pid_before = dist.cluster.nodes[replaced_node]
|
let pid_before = dist.cluster.nodes[replaced_node]
|
||||||
.process
|
.process
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or("target process is absent before graceful restart")?
|
.ok_or("target process is absent")?
|
||||||
.id();
|
.id();
|
||||||
dist.cluster.stop_node_gracefully(replaced_node).await?;
|
dist.cluster.stop_node_gracefully(replaced_node).await?;
|
||||||
|
let unclean_shutdown_marker = Path::new(&dist.cluster.nodes[replaced_node].data_dir)
|
||||||
|
.join(".rustfs.sys")
|
||||||
|
.join("unclean-shutdown")
|
||||||
|
.is_file();
|
||||||
|
assert!(!unclean_shutdown_marker, "graceful target shutdown must remove its unclean marker");
|
||||||
let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display()));
|
let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display()));
|
||||||
std::fs::rename(&replaced_drive, &retired_drive)?;
|
std::fs::rename(&replaced_drive, &retired_drive)?;
|
||||||
std::fs::create_dir_all(format_path.parent().ok_or("replacement format path has no parent")?)?;
|
std::fs::create_dir_all(format_path.parent().ok_or("replacement format path has no parent")?)?;
|
||||||
@@ -295,11 +165,6 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
dist.cluster.start_node(replaced_node).await?;
|
dist.cluster.start_node(replaced_node).await?;
|
||||||
let target_pid_after = dist.cluster.nodes[replaced_node]
|
|
||||||
.process
|
|
||||||
.as_ref()
|
|
||||||
.ok_or("target process is absent after restart")?
|
|
||||||
.id();
|
|
||||||
let heal_body =
|
let heal_body =
|
||||||
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
||||||
let heal_url = format!("{}/rustfs/admin/v3/heal/{bucket}?forceStart=true", dist.cluster.nodes[0].url);
|
let heal_url = format!("{}/rustfs/admin/v3/heal/{bucket}?forceStart=true", dist.cluster.nodes[0].url);
|
||||||
@@ -340,24 +205,45 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
|
|||||||
.filter_map(|object| object.key().map(str::to_owned))
|
.filter_map(|object| object.key().map(str::to_owned))
|
||||||
.collect::<HashSet<_>>();
|
.collect::<HashSet<_>>();
|
||||||
assert_eq!(observed, expected_keys, "node {node_index} listing diverged after EC8+4 heal");
|
assert_eq!(observed, expected_keys, "node {node_index} listing diverged after EC8+4 heal");
|
||||||
let mut observed = observed.into_iter().collect::<Vec<_>>();
|
let mut keys = observed.into_iter().collect::<Vec<_>>();
|
||||||
observed.sort();
|
keys.sort();
|
||||||
node_listings.push(observed);
|
node_listings.push(keys);
|
||||||
}
|
}
|
||||||
if let Some(context) = evidence_context {
|
|
||||||
write_scanner_heal_evidence(
|
if let Some(evidence_run) = evidence_run {
|
||||||
context,
|
let target_client = dist.client(replaced_node)?;
|
||||||
&dist,
|
let mut objects = Vec::with_capacity(inventory.len());
|
||||||
&bucket,
|
for (key, body) in &inventory {
|
||||||
&expected,
|
let response = target_client.get_object().bucket(&bucket).key(key).send().await?;
|
||||||
outage_key,
|
let actual = response.body.collect().await?.into_bytes();
|
||||||
&outage_body,
|
assert_eq!(actual.as_ref(), body.as_slice(), "object body changed for {key}");
|
||||||
&replaced_drive,
|
let physical = census_object_version_on_disk(&replaced_drive, &bucket, key, None)?;
|
||||||
target_pid_before,
|
assert_ec84_geometry(&physical, key)?;
|
||||||
target_pid_after,
|
let baseline = expected.iter().find(|item| item.key == *key).map(|item| &item.baseline);
|
||||||
|
objects.push(serde_json::json!({
|
||||||
|
"key": key, "version_id": null,
|
||||||
|
"expected_bytes": body.len(), "actual_bytes": actual.len(),
|
||||||
|
"expected_sha256": sha256_hex(body), "actual_sha256": sha256_hex(&actual),
|
||||||
|
"expected_physical": baseline, "physical": physical,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let pid_after = dist.cluster.nodes[replaced_node]
|
||||||
|
.process
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("restarted target is absent")?
|
||||||
|
.id();
|
||||||
|
evidence_run.write(
|
||||||
|
&server_binary,
|
||||||
|
RestartObservation {
|
||||||
|
nodes: dist.cluster.nodes.len(),
|
||||||
|
drives_per_node: dist.cluster.topology.drives_per_node,
|
||||||
|
pid_before,
|
||||||
|
pid_after,
|
||||||
|
unclean_shutdown_marker,
|
||||||
|
objects,
|
||||||
node_listings,
|
node_listings,
|
||||||
)
|
},
|
||||||
.await?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -498,7 +498,9 @@ mod tests {
|
|||||||
.stderr(log)
|
.stderr(log)
|
||||||
.spawn()?,
|
.spawn()?,
|
||||||
);
|
);
|
||||||
let status = tokio::time::timeout(Duration::from_secs(10), async {
|
// macOS evaluates each fresh binary copy before its capability hook can run.
|
||||||
|
let probe_timeout = if cfg!(target_os = "macos") { 60 } else { 10 };
|
||||||
|
let status = tokio::time::timeout(Duration::from_secs(probe_timeout), async {
|
||||||
loop {
|
loop {
|
||||||
if let Some(status) = child.0.try_wait()? {
|
if let Some(status) = child.0.try_wait()? {
|
||||||
return Ok::<_, std::io::Error>(status);
|
return Ok::<_, std::io::Error>(status);
|
||||||
|
|||||||
@@ -114,7 +114,12 @@ impl FaultProxy {
|
|||||||
/// Bind a listener on `127.0.0.1:0` and start forwarding accepted
|
/// Bind a listener on `127.0.0.1:0` and start forwarding accepted
|
||||||
/// connections to `target`. Starts in [`FaultMode::Pass`].
|
/// connections to `target`. Starts in [`FaultMode::Pass`].
|
||||||
pub async fn start(target: SocketAddr) -> io::Result<Self> {
|
pub async fn start(target: SocketAddr) -> io::Result<Self> {
|
||||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
Self::start_on((Ipv4Addr::LOCALHOST, 0).into(), target).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind the selected address and forward accepted connections to `target`.
|
||||||
|
pub(crate) async fn start_on(address: SocketAddr, target: SocketAddr) -> io::Result<Self> {
|
||||||
|
let listener = TcpListener::bind(address).await?;
|
||||||
let listen_addr = listener.local_addr()?;
|
let listen_addr = listener.local_addr()?;
|
||||||
|
|
||||||
let (mode_tx, mode_rx) = watch::channel(FaultMode::Pass);
|
let (mode_tx, mode_rx) = watch::channel(FaultMode::Pass);
|
||||||
|
|||||||
@@ -401,11 +401,11 @@ mod tests {
|
|||||||
let mp_view = get_full(&client, multipart_key).await?;
|
let mp_view = get_full(&client, multipart_key).await?;
|
||||||
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded baseline multipart body mismatch");
|
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded baseline multipart body mismatch");
|
||||||
baseline_degraded.insert(multipart_key.to_string(), mp_view);
|
baseline_degraded.insert(multipart_key.to_string(), mp_view);
|
||||||
// Restore the disk so Phase B restarts from a clean, complete disk set.
|
// Stop disk writers before restoring the complete layout reused by Phase B.
|
||||||
|
harness.kill_server();
|
||||||
harness.bring_disk_online(0)?;
|
harness.bring_disk_online(0)?;
|
||||||
|
|
||||||
// ---- Phase B: codec streaming (gates opened) ----
|
// ---- Phase B: codec streaming (gates opened) ----
|
||||||
harness.kill_server();
|
|
||||||
for (k, v) in codec_env() {
|
for (k, v) in codec_env() {
|
||||||
harness.set_env(k, v);
|
harness.set_env(k, v);
|
||||||
}
|
}
|
||||||
@@ -497,6 +497,8 @@ mod tests {
|
|||||||
let mp_view = get_full(&client, multipart_key).await?;
|
let mp_view = get_full(&client, multipart_key).await?;
|
||||||
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded codec multipart body mismatch");
|
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded codec multipart body mismatch");
|
||||||
codec_degraded.insert(multipart_key.to_string(), mp_view);
|
codec_degraded.insert(multipart_key.to_string(), mp_view);
|
||||||
|
// All server reads are complete; stop disk writers before restoring disk0.
|
||||||
|
harness.kill_server();
|
||||||
harness.bring_disk_online(0)?;
|
harness.bring_disk_online(0)?;
|
||||||
|
|
||||||
// A/B under parity reconstruction: codec == legacy, byte-for-byte and
|
// A/B under parity reconstruction: codec == legacy, byte-for-byte and
|
||||||
|
|||||||
@@ -16,18 +16,20 @@
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post};
|
use crate::chaos::{
|
||||||
use crate::common::{
|
VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post,
|
||||||
ClusterTopology, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request,
|
wait_for_complete_physical_shard_on_disk,
|
||||||
init_logging, rustfs_binary_path,
|
|
||||||
};
|
};
|
||||||
|
use crate::common::{
|
||||||
|
FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging,
|
||||||
|
rustfs_binary_path,
|
||||||
|
};
|
||||||
|
use crate::scanner_heal_evidence::{EvidenceTopology, RestartObservation, ScannerHealEvidenceCase, restart_evidence_run};
|
||||||
use crate::storage_api::RUSTFS_META_BUCKET;
|
use crate::storage_api::RUSTFS_META_BUCKET;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use http::Method;
|
use http::Method;
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
@@ -39,52 +41,6 @@ mod tests {
|
|||||||
|
|
||||||
const POOL_METADATA_OBJECT: &str = "pool.bin";
|
const POOL_METADATA_OBJECT: &str = "pool.bin";
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct EvidenceBuild {
|
|
||||||
sha256: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct RestartEvidenceRun {
|
|
||||||
schema: u32,
|
|
||||||
run_id: String,
|
|
||||||
source_revision: String,
|
|
||||||
test_build: serde_json::Value,
|
|
||||||
binary: EvidenceBuild,
|
|
||||||
test_binary: EvidenceBuild,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct ScannerHealEvidenceCase {
|
|
||||||
id: &'static str,
|
|
||||||
oracle: &'static str,
|
|
||||||
evidence: &'static str,
|
|
||||||
unclean_shutdown_marker: bool,
|
|
||||||
topology: EvidenceTopology,
|
|
||||||
storage_class_standard: Option<&'static str>,
|
|
||||||
erasure_set_drive_count: Option<&'static str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct EvidenceTopology {
|
|
||||||
nodes: usize,
|
|
||||||
drives_per_node: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EvidenceTopology {
|
|
||||||
const fn new(nodes: usize, drives_per_node: usize) -> Self {
|
|
||||||
Self { nodes, drives_per_node }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn total_drives(self) -> usize {
|
|
||||||
self.nodes * self.drives_per_node
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cluster_topology(self) -> ClusterTopology {
|
|
||||||
ClusterTopology::single_pool_multidrive(self.nodes, self.drives_per_node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
id: "background-target-restart",
|
id: "background-target-restart",
|
||||||
oracle: "background-target-restart.json",
|
oracle: "background-target-restart.json",
|
||||||
@@ -125,81 +81,6 @@ mod tests {
|
|||||||
erasure_set_drive_count: Some("12"),
|
erasure_set_drive_count: Some("12"),
|
||||||
};
|
};
|
||||||
|
|
||||||
struct RestartEvidenceContext {
|
|
||||||
directory: PathBuf,
|
|
||||||
run: RestartEvidenceRun,
|
|
||||||
case: ScannerHealEvidenceCase,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn file_sha256(path: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
|
|
||||||
let mut file = std::fs::File::open(path)?;
|
|
||||||
let mut digest = Sha256::new();
|
|
||||||
let mut buffer = [0_u8; 64 * 1024];
|
|
||||||
loop {
|
|
||||||
let read = file.read(&mut buffer)?;
|
|
||||||
if read == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
digest.update(&buffer[..read]);
|
|
||||||
}
|
|
||||||
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn restart_evidence_run(
|
|
||||||
binary: &Path,
|
|
||||||
case: ScannerHealEvidenceCase,
|
|
||||||
) -> Result<Option<RestartEvidenceContext>, Box<dyn Error + Send + Sync>> {
|
|
||||||
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
if case.id.is_empty()
|
|
||||||
|| case.oracle.is_empty()
|
|
||||||
|| !case.oracle.ends_with(".json")
|
|
||||||
|| case.oracle.contains('/')
|
|
||||||
|| case.oracle.contains('\\')
|
|
||||||
|| case.oracle.contains("..")
|
|
||||||
|| !matches!(case.evidence, "process-restart" | "process-crash-restart")
|
|
||||||
|| (case.evidence == "process-crash-restart") != case.unclean_shutdown_marker
|
|
||||||
{
|
|
||||||
return Err("invalid scanner/heal evidence case".into());
|
|
||||||
}
|
|
||||||
let directory = PathBuf::from(directory);
|
|
||||||
let receipt = directory.join("run.json");
|
|
||||||
if receipt.metadata()?.len() > 1024 * 1024 {
|
|
||||||
return Err("oversized scanner/heal execution receipt".into());
|
|
||||||
}
|
|
||||||
let run: RestartEvidenceRun = serde_json::from_slice(&std::fs::read(receipt)?)?;
|
|
||||||
if run.schema != 1 || run.run_id.len() != 32 || run.source_revision.len() != 40 {
|
|
||||||
return Err("invalid scanner/heal execution identity".into());
|
|
||||||
}
|
|
||||||
let built = compiled_test_identity();
|
|
||||||
for key in ["source_revision", "dirty", "lock_blob", "features"] {
|
|
||||||
assert_eq!(built[key], run.test_build[key], "compiled test identity differs for {key}");
|
|
||||||
}
|
|
||||||
assert_eq!(file_sha256(binary)?, run.binary.sha256, "server binary must match the run receipt");
|
|
||||||
assert_eq!(
|
|
||||||
file_sha256(&std::env::current_exe()?)?,
|
|
||||||
run.test_binary.sha256,
|
|
||||||
"test executable must match the run receipt"
|
|
||||||
);
|
|
||||||
if directory.join(case.oracle).exists() {
|
|
||||||
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
|
|
||||||
}
|
|
||||||
Ok(Some(RestartEvidenceContext { directory, run, case }))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compiled_test_identity() -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
|
|
||||||
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
|
|
||||||
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
|
|
||||||
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
|
|
||||||
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
|
|
||||||
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
|
|
||||||
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TcpPortBlackhole {
|
struct TcpPortBlackhole {
|
||||||
port: u16,
|
port: u16,
|
||||||
comment: String,
|
comment: String,
|
||||||
@@ -504,6 +385,79 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn wait_for_admin_cluster_start_log(
|
||||||
|
log_path: &Path,
|
||||||
|
client_token: &str,
|
||||||
|
deadline: Instant,
|
||||||
|
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
loop {
|
||||||
|
let coordinator_log = std::fs::read_to_string(log_path)?;
|
||||||
|
if coordinator_log
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
|
||||||
|
.any(|event| {
|
||||||
|
event["event"] == "heal_task_state"
|
||||||
|
&& event["task_id"] == client_token
|
||||||
|
&& event["heal_type"] == "cluster"
|
||||||
|
&& event["state"] == "started"
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Err(
|
||||||
|
format!("node 0 must have started the exact admin task before interruption: task_id={client_token}").into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_admin_cluster_start_log_waits_for_exact_delayed_event() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let mut log = tempfile::NamedTempFile::new()?;
|
||||||
|
for (task_id, heal_type, state) in [
|
||||||
|
("other-task", "cluster", "started"),
|
||||||
|
("admin-task", "object", "started"),
|
||||||
|
("admin-task", "cluster", "completed"),
|
||||||
|
] {
|
||||||
|
writeln!(
|
||||||
|
log,
|
||||||
|
"{}",
|
||||||
|
serde_json::json!({"event": "heal_task_state", "task_id": task_id, "heal_type": heal_type, "state": state})
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let log_path = log.path().to_path_buf();
|
||||||
|
let started = wait_for_admin_cluster_start_log(&log_path, "admin-task", Instant::now() + Duration::from_secs(1));
|
||||||
|
tokio::pin!(started);
|
||||||
|
// Poll the reader before publishing the start event, without depending
|
||||||
|
// on scheduling or a fixed writer delay to reproduce log visibility.
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
result = &mut started => panic!("unrelated events must leave the exact start pending: {result:?}"),
|
||||||
|
_ = std::future::ready(()) => {}
|
||||||
|
}
|
||||||
|
writeln!(
|
||||||
|
log,
|
||||||
|
"{}",
|
||||||
|
serde_json::json!({"event": "heal_task_state", "task_id": "admin-task", "heal_type": "cluster", "state": "started"})
|
||||||
|
)?;
|
||||||
|
started.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_admin_cluster_start_log_respects_existing_deadline() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
let log = tempfile::NamedTempFile::new()?;
|
||||||
|
let error = wait_for_admin_cluster_start_log(log.path(), "admin-task", Instant::now())
|
||||||
|
.await
|
||||||
|
.expect_err("missing exact start must fail at the supplied deadline");
|
||||||
|
assert!(error.to_string().contains("task_id=admin-task"), "{error}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn cluster_heal_is_idle(status: &serde_json::Value) -> bool {
|
fn cluster_heal_is_idle(status: &serde_json::Value) -> bool {
|
||||||
let operations = &status["healOperations"];
|
let operations = &status["healOperations"];
|
||||||
status["clusterStatusComplete"] == serde_json::Value::Bool(true)
|
status["clusterStatusComplete"] == serde_json::Value::Bool(true)
|
||||||
@@ -940,7 +894,10 @@ mod tests {
|
|||||||
cluster: &RustFSTestClusterEnvironment,
|
cluster: &RustFSTestClusterEnvironment,
|
||||||
previous_cycle_end: u64,
|
previous_cycle_end: u64,
|
||||||
) -> Result<u64, Box<dyn Error + Send + Sync>> {
|
) -> Result<u64, Box<dyn Error + Send + Sync>> {
|
||||||
let deadline = Instant::now() + Duration::from_secs(60);
|
let started = Instant::now();
|
||||||
|
let mut deadline = started + Duration::from_secs(60);
|
||||||
|
let catch_up_deadline = deadline + Duration::from_secs(300);
|
||||||
|
let mut catch_up_wait_observed = false;
|
||||||
loop {
|
loop {
|
||||||
let mut latest_cycle_end = 0;
|
let mut latest_cycle_end = 0;
|
||||||
let mut versions_observed = false;
|
let mut versions_observed = false;
|
||||||
@@ -968,24 +925,61 @@ mod tests {
|
|||||||
let versions_scanned = metrics["versions_scanned"]
|
let versions_scanned = metrics["versions_scanned"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.ok_or("scanner status is missing its version-coverage counter")?;
|
.ok_or("scanner status is missing its version-coverage counter")?;
|
||||||
|
let cycle_result = metrics["last_cycle_result"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or("scanner status is missing its cycle result")?;
|
||||||
|
if cycle_result == "success" {
|
||||||
latest_cycle_end = latest_cycle_end.max(cycle_end);
|
latest_cycle_end = latest_cycle_end.max(cycle_end);
|
||||||
|
}
|
||||||
versions_observed |= versions_scanned > 0;
|
versions_observed |= versions_scanned > 0;
|
||||||
|
let backlog = &status["pause_backlog"];
|
||||||
|
if !catch_up_wait_observed
|
||||||
|
&& backlog["persistence_state"].as_str() == Some("healthy")
|
||||||
|
&& backlog["durable"].as_bool() == Some(true)
|
||||||
|
&& backlog["phase"].as_str() == Some("catching_up")
|
||||||
|
&& backlog["rate_limited"].as_bool() == Some(true)
|
||||||
|
&& backlog["retry_exhausted"].as_bool() == Some(false)
|
||||||
|
{
|
||||||
|
let next_attempt = backlog["next_attempt_at_unix_secs"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or("rate-limited scanner backlog is missing its next attempt")?;
|
||||||
|
let interval = backlog["thresholds"]["catch_up_min_interval_seconds"]
|
||||||
|
.as_u64()
|
||||||
|
.ok_or("rate-limited scanner backlog is missing its catch-up interval")?;
|
||||||
|
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
|
||||||
|
let remaining = next_attempt.saturating_sub(now);
|
||||||
|
if remaining > 0 {
|
||||||
|
if interval > 300 || remaining > interval {
|
||||||
|
return Err(
|
||||||
|
format!("scanner catch-up schedule exceeds the bounded recovery budget: {backlog}").into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The durable catch-up interval overrides SCANNER_CYCLE=1.
|
||||||
|
// Honor one observed retry without restarting the deadline on every poll.
|
||||||
|
deadline = deadline
|
||||||
|
.max(Instant::now() + Duration::from_secs(remaining + 60))
|
||||||
|
.min(catch_up_deadline);
|
||||||
|
catch_up_wait_observed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
observations.push(format!(
|
observations.push(format!(
|
||||||
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}",
|
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}, backlog={}",
|
||||||
metrics["current_cycle"],
|
metrics["current_cycle"],
|
||||||
metrics["current_cycle_active"],
|
metrics["current_cycle_active"],
|
||||||
metrics["leader_lock_state"],
|
metrics["leader_lock_state"],
|
||||||
metrics["last_cycle_result"],
|
metrics["last_cycle_result"],
|
||||||
|
backlog,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// The coordinator records cycle completion, but remote workers
|
// Only a successful coordinator cycle counts as completion; deferred
|
||||||
// record scanned versions. Both witnesses need not share a node.
|
// and superseded attempts also advance its end timestamp. Remote
|
||||||
|
// workers record version coverage, so the witnesses can span nodes.
|
||||||
if latest_cycle_end > previous_cycle_end && versions_observed {
|
if latest_cycle_end > previous_cycle_end && versions_observed {
|
||||||
return Ok(latest_cycle_end);
|
return Ok(latest_cycle_end);
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"enabled scanner did not complete an object-scanning cycle after {previous_cycle_end}: {observations:?}"
|
"enabled scanner did not complete a successful object-scanning cycle after {previous_cycle_end}: {observations:?}"
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
@@ -1005,7 +999,7 @@ mod tests {
|
|||||||
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_restart()
|
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_restart()
|
||||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
timeout(
|
timeout(
|
||||||
Duration::from_secs(420),
|
Duration::from_secs(720),
|
||||||
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestart),
|
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestart),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
@@ -1015,7 +1009,7 @@ mod tests {
|
|||||||
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_crash()
|
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_crash()
|
||||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
timeout(
|
timeout(
|
||||||
Duration::from_secs(420),
|
Duration::from_secs(720),
|
||||||
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrash),
|
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrash),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
@@ -1025,7 +1019,7 @@ mod tests {
|
|||||||
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_restart()
|
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_restart()
|
||||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
timeout(
|
timeout(
|
||||||
Duration::from_secs(420),
|
Duration::from_secs(720),
|
||||||
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestartEc84),
|
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestartEc84),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
@@ -1035,7 +1029,7 @@ mod tests {
|
|||||||
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_crash()
|
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_crash()
|
||||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
timeout(
|
timeout(
|
||||||
Duration::from_secs(420),
|
Duration::from_secs(720),
|
||||||
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrashEc84),
|
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrashEc84),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
@@ -1045,7 +1039,7 @@ mod tests {
|
|||||||
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
|
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
|
||||||
{
|
{
|
||||||
timeout(
|
timeout(
|
||||||
Duration::from_secs(420),
|
Duration::from_secs(720),
|
||||||
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundCoordinatorRestart),
|
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundCoordinatorRestart),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
@@ -1152,10 +1146,19 @@ mod tests {
|
|||||||
let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG")
|
let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG")
|
||||||
.unwrap_or_else(|_| "rustfs::heal::task=info,rustfs=error".to_string());
|
.unwrap_or_else(|_| "rustfs::heal::task=info,rustfs=error".to_string());
|
||||||
cluster.set_env("RUST_LOG", server_rust_log);
|
cluster.set_env("RUST_LOG", server_rust_log);
|
||||||
let log_dir = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR").unwrap_or_else(|_| format!("{}/logs", cluster.temp_dir));
|
let log_dir = if let Some(directory) = std::env::var_os("RUSTFS_HEAL_CHAOS_LOG_DIR") {
|
||||||
|
PathBuf::from(directory)
|
||||||
|
} else if let Some(directory) = std::env::var_os("RUSTFS_E2E_LOG_DIR") {
|
||||||
|
let cluster_name = Path::new(&cluster.temp_dir)
|
||||||
|
.file_name()
|
||||||
|
.ok_or("cluster directory has no name")?;
|
||||||
|
PathBuf::from(directory).join(cluster_name).join("heal")
|
||||||
|
} else {
|
||||||
|
PathBuf::from(&cluster.temp_dir).join("logs")
|
||||||
|
};
|
||||||
std::fs::create_dir_all(&log_dir)?;
|
std::fs::create_dir_all(&log_dir)?;
|
||||||
for node_index in 0..cluster.nodes.len() {
|
for node_index in 0..cluster.nodes.len() {
|
||||||
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
|
cluster.set_node_capture_log_path(node_index, log_dir.join(format!("node{node_index}.log")).to_string_lossy())?;
|
||||||
}
|
}
|
||||||
cluster.start_with_binary(&server_binary).await?;
|
cluster.start_with_binary(&server_binary).await?;
|
||||||
let clients = cluster.create_all_clients()?;
|
let clients = cluster.create_all_clients()?;
|
||||||
@@ -1210,6 +1213,8 @@ mod tests {
|
|||||||
attempt_count += 1;
|
attempt_count += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let shard_census =
|
||||||
|
wait_for_complete_physical_shard_on_disk(&replaced_disk, bucket, &key, None, Duration::from_secs(10)).await?;
|
||||||
assert!(
|
assert!(
|
||||||
shard_census.is_complete(),
|
shard_census.is_complete(),
|
||||||
"node 1 should hold a complete baseline shard for {key}: {shard_census:?}"
|
"node 1 should hold a complete baseline shard for {key}: {shard_census:?}"
|
||||||
@@ -1396,6 +1401,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
sleep(Duration::from_millis(50)).await;
|
sleep(Duration::from_millis(50)).await;
|
||||||
}
|
}
|
||||||
|
// Task execution and its non-blocking log writer advance independently.
|
||||||
|
// Observe the exact start before taking the partial-rebuild snapshot.
|
||||||
|
wait_for_admin_cluster_start_log(&log_dir.join("node0.log"), client_token, partial_deadline).await?;
|
||||||
let (partial_count, partial_manifest) = loop {
|
let (partial_count, partial_manifest) = loop {
|
||||||
// Hash one committed shard to prove progress without letting a
|
// Hash one committed shard to prove progress without letting a
|
||||||
// full-corpus hash pass consume the interruption window.
|
// full-corpus hash pass consume the interruption window.
|
||||||
@@ -1430,19 +1438,6 @@ mod tests {
|
|||||||
let pre_interrupt_status: serde_json::Value = serde_json::from_str(&pre_interrupt_status_body)
|
let pre_interrupt_status: serde_json::Value = serde_json::from_str(&pre_interrupt_status_body)
|
||||||
.map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {pre_interrupt_status_body}"))?;
|
.map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {pre_interrupt_status_body}"))?;
|
||||||
let pre_interrupt_replacement = replacement_recovery_status(&cluster).await?;
|
let pre_interrupt_replacement = replacement_recovery_status(&cluster).await?;
|
||||||
let coordinator_log = std::fs::read_to_string(format!("{log_dir}/node0.log"))?;
|
|
||||||
assert!(
|
|
||||||
coordinator_log
|
|
||||||
.lines()
|
|
||||||
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
|
|
||||||
.any(|event| {
|
|
||||||
event["event"] == "heal_task_state"
|
|
||||||
&& event["task_id"] == client_token
|
|
||||||
&& event["heal_type"] == "cluster"
|
|
||||||
&& event["state"] == "started"
|
|
||||||
}),
|
|
||||||
"node 0 must have started the exact admin task before interruption"
|
|
||||||
);
|
|
||||||
let pre_interrupt_operations = &pre_interrupt_status["healOperations"];
|
let pre_interrupt_operations = &pre_interrupt_status["healOperations"];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pre_interrupt_operations["activeBySource"]["admin"].as_u64(),
|
pre_interrupt_operations["activeBySource"]["admin"].as_u64(),
|
||||||
@@ -1567,10 +1562,11 @@ mod tests {
|
|||||||
"Restored target endpoint forwarding"
|
"Restored target endpoint forwarding"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if matches!(
|
let graceful_restart = matches!(
|
||||||
scenario,
|
scenario,
|
||||||
InterruptionScenario::BackgroundTargetRestart | InterruptionScenario::BackgroundTargetRestartEc84
|
InterruptionScenario::BackgroundTargetRestart | InterruptionScenario::BackgroundTargetRestartEc84
|
||||||
) {
|
);
|
||||||
|
if graceful_restart {
|
||||||
cluster.stop_node_gracefully(interruption_node).await?;
|
cluster.stop_node_gracefully(interruption_node).await?;
|
||||||
} else {
|
} else {
|
||||||
cluster.stop_node(interruption_node)?;
|
cluster.stop_node(interruption_node)?;
|
||||||
@@ -1592,10 +1588,7 @@ mod tests {
|
|||||||
if background_enabled {
|
if background_enabled {
|
||||||
let marker_exists = unclean_shutdown_marker.is_file();
|
let marker_exists = unclean_shutdown_marker.is_file();
|
||||||
unclean_shutdown_marker_observed = Some(marker_exists);
|
unclean_shutdown_marker_observed = Some(marker_exists);
|
||||||
let expected_marker = matches!(
|
let expected_marker = !graceful_restart;
|
||||||
scenario,
|
|
||||||
InterruptionScenario::BackgroundTargetCrash | InterruptionScenario::BackgroundTargetCrashEc84
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
marker_exists == expected_marker,
|
marker_exists == expected_marker,
|
||||||
"background restart/crash lane observed unexpected unclean-shutdown marker state"
|
"background restart/crash lane observed unexpected unclean-shutdown marker state"
|
||||||
@@ -1651,7 +1644,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if Instant::now() >= heal_deadline {
|
if Instant::now() >= heal_deadline {
|
||||||
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
|
let mut mismatched_manifests = Vec::new();
|
||||||
|
for expected in &expected_manifests {
|
||||||
|
let actual = census_object_version_on_disk(&replaced_disk, bucket, &expected.key, None)?;
|
||||||
|
if !actual.matches_manifest(&expected.shard_census) {
|
||||||
|
mismatched_manifests.push(serde_json::json!({
|
||||||
|
"key": expected.key,
|
||||||
|
"expected": expected.shard_census,
|
||||||
|
"actual": actual,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let matching = expected_manifests.len() - mismatched_manifests.len();
|
||||||
|
let mismatched_manifests = serde_json::Value::Array(mismatched_manifests);
|
||||||
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
|
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
|
||||||
let pool_metadata =
|
let pool_metadata =
|
||||||
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
|
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
|
||||||
@@ -1674,7 +1679,7 @@ mod tests {
|
|||||||
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
|
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
|
||||||
};
|
};
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
|
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, mismatched_manifests={mismatched_manifests}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
|
||||||
expected_manifests.len()
|
expected_manifests.len()
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
@@ -1798,32 +1803,18 @@ mod tests {
|
|||||||
if let Some(evidence_context) = evidence_run {
|
if let Some(evidence_context) = evidence_run {
|
||||||
let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id();
|
let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id();
|
||||||
assert_ne!(target_pid, restarted_pid, "target must be a new process");
|
assert_ne!(target_pid, restarted_pid, "target must be a new process");
|
||||||
assert_eq!(
|
evidence_context.write(
|
||||||
file_sha256(&server_binary)?,
|
&server_binary,
|
||||||
evidence_context.run.binary.sha256,
|
RestartObservation {
|
||||||
"server build changed during restart"
|
nodes: cluster.nodes.len(),
|
||||||
);
|
drives_per_node: cluster.nodes[0].data_dirs.len(),
|
||||||
let evidence = serde_json::json!({
|
pid_before: target_pid,
|
||||||
"schema": 1, "case": evidence_context.case.id, "evidence": evidence_context.case.evidence,
|
pid_after: restarted_pid,
|
||||||
"run_id": evidence_context.run.run_id, "source_revision": evidence_context.run.source_revision,
|
unclean_shutdown_marker: unclean_shutdown_marker_observed.ok_or("missing shutdown marker observation")?,
|
||||||
"test_build": compiled_test_identity(),
|
objects: evidence_objects,
|
||||||
"binary_sha256": evidence_context.run.binary.sha256,
|
node_listings,
|
||||||
"test_binary_sha256": evidence_context.run.test_binary.sha256,
|
},
|
||||||
"topology": {"nodes": cluster.nodes.len(), "drives_per_node": cluster.nodes[0].data_dirs.len()},
|
)?;
|
||||||
"pid_before": target_pid, "pid_after": restarted_pid,
|
|
||||||
"unclean_shutdown_marker": unclean_shutdown_marker_observed.unwrap_or(false),
|
|
||||||
"objects": evidence_objects, "node_listings": node_listings,
|
|
||||||
});
|
|
||||||
let data = serde_json::to_vec(&evidence)?;
|
|
||||||
if data.len() > 1024 * 1024 {
|
|
||||||
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
|
|
||||||
}
|
|
||||||
let mut output = std::fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.open(evidence_context.directory.join(evidence_context.case.oracle))?;
|
|
||||||
output.write_all(&data)?;
|
|
||||||
output.sync_all()?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
//! One S3 GET can select readers on multiple EC nodes, so the counter tracks
|
//! One S3 GET can select readers on multiple EC nodes, so the counter tracks
|
||||||
//! distributed reader selection rather than HTTP request count.
|
//! distributed reader selection rather than HTTP request count.
|
||||||
|
|
||||||
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging};
|
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, signal_process};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
@@ -2207,6 +2207,33 @@ async fn four_node_manual_transition_job_status_survives_node_restart() -> TestR
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SuspendedTransitionTarget<'a> {
|
||||||
|
// Keep the owned child borrowed until it is resumed so its PID cannot be reused.
|
||||||
|
child: &'a std::process::Child,
|
||||||
|
suspended: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SuspendedTransitionTarget<'a> {
|
||||||
|
fn suspend(child: &'a std::process::Child) -> TestResult<Self> {
|
||||||
|
signal_process(child.id(), "STOP")?;
|
||||||
|
Ok(Self { child, suspended: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resume(&mut self) -> TestResult {
|
||||||
|
signal_process(self.child.id(), "CONT")?;
|
||||||
|
self.suspended = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SuspendedTransitionTarget<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.suspended {
|
||||||
|
let _ = signal_process(self.child.id(), "CONT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn four_node_manual_transition_distributed_admission_conflict_reports_status_and_backpressure() -> TestResult {
|
async fn four_node_manual_transition_distributed_admission_conflict_reports_status_and_backpressure() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
@@ -2234,7 +2261,6 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
|||||||
let bucket = format!("distributed-admission-{}", Uuid::new_v4().simple());
|
let bucket = format!("distributed-admission-{}", Uuid::new_v4().simple());
|
||||||
let prefix = "transition/distributed-admission/";
|
let prefix = "transition/distributed-admission/";
|
||||||
hot_client.create_bucket().bucket(&bucket).send().await?;
|
hot_client.create_bucket().bucket(&bucket).send().await?;
|
||||||
put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?;
|
|
||||||
for index in 0u8..64 {
|
for index in 0u8..64 {
|
||||||
let key = format!("{prefix}object-{index:02}.bin");
|
let key = format!("{prefix}object-{index:02}.bin");
|
||||||
hot_client
|
hot_client
|
||||||
@@ -2245,6 +2271,21 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
|||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
// Lifecycle PUT starts its own backfill. Keep its first page on a separate
|
||||||
|
// node and stop it at queue backpressure before it reaches the tested prefix:
|
||||||
|
// one active worker, one queued item, then the first rejected item.
|
||||||
|
for index in 0u8..3 {
|
||||||
|
hot_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(format!("transition/automatic-admission/object-{index:02}.bin"))
|
||||||
|
.body(ByteStream::from(payload(KIB, index)))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let mut suspended_cold = SuspendedTransitionTarget::suspend(cold.process.as_ref().ok_or("cold-tier process missing")?)?;
|
||||||
|
let lifecycle_client = hot.create_s3_client(2)?;
|
||||||
|
put_lifecycle_with_transition_retry(&lifecycle_client, &bucket, &tier_name).await?;
|
||||||
|
|
||||||
let (node0, node1) = tokio::join!(
|
let (node0, node1) = tokio::join!(
|
||||||
start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64),
|
start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64),
|
||||||
@@ -2304,6 +2345,31 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
|||||||
assert_eq!(status["job_id"].as_str(), Some(job_id));
|
assert_eq!(status["job_id"].as_str(), Some(job_id));
|
||||||
assert_eq!(status["status_endpoint"].as_str(), Some(status_endpoint));
|
assert_eq!(status["status_endpoint"].as_str(), Some(status_endpoint));
|
||||||
|
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
loop {
|
||||||
|
let status = read_manual_transition_job_status_endpoint(&hot, accepted.0, status_endpoint).await?;
|
||||||
|
assert_eq!(
|
||||||
|
status["status"].as_str(),
|
||||||
|
Some("running"),
|
||||||
|
"blocked cold tier must keep the admitted job running: {status}"
|
||||||
|
);
|
||||||
|
if status["report"]["skipped_queue_full"].as_u64().is_some_and(|count| count > 0) {
|
||||||
|
assert!(
|
||||||
|
status["report"]["enqueued"].as_u64().is_some_and(|count| count > 0),
|
||||||
|
"the job must own pending transitions while the cold tier is suspended: {status}"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Err(format!(
|
||||||
|
"manual transition job did not reach queue backpressure while the cold tier was suspended: {status}"
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
suspended_cold.resume()?;
|
||||||
let terminal = wait_for_manual_transition_job_terminal(&hot, conflict.0, job_id, false).await?;
|
let terminal = wait_for_manual_transition_job_terminal(&hot, conflict.0, job_id, false).await?;
|
||||||
assert_eq!(terminal["job_id"].as_str(), Some(job_id));
|
assert_eq!(terminal["job_id"].as_str(), Some(job_id));
|
||||||
assert_eq!(terminal["bucket"].as_str(), Some(bucket.as_str()));
|
assert_eq!(terminal["bucket"].as_str(), Some(bucket.as_str()));
|
||||||
|
|||||||
@@ -59,6 +59,36 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
|
|||||||
|
|
||||||
assert_eq!(put_response.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
assert_eq!(put_response.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
|
||||||
|
// A missing key in the healthy store is a client error, unlike a store outage.
|
||||||
|
let missing_key_object = "test-missing-kms-key";
|
||||||
|
let missing_key_error = s3_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(missing_key_object)
|
||||||
|
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"must not be published"))
|
||||||
|
.server_side_encryption(ServerSideEncryption::AwsKms)
|
||||||
|
.ssekms_key_id("rustfs-e2e-test-missing-key")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("an unknown key in a healthy Local KMS store must reject the write");
|
||||||
|
assert_eq!(missing_key_error.raw_response().map(|response| response.status().as_u16()), Some(400));
|
||||||
|
assert_eq!(
|
||||||
|
missing_key_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("KMS.NotFoundException")
|
||||||
|
);
|
||||||
|
let missing_key_absence = s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(missing_key_object)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("a write rejected by a missing KMS key must not publish an object");
|
||||||
|
assert_eq!(missing_key_absence.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(
|
||||||
|
missing_key_absence.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("NoSuchKey")
|
||||||
|
);
|
||||||
|
|
||||||
// Temporarily rename the key directory to simulate unavailability
|
// Temporarily rename the key directory to simulate unavailability
|
||||||
info!("🔧 Simulating key directory unavailability");
|
info!("🔧 Simulating key directory unavailability");
|
||||||
let backup_dir = format!("{}.backup", kms_env.kms_keys_dir);
|
let backup_dir = format!("{}.backup", kms_env.kms_keys_dir);
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ pub mod common;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub mod chaos;
|
pub mod chaos;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod scanner_heal_evidence;
|
||||||
|
|
||||||
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8)
|
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8)
|
||||||
// and on-demand-migration source scenarios (backlog#2151).
|
// and on-demand-migration source scenarios (backlog#2151).
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ pub async fn test_sftp_compliance_suite() -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{}", e))?;
|
.map_err(|e| anyhow!("{}", e))?;
|
||||||
|
|
||||||
|
// Protocol listeners can accept connections before IAM is initialized.
|
||||||
|
// A signed S3 request establishes readiness before the first SFTP login.
|
||||||
|
let s3 = build_test_s3_client(&format!("http://{COMPLIANCE_RW_S3_ADDRESS}"));
|
||||||
|
wait_for_s3_ready(&s3, 30).await?;
|
||||||
|
|
||||||
let (session, sftp) = connect_sftp_to(COMPLIANCE_RW_SFTP_ADDRESS).await?;
|
let (session, sftp) = connect_sftp_to(COMPLIANCE_RW_SFTP_ADDRESS).await?;
|
||||||
|
|
||||||
cmptst_01::run_medium_binary_round_trip(&sftp).await?;
|
cmptst_01::run_medium_binary_round_trip(&sftp).await?;
|
||||||
@@ -101,8 +106,6 @@ pub async fn test_sftp_compliance_suite() -> Result<()> {
|
|||||||
// reach the finalised object as x-amz-meta-* user metadata
|
// reach the finalised object as x-amz-meta-* user metadata
|
||||||
// through the CreateMultipartUpload input field. The S3 client
|
// through the CreateMultipartUpload input field. The S3 client
|
||||||
// connects to the same rustfs process this suite already drives.
|
// connects to the same rustfs process this suite already drives.
|
||||||
let s3 = build_test_s3_client(&format!("http://{COMPLIANCE_RW_S3_ADDRESS}"));
|
|
||||||
wait_for_s3_ready(&s3, 30).await?;
|
|
||||||
cmptst_34::run_open_attrs_round_trip_multipart(&sftp, &s3).await?;
|
cmptst_34::run_open_attrs_round_trip_multipart(&sftp, &s3).await?;
|
||||||
|
|
||||||
drop(sftp);
|
drop(sftp);
|
||||||
|
|||||||
@@ -168,6 +168,10 @@ pub async fn test_sftp_core_operations() -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{}", e))?;
|
.map_err(|e| anyhow!("{}", e))?;
|
||||||
|
|
||||||
|
// Protocol listeners can accept connections before IAM is initialized.
|
||||||
|
let s3 = build_test_s3_client(S3_ENDPOINT);
|
||||||
|
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
|
||||||
|
|
||||||
let (session, sftp) = connect_sftp().await?;
|
let (session, sftp) = connect_sftp().await?;
|
||||||
|
|
||||||
// --- 1. Subsystem canary: SFTP session reachable after password auth ---
|
// --- 1. Subsystem canary: SFTP session reachable after password auth ---
|
||||||
@@ -348,16 +352,6 @@ pub async fn test_sftp_core_operations() -> Result<()> {
|
|||||||
let _ = bad_session.disconnect(russh::Disconnect::ByApplication, "", "en").await;
|
let _ = bad_session.disconnect(russh::Disconnect::ByApplication, "", "en").await;
|
||||||
info!("PASS: bad-password authentication rejected");
|
info!("PASS: bad-password authentication rejected");
|
||||||
|
|
||||||
// --- Cross-protocol setup: aws-sdk-s3 client against the same server ---
|
|
||||||
// The rustfs binary spawned for this suite serves both SFTP on port
|
|
||||||
// 9022 and S3 on port 9000. The S3 stack may need a moment to finish
|
|
||||||
// initialising after TCP is listening, so list_buckets is polled
|
|
||||||
// until it succeeds before any cross-protocol assertion runs.
|
|
||||||
info!("Testing SFTP: prepare aws-sdk-s3 client and wait for S3 readiness");
|
|
||||||
let s3 = build_test_s3_client(S3_ENDPOINT);
|
|
||||||
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
|
|
||||||
info!("PASS: S3 endpoint reachable from cross-protocol client");
|
|
||||||
|
|
||||||
// --- SFTP write, S3 read: SHA256 round-trip ---
|
// --- SFTP write, S3 read: SHA256 round-trip ---
|
||||||
// SFTP creates the object, then assert_cross_protocol_sha_match
|
// SFTP creates the object, then assert_cross_protocol_sha_match
|
||||||
// fetches it via both S3 GetObject and SFTP READ and compares
|
// fetches it via both S3 GetObject and SFTP READ and compares
|
||||||
@@ -522,6 +516,9 @@ pub async fn test_sftp_idle_timeout_disconnects() -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{}", e))?;
|
.map_err(|e| anyhow!("{}", e))?;
|
||||||
|
|
||||||
|
let s3 = build_test_s3_client(&format!("http://{IDLE_S3_ADDRESS}"));
|
||||||
|
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
|
||||||
|
|
||||||
let (session, sftp) = connect_sftp_to(IDLE_SFTP_ADDRESS).await?;
|
let (session, sftp) = connect_sftp_to(IDLE_SFTP_ADDRESS).await?;
|
||||||
|
|
||||||
// Confirm the session is live before the wait so a failure in the
|
// Confirm the session is live before the wait so a failure in the
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Build-bound evidence for scanner and heal restart tests.
|
||||||
|
|
||||||
|
use crate::common::ClusterTopology;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::error::Error;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct EvidenceBuild {
|
||||||
|
sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct RestartEvidenceRun {
|
||||||
|
schema: u32,
|
||||||
|
run_id: String,
|
||||||
|
source_revision: String,
|
||||||
|
test_build: serde_json::Value,
|
||||||
|
binary: EvidenceBuild,
|
||||||
|
test_binary: EvidenceBuild,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) struct ScannerHealEvidenceCase {
|
||||||
|
pub(crate) id: &'static str,
|
||||||
|
pub(crate) oracle: &'static str,
|
||||||
|
pub(crate) evidence: &'static str,
|
||||||
|
pub(crate) unclean_shutdown_marker: bool,
|
||||||
|
pub(crate) topology: EvidenceTopology,
|
||||||
|
pub(crate) storage_class_standard: Option<&'static str>,
|
||||||
|
pub(crate) erasure_set_drive_count: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) struct EvidenceTopology {
|
||||||
|
pub(crate) nodes: usize,
|
||||||
|
pub(crate) drives_per_node: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EvidenceTopology {
|
||||||
|
pub(crate) const fn new(nodes: usize, drives_per_node: usize) -> Self {
|
||||||
|
Self { nodes, drives_per_node }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn total_drives(self) -> usize {
|
||||||
|
self.nodes * self.drives_per_node
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cluster_topology(self) -> ClusterTopology {
|
||||||
|
ClusterTopology::single_pool_multidrive(self.nodes, self.drives_per_node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct RestartEvidenceContext {
|
||||||
|
directory: PathBuf,
|
||||||
|
run: RestartEvidenceRun,
|
||||||
|
case: ScannerHealEvidenceCase,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_sha256(path: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||||
|
let mut file = std::fs::File::open(path)?;
|
||||||
|
let mut digest = Sha256::new();
|
||||||
|
let mut buffer = [0_u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let read = file.read(&mut buffer)?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
digest.update(&buffer[..read]);
|
||||||
|
}
|
||||||
|
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn restart_evidence_run(
|
||||||
|
binary: &Path,
|
||||||
|
case: ScannerHealEvidenceCase,
|
||||||
|
) -> Result<Option<RestartEvidenceContext>, Box<dyn Error + Send + Sync>> {
|
||||||
|
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if case.id.is_empty()
|
||||||
|
|| case.oracle.is_empty()
|
||||||
|
|| !case.oracle.ends_with(".json")
|
||||||
|
|| case.oracle.contains('/')
|
||||||
|
|| case.oracle.contains('\\')
|
||||||
|
|| case.oracle.contains("..")
|
||||||
|
|| !matches!(case.evidence, "process-restart" | "process-crash-restart")
|
||||||
|
|| (case.evidence == "process-crash-restart") != case.unclean_shutdown_marker
|
||||||
|
{
|
||||||
|
return Err("invalid scanner/heal evidence case".into());
|
||||||
|
}
|
||||||
|
let directory = PathBuf::from(directory);
|
||||||
|
let receipt = directory.join("run.json");
|
||||||
|
if receipt.metadata()?.len() > 1024 * 1024 {
|
||||||
|
return Err("oversized scanner/heal execution receipt".into());
|
||||||
|
}
|
||||||
|
let run: RestartEvidenceRun = serde_json::from_slice(&std::fs::read(receipt)?)?;
|
||||||
|
if run.schema != 1 || run.run_id.len() != 32 || run.source_revision.len() != 40 {
|
||||||
|
return Err("invalid scanner/heal execution identity".into());
|
||||||
|
}
|
||||||
|
let built = compiled_test_identity();
|
||||||
|
for key in ["source_revision", "dirty", "lock_blob", "features"] {
|
||||||
|
assert_eq!(built[key], run.test_build[key], "compiled test identity differs for {key}");
|
||||||
|
}
|
||||||
|
assert_eq!(file_sha256(binary)?, run.binary.sha256, "server binary must match the run receipt");
|
||||||
|
assert_eq!(
|
||||||
|
file_sha256(&std::env::current_exe()?)?,
|
||||||
|
run.test_binary.sha256,
|
||||||
|
"test executable must match the run receipt"
|
||||||
|
);
|
||||||
|
if directory.join(case.oracle).exists() {
|
||||||
|
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
|
||||||
|
}
|
||||||
|
Ok(Some(RestartEvidenceContext { directory, run, case }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compiled_test_identity() -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
|
||||||
|
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
|
||||||
|
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
|
||||||
|
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
|
||||||
|
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
|
||||||
|
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
|
||||||
|
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct RestartObservation {
|
||||||
|
pub(crate) nodes: usize,
|
||||||
|
pub(crate) drives_per_node: usize,
|
||||||
|
pub(crate) pid_before: u32,
|
||||||
|
pub(crate) pid_after: u32,
|
||||||
|
pub(crate) unclean_shutdown_marker: bool,
|
||||||
|
pub(crate) objects: Vec<serde_json::Value>,
|
||||||
|
pub(crate) node_listings: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RestartEvidenceContext {
|
||||||
|
pub(crate) fn write(self, binary: &Path, observed: RestartObservation) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
assert_ne!(observed.pid_before, observed.pid_after, "target must be a new process");
|
||||||
|
assert_eq!(file_sha256(binary)?, self.run.binary.sha256, "server build changed during restart");
|
||||||
|
let evidence = serde_json::json!({
|
||||||
|
"schema": 1, "case": self.case.id, "evidence": self.case.evidence,
|
||||||
|
"run_id": self.run.run_id, "source_revision": self.run.source_revision,
|
||||||
|
"test_build": compiled_test_identity(),
|
||||||
|
"binary_sha256": self.run.binary.sha256,
|
||||||
|
"test_binary_sha256": self.run.test_binary.sha256,
|
||||||
|
"topology": {"nodes": observed.nodes, "drives_per_node": observed.drives_per_node},
|
||||||
|
"pid_before": observed.pid_before, "pid_after": observed.pid_after,
|
||||||
|
"unclean_shutdown_marker": observed.unclean_shutdown_marker,
|
||||||
|
"objects": observed.objects, "node_listings": observed.node_listings,
|
||||||
|
});
|
||||||
|
let data = serde_json::to_vec(&evidence)?;
|
||||||
|
if data.len() > 1024 * 1024 {
|
||||||
|
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
|
||||||
|
}
|
||||||
|
let mut output = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(self.directory.join(self.case.oracle))?;
|
||||||
|
output.write_all(&data)?;
|
||||||
|
output.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4036,6 +4036,10 @@ impl ManualTransitionRunReport {
|
|||||||
|| self.skipped_queue_timeout > 0
|
|| self.skipped_queue_timeout > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_enqueue_backpressure(&self) -> bool {
|
||||||
|
self.skipped_queue_full > 0 || self.skipped_queue_closed > 0 || self.skipped_queue_timeout > 0
|
||||||
|
}
|
||||||
|
|
||||||
pub fn was_truncated(&self) -> bool {
|
pub fn was_truncated(&self) -> bool {
|
||||||
self.truncated_by_limit || self.truncated_by_duration || self.cancelled
|
self.truncated_by_limit || self.truncated_by_duration || self.cancelled
|
||||||
}
|
}
|
||||||
@@ -4251,7 +4255,7 @@ pub async fn enqueue_transition_for_existing_objects_scoped(
|
|||||||
}
|
}
|
||||||
report.scanned = report.scanned.saturating_add(1);
|
report.scanned = report.scanned.saturating_add(1);
|
||||||
enqueue_transition_with_lifecycle_report(Some(api.clone()), object, &lc, &src, &options, &mut report).await;
|
enqueue_transition_with_lifecycle_report(Some(api.clone()), object, &lc, &src, &options, &mut report).await;
|
||||||
if report.has_partial_enqueue() {
|
if report.has_enqueue_backpressure() {
|
||||||
report.next_marker.clone_from(&previous_marker);
|
report.next_marker.clone_from(&previous_marker);
|
||||||
report.next_version_idmarker.clone_from(&previous_version_marker);
|
report.next_version_idmarker.clone_from(&previous_version_marker);
|
||||||
report.continuation_token =
|
report.continuation_token =
|
||||||
@@ -9950,6 +9954,18 @@ mod tests {
|
|||||||
assert_eq!(report.skipped_queue_closed, 0);
|
assert_eq!(report.skipped_queue_closed, 0);
|
||||||
assert_eq!(report.skipped_queue_timeout, 0);
|
assert_eq!(report.skipped_queue_timeout, 0);
|
||||||
assert!(report.has_partial_enqueue());
|
assert!(report.has_partial_enqueue());
|
||||||
|
assert!(report.has_enqueue_backpressure());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_transition_in_flight_skip_does_not_stop_the_scan() {
|
||||||
|
let options = ManualTransitionRunOptions::default();
|
||||||
|
let mut report = ManualTransitionRunReport::new("bucket", &options);
|
||||||
|
|
||||||
|
report.record_enqueue_outcome(TransitionEnqueueOutcome::AlreadyInFlight);
|
||||||
|
|
||||||
|
assert!(report.has_partial_enqueue());
|
||||||
|
assert!(!report.has_enqueue_backpressure());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -8220,7 +8220,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
mod multipart_transport_tests {
|
mod multipart_transport_tests {
|
||||||
use super::super::super::replication_filemeta_boundary::ObjectPartInfo;
|
use super::super::super::replication_filemeta_boundary::ObjectPartInfo;
|
||||||
use super::super::super::replication_storage_boundary::{ObjectIO as _, ReadPlan};
|
use super::super::super::replication_storage_boundary::ObjectIO as _;
|
||||||
use super::*;
|
use super::*;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use http_body_util::{BodyExt, Full};
|
use http_body_util::{BodyExt, Full};
|
||||||
@@ -8229,7 +8229,6 @@ mod tests {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct Source {
|
struct Source {
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
stored: Option<Bytes>,
|
|
||||||
info: ObjectInfo,
|
info: ObjectInfo,
|
||||||
ranges: StdMutex<Vec<(i64, i64)>>,
|
ranges: StdMutex<Vec<(i64, i64)>>,
|
||||||
full_reads: std::sync::atomic::AtomicUsize,
|
full_reads: std::sync::atomic::AtomicUsize,
|
||||||
@@ -8258,17 +8257,6 @@ mod tests {
|
|||||||
self.info.version_id.map(|id| id.to_string()),
|
self.info.version_id.map(|id| id.to_string()),
|
||||||
"every read retains the selected source version"
|
"every read retains the selected source version"
|
||||||
);
|
);
|
||||||
if let Some(stored) = &self.stored {
|
|
||||||
if let Some(range) = &range {
|
|
||||||
self.ranges.lock().expect("range journal lock").push((range.start, range.end));
|
|
||||||
} else {
|
|
||||||
self.full_reads.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
let plan = ReadPlan::build_for_request(range, &self.info, opts, &HeaderMap::new(), None).await?;
|
|
||||||
let start = plan.storage_offset();
|
|
||||||
let end = start + usize::try_from(plan.storage_length()).expect("nonnegative storage length");
|
|
||||||
return plan.into_object_reader(Box::new(std::io::Cursor::new(stored.slice(start..end))), &self.info);
|
|
||||||
}
|
|
||||||
if range.is_none() {
|
if range.is_none() {
|
||||||
self.full_reads.fetch_add(1, Ordering::Relaxed);
|
self.full_reads.fetch_add(1, Ordering::Relaxed);
|
||||||
return Ok(GetObjectReader {
|
return Ok(GetObjectReader {
|
||||||
@@ -8427,7 +8415,6 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
body: body.clone(),
|
body: body.clone(),
|
||||||
stored: Some(Bytes::from(stored)),
|
|
||||||
ranges: StdMutex::new(Vec::new()),
|
ranges: StdMutex::new(Vec::new()),
|
||||||
full_reads: std::sync::atomic::AtomicUsize::new(0),
|
full_reads: std::sync::atomic::AtomicUsize::new(0),
|
||||||
});
|
});
|
||||||
@@ -8669,7 +8656,6 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
body: body.clone(),
|
body: body.clone(),
|
||||||
stored: None,
|
|
||||||
ranges: StdMutex::new(Vec::new()),
|
ranges: StdMutex::new(Vec::new()),
|
||||||
full_reads: std::sync::atomic::AtomicUsize::new(0),
|
full_reads: std::sync::atomic::AtomicUsize::new(0),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pub(crate) use crate::object_api::{
|
|||||||
GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader, ReplicationStatusWritebackCondition, ReplicationStatusWritebackMode,
|
GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader, ReplicationStatusWritebackCondition, ReplicationStatusWritebackMode,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use crate::object_api::{NamespaceLockFence, NamespaceLockSignalTestFence, ReadPlan};
|
pub(crate) use crate::object_api::{NamespaceLockFence, NamespaceLockSignalTestFence};
|
||||||
pub(crate) use crate::storage_api_contracts::list::{
|
pub(crate) use crate::storage_api_contracts::list::{
|
||||||
ListOperations, StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions,
|
ListOperations, StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,21 +22,152 @@ use rustfs_lock::{
|
|||||||
LockClient, LockError, LockInfo, LockRequest, LockResponse, LockStats, LockStatus, LockType, Result,
|
LockClient, LockError, LockInfo, LockRequest, LockResponse, LockStats, LockStatus, LockType, Result,
|
||||||
types::{LockId, LockMetadata, LockPriority},
|
types::{LockId, LockMetadata, LockPriority},
|
||||||
};
|
};
|
||||||
use rustfs_protos::proto_gen::node_service::{BatchGenerallyLockRequest, GenerallyLockRequest, PingRequest};
|
use rustfs_protos::proto_gen::node_service::{
|
||||||
|
BatchGenerallyLockRequest, BatchGenerallyLockResponse, GenerallyLockRequest, GenerallyLockResponse, GenerallyLockResult,
|
||||||
|
PingRequest,
|
||||||
|
};
|
||||||
use rustfs_protos::{
|
use rustfs_protos::{
|
||||||
ConnectionEvictionLogLevel, evict_failed_connection_with_log_level, models::PingBodyBuilder,
|
ConnectionEvictionLogLevel, evict_failed_connection_with_log_level, models::PingBodyBuilder,
|
||||||
proto_gen::node_service::node_service_client::NodeServiceClient,
|
proto_gen::node_service::node_service_client::NodeServiceClient,
|
||||||
};
|
};
|
||||||
use std::{sync::OnceLock, time::Duration};
|
use std::collections::HashMap;
|
||||||
use tokio::time::timeout;
|
use std::future::Future;
|
||||||
use tonic::Request;
|
use std::pin::Pin;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tokio::time::{Instant, timeout};
|
||||||
use tonic::service::interceptor::InterceptedService;
|
use tonic::service::interceptor::InterceptedService;
|
||||||
|
use tonic::{Request, Response};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> {
|
fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> {
|
||||||
set_tonic_rolling_mutation_body_digest(request)
|
set_tonic_rolling_mutation_body_digest(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Work to run if an RPC that already timed out for its caller completes later.
|
||||||
|
type LateCompletion<T> = Option<Box<dyn FnOnce(T) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send>>;
|
||||||
|
|
||||||
|
/// The liveness window is this many RPC deadlines: a peer that completed a
|
||||||
|
/// lock RPC within it is slow, not gone, and keeps its channel on a timeout.
|
||||||
|
const LOCK_RPC_LIVENESS_WINDOW_DEADLINES: u32 = 2;
|
||||||
|
|
||||||
|
/// Recent history of the shared lock channel to one peer (issue #7363).
|
||||||
|
///
|
||||||
|
/// A single request deadline says nothing about the HTTP/2 connection it ran
|
||||||
|
/// on: a peer whose lock service is merely slow keeps answering other streams.
|
||||||
|
/// Evicting the cached channel on every timeout turned that slowness into a
|
||||||
|
/// `RST_STREAM`/`GOAWAY too_many_resets`/re-dial loop across the cluster, so
|
||||||
|
/// eviction now requires the peer to have gone quiet and is rate limited.
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
struct LockPeerChannelHealth {
|
||||||
|
last_success: Option<Instant>,
|
||||||
|
last_eviction: Option<Instant>,
|
||||||
|
consecutive_timeouts: u32,
|
||||||
|
/// Timed-out RPCs still running in the background for this peer.
|
||||||
|
detached_rpcs: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_peer_channel_health() -> &'static Mutex<HashMap<String, LockPeerChannelHealth>> {
|
||||||
|
static HEALTH: OnceLock<Mutex<HashMap<String, LockPeerChannelHealth>>> = OnceLock::new();
|
||||||
|
HEALTH.get_or_init(Mutex::default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_lock_peer_health<R>(addr: &str, update: impl FnOnce(&mut LockPeerChannelHealth) -> R) -> R {
|
||||||
|
let mut peers = lock_peer_channel_health()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
update(peers.entry(addr.to_string()).or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn lock_peer_health_for_test(addr: &str) -> LockPeerChannelHealth {
|
||||||
|
lock_peer_channel_health()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.get(addr)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn reset_lock_peer_health_for_test(addr: &str) {
|
||||||
|
lock_peer_channel_health()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.remove(addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum EvictionTrigger {
|
||||||
|
/// The caller's deadline expired while the stream was still open.
|
||||||
|
Timeout,
|
||||||
|
/// The transport itself reported the failure (refused, reset, GOAWAY, ...).
|
||||||
|
Transport,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EvictionTrigger {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Timeout => "timeout",
|
||||||
|
Self::Transport => "transport",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum EvictionVerdict {
|
||||||
|
Evict,
|
||||||
|
/// The peer completed a lock RPC within the liveness window: slow, not gone.
|
||||||
|
PeerRecentlyServed,
|
||||||
|
/// The channel was re-dialed within the cooldown; let it prove itself first.
|
||||||
|
CoolingDown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EvictionVerdict {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Evict => "evict",
|
||||||
|
Self::PeerRecentlyServed => "peer_recently_served",
|
||||||
|
Self::CoolingDown => "cooling_down",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether a failed lock RPC may evict the shared channel to its peer.
|
||||||
|
fn eviction_verdict(
|
||||||
|
health: &LockPeerChannelHealth,
|
||||||
|
now: Instant,
|
||||||
|
trigger: EvictionTrigger,
|
||||||
|
liveness_window: Duration,
|
||||||
|
cooldown: Duration,
|
||||||
|
) -> EvictionVerdict {
|
||||||
|
if trigger == EvictionTrigger::Timeout
|
||||||
|
&& health
|
||||||
|
.last_success
|
||||||
|
.is_some_and(|at| now.saturating_duration_since(at) < liveness_window)
|
||||||
|
{
|
||||||
|
return EvictionVerdict::PeerRecentlyServed;
|
||||||
|
}
|
||||||
|
if health
|
||||||
|
.last_eviction
|
||||||
|
.is_some_and(|at| now.saturating_duration_since(at) < cooldown)
|
||||||
|
{
|
||||||
|
return EvictionVerdict::CoolingDown;
|
||||||
|
}
|
||||||
|
EvictionVerdict::Evict
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock ids whose batch entry the server reports as granted.
|
||||||
|
fn acquired_lock_ids(lock_ids: &[LockId], results: &[GenerallyLockResult]) -> Vec<LockId> {
|
||||||
|
results
|
||||||
|
.iter()
|
||||||
|
.zip(lock_ids)
|
||||||
|
.filter(|(result, _)| result.success)
|
||||||
|
.map(|(_, lock_id)| lock_id.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Remote lock client implementation
|
/// Remote lock client implementation
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RemoteClient {
|
pub struct RemoteClient {
|
||||||
@@ -198,14 +329,202 @@ impl RemoteClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_rpc<T, F>(&self, op: &'static str, resource_summary: &str, future: F) -> std::result::Result<T, LockError>
|
fn eviction_cooldown() -> Duration {
|
||||||
|
Duration::from_millis(rustfs_utils::get_env_u64(
|
||||||
|
rustfs_config::ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS,
|
||||||
|
rustfs_config::DEFAULT_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detached_rpc_limit() -> usize {
|
||||||
|
rustfs_utils::get_env_usize(
|
||||||
|
rustfs_config::ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT,
|
||||||
|
rustfs_config::DEFAULT_OBJECT_LOCK_RPC_DETACHED_LIMIT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liveness_window(deadline: Duration) -> Duration {
|
||||||
|
deadline.saturating_mul(LOCK_RPC_LIVENESS_WINDOW_DEADLINES)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_rpc_success(&self) {
|
||||||
|
with_lock_peer_health(&self.addr, |health| {
|
||||||
|
health.last_success = Some(Instant::now());
|
||||||
|
health.consecutive_timeouts = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the per-peer eviction policy after a failed RPC.
|
||||||
|
async fn maybe_evict_connection(
|
||||||
|
&self,
|
||||||
|
op: &'static str,
|
||||||
|
reason: &str,
|
||||||
|
resource_summary: &str,
|
||||||
|
trigger: EvictionTrigger,
|
||||||
|
deadline: Duration,
|
||||||
|
) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let cooldown = Self::eviction_cooldown();
|
||||||
|
let liveness_window = Self::liveness_window(deadline);
|
||||||
|
let (verdict, consecutive_timeouts) = with_lock_peer_health(&self.addr, |health| {
|
||||||
|
if trigger == EvictionTrigger::Timeout {
|
||||||
|
health.consecutive_timeouts = health.consecutive_timeouts.saturating_add(1);
|
||||||
|
}
|
||||||
|
let verdict = eviction_verdict(health, now, trigger, liveness_window, cooldown);
|
||||||
|
if verdict == EvictionVerdict::Evict {
|
||||||
|
health.last_eviction = Some(now);
|
||||||
|
}
|
||||||
|
(verdict, health.consecutive_timeouts)
|
||||||
|
});
|
||||||
|
if verdict == EvictionVerdict::Evict {
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_channel_eviction(&self.addr, trigger.as_str());
|
||||||
|
self.evict_connection(op, reason, resource_summary).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_channel_eviction_suppressed(&self.addr, verdict.as_str());
|
||||||
|
debug!(
|
||||||
|
addr = %self.addr,
|
||||||
|
op,
|
||||||
|
resource_summary,
|
||||||
|
trigger = trigger.as_str(),
|
||||||
|
verdict = verdict.as_str(),
|
||||||
|
consecutive_timeouts,
|
||||||
|
"Keeping cached remote lock connection after RPC failure"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep a timed-out RPC running instead of cancelling its stream.
|
||||||
|
///
|
||||||
|
/// Dropping the future sends `RST_STREAM`; under load those resets pile up
|
||||||
|
/// in the server's pending-accept queue until it answers `GOAWAY
|
||||||
|
/// too_many_resets` and kills every stream on the connection. A detached
|
||||||
|
/// stream ends on its own within the internode RPC timeout, the number per
|
||||||
|
/// peer is bounded, and a lock granted after its caller gave up is released.
|
||||||
|
fn detach_timed_out_rpc<T: Send + 'static>(
|
||||||
|
&self,
|
||||||
|
op: &'static str,
|
||||||
|
resource_summary: &str,
|
||||||
|
handle: JoinHandle<std::result::Result<T, tonic::Status>>,
|
||||||
|
late: LateCompletion<T>,
|
||||||
|
) {
|
||||||
|
let limit = Self::detached_rpc_limit();
|
||||||
|
let admitted = with_lock_peer_health(&self.addr, |health| {
|
||||||
|
if health.detached_rpcs >= limit {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
health.detached_rpcs += 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if !admitted {
|
||||||
|
handle.abort();
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_detached(op, "aborted");
|
||||||
|
debug!(
|
||||||
|
addr = %self.addr,
|
||||||
|
op,
|
||||||
|
resource_summary,
|
||||||
|
limit,
|
||||||
|
"Cancelled timed-out remote lock RPC because the detached stream budget is exhausted"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_detached(op, "detached");
|
||||||
|
let addr = self.addr.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let outcome = handle.await;
|
||||||
|
with_lock_peer_health(&addr, |health| health.detached_rpcs = health.detached_rpcs.saturating_sub(1));
|
||||||
|
match outcome {
|
||||||
|
Ok(Ok(response)) => {
|
||||||
|
with_lock_peer_health(&addr, |health| {
|
||||||
|
health.last_success = Some(Instant::now());
|
||||||
|
health.consecutive_timeouts = 0;
|
||||||
|
});
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "success");
|
||||||
|
if let Some(late) = late {
|
||||||
|
late(response).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(status)) => {
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "error");
|
||||||
|
debug!(
|
||||||
|
addr = %addr,
|
||||||
|
op,
|
||||||
|
tonic_code = ?status.code(),
|
||||||
|
tonic_message = status.message(),
|
||||||
|
"Detached remote lock RPC failed after its caller timed out"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(join_error) => {
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "join_error");
|
||||||
|
debug!(addr = %addr, op, error = %join_error, "Detached remote lock RPC task ended abnormally");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn late_release_hook(&self, lock_id: LockId) -> LateCompletion<Response<GenerallyLockResponse>> {
|
||||||
|
let client = self.clone();
|
||||||
|
Some(Box::new(move |response: Response<GenerallyLockResponse>| {
|
||||||
|
Box::pin(async move {
|
||||||
|
if response.get_ref().success {
|
||||||
|
client.release_late_acquisitions(vec![lock_id]).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn late_release_batch_hook(&self, lock_ids: Vec<LockId>) -> LateCompletion<Response<BatchGenerallyLockResponse>> {
|
||||||
|
let client = self.clone();
|
||||||
|
Some(Box::new(move |response: Response<BatchGenerallyLockResponse>| {
|
||||||
|
Box::pin(async move {
|
||||||
|
let acquired = acquired_lock_ids(&lock_ids, &response.get_ref().results);
|
||||||
|
if !acquired.is_empty() {
|
||||||
|
client.release_late_acquisitions(acquired).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lock granted after its caller stopped waiting is an orphan until its
|
||||||
|
/// lease expires; hand it back right away, best effort.
|
||||||
|
async fn release_late_acquisitions(&self, lock_ids: Vec<LockId>) {
|
||||||
|
let outcome = match self.release_locks_batch(&lock_ids).await {
|
||||||
|
Ok(released) if released.iter().all(|released| *released) => "released",
|
||||||
|
Ok(_) => "partial",
|
||||||
|
Err(_) => "failed",
|
||||||
|
};
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_late_release(outcome);
|
||||||
|
if outcome == "released" {
|
||||||
|
debug!(addr = %self.addr, count = lock_ids.len(), "Released remote locks granted after their caller timed out");
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
addr = %self.addr,
|
||||||
|
count = lock_ids.len(),
|
||||||
|
outcome,
|
||||||
|
"Could not release every remote lock granted after its caller timed out; the server lease will expire it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_rpc<T, Fut>(
|
||||||
|
&self,
|
||||||
|
op: &'static str,
|
||||||
|
resource_summary: &str,
|
||||||
|
deadline: Duration,
|
||||||
|
future: Fut,
|
||||||
|
late: LateCompletion<T>,
|
||||||
|
) -> std::result::Result<T, LockError>
|
||||||
where
|
where
|
||||||
F: std::future::Future<Output = std::result::Result<T, tonic::Status>>,
|
Fut: Future<Output = std::result::Result<T, tonic::Status>> + Send + 'static,
|
||||||
|
T: Send + 'static,
|
||||||
{
|
{
|
||||||
let lock_timeout = Self::rpc_timeout();
|
let mut handle = tokio::spawn(future);
|
||||||
match timeout(lock_timeout, future).await {
|
match timeout(deadline, &mut handle).await {
|
||||||
Ok(Ok(response)) => Ok(response),
|
Ok(Ok(Ok(response))) => {
|
||||||
Ok(Err(err)) => {
|
self.record_rpc_success();
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
Ok(Ok(Err(err))) => {
|
||||||
let reason = err.to_string();
|
let reason = err.to_string();
|
||||||
// Only evict (and re-dial) the cached channel when the failure is a genuine
|
// Only evict (and re-dial) the cached channel when the failure is a genuine
|
||||||
// transport problem. A server-produced application status (auth denied, peer
|
// transport problem. A server-produced application status (auth denied, peer
|
||||||
@@ -217,7 +536,7 @@ impl RemoteClient {
|
|||||||
debug!(
|
debug!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
op,
|
op,
|
||||||
timeout_ms = lock_timeout.as_millis(),
|
timeout_ms = deadline.as_millis(),
|
||||||
resource_summary,
|
resource_summary,
|
||||||
tonic_code = ?err.code(),
|
tonic_code = ?err.code(),
|
||||||
tonic_message = err.message(),
|
tonic_message = err.message(),
|
||||||
@@ -228,7 +547,7 @@ impl RemoteClient {
|
|||||||
warn!(
|
warn!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
op,
|
op,
|
||||||
timeout_ms = lock_timeout.as_millis(),
|
timeout_ms = deadline.as_millis(),
|
||||||
resource_summary,
|
resource_summary,
|
||||||
tonic_code = ?err.code(),
|
tonic_code = ?err.code(),
|
||||||
tonic_message = err.message(),
|
tonic_message = err.message(),
|
||||||
@@ -237,17 +556,29 @@ impl RemoteClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if transport_failure {
|
if transport_failure {
|
||||||
self.evict_connection(op, &reason, resource_summary).await;
|
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Transport, deadline)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
Err(LockError::internal(format!("{op} RPC failed: {reason}")))
|
Err(LockError::internal(format!("{op} RPC failed: {reason}")))
|
||||||
}
|
}
|
||||||
|
Ok(Err(join_error)) => {
|
||||||
|
warn!(
|
||||||
|
addr = %self.addr,
|
||||||
|
op,
|
||||||
|
resource_summary,
|
||||||
|
error = %join_error,
|
||||||
|
"Remote lock RPC task ended abnormally"
|
||||||
|
);
|
||||||
|
Err(LockError::internal(format!("{op} RPC task failed: {join_error}")))
|
||||||
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let reason = format!("RPC timed out after {:?}", lock_timeout);
|
let reason = format!("RPC timed out after {deadline:?}");
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_timeout(&self.addr, op);
|
||||||
if Self::is_scanner_leader_lock(resource_summary) {
|
if Self::is_scanner_leader_lock(resource_summary) {
|
||||||
debug!(
|
debug!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
op,
|
op,
|
||||||
timeout_ms = lock_timeout.as_millis(),
|
timeout_ms = deadline.as_millis(),
|
||||||
resource_summary,
|
resource_summary,
|
||||||
"Remote lock RPC timed out for scanner leader lock"
|
"Remote lock RPC timed out for scanner leader lock"
|
||||||
);
|
);
|
||||||
@@ -255,13 +586,15 @@ impl RemoteClient {
|
|||||||
warn!(
|
warn!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
op,
|
op,
|
||||||
timeout_ms = lock_timeout.as_millis(),
|
timeout_ms = deadline.as_millis(),
|
||||||
resource_summary,
|
resource_summary,
|
||||||
"Remote lock RPC timed out"
|
"Remote lock RPC timed out"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
self.evict_connection(op, &reason, resource_summary).await;
|
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Timeout, deadline)
|
||||||
Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), lock_timeout))
|
.await;
|
||||||
|
self.detach_timed_out_rpc(op, resource_summary, handle, late);
|
||||||
|
Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), deadline))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,8 +687,18 @@ impl LockClient for RemoteClient {
|
|||||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
||||||
});
|
});
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
|
let late = self.late_release_hook(request.lock_id.clone());
|
||||||
|
|
||||||
let resp = match self.execute_rpc("lock", &resource_summary, client.lock(req)).await {
|
let resp = match self
|
||||||
|
.execute_rpc(
|
||||||
|
"lock",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.lock(req).await },
|
||||||
|
late,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(resp) => resp.into_inner(),
|
Ok(resp) => resp.into_inner(),
|
||||||
Err(err @ LockError::Timeout { .. }) => return Ok(Self::rpc_timeout_failure_response(request, &err)),
|
Err(err @ LockError::Timeout { .. }) => return Ok(Self::rpc_timeout_failure_response(request, &err)),
|
||||||
Err(err) => return Ok(Self::rpc_failure_response(request, &err)),
|
Err(err) => return Ok(Self::rpc_failure_response(request, &err)),
|
||||||
@@ -393,9 +736,16 @@ impl LockClient for RemoteClient {
|
|||||||
.collect::<Result<Vec<_>>>()?,
|
.collect::<Result<Vec<_>>>()?,
|
||||||
});
|
});
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
|
let late = self.late_release_batch_hook(requests.iter().map(|request| request.lock_id.clone()).collect());
|
||||||
|
|
||||||
let resp = match self
|
let resp = match self
|
||||||
.execute_rpc("lock_batch", &resource_summary, client.lock_batch(req))
|
.execute_rpc(
|
||||||
|
"lock_batch",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.lock_batch(req).await },
|
||||||
|
late,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) => resp.into_inner(),
|
Ok(resp) => resp.into_inner(),
|
||||||
@@ -436,7 +786,13 @@ impl LockClient for RemoteClient {
|
|||||||
let mut req = Request::new(GenerallyLockRequest { args: request_string });
|
let mut req = Request::new(GenerallyLockRequest { args: request_string });
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.execute_rpc("release", &resource_summary, client.un_lock(req))
|
.execute_rpc(
|
||||||
|
"release",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.un_lock(req).await },
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
if let Some(error_info) = resp.error_info {
|
if let Some(error_info) = resp.error_info {
|
||||||
@@ -464,7 +820,13 @@ impl LockClient for RemoteClient {
|
|||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.execute_rpc("release_batch", &resource_summary, client.un_lock_batch(req))
|
.execute_rpc(
|
||||||
|
"release_batch",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.un_lock_batch(req).await },
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
|
|
||||||
@@ -486,7 +848,13 @@ impl LockClient for RemoteClient {
|
|||||||
});
|
});
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.execute_rpc("refresh", &resource_summary, client.refresh(req))
|
.execute_rpc(
|
||||||
|
"refresh",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.refresh(req).await },
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
if let Some(error_info) = resp.error_info {
|
if let Some(error_info) = resp.error_info {
|
||||||
@@ -506,7 +874,13 @@ impl LockClient for RemoteClient {
|
|||||||
});
|
});
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.execute_rpc("force_release", &resource_summary, client.force_un_lock(req))
|
.execute_rpc(
|
||||||
|
"force_release",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.force_un_lock(req).await },
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
if let Some(error_info) = resp.error_info {
|
if let Some(error_info) = resp.error_info {
|
||||||
@@ -523,16 +897,26 @@ impl LockClient for RemoteClient {
|
|||||||
let status_request = Self::create_unlock_request(lock_id);
|
let status_request = Self::create_unlock_request(lock_id);
|
||||||
let resource_summary = status_request.resource.to_string();
|
let resource_summary = status_request.resource.to_string();
|
||||||
let mut client = self.get_client().await?;
|
let mut client = self.get_client().await?;
|
||||||
|
let args = serde_json::to_string(&status_request)
|
||||||
|
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?;
|
||||||
|
|
||||||
// Try to acquire a very short-lived lock to test availability
|
// Try to acquire a very short-lived lock to test availability
|
||||||
let mut req = Request::new(GenerallyLockRequest {
|
let mut req = Request::new(GenerallyLockRequest { args: args.clone() });
|
||||||
args: serde_json::to_string(&status_request)
|
|
||||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
|
||||||
});
|
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
|
// A probe lock granted after the deadline must not linger on the peer.
|
||||||
|
let late = self.late_release_hook(lock_id.clone());
|
||||||
|
|
||||||
// Try exclusive lock first with very short timeout
|
// Try exclusive lock first with very short timeout
|
||||||
let resp = match self.execute_rpc("check_status", &resource_summary, client.lock(req)).await {
|
let resp = match self
|
||||||
|
.execute_rpc(
|
||||||
|
"check_status",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.lock(req).await },
|
||||||
|
late,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(response) => response.into_inner(),
|
Ok(response) => response.into_inner(),
|
||||||
Err(_) => return Ok(Some(Self::unknown_lock_info(lock_id))),
|
Err(_) => return Ok(Some(Self::unknown_lock_info(lock_id))),
|
||||||
};
|
};
|
||||||
@@ -540,14 +924,19 @@ impl LockClient for RemoteClient {
|
|||||||
if resp.success {
|
if resp.success {
|
||||||
// If we successfully acquired the lock, the resource was free.
|
// If we successfully acquired the lock, the resource was free.
|
||||||
// Immediately release it on a best-effort basis.
|
// Immediately release it on a best-effort basis.
|
||||||
let mut release_req = Request::new(GenerallyLockRequest {
|
let mut release_req = Request::new(GenerallyLockRequest { args });
|
||||||
args: serde_json::to_string(&status_request)
|
|
||||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
|
||||||
});
|
|
||||||
attach_lock_mutation_body_digest(&mut release_req)?;
|
attach_lock_mutation_body_digest(&mut release_req)?;
|
||||||
|
if let Ok(mut client) = self.get_client().await {
|
||||||
let _ = self
|
let _ = self
|
||||||
.execute_rpc("check_status_release", &resource_summary, client.un_lock(release_req))
|
.execute_rpc(
|
||||||
|
"check_status_release",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.un_lock(release_req).await },
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
} else {
|
} else {
|
||||||
@@ -582,19 +971,8 @@ impl LockClient for RemoteClient {
|
|||||||
|
|
||||||
async fn is_online(&self) -> bool {
|
async fn is_online(&self) -> bool {
|
||||||
let online_timeout = Self::online_check_timeout();
|
let online_timeout = Self::online_check_timeout();
|
||||||
match timeout(online_timeout, async {
|
let mut client = match timeout(online_timeout, self.get_client()).await {
|
||||||
let mut client = self.get_client().await?;
|
Ok(Ok(client)) => client,
|
||||||
let ping_req = Request::new(Self::build_ping_request());
|
|
||||||
self.execute_rpc("ping", Self::ONLINE_CHECK_RESOURCE, client.ping(ping_req))
|
|
||||||
.await?;
|
|
||||||
Ok::<(), LockError>(())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Ok(())) => {
|
|
||||||
debug!(addr = %self.addr, timeout_ms = online_timeout.as_millis(), "remote lock client is online");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
debug!(
|
debug!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
@@ -602,16 +980,39 @@ impl LockClient for RemoteClient {
|
|||||||
error = %err,
|
error = %err,
|
||||||
"remote lock client online check failed"
|
"remote lock client online check failed"
|
||||||
);
|
);
|
||||||
false
|
return false;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let reason = format!("online check timed out after {:?}", online_timeout);
|
|
||||||
warn!(
|
warn!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
timeout_ms = online_timeout.as_millis(),
|
timeout_ms = online_timeout.as_millis(),
|
||||||
"remote lock client online check timed out"
|
"remote lock client online check timed out while dialing"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let ping_req = Request::new(Self::build_ping_request());
|
||||||
|
match self
|
||||||
|
.execute_rpc(
|
||||||
|
"ping",
|
||||||
|
Self::ONLINE_CHECK_RESOURCE,
|
||||||
|
online_timeout,
|
||||||
|
async move { client.ping(ping_req).await },
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
debug!(addr = %self.addr, timeout_ms = online_timeout.as_millis(), "remote lock client is online");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
debug!(
|
||||||
|
addr = %self.addr,
|
||||||
|
timeout_ms = online_timeout.as_millis(),
|
||||||
|
error = %err,
|
||||||
|
"remote lock client online check failed"
|
||||||
);
|
);
|
||||||
self.evict_connection("ping", &reason, Self::ONLINE_CHECK_RESOURCE).await;
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -673,6 +1074,232 @@ mod tests {
|
|||||||
.with_priority(LockPriority::Normal)
|
.with_priority(LockPriority::Normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eviction_verdict_distinguishes_slow_peers_from_dead_channels() {
|
||||||
|
let now = Instant::now() + Duration::from_secs(3600);
|
||||||
|
let window = Duration::from_secs(6);
|
||||||
|
let cooldown = Duration::from_secs(5);
|
||||||
|
|
||||||
|
let idle = LockPeerChannelHealth::default();
|
||||||
|
assert_eq!(
|
||||||
|
eviction_verdict(&idle, now, EvictionTrigger::Timeout, window, cooldown),
|
||||||
|
EvictionVerdict::Evict
|
||||||
|
);
|
||||||
|
|
||||||
|
let serving = LockPeerChannelHealth {
|
||||||
|
last_success: Some(now - Duration::from_secs(1)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
eviction_verdict(&serving, now, EvictionTrigger::Timeout, window, cooldown),
|
||||||
|
EvictionVerdict::PeerRecentlyServed,
|
||||||
|
"a timeout on a peer that just answered is load, not a dead channel"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
eviction_verdict(&serving, now, EvictionTrigger::Transport, window, cooldown),
|
||||||
|
EvictionVerdict::Evict,
|
||||||
|
"a transport failure is reported by the channel itself and still evicts"
|
||||||
|
);
|
||||||
|
|
||||||
|
let quiet = LockPeerChannelHealth {
|
||||||
|
last_success: Some(now - Duration::from_secs(30)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
eviction_verdict(&quiet, now, EvictionTrigger::Timeout, window, cooldown),
|
||||||
|
EvictionVerdict::Evict
|
||||||
|
);
|
||||||
|
|
||||||
|
let just_evicted = LockPeerChannelHealth {
|
||||||
|
last_eviction: Some(now - Duration::from_secs(1)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
eviction_verdict(&just_evicted, now, EvictionTrigger::Timeout, window, cooldown),
|
||||||
|
EvictionVerdict::CoolingDown
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
eviction_verdict(&just_evicted, now, EvictionTrigger::Transport, window, cooldown),
|
||||||
|
EvictionVerdict::CoolingDown
|
||||||
|
);
|
||||||
|
|
||||||
|
let cooled = LockPeerChannelHealth {
|
||||||
|
last_eviction: Some(now - Duration::from_secs(10)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
eviction_verdict(&cooled, now, EvictionTrigger::Timeout, window, cooldown),
|
||||||
|
EvictionVerdict::Evict
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acquired_lock_ids_picks_only_granted_batch_entries() {
|
||||||
|
let lock_ids = vec![
|
||||||
|
LockId::new_unique(&ObjectKey::new("bucket", "a")),
|
||||||
|
LockId::new_unique(&ObjectKey::new("bucket", "b")),
|
||||||
|
LockId::new_unique(&ObjectKey::new("bucket", "c")),
|
||||||
|
];
|
||||||
|
let results = vec![
|
||||||
|
GenerallyLockResult {
|
||||||
|
success: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
GenerallyLockResult {
|
||||||
|
success: false,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let acquired = acquired_lock_ids(&lock_ids, &results);
|
||||||
|
assert_eq!(
|
||||||
|
acquired,
|
||||||
|
vec![lock_ids[0].clone()],
|
||||||
|
"only granted entries with a matching id are released"
|
||||||
|
);
|
||||||
|
assert!(acquired_lock_ids(&lock_ids, &[]).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn test_remote_client_timeout_keeps_channel_of_recently_serving_peer() {
|
||||||
|
ensure_test_rpc_secret();
|
||||||
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
cache_lazy_channel(&addr).await;
|
||||||
|
with_lock_peer_health(&addr, |health| health.last_success = Some(Instant::now()));
|
||||||
|
|
||||||
|
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50"))], async {
|
||||||
|
let client = RemoteClient::new(addr.clone());
|
||||||
|
let response = client
|
||||||
|
.acquire_lock(&test_lock_request(Duration::from_millis(5)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!response.success, "timed out lock acquisition should fail");
|
||||||
|
assert!(
|
||||||
|
runtime_sources::test_node_channel_is_cached(&addr).await,
|
||||||
|
"a peer that served a lock RPC within the liveness window is slow, not gone"
|
||||||
|
);
|
||||||
|
assert_eq!(lock_peer_health_for_test(&addr).consecutive_timeouts, 1);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
accept_task.abort();
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn test_remote_client_repeated_timeouts_evict_at_most_once_per_cooldown() {
|
||||||
|
ensure_test_rpc_secret();
|
||||||
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
cache_lazy_channel(&addr).await;
|
||||||
|
|
||||||
|
temp_env::async_with_vars(
|
||||||
|
[
|
||||||
|
(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50")),
|
||||||
|
(rustfs_config::ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS, Some("60000")),
|
||||||
|
],
|
||||||
|
async {
|
||||||
|
let client = RemoteClient::new(addr.clone());
|
||||||
|
let request = test_lock_request(Duration::from_millis(5));
|
||||||
|
|
||||||
|
let _ = client.acquire_lock(&request).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
!runtime_sources::test_node_channel_is_cached(&addr).await,
|
||||||
|
"the first timeout on a quiet peer evicts the cached channel"
|
||||||
|
);
|
||||||
|
|
||||||
|
cache_lazy_channel(&addr).await;
|
||||||
|
let _ = client.acquire_lock(&request).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
runtime_sources::test_node_channel_is_cached(&addr).await,
|
||||||
|
"a second timeout inside the cooldown must not tear the fresh channel down again"
|
||||||
|
);
|
||||||
|
assert_eq!(lock_peer_health_for_test(&addr).consecutive_timeouts, 2);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
accept_task.abort();
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn test_remote_client_detaches_timed_out_rpc_and_reclaims_its_slot() {
|
||||||
|
ensure_test_rpc_secret();
|
||||||
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
cache_lazy_channel(&addr).await;
|
||||||
|
|
||||||
|
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50"))], async {
|
||||||
|
let client = RemoteClient::new(addr.clone());
|
||||||
|
let _ = client
|
||||||
|
.acquire_lock(&test_lock_request(Duration::from_millis(5)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
lock_peer_health_for_test(&addr).detached_rpcs,
|
||||||
|
1,
|
||||||
|
"the timed-out stream keeps running instead of being reset"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The hanging listener drops its socket after two seconds; the detached
|
||||||
|
// task then observes the transport failure and frees its slot.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
while lock_peer_health_for_test(&addr).detached_rpcs != 0 {
|
||||||
|
assert!(Instant::now() < deadline, "detached RPC slot must be reclaimed once the stream ends");
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
accept_task.abort();
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn test_remote_client_cancels_timed_out_rpc_when_detached_budget_is_exhausted() {
|
||||||
|
ensure_test_rpc_secret();
|
||||||
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
cache_lazy_channel(&addr).await;
|
||||||
|
|
||||||
|
temp_env::async_with_vars(
|
||||||
|
[
|
||||||
|
(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50")),
|
||||||
|
(rustfs_config::ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT, Some("0")),
|
||||||
|
],
|
||||||
|
async {
|
||||||
|
let client = RemoteClient::new(addr.clone());
|
||||||
|
let response = client
|
||||||
|
.acquire_lock(&test_lock_request(Duration::from_millis(5)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!response.success);
|
||||||
|
assert_eq!(
|
||||||
|
lock_peer_health_for_test(&addr).detached_rpcs,
|
||||||
|
0,
|
||||||
|
"an exhausted detached budget falls back to cancelling the stream"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
accept_task.abort();
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lock_mutation_helper_marks_single_and_batch_requests_for_rolling_auth() {
|
fn lock_mutation_helper_marks_single_and_batch_requests_for_rolling_auth() {
|
||||||
let mut single = Request::new(GenerallyLockRequest {
|
let mut single = Request::new(GenerallyLockRequest {
|
||||||
@@ -714,6 +1341,7 @@ mod tests {
|
|||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
cache_lazy_channel(&addr).await;
|
cache_lazy_channel(&addr).await;
|
||||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||||
|
|
||||||
@@ -759,6 +1387,7 @@ mod tests {
|
|||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
cache_lazy_channel(&addr).await;
|
cache_lazy_channel(&addr).await;
|
||||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||||
|
|
||||||
@@ -805,6 +1434,7 @@ mod tests {
|
|||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
cache_lazy_channel(&addr).await;
|
cache_lazy_channel(&addr).await;
|
||||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||||
|
|
||||||
@@ -842,6 +1472,7 @@ mod tests {
|
|||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
cache_lazy_channel(&addr).await;
|
cache_lazy_channel(&addr).await;
|
||||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||||
|
|
||||||
@@ -884,6 +1515,7 @@ mod tests {
|
|||||||
let Some(addr) = closed_listener_addr().await else {
|
let Some(addr) = closed_listener_addr().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
cache_lazy_channel(&addr).await;
|
cache_lazy_channel(&addr).await;
|
||||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||||
|
|
||||||
|
|||||||
@@ -840,15 +840,21 @@ fn is_decommission_start_active_pool(pool: &PoolStatus) -> bool {
|
|||||||
decommission_start_pool_state(Some(pool)) == DecommissionStartPoolState::Active
|
decommission_start_pool_state(Some(pool)) == DecommissionStartPoolState::Active
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn invalid_decommission_request(reason: impl Into<String>) -> Error {
|
||||||
|
Error::InvalidArgument("decommission".to_string(), "pool-state".to_string(), reason.into())
|
||||||
|
}
|
||||||
|
|
||||||
fn ensure_decommission_start_allowed(state: DecommissionStartPoolState) -> Result<()> {
|
fn ensure_decommission_start_allowed(state: DecommissionStartPoolState) -> Result<()> {
|
||||||
match state {
|
match state {
|
||||||
DecommissionStartPoolState::Missing => Err(Error::other("failed to start decommission: target pool was not found")),
|
DecommissionStartPoolState::Missing => {
|
||||||
|
Err(invalid_decommission_request("failed to start decommission: target pool was not found"))
|
||||||
|
}
|
||||||
DecommissionStartPoolState::Active | DecommissionStartPoolState::Retryable => Ok(()),
|
DecommissionStartPoolState::Active | DecommissionStartPoolState::Retryable => Ok(()),
|
||||||
DecommissionStartPoolState::Decommissioning => Err(StorageError::DecommissionAlreadyRunning),
|
DecommissionStartPoolState::Decommissioning => Err(StorageError::DecommissionAlreadyRunning),
|
||||||
DecommissionStartPoolState::Decommissioned => {
|
DecommissionStartPoolState::Decommissioned => Err(invalid_decommission_request(
|
||||||
Err(Error::other("failed to start decommission: target pool is already decommissioned"))
|
"failed to start decommission: target pool is already decommissioned",
|
||||||
}
|
)),
|
||||||
DecommissionStartPoolState::Blocked => Err(Error::other(
|
DecommissionStartPoolState::Blocked => Err(invalid_decommission_request(
|
||||||
"failed to start decommission: target pool decommission is blocked; clear failed or canceled metadata before starting again",
|
"failed to start decommission: target pool decommission is blocked; clear failed or canceled metadata before starting again",
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
@@ -865,7 +871,7 @@ fn ensure_decommission_start_keeps_active_pool(meta: &PoolMeta, indices: &[usize
|
|||||||
.filter(|idx| meta.pools.get(**idx).is_some_and(is_decommission_start_active_pool))
|
.filter(|idx| meta.pools.get(**idx).is_some_and(is_decommission_start_active_pool))
|
||||||
.count();
|
.count();
|
||||||
if active_count.saturating_sub(active_target_count) == 0 {
|
if active_count.saturating_sub(active_target_count) == 0 {
|
||||||
return Err(Error::other(
|
return Err(invalid_decommission_request(
|
||||||
"failed to start decommission: at least one active pool must remain after decommission start",
|
"failed to start decommission: at least one active pool must remain after decommission start",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1751,8 +1757,53 @@ fn ensure_decommission_capacity_reservations_available(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_external_decommission_target_admission(meta: &PoolMeta, target_pool_index: usize, phase: &'static str) -> Result<()> {
|
#[derive(Clone, Copy)]
|
||||||
if active_decommission_source_indices(meta).into_iter().any(|source_pool_index| {
|
pub(crate) enum DecommissionCapacityAdmission {
|
||||||
|
Mutation,
|
||||||
|
ExistingMultipart,
|
||||||
|
ScannerBacklog,
|
||||||
|
BatchDelete,
|
||||||
|
Heal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecommissionCapacityAdmission {
|
||||||
|
fn phase(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Mutation => "mutation",
|
||||||
|
Self::ExistingMultipart => "existing_multipart",
|
||||||
|
Self::ScannerBacklog => "scanner_backlog",
|
||||||
|
Self::BatchDelete => "batch_delete",
|
||||||
|
Self::Heal => "heal",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_external_decommission_target_admission(
|
||||||
|
meta: &PoolMeta,
|
||||||
|
target_pool_index: usize,
|
||||||
|
admission: DecommissionCapacityAdmission,
|
||||||
|
) -> Result<()> {
|
||||||
|
let phase = admission.phase();
|
||||||
|
// Pool selection may predate retirement or use a stale node-local snapshot.
|
||||||
|
// Recheck publication against the fenced durable state. Repair and pure
|
||||||
|
// capacity release retain their separate admission contracts.
|
||||||
|
if matches!(admission, DecommissionCapacityAdmission::ScannerBacklog)
|
||||||
|
&& !meta.scanner_pause_backlog_pool_writable(target_pool_index)
|
||||||
|
{
|
||||||
|
return Err(Error::SlowDown);
|
||||||
|
}
|
||||||
|
let active_sources = active_decommission_source_indices(meta);
|
||||||
|
if meta.is_suspended(target_pool_index) {
|
||||||
|
let active_source = active_sources.contains(&target_pool_index);
|
||||||
|
if !matches!(
|
||||||
|
admission,
|
||||||
|
DecommissionCapacityAdmission::Heal | DecommissionCapacityAdmission::ScannerBacklog
|
||||||
|
) && !(matches!(admission, DecommissionCapacityAdmission::ExistingMultipart) && active_source)
|
||||||
|
{
|
||||||
|
return Err(Error::SlowDown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if active_sources.into_iter().any(|source_pool_index| {
|
||||||
meta.pools
|
meta.pools
|
||||||
.get(source_pool_index)
|
.get(source_pool_index)
|
||||||
.and_then(|pool| pool.decommission.as_ref())
|
.and_then(|pool| pool.decommission.as_ref())
|
||||||
@@ -1762,6 +1813,13 @@ fn ensure_external_decommission_target_admission(meta: &PoolMeta, target_pool_in
|
|||||||
metrics::counter!(METRIC_DECOMMISSION_CAPACITY_CONFLICTS_TOTAL, "phase" => phase).increment(1);
|
metrics::counter!(METRIC_DECOMMISSION_CAPACITY_CONFLICTS_TOTAL, "phase" => phase).increment(1);
|
||||||
return Err(Error::SlowDown);
|
return Err(Error::SlowDown);
|
||||||
}
|
}
|
||||||
|
// Migration reservations budget the mover, not exclusive ownership of a
|
||||||
|
// healthy pool. Foreground publication shares its actual disk capacity;
|
||||||
|
// migration must retain the source if its capacity or target write fails.
|
||||||
|
// Repair keeps its separate, conservative reservation admission contract.
|
||||||
|
if !matches!(admission, DecommissionCapacityAdmission::Heal) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let reserved = active_decommission_target_reservations(meta)
|
let reserved = active_decommission_target_reservations(meta)
|
||||||
.get(&target_pool_index)
|
.get(&target_pool_index)
|
||||||
.copied()
|
.copied()
|
||||||
@@ -4268,7 +4326,7 @@ fn should_retry_decommission_cancel_reload(changed: bool, already_canceled: bool
|
|||||||
|
|
||||||
fn ensure_decommission_cancel_allowed(pool_present: bool, decommission_present: bool, terminal: bool) -> Result<()> {
|
fn ensure_decommission_cancel_allowed(pool_present: bool, decommission_present: bool, terminal: bool) -> Result<()> {
|
||||||
if !pool_present {
|
if !pool_present {
|
||||||
return Err(Error::other("failed to cancel decommission: target pool was not found"));
|
return Err(invalid_decommission_request("failed to cancel decommission: target pool was not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !decommission_present || terminal {
|
if !decommission_present || terminal {
|
||||||
@@ -4287,7 +4345,7 @@ fn ensure_decommission_clear_allowed(
|
|||||||
unresolved_entries: usize,
|
unresolved_entries: usize,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !pool_present {
|
if !pool_present {
|
||||||
return Err(Error::other("failed to clear decommission: target pool was not found"));
|
return Err(invalid_decommission_request("failed to clear decommission: target pool was not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !decommission_present {
|
if !decommission_present {
|
||||||
@@ -4303,7 +4361,7 @@ fn ensure_decommission_clear_allowed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if unresolved_entries > 0 {
|
if unresolved_entries > 0 {
|
||||||
return Err(Error::other(format!(
|
return Err(invalid_decommission_request(format!(
|
||||||
"failed to clear decommission: {unresolved_entries} unresolved listing entries must be reconciled by retrying decommission"
|
"failed to clear decommission: {unresolved_entries} unresolved listing entries must be reconciled by retrying decommission"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -4313,7 +4371,7 @@ fn ensure_decommission_clear_allowed(
|
|||||||
|
|
||||||
fn ensure_decommission_terminal_operation_supported(single_pool: bool, operation: &str) -> Result<()> {
|
fn ensure_decommission_terminal_operation_supported(single_pool: bool, operation: &str) -> Result<()> {
|
||||||
if single_pool {
|
if single_pool {
|
||||||
return Err(Error::other(format!(
|
return Err(invalid_decommission_request(format!(
|
||||||
"failed to {operation}: single pool deployments do not support decommission"
|
"failed to {operation}: single pool deployments do not support decommission"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -4323,7 +4381,9 @@ fn ensure_decommission_terminal_operation_supported(single_pool: bool, operation
|
|||||||
|
|
||||||
fn validate_start_decommission_request(indices: &[usize], single_pool: bool) -> Result<()> {
|
fn validate_start_decommission_request(indices: &[usize], single_pool: bool) -> Result<()> {
|
||||||
if indices.is_empty() {
|
if indices.is_empty() {
|
||||||
return Err(Error::other("failed to start decommission: no target pools were provided"));
|
return Err(invalid_decommission_request(
|
||||||
|
"failed to start decommission: no target pools were provided",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_decommission_terminal_operation_supported(single_pool, "start decommission")
|
ensure_decommission_terminal_operation_supported(single_pool, "start decommission")
|
||||||
@@ -4462,9 +4522,41 @@ pub(crate) struct PoolMetaWriteState {
|
|||||||
cluster_epoch: Option<u64>,
|
cluster_epoch: Option<u64>,
|
||||||
pool_meta_absent: bool,
|
pool_meta_absent: bool,
|
||||||
bootstrap_authority: PoolMetaBootstrapAuthority,
|
bootstrap_authority: PoolMetaBootstrapAuthority,
|
||||||
|
/// First-hand bootstrap authority this process holds for each pool
|
||||||
|
/// (index = pool index): `Fresh` only for pools it formatted itself,
|
||||||
|
/// `LegacyAdoption` only for pools whose migration it verified. Empty when
|
||||||
|
/// the caller tracks deployment-wide authority only.
|
||||||
|
pool_bootstrap_authorities: Vec<PoolMetaBootstrapAuthority>,
|
||||||
|
/// Whether this process hosts the first endpoint of the first pool and is
|
||||||
|
/// therefore the only writer allowed to publish the initial `pool.bin`.
|
||||||
|
/// `None` when the caller did not say; unknown writers are treated as
|
||||||
|
/// elected so every fail-closed rule still applies to them.
|
||||||
|
elected_bootstrap_writer: Option<bool>,
|
||||||
identity_initialized: Option<bool>,
|
identity_initialized: Option<bool>,
|
||||||
identity_fresh_bootstrap_nonce: Option<uuid::Uuid>,
|
identity_fresh_bootstrap_nonce: Option<uuid::Uuid>,
|
||||||
identity_needs_repair: bool,
|
identity_needs_repair: bool,
|
||||||
|
/// At least one pool has no identity replica at all.
|
||||||
|
identity_replicas_missing: bool,
|
||||||
|
/// At least one pool has a replica that is present but not a valid identity.
|
||||||
|
identity_replicas_invalid: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why an all-missing `pool.bin` set may not be initialized right now.
|
||||||
|
enum MissingMetadataRejection {
|
||||||
|
/// Another node still has to act (mint, attest, or publish); retrying the
|
||||||
|
/// startup loop is the remedy, so the write gate stays open.
|
||||||
|
BootstrapPending(Error),
|
||||||
|
/// The durable state contradicts a fresh bootstrap; writes stay blocked
|
||||||
|
/// until an operator recovers the metadata.
|
||||||
|
RecoveryRequired(Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MissingMetadataRejection {
|
||||||
|
fn into_error(self) -> Error {
|
||||||
|
match self {
|
||||||
|
Self::BootstrapPending(err) | Self::RecoveryRequired(err) => err,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
@@ -4496,6 +4588,7 @@ impl PoolMetaWriteState {
|
|||||||
Self::for_startup_with_bootstrap_authority(cluster_id, bootstrap_authority)
|
Self::for_startup_with_bootstrap_authority(cluster_id, bootstrap_authority)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) fn for_startup_with_bootstrap_authority(
|
pub(crate) fn for_startup_with_bootstrap_authority(
|
||||||
cluster_id: uuid::Uuid,
|
cluster_id: uuid::Uuid,
|
||||||
bootstrap_authority: PoolMetaBootstrapAuthority,
|
bootstrap_authority: PoolMetaBootstrapAuthority,
|
||||||
@@ -4507,10 +4600,82 @@ impl PoolMetaWriteState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Startup state for a process that loaded every pool format itself and
|
||||||
|
/// remembers, per pool, whether it created (or adopted) that pool
|
||||||
|
/// first-hand. Deployment-wide authority is the conjunction across pools:
|
||||||
|
/// any pool this process merely read yields `None`, exactly as before.
|
||||||
|
pub(crate) fn for_startup_with_pool_bootstrap_authorities(
|
||||||
|
cluster_id: uuid::Uuid,
|
||||||
|
pool_bootstrap_authorities: Vec<PoolMetaBootstrapAuthority>,
|
||||||
|
elected_bootstrap_writer: bool,
|
||||||
|
) -> Self {
|
||||||
|
let bootstrap_authority = pool_bootstrap_authorities
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.reduce(PoolMetaBootstrapAuthority::combine_across_pools)
|
||||||
|
.unwrap_or_default();
|
||||||
|
Self {
|
||||||
|
expected_cluster_id: Some(cluster_id),
|
||||||
|
bootstrap_authority,
|
||||||
|
pool_bootstrap_authorities,
|
||||||
|
elected_bootstrap_writer: Some(elected_bootstrap_writer),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn bootstrap_identity_proven(&self) -> bool {
|
pub(crate) fn bootstrap_identity_proven(&self) -> bool {
|
||||||
self.bootstrap_authority.is_proven()
|
self.bootstrap_authority.is_proven()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pool_bootstrap_authority_proven(&self, pool_idx: usize) -> bool {
|
||||||
|
self.pool_bootstrap_authorities
|
||||||
|
.get(pool_idx)
|
||||||
|
.is_some_and(|authority| authority.is_proven())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pools this process formatted or adopted first-hand during this startup.
|
||||||
|
pub(crate) fn attested_pool_indices(&self) -> Vec<usize> {
|
||||||
|
self.pool_bootstrap_authorities
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, authority)| authority.is_proven())
|
||||||
|
.map(|(pool_idx, _)| pool_idx)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deployment-level proof assembled from per-pool creators: this process
|
||||||
|
/// created the first pool itself, and every pool replica carries the same
|
||||||
|
/// pending identity. A pending replica is only ever written by the process
|
||||||
|
/// that formatted that pool with first-hand proof (see
|
||||||
|
/// [`PoolMetaIdentityWriteScope::Pools`]), so a complete, agreeing pending
|
||||||
|
/// set proves that every pool joined this bootstrap fresh. A missing,
|
||||||
|
/// corrupt, or disagreeing replica keeps the writer fail-closed, and a
|
||||||
|
/// restart without first-hand proof never reopens bootstrap on its own.
|
||||||
|
fn pending_identity_attested_by_every_pool(&self) -> bool {
|
||||||
|
self.elected_bootstrap_writer == Some(true)
|
||||||
|
&& self.pool_bootstrap_authority_proven(0)
|
||||||
|
&& self.identity_initialized == Some(false)
|
||||||
|
&& !self.identity_needs_repair
|
||||||
|
&& self.identity_fresh_bootstrap_nonce.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The elected writer minted (or holds) the nonce and the only thing
|
||||||
|
/// standing between it and a complete attestation is a pool whose creator
|
||||||
|
/// has not written its replica yet. Corrupt replicas are never transient.
|
||||||
|
fn awaiting_creator_attestation(&self) -> bool {
|
||||||
|
self.elected_bootstrap_writer == Some(true)
|
||||||
|
&& self.pool_bootstrap_authority_proven(0)
|
||||||
|
&& self.identity_initialized == Some(false)
|
||||||
|
&& self.identity_fresh_bootstrap_nonce.is_some()
|
||||||
|
&& self.identity_needs_repair
|
||||||
|
&& self.identity_replicas_missing
|
||||||
|
&& !self.identity_replicas_invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_non_elected_bootstrap_observer(&self) -> bool {
|
||||||
|
self.elected_bootstrap_writer == Some(false)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn identity_is_pending(&self) -> bool {
|
pub(crate) fn identity_is_pending(&self) -> bool {
|
||||||
self.identity_initialized == Some(false)
|
self.identity_initialized == Some(false)
|
||||||
}
|
}
|
||||||
@@ -4630,9 +4795,19 @@ impl PoolMetaWriteState {
|
|||||||
self.identity_needs_repair = selection.needs_repair;
|
self.identity_needs_repair = selection.needs_repair;
|
||||||
self.identity_initialized = selection.identity.map(|identity| identity.initialized);
|
self.identity_initialized = selection.identity.map(|identity| identity.initialized);
|
||||||
self.identity_fresh_bootstrap_nonce = selection.identity.and_then(|identity| identity.fresh_bootstrap_nonce);
|
self.identity_fresh_bootstrap_nonce = selection.identity.and_then(|identity| identity.fresh_bootstrap_nonce);
|
||||||
|
self.identity_replicas_missing = selection
|
||||||
|
.cas_tokens
|
||||||
|
.iter()
|
||||||
|
.any(|token| matches!(token, PoolMetaCasToken::Missing));
|
||||||
|
self.identity_replicas_invalid = selection
|
||||||
|
.valid_replicas
|
||||||
|
.iter()
|
||||||
|
.zip(&selection.cas_tokens)
|
||||||
|
.any(|(valid, token)| !valid && !matches!(token, PoolMetaCasToken::Missing));
|
||||||
if let Some(identity) = selection.identity {
|
if let Some(identity) = selection.identity {
|
||||||
if identity.initialized {
|
if identity.initialized {
|
||||||
self.bootstrap_authority = PoolMetaBootstrapAuthority::None;
|
self.bootstrap_authority = PoolMetaBootstrapAuthority::None;
|
||||||
|
self.pool_bootstrap_authorities.clear();
|
||||||
}
|
}
|
||||||
if let Some(metadata_epoch) = self.cluster_epoch
|
if let Some(metadata_epoch) = self.cluster_epoch
|
||||||
&& metadata_epoch != identity.epoch
|
&& metadata_epoch != identity.epoch
|
||||||
@@ -4655,22 +4830,44 @@ impl PoolMetaWriteState {
|
|||||||
if !self.pool_meta_absent {
|
if !self.pool_meta_absent {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
self.validate_missing_metadata_can_initialize()
|
match self.validate_missing_metadata_can_initialize() {
|
||||||
.map_err(|err| block_pool_meta_validation(self, err, "metadata_absence"))
|
Ok(()) => Ok(()),
|
||||||
|
Err(MissingMetadataRejection::BootstrapPending(err)) => Err(err),
|
||||||
|
Err(MissingMetadataRejection::RecoveryRequired(err)) => {
|
||||||
|
Err(block_pool_meta_validation(self, err, "metadata_absence"))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_missing_metadata_can_initialize(&self) -> Result<()> {
|
fn validate_missing_metadata_can_initialize(&self) -> std::result::Result<(), MissingMetadataRejection> {
|
||||||
|
use MissingMetadataRejection::{BootstrapPending, RecoveryRequired};
|
||||||
match self.identity_initialized {
|
match self.identity_initialized {
|
||||||
Some(false) if self.bootstrap_identity_proven() && self.identity_fresh_bootstrap_nonce.is_some() => Ok(()),
|
Some(false)
|
||||||
Some(false) => Err(Error::other(
|
if self.identity_fresh_bootstrap_nonce.is_some()
|
||||||
"pool metadata recovery required: pending cluster identity exists but this startup has no verified fresh-bootstrap proof or legacy-adoption proof",
|
&& (self.bootstrap_identity_proven() || self.pending_identity_attested_by_every_pool()) =>
|
||||||
)),
|
{
|
||||||
Some(true) => Err(Error::other(
|
Ok(())
|
||||||
|
}
|
||||||
|
Some(false) if self.awaiting_creator_attestation() => Err(BootstrapPending(Error::other(
|
||||||
|
"pool metadata bootstrap pending: waiting for every pool creator to attest the pending cluster identity",
|
||||||
|
))),
|
||||||
|
Some(false) if self.is_non_elected_bootstrap_observer() && self.identity_fresh_bootstrap_nonce.is_some() => {
|
||||||
|
Err(BootstrapPending(Error::other(
|
||||||
|
"pool metadata bootstrap pending: waiting for the elected writer to publish the initial pool.bin",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Some(false) => Err(RecoveryRequired(Error::other(
|
||||||
|
"pool metadata recovery required: pending cluster identity exists but this startup has no verified fresh-bootstrap proof, legacy-adoption proof, or complete per-pool creator attestation",
|
||||||
|
))),
|
||||||
|
Some(true) => Err(RecoveryRequired(Error::other(
|
||||||
"pool metadata recovery required: initialized cluster identity exists but every pool.bin replica is missing",
|
"pool metadata recovery required: initialized cluster identity exists but every pool.bin replica is missing",
|
||||||
)),
|
))),
|
||||||
None => Err(Error::other(
|
None if self.is_non_elected_bootstrap_observer() => Err(BootstrapPending(Error::other(
|
||||||
|
"pool metadata bootstrap pending: waiting for the elected writer to establish the cluster identity",
|
||||||
|
))),
|
||||||
|
None => Err(RecoveryRequired(Error::other(
|
||||||
"pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available",
|
"pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available",
|
||||||
)),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5381,7 +5578,9 @@ where
|
|||||||
write_state.validate_selection(&selection)?;
|
write_state.validate_selection(&selection)?;
|
||||||
selection.replica_state.ensure_write_safe(operation)?;
|
selection.replica_state.ensure_write_safe(operation)?;
|
||||||
if selection.absent && (write_state.expected_cluster_id.is_some() || write_state.identity_initialized.is_some()) {
|
if selection.absent && (write_state.expected_cluster_id.is_some() || write_state.identity_initialized.is_some()) {
|
||||||
write_state.validate_missing_metadata_can_initialize()?;
|
write_state
|
||||||
|
.validate_missing_metadata_can_initialize()
|
||||||
|
.map_err(MissingMetadataRejection::into_error)?;
|
||||||
}
|
}
|
||||||
Ok(selection)
|
Ok(selection)
|
||||||
}
|
}
|
||||||
@@ -5483,6 +5682,8 @@ struct PoolMetaIdentitySelection {
|
|||||||
needs_repair: bool,
|
needs_repair: bool,
|
||||||
repair_write_safe: bool,
|
repair_write_safe: bool,
|
||||||
cas_tokens: Vec<PoolMetaCasToken>,
|
cas_tokens: Vec<PoolMetaCasToken>,
|
||||||
|
/// Per pool: whether the replica decoded as a valid identity.
|
||||||
|
valid_replicas: Vec<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encode_pool_meta_identity(identity: PersistedPoolMetaIdentity) -> Result<Vec<u8>> {
|
fn encode_pool_meta_identity(identity: PersistedPoolMetaIdentity) -> Result<Vec<u8>> {
|
||||||
@@ -5531,6 +5732,17 @@ pub(crate) fn pool_meta_identity_initialized_for_test(data: &[u8]) -> Result<boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn pending_pool_meta_identity_for_test(cluster_id: uuid::Uuid, epoch: u64, nonce: uuid::Uuid) -> Result<Vec<u8>> {
|
||||||
|
encode_pool_meta_identity(PersistedPoolMetaIdentity {
|
||||||
|
version: POOL_META_IDENTITY_VERSION,
|
||||||
|
cluster_id,
|
||||||
|
epoch,
|
||||||
|
initialized: false,
|
||||||
|
fresh_bootstrap_nonce: Some(nonce),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn initialized_pool_meta_identity_for_test(cluster_id: uuid::Uuid, epoch: u64) -> Result<Vec<u8>> {
|
pub(crate) fn initialized_pool_meta_identity_for_test(cluster_id: uuid::Uuid, epoch: u64) -> Result<Vec<u8>> {
|
||||||
encode_pool_meta_identity(PersistedPoolMetaIdentity {
|
encode_pool_meta_identity(PersistedPoolMetaIdentity {
|
||||||
@@ -5571,6 +5783,10 @@ fn select_pool_meta_identity(
|
|||||||
expected_cluster_id: uuid::Uuid,
|
expected_cluster_id: uuid::Uuid,
|
||||||
) -> Result<PoolMetaIdentitySelection> {
|
) -> Result<PoolMetaIdentitySelection> {
|
||||||
let cas_tokens = reads.iter().map(|read| read.cas.clone()).collect();
|
let cas_tokens = reads.iter().map(|read| read.cas.clone()).collect();
|
||||||
|
let valid_replicas = reads
|
||||||
|
.iter()
|
||||||
|
.map(|read| matches!(read.replica, PoolMetaIdentityReplica::Valid(_)))
|
||||||
|
.collect();
|
||||||
let mut selected: Option<PersistedPoolMetaIdentity> = None;
|
let mut selected: Option<PersistedPoolMetaIdentity> = None;
|
||||||
let mut needs_repair = false;
|
let mut needs_repair = false;
|
||||||
let mut repair_write_safe = true;
|
let mut repair_write_safe = true;
|
||||||
@@ -5628,6 +5844,7 @@ fn select_pool_meta_identity(
|
|||||||
needs_repair,
|
needs_repair,
|
||||||
repair_write_safe,
|
repair_write_safe,
|
||||||
cas_tokens,
|
cas_tokens,
|
||||||
|
valid_replicas,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5897,10 +6114,43 @@ where
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which pool replicas a cluster-identity write may touch.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum PoolMetaIdentityWriteScope<'a> {
|
||||||
|
/// Every pool. Creating a pending identity here requires deployment-wide
|
||||||
|
/// fresh-bootstrap or legacy-adoption proof.
|
||||||
|
All,
|
||||||
|
/// Only the listed pools, each of which this process formatted or adopted
|
||||||
|
/// first-hand. Multi-pool bootstraps whose pools have distinct format
|
||||||
|
/// creators use this scope: the first pool's creator mints the deployment
|
||||||
|
/// nonce and every other creator copies it to its own pool, so the elected
|
||||||
|
/// writer can verify a complete, agreeing pending set instead of trusting
|
||||||
|
/// an in-process flag it cannot observe on another node.
|
||||||
|
Pools(&'a [usize]),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identity_write_satisfied(
|
||||||
|
selection: &PoolMetaIdentitySelection,
|
||||||
|
identity: PersistedPoolMetaIdentity,
|
||||||
|
scope: PoolMetaIdentityWriteScope<'_>,
|
||||||
|
targets: &[usize],
|
||||||
|
) -> bool {
|
||||||
|
if selection.identity != Some(identity) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match scope {
|
||||||
|
PoolMetaIdentityWriteScope::All => !selection.needs_repair,
|
||||||
|
PoolMetaIdentityWriteScope::Pools(_) => targets
|
||||||
|
.iter()
|
||||||
|
.all(|pool_idx| selection.valid_replicas.get(*pool_idx).copied().unwrap_or(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn persist_pool_meta_identity<S>(
|
async fn persist_pool_meta_identity<S>(
|
||||||
pools: Vec<Arc<S>>,
|
pools: Vec<Arc<S>>,
|
||||||
write_state: &mut PoolMetaWriteState,
|
write_state: &mut PoolMetaWriteState,
|
||||||
initialized: bool,
|
initialized: bool,
|
||||||
|
scope: PoolMetaIdentityWriteScope<'_>,
|
||||||
fence: &PoolMetaPersistenceFence<'_>,
|
fence: &PoolMetaPersistenceFence<'_>,
|
||||||
transaction_arm: &mut PoolMetaTransactionArm,
|
transaction_arm: &mut PoolMetaTransactionArm,
|
||||||
) -> Result<()>
|
) -> Result<()>
|
||||||
@@ -5910,6 +6160,23 @@ where
|
|||||||
let Some(cluster_id) = write_state.expected_cluster_id else {
|
let Some(cluster_id) = write_state.expected_cluster_id else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
let targets: Vec<usize> = match scope {
|
||||||
|
PoolMetaIdentityWriteScope::All => (0..pools.len()).collect(),
|
||||||
|
PoolMetaIdentityWriteScope::Pools(indices) => {
|
||||||
|
if initialized {
|
||||||
|
return Err(Error::other("pool metadata identity commit must address every pool"));
|
||||||
|
}
|
||||||
|
if indices
|
||||||
|
.iter()
|
||||||
|
.any(|pool_idx| *pool_idx >= pools.len() || !write_state.pool_bootstrap_authority_proven(*pool_idx))
|
||||||
|
{
|
||||||
|
return Err(Error::other(
|
||||||
|
"pool metadata recovery required: a pending cluster identity can only be attested for pools this startup formatted or adopted first-hand",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
indices.to_vec()
|
||||||
|
}
|
||||||
|
};
|
||||||
for attempt in 0..POOL_META_CAS_MAX_ATTEMPTS {
|
for attempt in 0..POOL_META_CAS_MAX_ATTEMPTS {
|
||||||
let selection = load_pool_meta_identity_selection_observing(pools.clone(), write_state, cluster_id).await?;
|
let selection = load_pool_meta_identity_selection_observing(pools.clone(), write_state, cluster_id).await?;
|
||||||
if !selection.repair_write_safe {
|
if !selection.repair_write_safe {
|
||||||
@@ -5918,20 +6185,39 @@ where
|
|||||||
"pool metadata recovery required: cluster identity has an unreadable replica",
|
"pool metadata recovery required: cluster identity has an unreadable replica",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let identity = match selection.identity {
|
let identity = match (selection.identity, scope) {
|
||||||
Some(identity) if identity.initialized || initialized => PersistedPoolMetaIdentity {
|
// An initialized deployment (for example a pool expansion) never
|
||||||
|
// reopens bootstrap: first-hand proof for a new pool is not a
|
||||||
|
// reason to publish a pending identity.
|
||||||
|
(Some(identity), PoolMetaIdentityWriteScope::Pools(_)) if identity.initialized => return Ok(()),
|
||||||
|
(Some(identity), _) if identity.initialized || initialized => PersistedPoolMetaIdentity {
|
||||||
initialized: true,
|
initialized: true,
|
||||||
fresh_bootstrap_nonce: None,
|
fresh_bootstrap_nonce: None,
|
||||||
..identity
|
..identity
|
||||||
},
|
},
|
||||||
Some(identity) => identity,
|
(Some(identity), _) => identity,
|
||||||
None if !initialized && !write_state.bootstrap_identity_proven() => {
|
// Only the first pool's creator mints the deployment nonce; every
|
||||||
|
// other creator waits until it is durable and copies it, so two
|
||||||
|
// concurrent creators can never publish disagreeing replicas.
|
||||||
|
(None, PoolMetaIdentityWriteScope::Pools(indices)) => {
|
||||||
|
if !indices.contains(&0) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
PersistedPoolMetaIdentity {
|
||||||
|
version: POOL_META_IDENTITY_VERSION,
|
||||||
|
cluster_id,
|
||||||
|
epoch: write_state.cluster_epoch.unwrap_or(POOL_META_INITIAL_EPOCH),
|
||||||
|
initialized: false,
|
||||||
|
fresh_bootstrap_nonce: Some(uuid::Uuid::new_v4()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, PoolMetaIdentityWriteScope::All) if !initialized && !write_state.bootstrap_identity_proven() => {
|
||||||
write_state.block_writes();
|
write_state.block_writes();
|
||||||
return Err(Error::other(
|
return Err(Error::other(
|
||||||
"pool metadata recovery required: cannot create a pending cluster identity without verified fresh-bootstrap proof or legacy-adoption proof",
|
"pool metadata recovery required: cannot create a pending cluster identity without verified fresh-bootstrap proof or legacy-adoption proof",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
None => PersistedPoolMetaIdentity {
|
(None, PoolMetaIdentityWriteScope::All) => PersistedPoolMetaIdentity {
|
||||||
version: POOL_META_IDENTITY_VERSION,
|
version: POOL_META_IDENTITY_VERSION,
|
||||||
cluster_id,
|
cluster_id,
|
||||||
epoch: write_state.cluster_epoch.unwrap_or(POOL_META_INITIAL_EPOCH),
|
epoch: write_state.cluster_epoch.unwrap_or(POOL_META_INITIAL_EPOCH),
|
||||||
@@ -5939,12 +6225,15 @@ where
|
|||||||
fresh_bootstrap_nonce: (!initialized).then(uuid::Uuid::new_v4),
|
fresh_bootstrap_nonce: (!initialized).then(uuid::Uuid::new_v4),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if selection.identity == Some(identity) && !selection.needs_repair {
|
if identity_write_satisfied(&selection, identity, scope, &targets) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let data = encode_pool_meta_identity(identity)?;
|
let data = encode_pool_meta_identity(identity)?;
|
||||||
let mut conflict = false;
|
let mut conflict = false;
|
||||||
for (pool, token) in pools.iter().cloned().zip(&selection.cas_tokens) {
|
for (pool_idx, (pool, token)) in pools.iter().cloned().zip(&selection.cas_tokens).enumerate() {
|
||||||
|
if !targets.contains(&pool_idx) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match save_pool_meta_object_cas(
|
match save_pool_meta_object_cas(
|
||||||
pool,
|
pool,
|
||||||
POOL_META_IDENTITY_NAME,
|
POOL_META_IDENTITY_NAME,
|
||||||
@@ -5971,7 +6260,7 @@ where
|
|||||||
return Err(Error::PreconditionFailed);
|
return Err(Error::PreconditionFailed);
|
||||||
}
|
}
|
||||||
let confirmed = load_pool_meta_identity_selection_observing(pools.clone(), write_state, cluster_id).await?;
|
let confirmed = load_pool_meta_identity_selection_observing(pools.clone(), write_state, cluster_id).await?;
|
||||||
if confirmed.identity == Some(identity) && !confirmed.needs_repair {
|
if identity_write_satisfied(&confirmed, identity, scope, &targets) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6003,6 +6292,34 @@ where
|
|||||||
pools,
|
pools,
|
||||||
write_state,
|
write_state,
|
||||||
initialized,
|
initialized,
|
||||||
|
PoolMetaIdentityWriteScope::All,
|
||||||
|
&PoolMetaPersistenceFence::Distributed(None),
|
||||||
|
&mut transaction_arm,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
transaction_arm.disarm();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attest, during startup, the pending cluster identity for the pools this
|
||||||
|
/// process formatted or adopted first-hand. The first pool's creator mints the
|
||||||
|
/// deployment nonce; every other creator copies it once it is durable. Nothing
|
||||||
|
/// is written while the deployment is already initialized or while the nonce
|
||||||
|
/// is not yet durable, so callers simply retry through the startup loop.
|
||||||
|
pub(crate) async fn persist_pool_meta_identity_for_attested_pools<S>(
|
||||||
|
pools: Vec<Arc<S>>,
|
||||||
|
write_state: &mut PoolMetaWriteState,
|
||||||
|
pool_indices: &[usize],
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
S: EcstoreObjectIO,
|
||||||
|
{
|
||||||
|
let mut transaction_arm = write_state.arm_transaction();
|
||||||
|
persist_pool_meta_identity(
|
||||||
|
pools,
|
||||||
|
write_state,
|
||||||
|
false,
|
||||||
|
PoolMetaIdentityWriteScope::Pools(pool_indices),
|
||||||
&PoolMetaPersistenceFence::Distributed(None),
|
&PoolMetaPersistenceFence::Distributed(None),
|
||||||
&mut transaction_arm,
|
&mut transaction_arm,
|
||||||
)
|
)
|
||||||
@@ -6618,7 +6935,15 @@ where
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
persist_pool_meta_identity(pools.clone(), write_state, true, fence, &mut transaction_arm).await?;
|
persist_pool_meta_identity(
|
||||||
|
pools.clone(),
|
||||||
|
write_state,
|
||||||
|
true,
|
||||||
|
PoolMetaIdentityWriteScope::All,
|
||||||
|
fence,
|
||||||
|
&mut transaction_arm,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let confirmed = load_pool_meta_for_transaction_recovery(pools, write_state).await?;
|
let confirmed = load_pool_meta_for_transaction_recovery(pools, write_state).await?;
|
||||||
if confirmed.revision != expected_revision
|
if confirmed.revision != expected_revision
|
||||||
|| confirmed.canonical.as_ref() != Some(&expected_canonical)
|
|| confirmed.canonical.as_ref() != Some(&expected_canonical)
|
||||||
@@ -6751,6 +7076,15 @@ impl PoolMeta {
|
|||||||
.is_some_and(is_decommission_suspended)
|
.is_some_and(is_decommission_suspended)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn scanner_pause_backlog_pool_writable(&self, idx: usize) -> bool {
|
||||||
|
self.pools.get(idx).is_some_and(|pool| {
|
||||||
|
!pool
|
||||||
|
.decommission
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|info| info.has_decommission_state() && !info.failed && !info.canceled)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn mark_decommission_progress_saved(&mut self) {
|
fn mark_decommission_progress_saved(&mut self) {
|
||||||
for pool in &mut self.pools {
|
for pool in &mut self.pools {
|
||||||
if let Some(info) = pool.decommission.as_mut() {
|
if let Some(info) = pool.decommission.as_mut() {
|
||||||
@@ -7235,7 +7569,15 @@ impl PoolMeta {
|
|||||||
}
|
}
|
||||||
if !selection.absent && write_state.identity_requires_repair() {
|
if !selection.absent && write_state.identity_requires_repair() {
|
||||||
let initialized = write_state.identity_initialized != Some(false) || selection.revision.is_generation_protocol();
|
let initialized = write_state.identity_initialized != Some(false) || selection.revision.is_generation_protocol();
|
||||||
persist_pool_meta_identity(pools.clone(), write_state, initialized, fence, transaction_arm).await?;
|
persist_pool_meta_identity(
|
||||||
|
pools.clone(),
|
||||||
|
write_state,
|
||||||
|
initialized,
|
||||||
|
PoolMetaIdentityWriteScope::All,
|
||||||
|
fence,
|
||||||
|
transaction_arm,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Startup is the only path allowed to create an all-missing metadata
|
// Startup is the only path allowed to create an all-missing metadata
|
||||||
@@ -7425,7 +7767,7 @@ impl PoolMeta {
|
|||||||
confirmed
|
confirmed
|
||||||
};
|
};
|
||||||
if confirmed.revision == revision && confirmed.canonical.as_ref() == Some(&durable) {
|
if confirmed.revision == revision && confirmed.canonical.as_ref() == Some(&durable) {
|
||||||
persist_pool_meta_identity(pools, write_state, true, fence, transaction_arm).await?;
|
persist_pool_meta_identity(pools, write_state, true, PoolMetaIdentityWriteScope::All, fence, transaction_arm).await?;
|
||||||
#[cfg(feature = "e2e-test-hooks")]
|
#[cfg(feature = "e2e-test-hooks")]
|
||||||
startup_cas_test_observe(serde_json::json!({
|
startup_cas_test_observe(serde_json::json!({
|
||||||
"kind": "confirmed", "object": POOL_META_NAME,
|
"kind": "confirmed", "object": POOL_META_NAME,
|
||||||
@@ -9760,10 +10102,10 @@ impl ECStore {
|
|||||||
pub(crate) async fn acquire_external_decommission_capacity_fence(
|
pub(crate) async fn acquire_external_decommission_capacity_fence(
|
||||||
&self,
|
&self,
|
||||||
target_pool_indices: &[usize],
|
target_pool_indices: &[usize],
|
||||||
phase: &'static str,
|
admission: DecommissionCapacityAdmission,
|
||||||
) -> Result<rustfs_lock::NamespaceLockGuard> {
|
) -> Result<rustfs_lock::NamespaceLockGuard> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.acquire_external_decommission_capacity_fence_with_active_source(target_pool_indices, phase)
|
.acquire_external_decommission_capacity_fence_with_active_source(target_pool_indices, admission)
|
||||||
.await?
|
.await?
|
||||||
.0)
|
.0)
|
||||||
}
|
}
|
||||||
@@ -9771,14 +10113,14 @@ impl ECStore {
|
|||||||
pub(crate) async fn acquire_external_decommission_capacity_fence_with_active_source(
|
pub(crate) async fn acquire_external_decommission_capacity_fence_with_active_source(
|
||||||
&self,
|
&self,
|
||||||
target_pool_indices: &[usize],
|
target_pool_indices: &[usize],
|
||||||
phase: &'static str,
|
admission: DecommissionCapacityAdmission,
|
||||||
) -> Result<(rustfs_lock::NamespaceLockGuard, bool)> {
|
) -> Result<(rustfs_lock::NamespaceLockGuard, bool)> {
|
||||||
let save_guard = self.pool_meta_save_gate.lock().await;
|
let save_guard = self.pool_meta_save_gate.lock().await;
|
||||||
let (pool_meta_guard, snapshot) = self
|
let (pool_meta_guard, snapshot) = self
|
||||||
.acquire_pool_meta_read_guard(&save_guard, "target capacity admission failed")
|
.acquire_pool_meta_read_guard(&save_guard, "target capacity admission failed")
|
||||||
.await?;
|
.await?;
|
||||||
for target_pool_index in target_pool_indices.iter().copied() {
|
for target_pool_index in target_pool_indices.iter().copied() {
|
||||||
ensure_external_decommission_target_admission(&snapshot, target_pool_index, phase)?;
|
ensure_external_decommission_target_admission(&snapshot, target_pool_index, admission)?;
|
||||||
}
|
}
|
||||||
let has_active_source = pool_meta_has_active_decommission(&snapshot);
|
let has_active_source = pool_meta_has_active_decommission(&snapshot);
|
||||||
drop(save_guard);
|
drop(save_guard);
|
||||||
@@ -9800,7 +10142,9 @@ impl ECStore {
|
|||||||
let admissions = target_pool_indices
|
let admissions = target_pool_indices
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.copied()
|
||||||
.map(|target_pool_index| ensure_external_decommission_target_admission(&snapshot, target_pool_index, "heal"))
|
.map(|target_pool_index| {
|
||||||
|
ensure_external_decommission_target_admission(&snapshot, target_pool_index, DecommissionCapacityAdmission::Heal)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
drop(save_guard);
|
drop(save_guard);
|
||||||
Ok((pool_meta_guard, admissions))
|
Ok((pool_meta_guard, admissions))
|
||||||
@@ -10474,7 +10818,7 @@ impl ECStore {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let Some((owner, model_version)) = admitted_owner else {
|
let Some((owner, model_version)) = admitted_owner else {
|
||||||
ensure_external_decommission_target_admission(&snapshot, target_pool_index, "mutation")?;
|
ensure_external_decommission_target_admission(&snapshot, target_pool_index, DecommissionCapacityAdmission::Mutation)?;
|
||||||
drop(save_guard);
|
drop(save_guard);
|
||||||
let capacity_lease = read_guard.lock_lost_signal();
|
let capacity_lease = read_guard.lock_lost_signal();
|
||||||
return operation.take().expect("capacity-admitted operation should run once")(capacity_lease).await;
|
return operation.take().expect("capacity-admitted operation should run once")(capacity_lease).await;
|
||||||
@@ -20638,17 +20982,17 @@ mod pools_tests {
|
|||||||
DecommissionStartPoolState, DecommissionTargetConsumption, DecommissionTerminalState, DecommissionUnresolvedEntry,
|
DecommissionStartPoolState, DecommissionTargetConsumption, DecommissionTerminalState, DecommissionUnresolvedEntry,
|
||||||
ListCallback, POOL_META_GENERATION_VERSION, POOL_META_IDENTITY_NAME, POOL_META_NAME, POOL_META_V1_VERSION,
|
ListCallback, POOL_META_GENERATION_VERSION, POOL_META_IDENTITY_NAME, POOL_META_NAME, POOL_META_V1_VERSION,
|
||||||
POOL_META_VERSION, PoolDecommissionInfo, PoolMeta, PoolMetaCasToken, PoolMetaPersistenceFence, PoolSpaceInfo, PoolStatus,
|
POOL_META_VERSION, PoolDecommissionInfo, PoolMeta, PoolMetaCasToken, PoolMetaPersistenceFence, PoolSpaceInfo, PoolStatus,
|
||||||
QueuedDecommissionEntry, REBAL_META_NAME, acquire_pool_rebalance_activation_locks, apply_decommission_status_space_info,
|
QueuedDecommissionEntry, REBAL_META_NAME, acquire_pool_rebalance_activation_locks, active_decommission_source_indices,
|
||||||
await_decommission_worker, bind_decommission_cancelers, bind_missing_decommission_cancelers,
|
apply_decommission_status_space_info, await_decommission_worker, bind_decommission_cancelers,
|
||||||
build_decommission_capacity_reservation, build_decommission_capacity_reservation_with_model,
|
bind_missing_decommission_cancelers, build_decommission_capacity_reservation,
|
||||||
cancel_decommission_canceler, clamp_decommission_entry_concurrency, classify_decommission_terminal_state,
|
build_decommission_capacity_reservation_with_model, cancel_decommission_canceler, clamp_decommission_entry_concurrency,
|
||||||
count_decommission_item, decommission_cancel_signal_result, decommission_durable_ilm_receipt_path,
|
classify_decommission_terminal_state, count_decommission_item, decommission_cancel_signal_result,
|
||||||
decommission_durable_ilm_receipt_run_prefix, decommission_durable_ilm_receipt_run_token,
|
decommission_durable_ilm_receipt_path, decommission_durable_ilm_receipt_run_prefix,
|
||||||
decommission_entry_queue_capacity, decommission_item_size, decommission_meta_bucket_options,
|
decommission_durable_ilm_receipt_run_token, decommission_entry_queue_capacity, decommission_item_size,
|
||||||
decommission_physical_pool_capacity, decommission_retry_backoff_delay, decommission_start_pool_state,
|
decommission_meta_bucket_options, decommission_physical_pool_capacity, decommission_retry_backoff_delay,
|
||||||
decommission_unresolved_listing_error, dedup_indices, default_decommission_bucket_concurrency,
|
decommission_start_pool_state, decommission_unresolved_listing_error, dedup_indices,
|
||||||
default_decommission_entry_concurrency, drain_decommission_entry_queue, enqueue_decommission_entry,
|
default_decommission_bucket_concurrency, default_decommission_entry_concurrency, drain_decommission_entry_queue,
|
||||||
ensure_decommission_cancel_allowed, ensure_decommission_capacity_reservations_available,
|
enqueue_decommission_entry, ensure_decommission_cancel_allowed, ensure_decommission_capacity_reservations_available,
|
||||||
ensure_decommission_clear_allowed, ensure_decommission_generation, ensure_decommission_listing_disks_available,
|
ensure_decommission_clear_allowed, ensure_decommission_generation, ensure_decommission_listing_disks_available,
|
||||||
ensure_decommission_not_rebalancing, ensure_decommission_start_allowed, ensure_decommission_start_keeps_active_pool,
|
ensure_decommission_not_rebalancing, ensure_decommission_start_allowed, ensure_decommission_start_keeps_active_pool,
|
||||||
ensure_decommission_start_local_leader, ensure_decommission_start_pool_states,
|
ensure_decommission_start_local_leader, ensure_decommission_start_pool_states,
|
||||||
@@ -20684,12 +21028,13 @@ mod pools_tests {
|
|||||||
with_decommission_entry_context,
|
with_decommission_entry_context,
|
||||||
};
|
};
|
||||||
use super::{
|
use super::{
|
||||||
DecommissionCapacityOwner, DecommissionCapacityReleaseProof, DecommissionCapacityReservation,
|
DecommissionCapacityAdmission, DecommissionCapacityOwner, DecommissionCapacityReleaseProof,
|
||||||
DecommissionCapacityTemporaryMutation, decommission_capacity_mutation_id, ensure_decommission_target_owner_admission,
|
DecommissionCapacityReservation, DecommissionCapacityTemporaryMutation, decommission_capacity_mutation_id,
|
||||||
ensure_exact_delete_capacity_namespace_fences, ensure_external_decommission_target_admission,
|
ensure_decommission_target_owner_admission, ensure_exact_delete_capacity_namespace_fences,
|
||||||
is_decommission_capacity_blocked_error, plan_exact_delete_capacity_reconciliations,
|
ensure_external_decommission_target_admission, is_decommission_capacity_blocked_error,
|
||||||
record_decommission_target_consumption, release_decommission_target_inflight, reserve_decommission_target_pending,
|
plan_exact_delete_capacity_reconciliations, record_decommission_target_consumption, release_decommission_target_inflight,
|
||||||
resolve_decommission_target_pending, set_decommission_capacity_info_overrides_for_test,
|
reserve_decommission_target_pending, resolve_decommission_target_pending,
|
||||||
|
set_decommission_capacity_info_overrides_for_test,
|
||||||
};
|
};
|
||||||
use crate::bucket::lifecycle::{
|
use crate::bucket::lifecycle::{
|
||||||
DurableIlmRecordCheckpoint,
|
DurableIlmRecordCheckpoint,
|
||||||
@@ -24843,6 +25188,25 @@ mod pools_tests {
|
|||||||
assert!(!pool_meta_has_active_decommission(&terminal_meta));
|
assert!(!pool_meta_has_active_decommission(&terminal_meta));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decommission_request_rejections_preserve_invalid_argument_type() {
|
||||||
|
for result in [
|
||||||
|
ensure_decommission_start_allowed(DecommissionStartPoolState::Missing),
|
||||||
|
ensure_decommission_start_allowed(DecommissionStartPoolState::Decommissioned),
|
||||||
|
ensure_decommission_start_allowed(DecommissionStartPoolState::Blocked),
|
||||||
|
ensure_decommission_cancel_allowed(false, false, false),
|
||||||
|
ensure_decommission_clear_allowed(false, false, false, false, false, 0),
|
||||||
|
ensure_decommission_clear_allowed(true, true, false, true, false, 1),
|
||||||
|
ensure_decommission_terminal_operation_supported(true, "cancel decommission"),
|
||||||
|
validate_start_decommission_request(&[], false),
|
||||||
|
validate_start_decommission_request(&[0], true),
|
||||||
|
ensure_decommission_start_keeps_active_pool(&PoolMeta::default(), &[]),
|
||||||
|
] {
|
||||||
|
let err = result.expect_err("invalid lifecycle requests must be rejected before mutation");
|
||||||
|
assert!(matches!(&err, Error::InvalidArgument(_, _, reason) if !reason.is_empty()), "{err:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_ensure_decommission_start_allowed_rejects_missing_pool() {
|
fn test_ensure_decommission_start_allowed_rejects_missing_pool() {
|
||||||
let err =
|
let err =
|
||||||
@@ -25545,7 +25909,7 @@ mod pools_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ordinary_write_admission_cannot_race_into_a_reserved_target() {
|
fn ordinary_write_admission_shares_a_reserved_target_without_becoming_its_owner() {
|
||||||
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(2);
|
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(2);
|
||||||
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||||
let capacity_infos = vec![
|
let capacity_infos = vec![
|
||||||
@@ -25569,13 +25933,18 @@ mod pools_tests {
|
|||||||
)
|
)
|
||||||
.expect("the decommission reservation should fit");
|
.expect("the decommission reservation should fit");
|
||||||
|
|
||||||
assert!(
|
for admission in [
|
||||||
matches!(
|
DecommissionCapacityAdmission::Mutation,
|
||||||
ensure_external_decommission_target_admission(&meta, 1, "ordinary_put"),
|
DecommissionCapacityAdmission::BatchDelete,
|
||||||
|
DecommissionCapacityAdmission::ScannerBacklog,
|
||||||
|
] {
|
||||||
|
ensure_external_decommission_target_admission(&meta, 1, admission)
|
||||||
|
.expect("a healthy target must remain writable while sharing capacity with migration");
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
ensure_external_decommission_target_admission(&meta, 1, DecommissionCapacityAdmission::Heal),
|
||||||
Err(Error::SlowDown)
|
Err(Error::SlowDown)
|
||||||
),
|
));
|
||||||
"an ordinary write must not consume a target reservation"
|
|
||||||
);
|
|
||||||
let rebalance_opts = ObjectOptions {
|
let rebalance_opts = ObjectOptions {
|
||||||
data_movement: true,
|
data_movement: true,
|
||||||
src_pool_idx: 0,
|
src_pool_idx: 0,
|
||||||
@@ -25602,6 +25971,119 @@ mod pools_tests {
|
|||||||
let mut decommission_opts = rebalance_opts;
|
let mut decommission_opts = rebalance_opts;
|
||||||
expected_owner.apply_to(&mut decommission_opts);
|
expected_owner.apply_to(&mut decommission_opts);
|
||||||
assert_eq!(DecommissionCapacityOwner::from_options(&decommission_opts), Some(expected_owner));
|
assert_eq!(DecommissionCapacityOwner::from_options(&decommission_opts), Some(expected_owner));
|
||||||
|
|
||||||
|
meta.pools[0]
|
||||||
|
.decommission
|
||||||
|
.as_mut()
|
||||||
|
.expect("active source")
|
||||||
|
.capacity_reservation = None;
|
||||||
|
for admission in [
|
||||||
|
DecommissionCapacityAdmission::Mutation,
|
||||||
|
DecommissionCapacityAdmission::BatchDelete,
|
||||||
|
DecommissionCapacityAdmission::ScannerBacklog,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
matches!(ensure_external_decommission_target_admission(&meta, 1, admission), Err(Error::SlowDown)),
|
||||||
|
"shared capacity must not bypass an active source's missing durable ledger"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_decommission_admission_fences_suspended_sources_but_preserves_repair() {
|
||||||
|
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(2);
|
||||||
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||||
|
let capacity_infos = vec![
|
||||||
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 30, 30),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(1, layout, 100, 100, 0),
|
||||||
|
];
|
||||||
|
let mut active = PoolMeta {
|
||||||
|
version: POOL_META_VERSION,
|
||||||
|
pools: vec![decommission_test_pool_status(0, None), decommission_test_pool_status(1, None)],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
active
|
||||||
|
.decommission(0, capacity_infos[0].space)
|
||||||
|
.expect("start the source admission fixture");
|
||||||
|
reserve_decommission_start_target_capacity(
|
||||||
|
&mut active,
|
||||||
|
&[0],
|
||||||
|
&capacity_infos,
|
||||||
|
uuid::Uuid::new_v4(),
|
||||||
|
1,
|
||||||
|
now,
|
||||||
|
DECOMMISSION_CAPACITY_MODEL_VERSION,
|
||||||
|
)
|
||||||
|
.expect("the active source must have a valid reservation to isolate its write fence");
|
||||||
|
|
||||||
|
for (state, queued, failed, canceled, complete) in [
|
||||||
|
("running", false, false, false, false),
|
||||||
|
("queued", true, false, false, false),
|
||||||
|
("failed", false, true, false, false),
|
||||||
|
("canceled", false, false, true, false),
|
||||||
|
("completed", false, false, false, true),
|
||||||
|
] {
|
||||||
|
let mut meta = active.clone();
|
||||||
|
let info = meta.pools[0].decommission.as_mut().expect("the source fixture must exist");
|
||||||
|
info.queued = queued;
|
||||||
|
info.failed = failed;
|
||||||
|
info.canceled = canceled;
|
||||||
|
info.complete = complete;
|
||||||
|
if queued || failed || canceled || complete {
|
||||||
|
info.start_time = None;
|
||||||
|
}
|
||||||
|
for admission in [
|
||||||
|
DecommissionCapacityAdmission::Mutation,
|
||||||
|
DecommissionCapacityAdmission::BatchDelete,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
matches!(ensure_external_decommission_target_admission(&meta, 0, admission), Err(Error::SlowDown)),
|
||||||
|
"{state} source must reject new publication until its decommission metadata is cleared"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let existing_multipart =
|
||||||
|
ensure_external_decommission_target_admission(&meta, 0, DecommissionCapacityAdmission::ExistingMultipart);
|
||||||
|
if active_decommission_source_indices(&meta).contains(&0) {
|
||||||
|
existing_multipart
|
||||||
|
.unwrap_or_else(|err| panic!("{state} source must allow an existing multipart upload to drain: {err}"));
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
matches!(existing_multipart, Err(Error::SlowDown)),
|
||||||
|
"{state} terminal source must reject an existing multipart publication"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ensure_external_decommission_target_admission(&meta, 0, DecommissionCapacityAdmission::Heal)
|
||||||
|
.unwrap_or_else(|err| panic!("{state} source repair must retain its capacity-only admission: {err}"));
|
||||||
|
let scanner_result =
|
||||||
|
ensure_external_decommission_target_admission(&meta, 0, DecommissionCapacityAdmission::ScannerBacklog);
|
||||||
|
assert_eq!(
|
||||||
|
meta.scanner_pause_backlog_pool_writable(0),
|
||||||
|
failed || canceled,
|
||||||
|
"{state} scanner selection"
|
||||||
|
);
|
||||||
|
if failed || canceled {
|
||||||
|
scanner_result.unwrap_or_else(|err| panic!("{state} scanner membership repair must remain writable: {err}"));
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
matches!(scanner_result, Err(Error::SlowDown)),
|
||||||
|
"{state} scanner publication must reject its source"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
meta.pools[0].decommission = None;
|
||||||
|
ensure_external_decommission_target_admission(&meta, 0, DecommissionCapacityAdmission::Mutation)
|
||||||
|
.unwrap_or_else(|err| panic!("cleared {state} source must become writable again: {err}"));
|
||||||
|
ensure_external_decommission_target_admission(&meta, 0, DecommissionCapacityAdmission::ScannerBacklog)
|
||||||
|
.unwrap_or_else(|err| panic!("cleared {state} scanner source must rejoin membership: {err}"));
|
||||||
|
}
|
||||||
|
assert!(!active.scanner_pause_backlog_pool_writable(active.pools.len()));
|
||||||
|
assert!(matches!(
|
||||||
|
ensure_external_decommission_target_admission(
|
||||||
|
&active,
|
||||||
|
active.pools.len(),
|
||||||
|
DecommissionCapacityAdmission::ScannerBacklog
|
||||||
|
),
|
||||||
|
Err(Error::SlowDown)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -570,6 +570,465 @@ mod decommission_lock_order_tests {
|
|||||||
.expect("decommission activation should commit after the probe release");
|
.expect("decommission activation should commit after the probe release");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn staged_external_put_rechecks_retiring_source_on_another_node() {
|
||||||
|
run_large_stack_current_thread_async_test("staged-retiring-source", || async {
|
||||||
|
let (_temp_dirs, store, other_store) = test_three_pool_stores_with_isolated_node_contexts(None).await;
|
||||||
|
let bucket = test_bucket("staged-source");
|
||||||
|
let object = "selected-before-retirement.bin";
|
||||||
|
let original = b"original source object";
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("create staged source bucket");
|
||||||
|
store.pools[0]
|
||||||
|
.put_object(&bucket, object, &mut PutObjReader::from_vec(original.to_vec()), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("seed the source selected before retirement");
|
||||||
|
|
||||||
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||||
|
set_decommission_capacity_info_overrides_for_test(
|
||||||
|
other_store.id,
|
||||||
|
vec![vec![
|
||||||
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 1024, 1024),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(1, layout, 4096, 4096, 0),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(2, layout, 0, 4096, 4096),
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
let barrier = DecommissionCapacityLockOrderBarrier::install(store.id, store.id);
|
||||||
|
barrier.pause_external_object_commit_phase();
|
||||||
|
let put_store = Arc::clone(&store);
|
||||||
|
let put_bucket = bucket.clone();
|
||||||
|
let put = tokio::spawn(async move {
|
||||||
|
put_store
|
||||||
|
.put_object(
|
||||||
|
&put_bucket,
|
||||||
|
object,
|
||||||
|
&mut PutObjReader::from_vec(b"must not replace a retiring source".to_vec()),
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), barrier.wait_until_external_object_commit_phase_started())
|
||||||
|
.await
|
||||||
|
.expect("public PUT must stage before its decommission commit probe");
|
||||||
|
assert!(!store.pool_meta.read().await.is_suspended(0));
|
||||||
|
other_store
|
||||||
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
||||||
|
.await
|
||||||
|
.expect("the other node should activate retirement before the staged PUT commits");
|
||||||
|
assert!(other_store.pool_meta.read().await.is_suspended(0));
|
||||||
|
assert!(
|
||||||
|
!store.pool_meta.read().await.is_suspended(0),
|
||||||
|
"the writer's local snapshot must remain stale to exercise the durable admission probe"
|
||||||
|
);
|
||||||
|
barrier.release_external_object_commit_phase();
|
||||||
|
let result = tokio::time::timeout(Duration::from_secs(30), put)
|
||||||
|
.await
|
||||||
|
.expect("staged PUT must finish after the commit probe is released")
|
||||||
|
.expect("staged PUT must not panic");
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(crate::error::Error::SlowDown)),
|
||||||
|
"a staged PUT must retry pool selection instead of committing to a newly retiring source: {result:?}"
|
||||||
|
);
|
||||||
|
let mut reader = store.pools[0]
|
||||||
|
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the original source must remain readable after admission rejects the replacement");
|
||||||
|
let mut body = Vec::new();
|
||||||
|
reader
|
||||||
|
.stream
|
||||||
|
.read_to_end(&mut body)
|
||||||
|
.await
|
||||||
|
.expect("read the full retained source body");
|
||||||
|
assert_eq!(body, original);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn reserved_target_shares_business_io_and_retains_source_after_capacity_loss() {
|
||||||
|
run_large_stack_current_thread_async_test("shared-decommission-capacity", || async {
|
||||||
|
for lose_capacity in [false, true] {
|
||||||
|
let (_temp_dirs, store, other_store) =
|
||||||
|
test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
|
||||||
|
let bucket = test_bucket("shared-capacity");
|
||||||
|
let object = "migrating-source.bin";
|
||||||
|
let business_object = "business-write.bin";
|
||||||
|
let multipart_object = "business-multipart.bin";
|
||||||
|
let source_body = vec![0x35; 256 * 1024];
|
||||||
|
let business_body = vec![0x57; 64 * 1024];
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("create shared-capacity bucket");
|
||||||
|
store.pools[0]
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
object,
|
||||||
|
&mut PutObjReader::from_vec(source_body.clone()),
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("seed the retiring source");
|
||||||
|
store.pools[2]
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
business_object,
|
||||||
|
&mut PutObjReader::from_vec(b"previous business value".to_vec()),
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("pin the public overwrite to the migration target");
|
||||||
|
let multipart_opts = ObjectOptions {
|
||||||
|
expected_bucket_incarnation_id: Some(
|
||||||
|
store
|
||||||
|
.bucket_incarnation_id(&bucket)
|
||||||
|
.await
|
||||||
|
.expect("load the multipart bucket identity"),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let routing_upload = new_multipart_upload(&store, 2, &bucket, multipart_object, multipart_opts.clone())
|
||||||
|
.await
|
||||||
|
.expect("pin subsequent public multipart creation to the migration target");
|
||||||
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||||
|
let target_total = source_body.len() * 8;
|
||||||
|
let capacities = vec![
|
||||||
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, source_body.len() * 2, source_body.len() * 2),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(1, layout, 0, target_total, target_total),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(2, layout, target_total, target_total, 0),
|
||||||
|
];
|
||||||
|
set_decommission_capacity_info_overrides_for_test(store.id, vec![capacities.clone()]);
|
||||||
|
store
|
||||||
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
||||||
|
.await
|
||||||
|
.expect("activate source retirement");
|
||||||
|
*other_store.pool_meta.write().await = store.pool_meta.read().await.clone();
|
||||||
|
let before = other_store.pool_meta.read().await.clone();
|
||||||
|
let reservation = before.pools[0]
|
||||||
|
.decommission
|
||||||
|
.as_ref()
|
||||||
|
.expect("active source")
|
||||||
|
.capacity_reservation
|
||||||
|
.as_ref()
|
||||||
|
.expect("durable reservation");
|
||||||
|
assert_eq!(
|
||||||
|
reservation.model_version, 2,
|
||||||
|
"exercise migration I/O outside the global metadata write lock"
|
||||||
|
);
|
||||||
|
assert_eq!(reservation.targets[0].pool_index, 2);
|
||||||
|
|
||||||
|
let barrier =
|
||||||
|
crate::set_disk::rename_fanout_barrier::arm(object, 0, crate::set_disk::rename_fanout_barrier::PHASE_RENAME);
|
||||||
|
let migration_store = Arc::clone(&store);
|
||||||
|
let migration_bucket = bucket.clone();
|
||||||
|
let migration = tokio::spawn(async move {
|
||||||
|
migration_store
|
||||||
|
.decommission_entry_for_test_with_bucket_incarnation(
|
||||||
|
0,
|
||||||
|
MetaCacheEntry {
|
||||||
|
name: object.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
migration_bucket,
|
||||||
|
migration_store.pools[0].get_disks_by_key(object),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), barrier.wait_until_paused())
|
||||||
|
.await
|
||||||
|
.expect("migration must reach target publication");
|
||||||
|
assert!(!migration.is_finished());
|
||||||
|
let mut pending = crate::core::pools::PoolMeta::default();
|
||||||
|
pending
|
||||||
|
.load_no_lock_from_replicas(other_store.pools.clone())
|
||||||
|
.await
|
||||||
|
.expect("read migration intent from the other node");
|
||||||
|
let pending_reservation = pending.pools[0]
|
||||||
|
.decommission
|
||||||
|
.as_ref()
|
||||||
|
.expect("active source")
|
||||||
|
.capacity_reservation
|
||||||
|
.as_ref()
|
||||||
|
.expect("pending reservation")
|
||||||
|
.clone();
|
||||||
|
assert_eq!(pending_reservation.pending_target_physical_bytes, source_body.len());
|
||||||
|
assert_eq!(pending_reservation.consumed_target_physical_bytes, 0);
|
||||||
|
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
other_store.put_object(
|
||||||
|
&bucket,
|
||||||
|
business_object,
|
||||||
|
&mut PutObjReader::from_vec(business_body.clone()),
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("business PUT must finish without waiting for the migration target gate")
|
||||||
|
.expect("a reserved healthy pool must accept ordinary PUT");
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
let upload = other_store
|
||||||
|
.new_multipart_upload(&bucket, multipart_object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the reserved target must accept public multipart creation");
|
||||||
|
assert_ne!(upload.upload_id, routing_upload.upload_id);
|
||||||
|
let lifecycle_guard = other_store
|
||||||
|
.acquire_bucket_lifecycle_read_lock(&bucket)
|
||||||
|
.await
|
||||||
|
.expect("fence the exact-pool multipart placement check");
|
||||||
|
let mut lookup_opts = multipart_opts.clone();
|
||||||
|
lookup_opts.add_bucket_lifecycle_lock_guard(&lifecycle_guard);
|
||||||
|
other_store.pools[2]
|
||||||
|
.get_multipart_info(&bucket, multipart_object, &upload.upload_id, &lookup_opts)
|
||||||
|
.await
|
||||||
|
.expect("public multipart creation must actually select the reserved target");
|
||||||
|
drop(lifecycle_guard);
|
||||||
|
let mut final_part = None;
|
||||||
|
for payload in [vec![0x18; business_body.len()], business_body.clone()] {
|
||||||
|
final_part = Some(
|
||||||
|
other_store
|
||||||
|
.put_object_part(
|
||||||
|
&bucket,
|
||||||
|
multipart_object,
|
||||||
|
&upload.upload_id,
|
||||||
|
1,
|
||||||
|
&mut PutObjReader::from_vec(payload),
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the reserved target must accept UploadPart and replacement of the same part"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let part = final_part.expect("the replacement part must be present");
|
||||||
|
Arc::clone(&other_store)
|
||||||
|
.complete_multipart_upload(
|
||||||
|
&bucket,
|
||||||
|
multipart_object,
|
||||||
|
&upload.upload_id,
|
||||||
|
vec![crate::storage_api_contracts::multipart::CompletePart {
|
||||||
|
part_num: part.part_num,
|
||||||
|
etag: part.etag,
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the reserved target must accept multipart completion");
|
||||||
|
other_store
|
||||||
|
.abort_multipart_upload(&bucket, multipart_object, &routing_upload.upload_id, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("ordinary multipart cleanup must not consume the migration's pending intent");
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("business multipart operations must finish while migration I/O is paused");
|
||||||
|
assert!(!migration.is_finished(), "business publication must overlap paused migration I/O");
|
||||||
|
let mut after_business = crate::core::pools::PoolMeta::default();
|
||||||
|
after_business
|
||||||
|
.load_no_lock_from_replicas(other_store.pools.clone())
|
||||||
|
.await
|
||||||
|
.expect("reload the shared-capacity ledger");
|
||||||
|
assert_eq!(
|
||||||
|
after_business.pools[0]
|
||||||
|
.decommission
|
||||||
|
.as_ref()
|
||||||
|
.expect("active source")
|
||||||
|
.capacity_reservation
|
||||||
|
.as_ref(),
|
||||||
|
Some(&pending_reservation),
|
||||||
|
"ordinary PUT and multipart operations must not settle or consume the migration's pending identity"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut after_capacity = capacities;
|
||||||
|
// Capacity injection is deterministic; the object I/O and durable metadata use real temporary disks.
|
||||||
|
let free = if lose_capacity {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
target_total - source_body.len() - business_body.len() * 2
|
||||||
|
};
|
||||||
|
after_capacity[2] = DecommissionPoolCapacityInfo::for_test(2, layout, free, target_total, target_total - free);
|
||||||
|
set_decommission_capacity_info_overrides_for_test(store.id, vec![after_capacity]);
|
||||||
|
barrier.release();
|
||||||
|
drop(barrier);
|
||||||
|
let migrated = tokio::time::timeout(Duration::from_secs(30), migration)
|
||||||
|
.await
|
||||||
|
.expect("migration must finish after publication resumes")
|
||||||
|
.expect("migration task must not panic");
|
||||||
|
if lose_capacity {
|
||||||
|
let err =
|
||||||
|
migrated.expect_err("capacity loss must prevent source cleanup, even after the target write commits");
|
||||||
|
assert!(err.to_string().contains("capacity"), "unexpected migration error: {err}");
|
||||||
|
} else {
|
||||||
|
migrated.expect("shared-capacity migration should finish when space remains sufficient");
|
||||||
|
}
|
||||||
|
let mut persisted = crate::core::pools::PoolMeta::default();
|
||||||
|
persisted
|
||||||
|
.load_no_lock_from_replicas(other_store.pools.clone())
|
||||||
|
.await
|
||||||
|
.expect("reload finalized migration state");
|
||||||
|
let info = persisted.pools[0].decommission.as_ref().expect("source state");
|
||||||
|
let reservation = info.capacity_reservation.as_ref().expect("migration ledger");
|
||||||
|
assert_eq!(reservation.pending_target_physical_bytes, 0);
|
||||||
|
assert_eq!(
|
||||||
|
reservation.consumed_target_physical_bytes,
|
||||||
|
source_body.len(),
|
||||||
|
"foreign writes must not count as committed source bytes"
|
||||||
|
);
|
||||||
|
assert_eq!(reservation.committed_data_bytes, source_body.len());
|
||||||
|
assert_eq!(info.capacity_blocked_reason.is_some(), lose_capacity);
|
||||||
|
for (pool, key, expected) in [
|
||||||
|
(2, business_object, &business_body),
|
||||||
|
(2, multipart_object, &business_body),
|
||||||
|
(2, object, &source_body),
|
||||||
|
] {
|
||||||
|
let mut reader = other_store.pools[pool]
|
||||||
|
.get_object_reader(&bucket, key, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("all acknowledged target objects must remain readable");
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
reader.read_to_end(&mut actual).await.expect("read the complete target body");
|
||||||
|
assert_eq!(&actual, expected);
|
||||||
|
}
|
||||||
|
if lose_capacity {
|
||||||
|
let mut source = other_store.pools[0]
|
||||||
|
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("capacity-blocked migration must retain its source");
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
source
|
||||||
|
.read_to_end(&mut actual)
|
||||||
|
.await
|
||||||
|
.expect("read the complete retained source");
|
||||||
|
assert_eq!(actual, source_body);
|
||||||
|
other_store
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
business_object,
|
||||||
|
&mut PutObjReader::from_vec(business_body.clone()),
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("a capacity-blocked migration must not itself make the healthy target read-only");
|
||||||
|
} else {
|
||||||
|
let err = other_store.pools[0]
|
||||||
|
.get_object_info(&bucket, object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect_err("successful migration must clean the exact source");
|
||||||
|
assert!(crate::error::is_err_object_not_found(&err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn mixed_batch_delete_admits_only_marker_destinations_during_retirement() {
|
||||||
|
run_large_stack_current_thread_async_test("batch-marker-admission", || async {
|
||||||
|
use crate::storage_api_contracts::object::ObjectToDelete;
|
||||||
|
|
||||||
|
for marker_target in [1, 2] {
|
||||||
|
let (_temp_dirs, store, _other_store) = test_three_pool_stores_with_isolated_node_contexts(None).await;
|
||||||
|
let bucket = test_bucket("batch-marker");
|
||||||
|
store
|
||||||
|
.make_bucket(
|
||||||
|
&bucket,
|
||||||
|
&MakeBucketOptions {
|
||||||
|
versioning_enabled: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create a versioned batch-delete bucket");
|
||||||
|
let source_version = uuid::Uuid::new_v4();
|
||||||
|
for (pool, object, version) in [
|
||||||
|
(0, "purge-source", source_version),
|
||||||
|
(marker_target, "mark-active", uuid::Uuid::new_v4()),
|
||||||
|
] {
|
||||||
|
store.pools[pool]
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
object,
|
||||||
|
&mut PutObjReader::from_vec(b"version to delete".to_vec()),
|
||||||
|
&ObjectOptions {
|
||||||
|
versioned: true,
|
||||||
|
version_id: Some(version.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("seed each exact batch-delete destination");
|
||||||
|
}
|
||||||
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||||
|
set_decommission_capacity_info_overrides_for_test(
|
||||||
|
store.id,
|
||||||
|
vec![vec![
|
||||||
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 1024, 1024),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(1, layout, 4096, 4096, 0),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(2, layout, 0, 4096, 4096),
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
||||||
|
.await
|
||||||
|
.expect("reserve pool 1 while pool 0 retires and pool 2 remains unreserved");
|
||||||
|
let (deleted, errors) = store
|
||||||
|
.delete_objects(
|
||||||
|
&bucket,
|
||||||
|
vec![
|
||||||
|
ObjectToDelete {
|
||||||
|
object_name: "mark-active".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
ObjectToDelete {
|
||||||
|
object_name: "purge-source".to_string(),
|
||||||
|
version_id: Some(source_version),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ObjectOptions::default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(errors.len(), 2);
|
||||||
|
assert!(
|
||||||
|
errors.iter().all(Option::is_none),
|
||||||
|
"the unrelated retiring/reserved pools must not reject marker admission: {errors:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(deleted.len(), 2);
|
||||||
|
assert_eq!(deleted[0].object_name, "mark-active");
|
||||||
|
assert!(deleted[0].delete_marker);
|
||||||
|
assert!(
|
||||||
|
deleted[0].version_id.is_none(),
|
||||||
|
"a latest-version delete does not request an explicit version"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
deleted[0].delete_marker_version_id.is_some(),
|
||||||
|
"the newly created marker must have its own version identity"
|
||||||
|
);
|
||||||
|
assert_eq!(deleted[1].object_name, "purge-source");
|
||||||
|
assert!(!deleted[1].delete_marker);
|
||||||
|
assert_eq!(deleted[1].version_id, Some(source_version));
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
store.pools[0]
|
||||||
|
.get_object_info(
|
||||||
|
&bucket,
|
||||||
|
"purge-source",
|
||||||
|
&ObjectOptions {
|
||||||
|
version_id: Some(source_version.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
Err(crate::error::Error::ObjectNotFound(..) | crate::error::Error::VersionNotFound(..))
|
||||||
|
),
|
||||||
|
"an exact source deletion must retain its capacity-release path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn public_upload_part_holds_decommission_capacity_until_rename() {
|
async fn public_upload_part_holds_decommission_capacity_until_rename() {
|
||||||
@@ -4499,6 +4958,470 @@ mod decommission_lock_order_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn scanner_backlog_cas_keeps_fences_after_waiter_cancellation_until_rename_drains() {
|
||||||
|
run_large_stack_current_thread_async_test("scanner-backlog-canceled-waiter", async || {
|
||||||
|
temp_env::async_with_vars([(crate::set_disk::ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
|
||||||
|
let (_temp_dirs, writer, other) =
|
||||||
|
test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
|
||||||
|
let object = "buckets/.scanner-pause-backlog.json";
|
||||||
|
let set_index = 1;
|
||||||
|
let body = vec![0x37; 1024];
|
||||||
|
assert!(
|
||||||
|
!writer.pools[0].disk_set[0]
|
||||||
|
.shares_namespace_lock_domain(&writer.pools[0].disk_set[set_index])
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
let rename_tasks = crate::set_disk::rename_fanout_barrier::observe_tasks(object);
|
||||||
|
let tail =
|
||||||
|
crate::set_disk::rename_fanout_barrier::arm(object, 0, crate::set_disk::rename_fanout_barrier::PHASE_RENAME);
|
||||||
|
let put_store = Arc::clone(&writer);
|
||||||
|
let put_body = body.clone();
|
||||||
|
let mut put = tokio::spawn(async move {
|
||||||
|
put_store
|
||||||
|
.save_scanner_pause_backlog_replica(0, set_index, put_body, Default::default())
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), tail.wait_until_paused())
|
||||||
|
.await
|
||||||
|
.expect("the native write must reach its held rename");
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
while rename_tasks.running() != 1 {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("the other disks must reach quorum before canceling the waiter");
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(100), &mut put).await.is_err(),
|
||||||
|
"native replica publication must await the entire rename tail"
|
||||||
|
);
|
||||||
|
put.abort();
|
||||||
|
assert!(put.await.expect_err("the scanner waiter must be canceled").is_cancelled());
|
||||||
|
|
||||||
|
let capacity_lock = other
|
||||||
|
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
|
||||||
|
.await
|
||||||
|
.expect("capacity lock probe");
|
||||||
|
let object_lock = other
|
||||||
|
.new_ns_lock(RUSTFS_META_BUCKET, object)
|
||||||
|
.await
|
||||||
|
.expect("fixed object lock probe");
|
||||||
|
let mut capacity_probe = tokio::spawn(async move { capacity_lock.get_write_lock(Duration::from_secs(30)).await });
|
||||||
|
let mut object_probe = tokio::spawn(async move { object_lock.get_write_lock(Duration::from_secs(30)).await });
|
||||||
|
for (label, probe) in [("capacity", &mut capacity_probe), ("fixed object", &mut object_probe)] {
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(100), probe).await.is_err(),
|
||||||
|
"canceling the scanner waiter must retain its {label} fence while rename is pending"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tail.release();
|
||||||
|
drop(tail);
|
||||||
|
for probe in [capacity_probe, object_probe] {
|
||||||
|
drop(
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), probe)
|
||||||
|
.await
|
||||||
|
.expect("publication fence must drain after rename")
|
||||||
|
.expect("lock probe must not panic")
|
||||||
|
.expect("publication fence must eventually be released"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut reader = writer.pools[0].disk_set[set_index]
|
||||||
|
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("canceled waiter must leave the committed replica readable");
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
reader
|
||||||
|
.read_to_end(&mut actual)
|
||||||
|
.await
|
||||||
|
.expect("read the full native replica after tail drain");
|
||||||
|
assert_eq!(actual, body);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn scanner_backlog_cas_rejects_lost_capacity_lease_before_publication() {
|
||||||
|
run_large_stack_current_thread_async_test("scanner-backlog-lease-loss", async || {
|
||||||
|
let (_temp_dirs, writer, other) = test_three_pool_stores_with_isolated_node_contexts(None).await;
|
||||||
|
let object = "buckets/.scanner-pause-backlog.json";
|
||||||
|
let body = b"native source before lease loss".to_vec();
|
||||||
|
let original = writer
|
||||||
|
.save_scanner_pause_backlog_replica(2, 1, body.clone(), Default::default())
|
||||||
|
.await
|
||||||
|
.expect("seed the exact native replica set");
|
||||||
|
let (lossy, refresh_calls) = store_with_capacity_lease_loss(&other).await;
|
||||||
|
let barrier = PutObjectCommitBarrier::install(RUSTFS_META_BUCKET, object, PutObjectCommitPause::BeforeQuotaRename);
|
||||||
|
let put = tokio::spawn(async move {
|
||||||
|
lossy
|
||||||
|
.save_scanner_pause_backlog_replica(
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
b"must not commit after lease loss".to_vec(),
|
||||||
|
crate::storage_api_contracts::object::HTTPPreconditions {
|
||||||
|
if_match: original.etag,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), barrier.wait_until_paused())
|
||||||
|
.await
|
||||||
|
.expect("native CAS must reach its commit barrier");
|
||||||
|
tokio::time::pause();
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
refresh_calls.arm();
|
||||||
|
tokio::time::advance(Duration::from_secs(11)).await;
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
assert!(
|
||||||
|
refresh_calls.load(Ordering::Acquire) > 0,
|
||||||
|
"the durable metadata lease must lose refresh quorum"
|
||||||
|
);
|
||||||
|
barrier.release();
|
||||||
|
tokio::time::resume();
|
||||||
|
let err = tokio::time::timeout(Duration::from_secs(30), put)
|
||||||
|
.await
|
||||||
|
.expect("native CAS must finish after the barrier release")
|
||||||
|
.expect("native CAS task must not panic")
|
||||||
|
.expect_err("a lost outer capacity lease must reject native publication");
|
||||||
|
assert!(
|
||||||
|
matches!(err, crate::error::Error::NamespaceLockQuorumUnavailable { .. }),
|
||||||
|
"unexpected lease error: {err}"
|
||||||
|
);
|
||||||
|
drop(barrier);
|
||||||
|
let mut reader = writer.pools[2].disk_set[1]
|
||||||
|
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the preexisting replica must survive lease loss");
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
reader.read_to_end(&mut actual).await.expect("read the full retained replica");
|
||||||
|
assert_eq!(actual, body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn scanner_backlog_cas_rejects_a_retiring_source_on_a_stale_node() {
|
||||||
|
run_large_stack_current_thread_async_test("scanner-backlog-source-fence", async || {
|
||||||
|
let (_temp_dirs, store, writer) = test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
|
||||||
|
let object = "buckets/.scanner-pause-backlog.json";
|
||||||
|
let body = b"frozen native scanner replica".to_vec();
|
||||||
|
let source_set_index = (writer.pools[0].get_disks_by_key(object).set_index + 1) % writer.pools[0].disk_set.len();
|
||||||
|
assert_ne!(
|
||||||
|
source_set_index,
|
||||||
|
writer.pools[0].get_disks_by_key(object).set_index,
|
||||||
|
"exercise a non-routed native set"
|
||||||
|
);
|
||||||
|
let original = writer
|
||||||
|
.save_scanner_pause_backlog_replica(
|
||||||
|
0,
|
||||||
|
source_set_index,
|
||||||
|
body.clone(),
|
||||||
|
crate::storage_api_contracts::object::HTTPPreconditions {
|
||||||
|
if_none_match: Some("*".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("seed a native scanner replica before retirement");
|
||||||
|
assert!(
|
||||||
|
writer
|
||||||
|
.scanner_pause_backlog_writable_set_disks()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|set| set.pool_index == 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||||
|
let target_total = body.len() * 8;
|
||||||
|
set_decommission_capacity_info_overrides_for_test(
|
||||||
|
store.id,
|
||||||
|
vec![vec![
|
||||||
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, body.len() * 2, body.len() * 2),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(1, layout, 0, target_total, target_total),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(2, layout, target_total, target_total, 0),
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
||||||
|
.await
|
||||||
|
.expect("another node durably retires the selected source");
|
||||||
|
assert!(
|
||||||
|
writer.pool_meta.read().await.pools[0].decommission.is_none(),
|
||||||
|
"the writer must retain a stale snapshot"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
writer
|
||||||
|
.scanner_pause_backlog_writable_set_disks()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|set| set.pool_index == 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = writer
|
||||||
|
.save_scanner_pause_backlog_replica(
|
||||||
|
0,
|
||||||
|
source_set_index,
|
||||||
|
b"late native scanner update".to_vec(),
|
||||||
|
crate::storage_api_contracts::object::HTTPPreconditions {
|
||||||
|
if_match: original.etag.clone(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(crate::error::Error::SlowDown)),
|
||||||
|
"late native source publication must fail: {result:?}"
|
||||||
|
);
|
||||||
|
let mut source = writer.pools[0].disk_set[source_set_index]
|
||||||
|
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the original source must remain readable");
|
||||||
|
assert_eq!(source.object_info.etag, original.etag);
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
source
|
||||||
|
.read_to_end(&mut actual)
|
||||||
|
.await
|
||||||
|
.expect("read the entire retained source");
|
||||||
|
assert_eq!(actual, body);
|
||||||
|
|
||||||
|
for set in &writer.pools[2].disk_set {
|
||||||
|
let target_body = format!("surviving native scanner set {}", set.set_index).into_bytes();
|
||||||
|
let committed = writer
|
||||||
|
.save_scanner_pause_backlog_replica(
|
||||||
|
2,
|
||||||
|
set.set_index,
|
||||||
|
target_body.clone(),
|
||||||
|
crate::storage_api_contracts::object::HTTPPreconditions {
|
||||||
|
if_none_match: Some("*".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("every reserved healthy target set must still accept scanner replicas");
|
||||||
|
let conflict = writer
|
||||||
|
.save_scanner_pause_backlog_replica(
|
||||||
|
2,
|
||||||
|
set.set_index,
|
||||||
|
b"must not bypass CAS".to_vec(),
|
||||||
|
crate::storage_api_contracts::object::HTTPPreconditions {
|
||||||
|
if_match: Some("stale-native-revision".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("capacity admission must retain the native writer's CAS");
|
||||||
|
assert!(matches!(conflict, crate::error::Error::PreconditionFailed));
|
||||||
|
let mut target = set
|
||||||
|
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("read the actual replica set, not the hash-routed set");
|
||||||
|
assert_eq!(target.object_info.etag, committed.etag);
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
target
|
||||||
|
.read_to_end(&mut actual)
|
||||||
|
.await
|
||||||
|
.expect("read the complete native target");
|
||||||
|
assert_eq!(actual, target_body);
|
||||||
|
}
|
||||||
|
for (pool_index, set_index) in [(writer.pools.len(), 0), (0, writer.pools[0].disk_set.len())] {
|
||||||
|
assert!(matches!(
|
||||||
|
writer
|
||||||
|
.save_scanner_pause_backlog_replica(pool_index, set_index, Vec::new(), Default::default())
|
||||||
|
.await,
|
||||||
|
Err(crate::error::Error::InvalidArgument(_, _, _))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
store
|
||||||
|
.decommission_cancel(0)
|
||||||
|
.await
|
||||||
|
.expect("cancel retirement before restoring native membership");
|
||||||
|
writer
|
||||||
|
.save_scanner_pause_backlog_replica(
|
||||||
|
0,
|
||||||
|
source_set_index,
|
||||||
|
b"canceled source membership repair".to_vec(),
|
||||||
|
crate::storage_api_contracts::object::HTTPPreconditions {
|
||||||
|
if_match: original.etag,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("cancel must retain scanner's existing native membership repair contract");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn scanner_backlog_native_replica_reconciles_capacity_and_cleans_source() {
|
||||||
|
run_large_stack_current_thread_async_test("scanner-backlog-reconcile", async || {
|
||||||
|
let (_temp_dirs, store, other_store) =
|
||||||
|
test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
|
||||||
|
let object = "buckets/.scanner-pause-backlog.json";
|
||||||
|
let body = br#"{"schemaVersion":1,"generation":2}"#.to_vec();
|
||||||
|
let old_body = br#"{"schemaVersion":1,"generation":1}"#.to_vec();
|
||||||
|
let source_time = time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(20);
|
||||||
|
let target_time = time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(10);
|
||||||
|
for (pool_index, payload, mod_time) in [(0, body.clone(), source_time), (2, old_body, target_time)] {
|
||||||
|
store.pools[pool_index]
|
||||||
|
.put_object(
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
object,
|
||||||
|
&mut PutObjReader::from_vec(payload),
|
||||||
|
&ObjectOptions {
|
||||||
|
max_parity: true,
|
||||||
|
mod_time: Some(mod_time),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("seed native scanner replicas with independent write times");
|
||||||
|
}
|
||||||
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||||
|
let target_total = body.len() * 8;
|
||||||
|
let capacities = vec![
|
||||||
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, body.len() * 2, body.len() * 2),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(1, layout, 0, target_total, target_total),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(2, layout, target_total, target_total, 0),
|
||||||
|
];
|
||||||
|
set_decommission_capacity_info_overrides_for_test(store.id, vec![capacities.clone()]);
|
||||||
|
store
|
||||||
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
||||||
|
.await
|
||||||
|
.expect("activate the source reservation");
|
||||||
|
let owner = decommission_capacity_owner(&*store.pool_meta.read().await);
|
||||||
|
let source_reader = store.pools[0]
|
||||||
|
.get_object_reader(
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
object,
|
||||||
|
None,
|
||||||
|
HeaderMap::new(),
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
data_movement: true,
|
||||||
|
raw_data_movement_read: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("read the frozen source replica");
|
||||||
|
let conflict = data_movement::migrate_decommission_object(
|
||||||
|
Arc::clone(&store),
|
||||||
|
0,
|
||||||
|
RUSTFS_META_BUCKET.to_string(),
|
||||||
|
source_reader,
|
||||||
|
None,
|
||||||
|
"scanner_backlog_conflict",
|
||||||
|
Some(owner),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("a different older native ledger must retain its source and capacity intent");
|
||||||
|
assert!(conflict.to_string().contains("Precondition failed"), "unexpected conflict: {conflict}");
|
||||||
|
let mut persisted = crate::core::pools::PoolMeta::default();
|
||||||
|
persisted
|
||||||
|
.load_no_lock_from_replicas(store.pools.clone())
|
||||||
|
.await
|
||||||
|
.expect("reload the unresolved intent");
|
||||||
|
assert_eq!(
|
||||||
|
persisted.pools[0]
|
||||||
|
.decommission
|
||||||
|
.as_ref()
|
||||||
|
.expect("source state")
|
||||||
|
.capacity_reservation
|
||||||
|
.as_ref()
|
||||||
|
.expect("durable capacity")
|
||||||
|
.pending_target_physical_bytes,
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
let previous = store.pools[2]
|
||||||
|
.get_object_info(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("read the native writer's CAS revision");
|
||||||
|
let replacement = store.pools[2]
|
||||||
|
.put_object(
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
object,
|
||||||
|
&mut PutObjReader::from_vec(body.clone()),
|
||||||
|
&ObjectOptions {
|
||||||
|
max_parity: true,
|
||||||
|
mod_time: Some(target_time),
|
||||||
|
http_preconditions: Some(crate::storage_api_contracts::object::HTTPPreconditions {
|
||||||
|
if_match: previous.etag,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("native scanner CAS converges the payload without a migration marker");
|
||||||
|
assert!(!data_movement::is_owned_data_movement_target(&replacement));
|
||||||
|
*other_store.pool_meta.write().await = persisted;
|
||||||
|
set_decommission_capacity_info_overrides_for_test(other_store.id, vec![capacities]);
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
other_store.decommission_entry_for_test(
|
||||||
|
0,
|
||||||
|
MetaCacheEntry {
|
||||||
|
name: object.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
RUSTFS_META_BUCKET.to_string(),
|
||||||
|
other_store.pools[0].get_disks_by_key(object),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("replica conflict recovery must be bounded")
|
||||||
|
.expect("identical native replica should finish migration on the reloaded node");
|
||||||
|
let mut reconciled = crate::core::pools::PoolMeta::default();
|
||||||
|
reconciled
|
||||||
|
.load_no_lock_from_replicas(other_store.pools.clone())
|
||||||
|
.await
|
||||||
|
.expect("reload reconciled capacity");
|
||||||
|
let reservation = reconciled.pools[0]
|
||||||
|
.decommission
|
||||||
|
.as_ref()
|
||||||
|
.expect("source state")
|
||||||
|
.capacity_reservation
|
||||||
|
.as_ref()
|
||||||
|
.expect("reconciled capacity");
|
||||||
|
assert_eq!(reservation.pending_target_physical_bytes, 0);
|
||||||
|
assert_eq!(reservation.committed_data_bytes, body.len());
|
||||||
|
assert_eq!(reservation.consumed_target_physical_bytes, body.len());
|
||||||
|
assert!(reservation.targets.iter().all(|target| target.pending_mutation_id.is_none()));
|
||||||
|
assert_eq!(
|
||||||
|
other_store.pool_meta.read().await.pools[0]
|
||||||
|
.decommission
|
||||||
|
.as_ref()
|
||||||
|
.expect("worker progress")
|
||||||
|
.items_decommission_failed,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
let missing = other_store.pools[0]
|
||||||
|
.get_object_info(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect_err("the source should be cleaned only after equivalent-target capacity reconciliation");
|
||||||
|
assert!(crate::error::is_err_object_not_found(&missing));
|
||||||
|
let mut target_reader = other_store.pools[2]
|
||||||
|
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the surviving replica should remain readable");
|
||||||
|
assert_eq!(
|
||||||
|
target_reader.object_info.mod_time,
|
||||||
|
Some(target_time),
|
||||||
|
"recovery must not overwrite the native target"
|
||||||
|
);
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
target_reader
|
||||||
|
.read_to_end(&mut actual)
|
||||||
|
.await
|
||||||
|
.expect("read surviving ledger bytes");
|
||||||
|
assert_eq!(actual, body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
fn data_movement_equivalent_target_reconciles_published_capacity_after_restart() {
|
fn data_movement_equivalent_target_reconciles_published_capacity_after_restart() {
|
||||||
|
|||||||
@@ -984,6 +984,24 @@ fn is_superseding_unversioned_data_movement_object(source: &ObjectInfo, target:
|
|||||||
.is_some_and(|(source_time, target_time)| target_time > source_time)
|
.is_some_and(|(source_time, target_time)| target_time > source_time)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_equivalent_scanner_backlog_replica(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool {
|
||||||
|
// Scanner publishes this exact payload to surviving sets with CAS. Each
|
||||||
|
// set assigns its own write time; that timestamp is not a ledger generation.
|
||||||
|
// Accept only an identical, known unversioned identity, never a different
|
||||||
|
// record based on timestamp ordering or a similarly named user object.
|
||||||
|
source.bucket == crate::disk::RUSTFS_META_BUCKET
|
||||||
|
&& target.bucket == source.bucket
|
||||||
|
&& source.name == "buckets/.scanner-pause-backlog.json"
|
||||||
|
&& target.name == source.name
|
||||||
|
&& is_unversioned_data_movement_object(source)
|
||||||
|
&& is_unversioned_data_movement_object(target)
|
||||||
|
&& !source.delete_marker
|
||||||
|
&& source.mod_time.is_some()
|
||||||
|
&& target.mod_time.is_some()
|
||||||
|
&& source.etag.as_ref().is_some_and(|etag| !etag.is_empty())
|
||||||
|
&& is_equivalent_data_movement_object_identity(source, target, false, compare_part_checksums)
|
||||||
|
}
|
||||||
|
|
||||||
fn is_data_movement_upload_takeover_target(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool {
|
fn is_data_movement_upload_takeover_target(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool {
|
||||||
let identity = data_movement_upload_identity(source);
|
let identity = data_movement_upload_identity(source);
|
||||||
source.mod_time.is_some()
|
source.mod_time.is_some()
|
||||||
@@ -1453,7 +1471,9 @@ fn resolve_data_movement_overwrite_resume_result_for(
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(matches!(err, Error::PreconditionFailed) && is_superseding_unversioned_data_movement_object(source, &target))
|
Ok(matches!(err, Error::PreconditionFailed)
|
||||||
|
&& (is_equivalent_scanner_backlog_replica(source, &target, compare_part_checksums)
|
||||||
|
|| is_superseding_unversioned_data_movement_object(source, &target)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -3288,6 +3308,132 @@ mod tests {
|
|||||||
assert!(overwrite_resume_for_target(&source, source.clone()));
|
assert!(overwrite_resume_for_target(&source, source.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scanner_backlog_replica_pair() -> (ObjectInfo, ObjectInfo) {
|
||||||
|
let source = ObjectInfo {
|
||||||
|
bucket: crate::disk::RUSTFS_META_BUCKET.to_string(),
|
||||||
|
name: "buckets/.scanner-pause-backlog.json".to_string(),
|
||||||
|
version_id: None,
|
||||||
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND),
|
||||||
|
..overwrite_equivalence_source()
|
||||||
|
};
|
||||||
|
let target = ObjectInfo {
|
||||||
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||||
|
..source.clone()
|
||||||
|
};
|
||||||
|
(source, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scanner_backlog_precondition_resumes(source: &ObjectInfo, target: ObjectInfo) -> bool {
|
||||||
|
resolve_data_movement_overwrite_resume_result_for(&Error::PreconditionFailed, Ok(Some(target)), source, 0, 1, true)
|
||||||
|
.expect("scanner replica conflict should be adjudicated")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scanner_backlog_resume_accepts_identical_native_replica_with_older_write_time() {
|
||||||
|
let (source, target) = scanner_backlog_replica_pair();
|
||||||
|
assert!(!is_owned_data_movement_target(&target), "native scanner writes are not migration copies");
|
||||||
|
assert!(!is_equivalent_data_movement_object(&source, &target));
|
||||||
|
assert!(
|
||||||
|
scanner_backlog_precondition_resumes(&source, target),
|
||||||
|
"identical ledger payloads have replica-local write times, not distinct committed generations"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scanner_backlog_resume_rejects_changed_payload_or_metadata() {
|
||||||
|
let (source, target) = scanner_backlog_replica_pair();
|
||||||
|
let mut different_etag = target.clone();
|
||||||
|
different_etag.etag = Some("different-ledger-generation".to_string());
|
||||||
|
let mut different_size = target.clone();
|
||||||
|
different_size.size += 1;
|
||||||
|
let mut different_checksum = target.clone();
|
||||||
|
different_checksum.checksum = Some(Bytes::from_static(b"different-checksum"));
|
||||||
|
let mut different_metadata = target.clone();
|
||||||
|
Arc::make_mut(&mut different_metadata.user_defined).insert("x-amz-meta-key".to_string(), "different".to_string());
|
||||||
|
let mut different_tags = target.clone();
|
||||||
|
different_tags.user_tags = Arc::new("tag=changed".to_string());
|
||||||
|
let mut different_parts = target.clone();
|
||||||
|
Arc::make_mut(&mut different_parts.parts)[0].etag = "different-part".to_string();
|
||||||
|
let mut different_tier = target;
|
||||||
|
different_tier.transitioned_object.tier = "different-tier".to_string();
|
||||||
|
for (label, different) in [
|
||||||
|
("etag", different_etag),
|
||||||
|
("size", different_size),
|
||||||
|
("checksum", different_checksum),
|
||||||
|
("metadata", different_metadata),
|
||||||
|
("tags", different_tags),
|
||||||
|
("parts", different_parts),
|
||||||
|
("tier", different_tier),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!scanner_backlog_precondition_resumes(&source, different),
|
||||||
|
"replica-local timestamps do not authorize a changed {label}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scanner_backlog_resume_rejects_other_namespaces_and_incomplete_identity() {
|
||||||
|
let (source, target) = scanner_backlog_replica_pair();
|
||||||
|
for (bucket, name) in [
|
||||||
|
("user-bucket", "buckets/.scanner-pause-backlog.json"),
|
||||||
|
(crate::disk::RUSTFS_META_BUCKET, "buckets/.scanner-pause-backlog.json.bkp"),
|
||||||
|
(crate::disk::RUSTFS_META_BUCKET, "buckets/.usage-cache.bin"),
|
||||||
|
] {
|
||||||
|
let mut source = source.clone();
|
||||||
|
let mut target = target.clone();
|
||||||
|
for replica in [&mut source, &mut target] {
|
||||||
|
replica.bucket = bucket.to_string();
|
||||||
|
replica.name = name.to_string();
|
||||||
|
}
|
||||||
|
assert!(!scanner_backlog_precondition_resumes(&source, target), "out-of-scope key {bucket}/{name}");
|
||||||
|
}
|
||||||
|
for missing in ["etag", "empty-etag", "source-time", "target-time", "version", "delete-marker"] {
|
||||||
|
let mut source = source.clone();
|
||||||
|
let mut target = target.clone();
|
||||||
|
match missing {
|
||||||
|
"etag" => {
|
||||||
|
source.etag = None;
|
||||||
|
target.etag = None;
|
||||||
|
}
|
||||||
|
"empty-etag" => {
|
||||||
|
source.etag = Some(String::new());
|
||||||
|
target.etag = Some(String::new());
|
||||||
|
}
|
||||||
|
"source-time" => source.mod_time = None,
|
||||||
|
"target-time" => target.mod_time = None,
|
||||||
|
"version" => {
|
||||||
|
source.version_id = Some(Uuid::from_u128(1));
|
||||||
|
target.version_id = source.version_id;
|
||||||
|
}
|
||||||
|
"delete-marker" => {
|
||||||
|
source.delete_marker = true;
|
||||||
|
target.delete_marker = true;
|
||||||
|
}
|
||||||
|
_ => unreachable!("all identity variants are enumerated above"),
|
||||||
|
}
|
||||||
|
assert!(!scanner_backlog_precondition_resumes(&source, target), "unsupported identity: {missing}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scanner_backlog_resume_requires_a_cross_pool_precondition_conflict() {
|
||||||
|
let (source, target) = scanner_backlog_replica_pair();
|
||||||
|
for (err, target_pool) in [
|
||||||
|
(Error::PreconditionFailed, 0),
|
||||||
|
(Error::SlowDown, 1),
|
||||||
|
(
|
||||||
|
Error::InvalidUploadID(source.bucket.clone(), source.name.clone(), "upload".to_string()),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!resolve_data_movement_overwrite_resume_result_for(&err, Ok(Some(target.clone())), &source, 0, target_pool, true)
|
||||||
|
.expect("non-resumable conflict should return false")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_data_movement_overwrite_resume_accepts_part_mod_time_drift() {
|
fn test_data_movement_overwrite_resume_accepts_part_mod_time_drift() {
|
||||||
let source = overwrite_equivalence_source();
|
let source = overwrite_equivalence_source();
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ impl<'a> MultiWriter<'a> {
|
|||||||
errs = ?self.errs,
|
errs = ?self.errs,
|
||||||
"Erasure encode write quorum unavailable: {summary_text}"
|
"Erasure encode write quorum unavailable: {summary_text}"
|
||||||
);
|
);
|
||||||
Err(std::io::Error::other(format!("Failed to write data: {summary_text}")))
|
Err(write_err.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn shutdown_writer(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>) {
|
async fn shutdown_writer(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>) {
|
||||||
@@ -503,7 +503,7 @@ impl<'a> MultiWriter<'a> {
|
|||||||
errs = ?self.errs,
|
errs = ?self.errs,
|
||||||
"Erasure encode shutdown quorum unavailable: {summary_text}"
|
"Erasure encode shutdown quorum unavailable: {summary_text}"
|
||||||
);
|
);
|
||||||
Err(std::io::Error::other(format!("Failed to shutdown writers: {summary_text}")))
|
Err(write_err.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1002,6 +1002,7 @@ impl Erasure {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::erasure::coding::{BitrotWriterWrapper, CustomWriter};
|
use crate::erasure::coding::{BitrotWriterWrapper, CustomWriter};
|
||||||
|
use crate::error::StorageError;
|
||||||
use rustfs_rio::HardLimitReader;
|
use rustfs_rio::HardLimitReader;
|
||||||
use rustfs_utils::HashAlgorithm;
|
use rustfs_utils::HashAlgorithm;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
@@ -1451,7 +1452,14 @@ mod tests {
|
|||||||
Ok(_) => panic!("writer quorum failure should fail the encode pipeline"),
|
Ok(_) => panic!("writer quorum failure should fail the encode pipeline"),
|
||||||
Err(err) => err,
|
Err(err) => err,
|
||||||
};
|
};
|
||||||
assert!(err.to_string().contains("Failed to write data"));
|
let err = StorageError::from(err);
|
||||||
|
assert!(matches!(
|
||||||
|
&err,
|
||||||
|
StorageError::Io(source)
|
||||||
|
if source.kind() == std::io::ErrorKind::Other
|
||||||
|
&& source.to_string() == "injected write failure after producer blocks"
|
||||||
|
));
|
||||||
|
assert!(!err.is_quorum_error());
|
||||||
tokio::time::timeout(Duration::from_secs(1), reader_dropped)
|
tokio::time::timeout(Duration::from_secs(1), reader_dropped)
|
||||||
.await
|
.await
|
||||||
.expect("writer failure should abort the blocked producer")
|
.expect("writer failure should abort the blocked producer")
|
||||||
@@ -1644,7 +1652,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn multi_writer_short_write_fails_before_shutdown() {
|
async fn multi_writer_short_write_fails_before_shutdown() {
|
||||||
let mut writers = vec![Some(bitrot_writer(ShortWriteWriter, 16))];
|
let mut writers = vec![Some(bitrot_writer(ShortWriteWriter, 32))];
|
||||||
let err = {
|
let err = {
|
||||||
let mut writer = MultiWriter::new(&mut writers, 1);
|
let mut writer = MultiWriter::new(&mut writers, 1);
|
||||||
writer
|
writer
|
||||||
@@ -1653,63 +1661,93 @@ mod tests {
|
|||||||
.expect_err("short writes must fail the shard writer")
|
.expect_err("short writes must fail the shard writer")
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(err.to_string().contains("Failed to write data"));
|
let err = StorageError::from(err);
|
||||||
|
assert!(matches!(&err, StorageError::Io(source) if source.kind() == std::io::ErrorKind::WriteZero));
|
||||||
|
assert!(!err.is_quorum_error());
|
||||||
assert!(writers[0].is_none(), "short-write shard must be removed before commit");
|
assert!(writers[0].is_none(), "short-write shard must be removed before commit");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn multi_writer_reports_fallback_summary_when_only_offline_writers_remain() {
|
async fn multi_writer_reports_fallback_summary_when_only_offline_writers_remain() {
|
||||||
let mut writers = vec![None, None];
|
let mut writers = vec![None, None];
|
||||||
let err = {
|
let (err, summary) = {
|
||||||
let mut writer = MultiWriter::new(&mut writers, 1);
|
let mut writer = MultiWriter::new(&mut writers, 1);
|
||||||
writer
|
let err = writer
|
||||||
.write(vec![Bytes::from_static(b"offline-a"), Bytes::from_static(b"offline-b")])
|
.write(vec![Bytes::from_static(b"offline-a"), Bytes::from_static(b"offline-b")])
|
||||||
.await
|
.await
|
||||||
.expect_err("offline writers cannot satisfy write quorum")
|
.expect_err("offline writers cannot satisfy write quorum");
|
||||||
|
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
||||||
|
(err, format_write_quorum_failure(&summary))
|
||||||
};
|
};
|
||||||
|
|
||||||
let err = err.to_string();
|
assert_eq!(
|
||||||
assert!(err.contains("Failed to write data"));
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
assert!(err.contains("offline-disks=2/2"));
|
Some(&Error::ErasureWriteQuorum),
|
||||||
assert!(err.contains("required=1"));
|
);
|
||||||
|
let err = StorageError::from(err);
|
||||||
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(err.is_quorum_error());
|
||||||
|
assert!(summary.contains("offline-disks=2/2"));
|
||||||
|
assert!(summary.contains("required=1"));
|
||||||
|
|
||||||
let shutdown_err = {
|
let (shutdown_err, summary) = {
|
||||||
let mut writer = MultiWriter::new(&mut writers, 1);
|
let mut writer = MultiWriter::new(&mut writers, 1);
|
||||||
writer
|
let err = writer
|
||||||
.shutdown()
|
.shutdown()
|
||||||
.await
|
.await
|
||||||
.expect_err("offline writers cannot satisfy shutdown quorum")
|
.expect_err("offline writers cannot satisfy shutdown quorum");
|
||||||
|
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
||||||
|
(err, format_write_quorum_failure(&summary))
|
||||||
};
|
};
|
||||||
|
|
||||||
let shutdown_err = shutdown_err.to_string();
|
assert_eq!(
|
||||||
assert!(shutdown_err.contains("Failed to shutdown writers"));
|
shutdown_err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
assert!(shutdown_err.contains("offline-disks=2/2"));
|
Some(&Error::ErasureWriteQuorum),
|
||||||
assert!(shutdown_err.contains("required=1"));
|
);
|
||||||
|
let shutdown_err = StorageError::from(shutdown_err);
|
||||||
|
assert_eq!(shutdown_err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(shutdown_err.is_quorum_error());
|
||||||
|
assert!(summary.contains("offline-disks=2/2"));
|
||||||
|
assert!(summary.contains("required=1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn multi_writer_reports_quorum_failure_when_quorum_exceeds_writer_count() {
|
async fn multi_writer_reports_quorum_failure_when_quorum_exceeds_writer_count() {
|
||||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||||
let mut writers = vec![Some(bitrot_writer(DeferredCommitWriter::new(committed), 16))];
|
let mut writers = vec![Some(bitrot_writer(DeferredCommitWriter::new(committed), 32))];
|
||||||
let mut writer = MultiWriter::new(&mut writers, 2);
|
let mut writer = MultiWriter::new(&mut writers, 2);
|
||||||
|
|
||||||
let err = writer
|
let err = writer
|
||||||
.write(vec![Bytes::from_static(b"quorum impossible")])
|
.write(vec![Bytes::from_static(b"quorum impossible")])
|
||||||
.await
|
.await
|
||||||
.expect_err("write quorum above writer count must fail");
|
.expect_err("write quorum above writer count must fail");
|
||||||
let err = err.to_string();
|
assert_eq!(
|
||||||
assert!(err.contains("Failed to write data"));
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
assert!(err.contains("required=2"));
|
Some(&Error::ErasureWriteQuorum),
|
||||||
assert!(err.contains("erasure write quorum"));
|
);
|
||||||
|
let err = StorageError::from(err);
|
||||||
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(err.is_quorum_error());
|
||||||
|
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
||||||
|
let summary = format_write_quorum_failure(&summary);
|
||||||
|
assert!(summary.contains("required=2"));
|
||||||
|
assert!(summary.contains("erasure write quorum"));
|
||||||
|
|
||||||
let shutdown_err = writer
|
let shutdown_err = writer
|
||||||
.shutdown()
|
.shutdown()
|
||||||
.await
|
.await
|
||||||
.expect_err("shutdown quorum above writer count must fail");
|
.expect_err("shutdown quorum above writer count must fail");
|
||||||
let shutdown_err = shutdown_err.to_string();
|
assert_eq!(
|
||||||
assert!(shutdown_err.contains("Failed to shutdown writers"));
|
shutdown_err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
assert!(shutdown_err.contains("required=2"));
|
Some(&Error::ErasureWriteQuorum),
|
||||||
assert!(shutdown_err.contains("erasure write quorum"));
|
);
|
||||||
|
let shutdown_err = StorageError::from(shutdown_err);
|
||||||
|
assert_eq!(shutdown_err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(shutdown_err.is_quorum_error());
|
||||||
|
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
||||||
|
let summary = format_write_quorum_failure(&summary);
|
||||||
|
assert!(summary.contains("required=2"));
|
||||||
|
assert!(summary.contains("erasure write quorum"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The production wiring (`MultiWriter::new`) must arm a real deadline by
|
// The production wiring (`MultiWriter::new`) must arm a real deadline by
|
||||||
@@ -1794,7 +1832,13 @@ mod tests {
|
|||||||
.write(four_shards())
|
.write(four_shards())
|
||||||
.await
|
.await
|
||||||
.expect_err("two stalled writers must fail the write quorum instead of hanging");
|
.expect_err("two stalled writers must fail the write quorum instead of hanging");
|
||||||
assert!(err.to_string().contains("Failed to write data"));
|
assert_eq!(
|
||||||
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
|
Some(&Error::ErasureWriteQuorum),
|
||||||
|
);
|
||||||
|
let err = StorageError::from(err);
|
||||||
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(err.is_quorum_error());
|
||||||
}
|
}
|
||||||
|
|
||||||
// A small object whose bytes were fully buffered leaves `write` succeeding
|
// A small object whose bytes were fully buffered leaves `write` succeeding
|
||||||
@@ -1839,7 +1883,13 @@ mod tests {
|
|||||||
.shutdown()
|
.shutdown()
|
||||||
.await
|
.await
|
||||||
.expect_err("two shutdown stalls must fail the shutdown quorum instead of hanging");
|
.expect_err("two shutdown stalls must fail the shutdown quorum instead of hanging");
|
||||||
assert!(err.to_string().contains("Failed to shutdown writers"));
|
assert_eq!(
|
||||||
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
|
Some(&Error::ErasureWriteQuorum),
|
||||||
|
);
|
||||||
|
let err = StorageError::from(err);
|
||||||
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(err.is_quorum_error());
|
||||||
}
|
}
|
||||||
|
|
||||||
// A slow-but-honest writer that keeps completing shards (delay < stall
|
// A slow-but-honest writer that keeps completing shards (delay < stall
|
||||||
@@ -2121,7 +2171,13 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect_err("streaming encode must fail when write quorum is unavailable");
|
.expect_err("streaming encode must fail when write quorum is unavailable");
|
||||||
|
|
||||||
assert!(err.to_string().contains("Failed to write data"));
|
assert_eq!(
|
||||||
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
|
Some(&Error::ErasureWriteQuorum),
|
||||||
|
);
|
||||||
|
let err = StorageError::from(err);
|
||||||
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(err.is_quorum_error());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -2145,7 +2201,13 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect_err("write quorum failure must fail the inline encode");
|
.expect_err("write quorum failure must fail the inline encode");
|
||||||
|
|
||||||
assert!(err.to_string().contains("Failed to write data"));
|
assert_eq!(
|
||||||
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
|
Some(&Error::ErasureWriteQuorum),
|
||||||
|
);
|
||||||
|
let err = StorageError::from(err);
|
||||||
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(err.is_quorum_error());
|
||||||
assert!(
|
assert!(
|
||||||
committed.lock().expect("committed buffer should be lockable").is_empty(),
|
committed.lock().expect("committed buffer should be lockable").is_empty(),
|
||||||
"successful writer must not be committed when write quorum fails before shutdown"
|
"successful writer must not be committed when write quorum fails before shutdown"
|
||||||
@@ -2173,7 +2235,13 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect_err("shutdown quorum failure must fail the inline encode");
|
.expect_err("shutdown quorum failure must fail the inline encode");
|
||||||
|
|
||||||
assert!(err.to_string().contains("Failed to shutdown writers"));
|
let err = StorageError::from(err);
|
||||||
|
assert!(matches!(
|
||||||
|
&err,
|
||||||
|
StorageError::Io(source)
|
||||||
|
if source.kind() == std::io::ErrorKind::Other && source.to_string() == "injected shutdown failure"
|
||||||
|
));
|
||||||
|
assert!(!err.is_quorum_error());
|
||||||
assert!(
|
assert!(
|
||||||
!committed.lock().expect("committed buffer should be lockable").is_empty(),
|
!committed.lock().expect("committed buffer should be lockable").is_empty(),
|
||||||
"the successful writer should have committed before shutdown quorum failure was reported"
|
"the successful writer should have committed before shutdown quorum failure was reported"
|
||||||
@@ -2395,7 +2463,13 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect_err("batched encode must fail when write quorum is unavailable");
|
.expect_err("batched encode must fail when write quorum is unavailable");
|
||||||
|
|
||||||
assert!(err.to_string().contains("Failed to write data"));
|
assert_eq!(
|
||||||
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
|
Some(&Error::ErasureWriteQuorum),
|
||||||
|
);
|
||||||
|
let err = StorageError::from(err);
|
||||||
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(err.is_quorum_error());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -570,11 +570,25 @@ pub(crate) async fn initialize_local_disk_maps(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
tokio::task_local! {
|
||||||
|
pub(crate) static TEST_TIER_CONFIG_MGR: Arc<tokio::sync::RwLock<TierConfigMgr>>;
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn init_tier_config_mgr(store: Arc<ECStore>) -> Result<()> {
|
pub(crate) async fn init_tier_config_mgr(store: Arc<ECStore>) -> Result<()> {
|
||||||
|
#[cfg(not(test))]
|
||||||
let handle = get_global_tier_config_mgr();
|
let handle = get_global_tier_config_mgr();
|
||||||
TierConfigMgr::reload_handle(&handle, store.clone()).await?;
|
#[cfg(test)]
|
||||||
|
let handle = TEST_TIER_CONFIG_MGR
|
||||||
|
.try_with(Arc::clone)
|
||||||
|
.unwrap_or_else(|_| get_global_tier_config_mgr());
|
||||||
|
let initial_reload = TierConfigMgr::reload_handle(&handle, store.clone()).await;
|
||||||
|
if initial_reload.is_err() {
|
||||||
|
// Keep local recovery active when the initial snapshot cannot be loaded.
|
||||||
|
TierConfigMgr::request_committed_mutation_refresh(&handle).await;
|
||||||
|
}
|
||||||
tokio::spawn(TierConfigMgr::refresh_tier_config_handle(handle, store));
|
tokio::spawn(TierConfigMgr::refresh_tier_config_handle(handle, store));
|
||||||
Ok(())
|
initial_reload.map_err(Error::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
//! contract stays implemented `for SetDisks`, so its associated-type bounds are
|
//! contract stays implemented `for SetDisks`, so its associated-type bounds are
|
||||||
//! unchanged; method bodies are moved verbatim and runtime behavior is the same.
|
//! unchanged; method bodies are moved verbatim and runtime behavior is the same.
|
||||||
|
|
||||||
|
use crate::core::pools::DecommissionCapacityAdmission;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use super::super::GetObjectMetadataCacheKey;
|
use super::super::GetObjectMetadataCacheKey;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1809,7 +1811,10 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
|||||||
let decommission_capacity_guard = if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
let decommission_capacity_guard = if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
||||||
Some(
|
Some(
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
.acquire_external_decommission_capacity_fence(
|
||||||
|
&[self.pool_index],
|
||||||
|
DecommissionCapacityAdmission::ExistingMultipart,
|
||||||
|
)
|
||||||
.await?,
|
.await?,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -2345,6 +2350,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
|||||||
bucket,
|
bucket,
|
||||||
object,
|
object,
|
||||||
opts.no_lock || object_lock_guard.is_some(),
|
opts.no_lock || object_lock_guard.is_some(),
|
||||||
|
DecommissionCapacityAdmission::ExistingMultipart,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
decommission_object_lock_guard = object_guard;
|
decommission_object_lock_guard = object_guard;
|
||||||
@@ -3110,7 +3116,10 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
|||||||
{
|
{
|
||||||
decommission_capacity_guard = Some(
|
decommission_capacity_guard = Some(
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
.acquire_external_decommission_capacity_fence(
|
||||||
|
&[self.pool_index],
|
||||||
|
DecommissionCapacityAdmission::ExistingMultipart,
|
||||||
|
)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
//! bounds are unchanged, and the impls reach shared primitives through the
|
//! bounds are unchanged, and the impls reach shared primitives through the
|
||||||
//! SetDisks core (io_primitives) via inherent calls.
|
//! SetDisks core (io_primitives) via inherent calls.
|
||||||
|
|
||||||
|
use crate::core::pools::DecommissionCapacityAdmission;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use super::super::MetadataCacheInvalidationProbe;
|
use super::super::MetadataCacheInvalidationProbe;
|
||||||
use super::super::{
|
use super::super::{
|
||||||
@@ -3905,6 +3907,7 @@ impl SetDisks {
|
|||||||
bucket,
|
bucket,
|
||||||
object,
|
object,
|
||||||
opts.no_lock || object_lock_guard.is_some(),
|
opts.no_lock || object_lock_guard.is_some(),
|
||||||
|
DecommissionCapacityAdmission::Mutation,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
decommission_object_lock_guard = object_guard;
|
decommission_object_lock_guard = object_guard;
|
||||||
@@ -4102,7 +4105,7 @@ impl SetDisks {
|
|||||||
{
|
{
|
||||||
decommission_capacity_guard = Some(
|
decommission_capacity_guard = Some(
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
.acquire_external_decommission_capacity_fence(&[self.pool_index], DecommissionCapacityAdmission::Mutation)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use super::{
|
|||||||
UpdateMetadataOpts, Uuid, X_AMZ_RESTORE, get_raw_etag, restore_operation_id_from_metadata,
|
UpdateMetadataOpts, Uuid, X_AMZ_RESTORE, get_raw_etag, restore_operation_id_from_metadata,
|
||||||
};
|
};
|
||||||
use crate::bucket::lifecycle::lifecycle;
|
use crate::bucket::lifecycle::lifecycle;
|
||||||
|
use crate::core::pools::DecommissionCapacityAdmission;
|
||||||
use rustfs_filemeta::RestoreStatusOps;
|
use rustfs_filemeta::RestoreStatusOps;
|
||||||
use rustfs_utils::http::headers::{AMZ_RESTORE_EXPIRY_DAYS, AMZ_RESTORE_REQUEST_DATE};
|
use rustfs_utils::http::headers::{AMZ_RESTORE_EXPIRY_DAYS, AMZ_RESTORE_REQUEST_DATE};
|
||||||
use s3s::dto::{RestoreStatus, Timestamp};
|
use s3s::dto::{RestoreStatus, Timestamp};
|
||||||
@@ -160,7 +161,13 @@ impl SetDisks {
|
|||||||
let (decommission_object_lock_guard, decommission_target_lock_covered, mut decommission_capacity_guard) =
|
let (decommission_object_lock_guard, decommission_target_lock_covered, mut decommission_capacity_guard) =
|
||||||
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_commit_guards(self.pool_index, bucket, object, opts.no_lock)
|
.acquire_external_decommission_commit_guards(
|
||||||
|
self.pool_index,
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
opts.no_lock,
|
||||||
|
DecommissionCapacityAdmission::Mutation,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
(None, false, None)
|
(None, false, None)
|
||||||
@@ -178,7 +185,7 @@ impl SetDisks {
|
|||||||
{
|
{
|
||||||
decommission_capacity_guard = Some(
|
decommission_capacity_guard = Some(
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
.acquire_external_decommission_capacity_fence(&[self.pool_index], DecommissionCapacityAdmission::Mutation)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -264,7 +271,13 @@ impl SetDisks {
|
|||||||
let (decommission_object_lock_guard, decommission_target_lock_covered, mut decommission_capacity_guard) =
|
let (decommission_object_lock_guard, decommission_target_lock_covered, mut decommission_capacity_guard) =
|
||||||
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_commit_guards(self.pool_index, bucket, object, opts.no_lock)
|
.acquire_external_decommission_commit_guards(
|
||||||
|
self.pool_index,
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
opts.no_lock,
|
||||||
|
DecommissionCapacityAdmission::Mutation,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
(None, false, None)
|
(None, false, None)
|
||||||
@@ -282,7 +295,7 @@ impl SetDisks {
|
|||||||
{
|
{
|
||||||
decommission_capacity_guard = Some(
|
decommission_capacity_guard = Some(
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
.acquire_external_decommission_capacity_fence(&[self.pool_index], DecommissionCapacityAdmission::Mutation)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use crate::services::rebalance::{REBAL_META_NAME, RebalStatus};
|
|||||||
use crate::set_disk::get_lock_acquire_timeout;
|
use crate::set_disk::get_lock_acquire_timeout;
|
||||||
use crate::storage_api_contracts::heal::HealOperations as _;
|
use crate::storage_api_contracts::heal::HealOperations as _;
|
||||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||||
|
use rustfs_heal_contracts::heal_channel::DriveState;
|
||||||
use rustfs_lock::NamespaceLockGuard;
|
use rustfs_lock::NamespaceLockGuard;
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use tracing::trace;
|
use tracing::trace;
|
||||||
@@ -378,6 +379,79 @@ impl ECStore {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Heal every pool metadata owner in the selected scope without allowing
|
||||||
|
/// one healthy pool to hide another pool's failed repair.
|
||||||
|
pub async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
|
||||||
|
let scopes = self.heal_erasure_set_scopes(opts).await?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for (pool_index, set_index) in scopes {
|
||||||
|
if !self.replacement_pool_metadata_applies(pool_index, set_index)? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let set = &self.pools[pool_index].disk_set[set_index];
|
||||||
|
let targets = set.set_endpoints.iter().map(ToString::to_string).collect::<Vec<_>>();
|
||||||
|
if targets.is_empty()
|
||||||
|
|| targets.len() != set.set_drive_count
|
||||||
|
|| targets.iter().collect::<BTreeSet<_>>().len() != targets.len()
|
||||||
|
{
|
||||||
|
return Err(Error::SlowDown);
|
||||||
|
}
|
||||||
|
// Administrative remove/no-lock options apply to user objects,
|
||||||
|
// never to the cluster's authoritative metadata transaction.
|
||||||
|
let metadata_opts = HealOpts {
|
||||||
|
dry_run: opts.dry_run,
|
||||||
|
recreate: opts.recreate,
|
||||||
|
scan_mode: opts.scan_mode,
|
||||||
|
pool: Some(pool_index),
|
||||||
|
set: Some(set_index),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (result, error) = self
|
||||||
|
.handle_heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, "", &metadata_opts)
|
||||||
|
.await?;
|
||||||
|
if let Some(error) = error {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
if !opts.dry_run {
|
||||||
|
let ok_state = DriveState::Ok.to_string();
|
||||||
|
let complete = result.after.drives.len() == targets.len()
|
||||||
|
&& targets.iter().all(|target| {
|
||||||
|
let mut outcomes = result.after.drives.iter().filter(|drive| drive.endpoint == *target);
|
||||||
|
outcomes.next().is_some_and(|drive| drive.state == ok_state) && outcomes.next().is_none()
|
||||||
|
});
|
||||||
|
if !complete
|
||||||
|
|| !set
|
||||||
|
.replacement_targets_have_version(RUSTFS_META_BUCKET, POOL_META_NAME, "", &targets)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Err(Error::SlowDown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this replacement set owns the pool's metadata replica.
|
||||||
|
///
|
||||||
|
/// Pool metadata follows normal object placement within each pool. A valid
|
||||||
|
/// non-owner set has no replica to repair; missing metadata on the owner
|
||||||
|
/// set still requires healing and target-specific readback.
|
||||||
|
pub fn replacement_pool_metadata_applies(&self, pool_index: usize, set_index: usize) -> Result<bool> {
|
||||||
|
let pool = self
|
||||||
|
.pools
|
||||||
|
.get(pool_index)
|
||||||
|
.ok_or_else(|| invalid_heal_pool_index(pool_index, self.pools.len()))?;
|
||||||
|
let selected = pool.get_disks_for_heal_object(
|
||||||
|
POOL_META_NAME,
|
||||||
|
&HealOpts {
|
||||||
|
set: Some(set_index),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(Arc::ptr_eq(&selected, &pool.get_disks_by_key(POOL_META_NAME)))
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, targets), fields(pool_index, set_index, target_count = targets.len()))]
|
#[instrument(skip(self, targets), fields(pool_index, set_index, target_count = targets.len()))]
|
||||||
pub async fn replacement_targets_have_version(
|
pub async fn replacement_targets_have_version(
|
||||||
&self,
|
&self,
|
||||||
@@ -671,9 +745,9 @@ mod tests {
|
|||||||
use crate::cluster::rpc::PeerS3Client;
|
use crate::cluster::rpc::PeerS3Client;
|
||||||
use crate::config::com::{delete_config, read_config_no_lock_preserve_empty_with_metadata, save_config};
|
use crate::config::com::{delete_config, read_config_no_lock_preserve_empty_with_metadata, save_config};
|
||||||
use crate::core::pools::{
|
use crate::core::pools::{
|
||||||
DecommissionCapacityLockOrderBarrier, DecommissionErasureLayout, DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME,
|
DecommissionCapacityAdmission, DecommissionCapacityLockOrderBarrier, DecommissionErasureLayout,
|
||||||
PoolDecommissionInfo, PoolMetaReplicaState, PoolStatus, initialized_pool_meta_identity_for_test,
|
DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME, PoolDecommissionInfo, PoolMetaReplicaState, PoolStatus,
|
||||||
set_decommission_capacity_info_overrides_for_test,
|
initialized_pool_meta_identity_for_test, set_decommission_capacity_info_overrides_for_test,
|
||||||
};
|
};
|
||||||
use crate::core::sets::HealFormatAfterSaveBarrier;
|
use crate::core::sets::HealFormatAfterSaveBarrier;
|
||||||
use crate::disk::error::Result as DiskResult;
|
use crate::disk::error::Result as DiskResult;
|
||||||
@@ -829,6 +903,249 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replacement_pool_metadata_applies_to_the_written_replica_in_each_pool() {
|
||||||
|
let mut store = minimal_heal_store().await;
|
||||||
|
for pool_index in 0..store.pools.len() {
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.replacement_pool_metadata_applies(pool_index, 0)
|
||||||
|
.expect("a valid single-set pool should have a metadata owner")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
store.ctx = Arc::new(InstanceContext::new());
|
||||||
|
for algorithm in [
|
||||||
|
crate::disk::format::DistributionAlgoVersion::V1,
|
||||||
|
crate::disk::format::DistributionAlgoVersion::V2,
|
||||||
|
crate::disk::format::DistributionAlgoVersion::V3,
|
||||||
|
] {
|
||||||
|
let mut temp_dirs = Vec::new();
|
||||||
|
for pool_index in 0..store.pools.len() {
|
||||||
|
let (dirs, mut pool) =
|
||||||
|
crate::core::sets::make_local_two_set_sets_for_pool_with_ctx(Arc::clone(&store.ctx), pool_index).await;
|
||||||
|
temp_dirs.extend(dirs);
|
||||||
|
Arc::get_mut(&mut pool)
|
||||||
|
.expect("fixture pool should have one owner")
|
||||||
|
.distribution_algo = algorithm.clone();
|
||||||
|
store.pools[pool_index] = pool;
|
||||||
|
}
|
||||||
|
for (pool_index, pool) in store.pools.iter().enumerate() {
|
||||||
|
let mut required_sets = 0;
|
||||||
|
for set_index in 0..pool.disk_set.len() {
|
||||||
|
required_sets += usize::from(
|
||||||
|
store
|
||||||
|
.replacement_pool_metadata_applies(pool_index, set_index)
|
||||||
|
.expect("valid replacement topology should be classified before metadata exists"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(required_sets, 1, "missing metadata cannot exempt the owner set");
|
||||||
|
save_config(pool.clone(), POOL_META_NAME, b"pool metadata placement".to_vec())
|
||||||
|
.await
|
||||||
|
.expect("normal config writes should persist one metadata replica per pool");
|
||||||
|
for (set_index, set) in pool.disk_set.iter().enumerate() {
|
||||||
|
let applies = store
|
||||||
|
.replacement_pool_metadata_applies(pool_index, set_index)
|
||||||
|
.expect("valid replacement topology should be classified");
|
||||||
|
let disks = set.disks.read().await.clone();
|
||||||
|
for disk in disks.iter().flatten() {
|
||||||
|
let replica = disk.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await;
|
||||||
|
if applies {
|
||||||
|
replica.expect("the metadata owner must match actual persisted shards");
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
matches!(replica, Err(crate::disk::error::DiskError::FileNotFound)),
|
||||||
|
"non-owner sets must have no persisted metadata shard; observed error: {:?}",
|
||||||
|
replica.as_ref().err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.replacement_pool_metadata_applies(pool_index, pool.disk_set.len())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(store.replacement_pool_metadata_applies(store.pools.len(), 0).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn ordinary_pool_metadata_heal_repairs_each_owner_and_preserves_dry_run() {
|
||||||
|
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
|
||||||
|
let first_missing = remove_pool_meta_shard(&store, 0).await;
|
||||||
|
let second_missing = remove_pool_meta_shard(&store, 1).await;
|
||||||
|
let destructive_options = HealOpts {
|
||||||
|
remove: true,
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let results = store
|
||||||
|
.heal_pool_metadata(&HealOpts {
|
||||||
|
dry_run: true,
|
||||||
|
..destructive_options
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("dry-run should inspect both metadata owners without requiring a commit");
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert!(
|
||||||
|
first_missing
|
||||||
|
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
second_missing
|
||||||
|
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
let lock = store.pools[0]
|
||||||
|
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
|
||||||
|
.await
|
||||||
|
.expect("metadata namespace lock should be available");
|
||||||
|
let guard = lock
|
||||||
|
.get_read_lock(get_lock_acquire_timeout())
|
||||||
|
.await
|
||||||
|
.expect("a metadata reader should hold the shared fence");
|
||||||
|
let error = temp_env::async_with_vars(
|
||||||
|
[(rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT, Some("1"))],
|
||||||
|
store.heal_pool_metadata(&destructive_options),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("administrative no-lock cannot bypass the metadata write fence");
|
||||||
|
assert!(matches!(error, Error::Lock(rustfs_lock::LockError::Timeout { .. })));
|
||||||
|
drop(guard);
|
||||||
|
let results = store
|
||||||
|
.heal_pool_metadata(&destructive_options)
|
||||||
|
.await
|
||||||
|
.expect("every metadata owner should be repaired");
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert!(first_missing.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await.is_ok());
|
||||||
|
assert!(
|
||||||
|
second_missing
|
||||||
|
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn ordinary_pool_metadata_heal_does_not_hide_missing_later_pool() {
|
||||||
|
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
|
||||||
|
delete_config(store.pools[1].clone(), POOL_META_NAME)
|
||||||
|
.await
|
||||||
|
.expect("the second pool metadata replica should be removed");
|
||||||
|
|
||||||
|
let error = store
|
||||||
|
.heal_pool_metadata(&HealOpts::default())
|
||||||
|
.await
|
||||||
|
.expect_err("the healthy first pool must not hide the second owner's missing replica");
|
||||||
|
|
||||||
|
assert!(!matches!(error, Error::NoHealRequired));
|
||||||
|
let second_set = store.pools[1].get_disks_by_key(POOL_META_NAME);
|
||||||
|
for disk in second_set.disks.read().await.iter().flatten() {
|
||||||
|
assert!(disk.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ordinary_pool_metadata_heal_skips_only_valid_non_owner_sets() {
|
||||||
|
let mut store = minimal_heal_store().await;
|
||||||
|
store.ctx = Arc::new(InstanceContext::new());
|
||||||
|
for algorithm in [
|
||||||
|
crate::disk::format::DistributionAlgoVersion::V1,
|
||||||
|
crate::disk::format::DistributionAlgoVersion::V2,
|
||||||
|
crate::disk::format::DistributionAlgoVersion::V3,
|
||||||
|
] {
|
||||||
|
let mut temp_dirs = Vec::new();
|
||||||
|
for pool_index in 0..store.pools.len() {
|
||||||
|
let (dirs, mut pool) =
|
||||||
|
crate::core::sets::make_local_two_set_sets_for_pool_with_ctx(Arc::clone(&store.ctx), pool_index).await;
|
||||||
|
temp_dirs.extend(dirs);
|
||||||
|
Arc::get_mut(&mut pool)
|
||||||
|
.expect("fixture pool should have one owner")
|
||||||
|
.distribution_algo = algorithm.clone();
|
||||||
|
store.pools[pool_index] = pool;
|
||||||
|
}
|
||||||
|
for pool_index in 0..store.pools.len() {
|
||||||
|
let owner = (0..store.pools[pool_index].disk_set.len())
|
||||||
|
.find(|set_index| {
|
||||||
|
store
|
||||||
|
.replacement_pool_metadata_applies(pool_index, *set_index)
|
||||||
|
.expect("valid metadata placement")
|
||||||
|
})
|
||||||
|
.expect("every pool must have one metadata owner");
|
||||||
|
let non_owner = 1 - owner;
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.heal_pool_metadata(&HealOpts {
|
||||||
|
pool: Some(pool_index),
|
||||||
|
set: Some(non_owner),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("valid non-owner should need no metadata write")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.heal_pool_metadata(&HealOpts {
|
||||||
|
pool: Some(pool_index),
|
||||||
|
set: Some(owner),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"an owner with no authoritative metadata must fail"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.heal_pool_metadata(&HealOpts {
|
||||||
|
pool: Some(pool_index),
|
||||||
|
set: Some(2),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"invalid sets cannot claim the non-owner exemption"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.heal_pool_metadata(&HealOpts {
|
||||||
|
pool: Some(2),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn ordinary_pool_metadata_heal_requires_every_owner_endpoint() {
|
||||||
|
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
|
||||||
|
let owner = store.pools[0].get_disks_by_key(POOL_META_NAME);
|
||||||
|
let offline_disk = owner.disks.write().await[0]
|
||||||
|
.take()
|
||||||
|
.expect("fixture owner disk should start online");
|
||||||
|
|
||||||
|
let result = store
|
||||||
|
.heal_pool_metadata(&HealOpts {
|
||||||
|
pool: Some(0),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err(), "a surviving metadata shard must not hide an offline owner endpoint");
|
||||||
|
owner.disks.write().await[0] = Some(offline_disk);
|
||||||
|
}
|
||||||
|
|
||||||
async fn remove_pool_meta_shard(store: &ECStore, pool_idx: usize) -> DiskStore {
|
async fn remove_pool_meta_shard(store: &ECStore, pool_idx: usize) -> DiskStore {
|
||||||
let target_set = store.pools[pool_idx].get_disks_by_key(POOL_META_NAME);
|
let target_set = store.pools[pool_idx].get_disks_by_key(POOL_META_NAME);
|
||||||
let missing_disk = target_set.disks.read().await[0]
|
let missing_disk = target_set.disks.read().await[0]
|
||||||
@@ -1149,7 +1466,7 @@ mod tests {
|
|||||||
let (temp_dir, store, shutdown) = multi_pool_heal_store().await;
|
let (temp_dir, store, shutdown) = multi_pool_heal_store().await;
|
||||||
let target = remove_heal_test_format(&temp_dir, &store, 0, 3).await;
|
let target = remove_heal_test_format(&temp_dir, &store, 0, 3).await;
|
||||||
let capacity_guard = store
|
let capacity_guard = store
|
||||||
.acquire_external_decommission_capacity_fence(&[0], "heal")
|
.acquire_external_decommission_capacity_fence(&[0], DecommissionCapacityAdmission::Heal)
|
||||||
.await
|
.await
|
||||||
.expect("ordinary heal capacity fence should be acquired");
|
.expect("ordinary heal capacity fence should be acquired");
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::core::pools::{
|
use crate::core::pools::{
|
||||||
PoolMetaBootstrapAuthority, PoolMetaReplicaState, PoolMetaWriteState, local_decommission_queue_prefix,
|
PoolMetaReplicaState, PoolMetaWriteState, local_decommission_queue_prefix, persist_pool_meta_identity_for_attested_pools,
|
||||||
persist_pool_meta_identity_for_startup, pool_meta_has_active_decommission,
|
persist_pool_meta_identity_for_startup, pool_meta_has_active_decommission,
|
||||||
};
|
};
|
||||||
use crate::runtime::instance::InstanceContext;
|
use crate::runtime::instance::InstanceContext;
|
||||||
@@ -174,9 +174,20 @@ where
|
|||||||
S: EcstoreObjectIO,
|
S: EcstoreObjectIO,
|
||||||
{
|
{
|
||||||
if elected_writer && write_state.bootstrap_identity_proven() {
|
if elected_writer && write_state.bootstrap_identity_proven() {
|
||||||
persist_pool_meta_identity_for_startup(pools, write_state, false).await?;
|
return persist_pool_meta_identity_for_startup(pools, write_state, false).await;
|
||||||
}
|
}
|
||||||
Ok(())
|
if write_state.bootstrap_identity_proven() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Multi-pool bootstrap whose pools were formatted by different nodes: no
|
||||||
|
// single process can prove the whole deployment fresh in memory, so each
|
||||||
|
// creator attests the pools it formatted first-hand with the shared nonce
|
||||||
|
// and the elected writer waits for a complete, agreeing pending set.
|
||||||
|
let attested = write_state.attested_pool_indices();
|
||||||
|
if attested.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
persist_pool_meta_identity_for_attested_pools(pools, write_state, &attested).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_validated_pool_meta_for_startup<S>(
|
async fn save_validated_pool_meta_for_startup<S>(
|
||||||
@@ -407,7 +418,7 @@ impl ECStore {
|
|||||||
preflight_startup_rpc_secret(&endpoint_pools)?;
|
preflight_startup_rpc_secret(&endpoint_pools)?;
|
||||||
|
|
||||||
let mut deployment_id = None;
|
let mut deployment_id = None;
|
||||||
let mut pool_meta_bootstrap_authority = None;
|
let mut pool_meta_bootstrap_authorities = Vec::new();
|
||||||
|
|
||||||
// let (endpoint_pools, _) = EndpointServerPools::create_server_endpoints(address.as_str(), &layouts)?;
|
// let (endpoint_pools, _) = EndpointServerPools::create_server_endpoints(address.as_str(), &layouts)?;
|
||||||
|
|
||||||
@@ -523,12 +534,10 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}?;
|
}?;
|
||||||
pool_meta_bootstrap_authority = Some(pool_meta_bootstrap_authority.map_or(
|
// First-hand authority for this pool only: `Fresh` when this process
|
||||||
loaded_format.pool_meta_bootstrap_authority,
|
// formatted it, `LegacyAdoption` when it verified the migration, and
|
||||||
|authority: PoolMetaBootstrapAuthority| {
|
// `None` when it merely read a format another node created.
|
||||||
authority.combine_across_pools(loaded_format.pool_meta_bootstrap_authority)
|
pool_meta_bootstrap_authorities.push(loaded_format.pool_meta_bootstrap_authority);
|
||||||
},
|
|
||||||
));
|
|
||||||
let fm = loaded_format.format;
|
let fm = loaded_format.format;
|
||||||
|
|
||||||
// Format loading succeeded, enable health monitoring on all disks
|
// Format loading succeeded, enable health monitoring on all disks
|
||||||
@@ -569,9 +578,13 @@ impl ECStore {
|
|||||||
let peer_sys = S3PeerSys::new_with_instance_ctx(&endpoint_pools, instance_ctx.clone());
|
let peer_sys = S3PeerSys::new_with_instance_ctx(&endpoint_pools, instance_ctx.clone());
|
||||||
let mut pool_meta = PoolMeta::new(&pools, &PoolMeta::default());
|
let mut pool_meta = PoolMeta::new(&pools, &PoolMeta::default());
|
||||||
pool_meta.dont_save = true;
|
pool_meta.dont_save = true;
|
||||||
let pool_meta_write_state = PoolMetaWriteState::for_startup_with_bootstrap_authority(
|
let elected_bootstrap_writer = pools
|
||||||
|
.first()
|
||||||
|
.is_some_and(|pool| pool_first_endpoint_is_local(&pool.endpoints));
|
||||||
|
let pool_meta_write_state = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
||||||
deployment_id,
|
deployment_id,
|
||||||
pool_meta_bootstrap_authority.unwrap_or_default(),
|
pool_meta_bootstrap_authorities,
|
||||||
|
elected_bootstrap_writer,
|
||||||
);
|
);
|
||||||
|
|
||||||
let decommission_cancelers = RwLock::new(vec![None; pools.len()]);
|
let decommission_cancelers = RwLock::new(vec![None; pools.len()]);
|
||||||
@@ -961,8 +974,9 @@ mod tests {
|
|||||||
bucket::replication::{ReplicationState, ReplicationStatusType, replication_statuses_map},
|
bucket::replication::{ReplicationState, ReplicationStatusType, replication_statuses_map},
|
||||||
core::pools::{
|
core::pools::{
|
||||||
DecommissionErasureLayout, DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME, POOL_META_NAME, POOL_META_VERSION,
|
DecommissionErasureLayout, DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME, POOL_META_NAME, POOL_META_VERSION,
|
||||||
PoolDecommissionInfo, PoolMeta, PoolStatus, pool_meta_identity_initialized_for_test,
|
PoolDecommissionInfo, PoolMeta, PoolStatus, pending_pool_meta_identity_for_test,
|
||||||
pool_meta_v3_commit_state_for_test, set_decommission_capacity_info_overrides_for_test,
|
pool_meta_identity_initialized_for_test, pool_meta_v3_commit_state_for_test,
|
||||||
|
set_decommission_capacity_info_overrides_for_test,
|
||||||
},
|
},
|
||||||
disk::endpoint::Endpoint,
|
disk::endpoint::Endpoint,
|
||||||
error::{Error, Result, StorageError},
|
error::{Error, Result, StorageError},
|
||||||
@@ -1465,6 +1479,331 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn startup_object(storage: &StartupPoolMetaStorage, object: &str) -> Option<Vec<u8>> {
|
||||||
|
storage
|
||||||
|
.objects
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.get(object)
|
||||||
|
.map(|(payload, _)| payload.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Startup errors wrap their cause in context whose `Display` hides the
|
||||||
|
/// source, so assertions walk the chain the same way
|
||||||
|
/// `Error::pool_metadata_failure` does.
|
||||||
|
fn error_chain_text(err: &Error) -> String {
|
||||||
|
let mut parts = vec![err.to_string()];
|
||||||
|
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
|
||||||
|
while let Some(error) = current {
|
||||||
|
current = if let Some(io) = error.downcast_ref::<std::io::Error>() {
|
||||||
|
io.get_ref().map(|inner| inner as &(dyn std::error::Error + 'static))
|
||||||
|
} else {
|
||||||
|
error.source()
|
||||||
|
};
|
||||||
|
if let Some(next) = current {
|
||||||
|
parts.push(next.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.join(" <- ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_startup_object(storage: &StartupPoolMetaStorage, object: &str, payload: Vec<u8>) {
|
||||||
|
storage
|
||||||
|
.objects
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.insert(object.to_string(), (payload, format!("injected-{object}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_test_pool_meta_with_pools(pool_count: usize) -> PoolMeta {
|
||||||
|
PoolMeta {
|
||||||
|
version: POOL_META_VERSION,
|
||||||
|
pools: (0..pool_count)
|
||||||
|
.map(|id| PoolStatus {
|
||||||
|
id,
|
||||||
|
cmd_line: format!("pool-{id}"),
|
||||||
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
||||||
|
decommission: None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dont_save: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two single-node pools whose formats were created by different nodes:
|
||||||
|
/// node0 formatted pool0 and only read pool1's format, node1 the reverse.
|
||||||
|
fn two_pool_creator_states(deployment_id: Uuid) -> (PoolMetaWriteState, PoolMetaWriteState) {
|
||||||
|
let node0 = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
||||||
|
deployment_id,
|
||||||
|
vec![PoolMetaBootstrapAuthority::Fresh, PoolMetaBootstrapAuthority::None],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let node1 = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
||||||
|
deployment_id,
|
||||||
|
vec![PoolMetaBootstrapAuthority::None, PoolMetaBootstrapAuthority::Fresh],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
(node0, node1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_two_pool_bootstrap_with_distinct_format_creators_converges_through_creator_attestation() {
|
||||||
|
let deployment_id = Uuid::new_v4();
|
||||||
|
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pools = vec![pool0.clone(), pool1.clone()];
|
||||||
|
let (mut node0, mut node1) = two_pool_creator_states(deployment_id);
|
||||||
|
assert!(!node0.bootstrap_identity_proven(), "reading pool1's format is not deployment-wide proof");
|
||||||
|
assert!(!node1.bootstrap_identity_proven());
|
||||||
|
|
||||||
|
// node1 (pool1 creator, non-elected) starts first: no durable nonce exists
|
||||||
|
// yet, so it must neither mint one nor latch its write gate while waiting.
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
||||||
|
.await
|
||||||
|
.expect("a non-first creator without a durable nonce writes nothing");
|
||||||
|
assert!(startup_object(&pool0, POOL_META_IDENTITY_NAME).is_none());
|
||||||
|
assert!(startup_object(&pool1, POOL_META_IDENTITY_NAME).is_none());
|
||||||
|
let err = load_pool_meta_for_startup(pools.clone(), &mut node1)
|
||||||
|
.await
|
||||||
|
.expect_err("nothing durable authorizes a non-elected node");
|
||||||
|
assert!(err.to_string().contains("bootstrap pending"), "{err}");
|
||||||
|
node1
|
||||||
|
.ensure_write_safe("waiting non-elected creator")
|
||||||
|
.expect("waiting for the elected writer must not latch the write gate");
|
||||||
|
|
||||||
|
// node0 (pool0 creator, elected) mints the nonce on the pool it created;
|
||||||
|
// pool1 is still unattested, so it cannot publish pool.bin and must not latch.
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
||||||
|
.await
|
||||||
|
.expect("the first pool's creator mints the pending identity");
|
||||||
|
let minted = startup_object(&pool0, POOL_META_IDENTITY_NAME).expect("pool0 pending identity");
|
||||||
|
assert!(!pool_meta_identity_initialized_for_test(&minted).expect("decode pending identity"));
|
||||||
|
assert!(
|
||||||
|
startup_object(&pool1, POOL_META_IDENTITY_NAME).is_none(),
|
||||||
|
"node0 holds no first-hand proof for pool1 and must not attest it"
|
||||||
|
);
|
||||||
|
let err = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
||||||
|
.await
|
||||||
|
.expect_err("an unattested pool keeps the elected writer from publishing");
|
||||||
|
assert!(err.to_string().contains("waiting for every pool creator"), "{err}");
|
||||||
|
node0
|
||||||
|
.ensure_write_safe("waiting elected writer")
|
||||||
|
.expect("waiting for creators must not latch the write gate");
|
||||||
|
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
||||||
|
|
||||||
|
// node1 retries: it copies pool0's pending identity (same nonce) onto the
|
||||||
|
// pool it created, then keeps waiting for the elected writer's pool.bin.
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
||||||
|
.await
|
||||||
|
.expect("the pool1 creator attests with the durable nonce");
|
||||||
|
assert_eq!(startup_object(&pool1, POOL_META_IDENTITY_NAME).as_deref(), Some(minted.as_slice()));
|
||||||
|
let err = load_pool_meta_for_startup(pools.clone(), &mut node1)
|
||||||
|
.await
|
||||||
|
.expect_err("a complete pending set never unlocks a non-elected node");
|
||||||
|
assert!(err.to_string().contains("waiting for the elected writer to publish"), "{err}");
|
||||||
|
node1
|
||||||
|
.ensure_write_safe("attested non-elected creator")
|
||||||
|
.expect("waiting for pool.bin must not latch the write gate");
|
||||||
|
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
||||||
|
|
||||||
|
// node0 retries: every pool is attested under one nonce, so it publishes
|
||||||
|
// pool.bin and commits the identity on both pools.
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
||||||
|
.await
|
||||||
|
.expect("re-establishing an already minted identity is idempotent");
|
||||||
|
let (_, replica_state) = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
||||||
|
.await
|
||||||
|
.expect("complete creator attestation authorizes the initial pool metadata write");
|
||||||
|
persist_pool_meta_for_startup_if_safe(
|
||||||
|
&init_test_pool_meta_with_pools(2),
|
||||||
|
pools.clone(),
|
||||||
|
replica_state,
|
||||||
|
&mut node0,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the elected writer publishes pool.bin and commits the identity");
|
||||||
|
for pool in [&pool0, &pool1] {
|
||||||
|
assert!(startup_object(pool, POOL_META_NAME).is_some());
|
||||||
|
let identity = startup_object(pool, POOL_META_IDENTITY_NAME).expect("committed identity");
|
||||||
|
assert!(pool_meta_identity_initialized_for_test(&identity).expect("decode committed identity"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// node1 retries once more: pool.bin exists and nothing is rewritten.
|
||||||
|
let before = startup_object(&pool1, POOL_META_IDENTITY_NAME);
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
||||||
|
.await
|
||||||
|
.expect("an initialized deployment never reopens bootstrap");
|
||||||
|
assert_eq!(startup_object(&pool1, POOL_META_IDENTITY_NAME), before);
|
||||||
|
load_pool_meta_for_startup(pools, &mut node1)
|
||||||
|
.await
|
||||||
|
.expect("published pool metadata admits the non-elected node");
|
||||||
|
node1
|
||||||
|
.ensure_write_safe("converged non-elected creator")
|
||||||
|
.expect("no latch remains after convergence");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_two_pool_bootstrap_rejects_pending_replicas_from_different_bootstraps() {
|
||||||
|
let deployment_id = Uuid::new_v4();
|
||||||
|
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pools = vec![pool0.clone(), pool1.clone()];
|
||||||
|
let (mut node0, _) = two_pool_creator_states(deployment_id);
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
||||||
|
.await
|
||||||
|
.expect("the first pool's creator mints the pending identity");
|
||||||
|
inject_startup_object(
|
||||||
|
&pool1,
|
||||||
|
POOL_META_IDENTITY_NAME,
|
||||||
|
pending_pool_meta_identity_for_test(deployment_id, 1, Uuid::new_v4()).expect("encode foreign pending identity"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
||||||
|
.await
|
||||||
|
.expect_err("a pending replica bound to another bootstrap nonce must fail closed");
|
||||||
|
let chain = error_chain_text(&err);
|
||||||
|
assert!(chain.contains("disagree on fresh-bootstrap proof"), "{chain}");
|
||||||
|
node0
|
||||||
|
.ensure_write_safe("split bootstrap")
|
||||||
|
.expect_err("a split bootstrap latches the write gate");
|
||||||
|
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_two_pool_bootstrap_treats_corrupt_creator_replica_as_recovery_not_waiting() {
|
||||||
|
let deployment_id = Uuid::new_v4();
|
||||||
|
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pools = vec![pool0.clone(), pool1.clone()];
|
||||||
|
let (mut node0, _) = two_pool_creator_states(deployment_id);
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
||||||
|
.await
|
||||||
|
.expect("the first pool's creator mints the pending identity");
|
||||||
|
// Keep the on-disk format/version header so the replica classifies as
|
||||||
|
// corrupt (undecodable payload) rather than as an incompatible format.
|
||||||
|
let mut corrupt = pending_pool_meta_identity_for_test(deployment_id, 1, Uuid::new_v4()).expect("encode identity");
|
||||||
|
corrupt.truncate(4);
|
||||||
|
corrupt.extend_from_slice(b"not a cluster identity");
|
||||||
|
inject_startup_object(&pool1, POOL_META_IDENTITY_NAME, corrupt);
|
||||||
|
|
||||||
|
let err = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
||||||
|
.await
|
||||||
|
.expect_err("a corrupt replica is not a creator that is still catching up");
|
||||||
|
let chain = error_chain_text(&err);
|
||||||
|
assert!(chain.contains("no verified fresh-bootstrap proof"), "{chain}");
|
||||||
|
node0
|
||||||
|
.ensure_write_safe("corrupt attestation")
|
||||||
|
.expect_err("a corrupt attestation latches the write gate");
|
||||||
|
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_elected_restart_without_first_hand_proof_cannot_reuse_a_complete_pending_set() {
|
||||||
|
let deployment_id = Uuid::new_v4();
|
||||||
|
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pools = vec![pool0.clone(), pool1.clone()];
|
||||||
|
let (mut node0, mut node1) = two_pool_creator_states(deployment_id);
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
||||||
|
.await
|
||||||
|
.expect("mint");
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
||||||
|
.await
|
||||||
|
.expect("attest");
|
||||||
|
assert_eq!(
|
||||||
|
startup_object(&pool0, POOL_META_IDENTITY_NAME),
|
||||||
|
startup_object(&pool1, POOL_META_IDENTITY_NAME),
|
||||||
|
"both creators attested the same pending identity"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The elected node restarts before publishing: it now merely reads both
|
||||||
|
// formats, so the complete pending set alone must not reopen bootstrap.
|
||||||
|
let mut restarted = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
||||||
|
deployment_id,
|
||||||
|
vec![PoolMetaBootstrapAuthority::None, PoolMetaBootstrapAuthority::None],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut restarted, true)
|
||||||
|
.await
|
||||||
|
.expect("a restart without first-hand proof writes nothing");
|
||||||
|
let err = load_pool_meta_for_startup(pools.clone(), &mut restarted)
|
||||||
|
.await
|
||||||
|
.expect_err("a pending set alone never authorizes a writer without first-hand proof");
|
||||||
|
assert!(err.to_string().contains("no verified fresh-bootstrap proof"), "{err}");
|
||||||
|
restarted
|
||||||
|
.ensure_write_safe("unproven restart")
|
||||||
|
.expect_err("the rejected restart latches the write gate");
|
||||||
|
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_fresh_pool_joining_an_initialized_deployment_never_reopens_bootstrap() {
|
||||||
|
let deployment_id = Uuid::new_v4();
|
||||||
|
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let mut founder = PoolMetaWriteState::for_startup(deployment_id, true);
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(vec![pool0.clone()], &mut founder, true)
|
||||||
|
.await
|
||||||
|
.expect("the founder mints");
|
||||||
|
let (_, replica_state) = load_pool_meta_for_startup(vec![pool0.clone()], &mut founder)
|
||||||
|
.await
|
||||||
|
.expect("the founder may initialize");
|
||||||
|
persist_pool_meta_for_startup_if_safe(
|
||||||
|
&init_test_pool_meta(None),
|
||||||
|
vec![pool0.clone()],
|
||||||
|
replica_state,
|
||||||
|
&mut founder,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the founder commits");
|
||||||
|
let founded = startup_object(&pool0, POOL_META_IDENTITY_NAME).expect("committed identity");
|
||||||
|
assert!(pool_meta_identity_initialized_for_test(&founded).expect("decode committed identity"));
|
||||||
|
|
||||||
|
// Expansion: pool1 is fresh and was formatted first-hand by the node
|
||||||
|
// hosting its first endpoint, whether or not that node is elected.
|
||||||
|
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pools = vec![pool0.clone(), pool1.clone()];
|
||||||
|
for elected in [false, true] {
|
||||||
|
let mut joiner = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
||||||
|
deployment_id,
|
||||||
|
vec![PoolMetaBootstrapAuthority::None, PoolMetaBootstrapAuthority::Fresh],
|
||||||
|
elected,
|
||||||
|
);
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut joiner, elected)
|
||||||
|
.await
|
||||||
|
.expect("an initialized deployment ignores first-hand proof for a new pool");
|
||||||
|
assert!(
|
||||||
|
startup_object(&pool1, POOL_META_IDENTITY_NAME).is_none(),
|
||||||
|
"no pending identity may be written to an expansion pool"
|
||||||
|
);
|
||||||
|
assert_eq!(startup_object(&pool0, POOL_META_IDENTITY_NAME).as_deref(), Some(founded.as_slice()));
|
||||||
|
let (_, replica_state) = load_pool_meta_for_startup(pools.clone(), &mut joiner)
|
||||||
|
.await
|
||||||
|
.expect("published pool metadata admits the joiner");
|
||||||
|
joiner
|
||||||
|
.ensure_write_safe("expansion joiner")
|
||||||
|
.expect("joining never latches the write gate");
|
||||||
|
if elected {
|
||||||
|
persist_pool_meta_for_startup_if_safe(
|
||||||
|
&init_test_pool_meta_with_pools(2),
|
||||||
|
pools.clone(),
|
||||||
|
replica_state,
|
||||||
|
&mut joiner,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the topology update repairs the new pool's replicas");
|
||||||
|
let identity = startup_object(&pool1, POOL_META_IDENTITY_NAME).expect("expansion pool identity");
|
||||||
|
assert!(pool_meta_identity_initialized_for_test(&identity).expect("decode repaired identity"));
|
||||||
|
assert!(startup_object(&pool1, POOL_META_NAME).is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_store_init_distinguishes_fresh_deployment_from_wiped_lagging_node() {
|
async fn test_store_init_distinguishes_fresh_deployment_from_wiped_lagging_node() {
|
||||||
let deployment_id = Uuid::new_v4();
|
let deployment_id = Uuid::new_v4();
|
||||||
@@ -3859,13 +4198,60 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("suspended source versions should be readable")
|
.expect("suspended source versions should be readable")
|
||||||
.expect("suspended source must exist before worker convergence");
|
.expect("suspended source must exist before worker convergence");
|
||||||
|
assert_eq!(versions.versions.len(), 1, "DELETE must not add a marker to the retiring source");
|
||||||
|
let source = &versions.versions[0];
|
||||||
assert!(
|
assert!(
|
||||||
versions
|
!source.deleted && source.version_id.is_none_or(|version_id| version_id.is_nil()),
|
||||||
.versions
|
"the source pool must retain its null data version until worker convergence"
|
||||||
.iter()
|
|
||||||
.any(|version| !version.deleted && version.version_id.is_none_or(|version_id| version_id.is_nil())),
|
|
||||||
"the source pool must retain its null data version while DELETE owns the fixed fence"
|
|
||||||
);
|
);
|
||||||
|
assert_eq!(source.mod_time, Some(OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND));
|
||||||
|
|
||||||
|
let mut reader = store.pools[0]
|
||||||
|
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the retiring source must remain directly readable before worker convergence");
|
||||||
|
let mut body = Vec::new();
|
||||||
|
reader
|
||||||
|
.stream
|
||||||
|
.read_to_end(&mut body)
|
||||||
|
.await
|
||||||
|
.expect("read retained source bytes");
|
||||||
|
assert_eq!(body, b"suspended source generation");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_suspended_null_delete_marker_visible(
|
||||||
|
store: &Arc<crate::store::ECStore>,
|
||||||
|
bucket: &str,
|
||||||
|
object: &str,
|
||||||
|
marker_mod_time: OffsetDateTime,
|
||||||
|
) {
|
||||||
|
let versions = store.pools[1]
|
||||||
|
.get_disks_by_key(object)
|
||||||
|
.load_file_info_versions_exact(bucket, object)
|
||||||
|
.await
|
||||||
|
.expect("healthy target versions should be readable")
|
||||||
|
.expect("the healthy target must retain the DELETE marker");
|
||||||
|
assert_eq!(versions.versions.len(), 1, "the target must contain only the null delete marker");
|
||||||
|
let marker = &versions.versions[0];
|
||||||
|
assert!(marker.deleted, "migration must not replace the DELETE marker with source data");
|
||||||
|
assert!(marker.version_id.is_none_or(|version_id| version_id.is_nil()));
|
||||||
|
assert_eq!(marker.size, 0);
|
||||||
|
assert_eq!(marker.mod_time, Some(marker_mod_time), "migration must preserve the marker generation");
|
||||||
|
assert!(marker_mod_time > OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND);
|
||||||
|
|
||||||
|
let head_err = store
|
||||||
|
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect_err("HEAD must observe the DELETE marker instead of the old null source");
|
||||||
|
assert!(matches!(head_err, Error::ObjectNotFound(_, _)), "unexpected HEAD result: {head_err:?}");
|
||||||
|
let get_err = match store
|
||||||
|
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => panic!("GET must not resurrect the deleted null source"),
|
||||||
|
Err(err) => err,
|
||||||
|
};
|
||||||
|
assert!(matches!(get_err, Error::ObjectNotFound(_, _)), "unexpected GET result: {get_err:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -7703,7 +8089,7 @@ mod tests {
|
|||||||
write_suspended_decommission_source(&store, &bucket, object).await;
|
write_suspended_decommission_source(&store, &bucket, object).await;
|
||||||
mark_test_pool_decommissioning(&store, 0).await;
|
mark_test_pool_decommissioning(&store, 0).await;
|
||||||
|
|
||||||
let delete_err = store
|
let deleted = store
|
||||||
.delete_object(
|
.delete_object(
|
||||||
&bucket,
|
&bucket,
|
||||||
object,
|
object,
|
||||||
@@ -7713,12 +8099,12 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("capacity-reserved target must reject a concurrent suspended DELETE");
|
.expect("a healthy reserved target must accept suspended DELETE");
|
||||||
assert!(
|
assert!(deleted.delete_marker);
|
||||||
matches!(delete_err, Error::SlowDown),
|
assert_eq!(deleted.version_id, Some(uuid::Uuid::nil()));
|
||||||
"unexpected suspended DELETE result: {delete_err:?}"
|
let marker_mod_time = deleted.mod_time.expect("DELETE must return the marker generation");
|
||||||
);
|
|
||||||
assert_suspended_null_source_present(&store, &bucket, object).await;
|
assert_suspended_null_source_present(&store, &bucket, object).await;
|
||||||
|
assert_suspended_null_delete_marker_visible(&store, &bucket, object, marker_mod_time).await;
|
||||||
|
|
||||||
let source_set = store.pools[0].get_disks_by_key(object);
|
let source_set = store.pools[0].get_disks_by_key(object);
|
||||||
let worker_store = Arc::clone(&store);
|
let worker_store = Arc::clone(&store);
|
||||||
@@ -7738,7 +8124,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("suspended decommission worker should join")
|
.expect("suspended decommission worker should join")
|
||||||
.expect("worker must migrate the fenced suspended source");
|
.expect("worker must converge the old null source behind the newer DELETE marker");
|
||||||
|
|
||||||
assert_decommission_source_absent(
|
assert_decommission_source_absent(
|
||||||
&store,
|
&store,
|
||||||
@@ -7750,10 +8136,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(
|
assert_suspended_null_delete_marker_visible(&store, &bucket, object, marker_mod_time).await;
|
||||||
read_decommission_target_body(&store, &bucket, object, &ObjectOptions::default()).await,
|
|
||||||
b"suspended source generation"
|
|
||||||
);
|
|
||||||
shutdown.cancel();
|
shutdown.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7786,7 +8169,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
let (_deleted, errors) = store
|
let (deleted, errors) = store
|
||||||
.delete_objects(
|
.delete_objects(
|
||||||
&bucket,
|
&bucket,
|
||||||
vec![ObjectToDelete {
|
vec![ObjectToDelete {
|
||||||
@@ -7800,10 +8183,23 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
matches!(errors.as_slice(), [Some(Error::SlowDown)]),
|
matches!(errors.as_slice(), [None]),
|
||||||
"unexpected suspended batch DELETE result: {errors:?}"
|
"unexpected suspended batch DELETE result: {errors:?}"
|
||||||
);
|
);
|
||||||
|
assert_eq!(deleted.len(), 1);
|
||||||
|
assert!(deleted[0].delete_marker);
|
||||||
|
assert_eq!(deleted[0].object_name, object);
|
||||||
|
assert!(
|
||||||
|
deleted[0]
|
||||||
|
.delete_marker_version_id
|
||||||
|
.is_none_or(|version_id| version_id.is_nil()),
|
||||||
|
"batch DELETE must retain the native null version identity"
|
||||||
|
);
|
||||||
|
let marker_mod_time = deleted[0]
|
||||||
|
.delete_marker_mtime
|
||||||
|
.expect("batch DELETE must return the marker generation");
|
||||||
assert_suspended_null_source_present(&store, &bucket, object).await;
|
assert_suspended_null_source_present(&store, &bucket, object).await;
|
||||||
|
assert_suspended_null_delete_marker_visible(&store, &bucket, object, marker_mod_time).await;
|
||||||
|
|
||||||
let source_set = store.pools[0].get_disks_by_key(object);
|
let source_set = store.pools[0].get_disks_by_key(object);
|
||||||
let worker_store = Arc::clone(&store);
|
let worker_store = Arc::clone(&store);
|
||||||
@@ -7823,7 +8219,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("suspended batch decommission worker should join")
|
.expect("suspended batch decommission worker should join")
|
||||||
.expect("worker must migrate the batch-fenced suspended source");
|
.expect("worker must converge the old null source behind the newer batch DELETE marker");
|
||||||
|
|
||||||
assert_decommission_source_absent(
|
assert_decommission_source_absent(
|
||||||
&store,
|
&store,
|
||||||
@@ -7835,10 +8231,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(
|
assert_suspended_null_delete_marker_visible(&store, &bucket, object, marker_mod_time).await;
|
||||||
read_decommission_target_body(&store, &bucket, object, &ObjectOptions::default()).await,
|
|
||||||
b"suspended source generation"
|
|
||||||
);
|
|
||||||
shutdown.cancel();
|
shutdown.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18539,6 +18932,124 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial(storage_class_env)]
|
||||||
|
fn tier_config_init_recovers_after_initial_reload_failure_without_another_mutation() {
|
||||||
|
run_large_stack_async_test("tier-config-init-recovery", || async {
|
||||||
|
use crate::services::tier::tier::{TIER_DRIVER_TEST_FACTORY, TierDriverTestFactory};
|
||||||
|
|
||||||
|
let temp_dir = tempfile::tempdir().expect("create tier startup recovery store dir");
|
||||||
|
let (ctx, store, _shutdown) = build_isolated_test_store(temp_dir.path(), "tier-startup-recovery", &[4]).await;
|
||||||
|
let manager = ctx.tier_config_mgr();
|
||||||
|
let candidate = TierConfigMgr::new();
|
||||||
|
let tier_name = "STARTUP-RECOVERY";
|
||||||
|
let backend = register_mock_tier(&candidate, tier_name).await;
|
||||||
|
let backend_identity = TierConfigMgr::acquire_operation_lease(&candidate, tier_name)
|
||||||
|
.await
|
||||||
|
.expect("candidate backend identity should resolve")
|
||||||
|
.backend_identity();
|
||||||
|
let candidate_digest = {
|
||||||
|
let candidate = candidate.read().await;
|
||||||
|
candidate
|
||||||
|
.save_tiering_config(store.clone())
|
||||||
|
.await
|
||||||
|
.expect("committed tier config should persist");
|
||||||
|
tier_config_candidate_digest(&candidate).expect("committed candidate digest should build")
|
||||||
|
};
|
||||||
|
let config_path = format!("{}/{}", com::CONFIG_PREFIX, TIER_CONFIG_FILE);
|
||||||
|
let config_bytes = com::read_config(store.clone(), &config_path)
|
||||||
|
.await
|
||||||
|
.expect("committed tier config bytes should load");
|
||||||
|
let config_etag = store
|
||||||
|
.get_object_info(RUSTFS_META_BUCKET, &config_path, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("committed tier config metadata should load")
|
||||||
|
.etag
|
||||||
|
.expect("committed tier config should have an ETag");
|
||||||
|
let mutation_id = uuid::Uuid::new_v4();
|
||||||
|
let intent = TierMutationIntent {
|
||||||
|
mutation_id,
|
||||||
|
revision: 2,
|
||||||
|
kind: TierMutationIntentKind::Add,
|
||||||
|
state: TierMutationIntentState::Committed,
|
||||||
|
old_config_etag: None,
|
||||||
|
committed_config_etag: Some(config_etag),
|
||||||
|
candidate_digest,
|
||||||
|
affected_targets: vec![TierMutationIntentTarget {
|
||||||
|
tier_name: tier_name.to_string(),
|
||||||
|
old_backend_identity: None,
|
||||||
|
new_backend_identity: Some(backend_identity),
|
||||||
|
}],
|
||||||
|
expires_at_unix_nanos: 1,
|
||||||
|
};
|
||||||
|
// Persist the restart state directly: no peer Commit or runtime block
|
||||||
|
// installation may leave a notification that masks a missing startup wakeup.
|
||||||
|
save_tier_mutation_intent_record(store.clone(), &intent)
|
||||||
|
.await
|
||||||
|
.expect("committed restart intent should persist without notifying the manager");
|
||||||
|
com::save_config(store.clone(), &config_path, vec![0])
|
||||||
|
.await
|
||||||
|
.expect("controlled invalid config should persist");
|
||||||
|
|
||||||
|
runtime_sources::TEST_TIER_CONFIG_MGR
|
||||||
|
.scope(manager.clone(), async {
|
||||||
|
let err = runtime_sources::init_tier_config_mgr(store.clone())
|
||||||
|
.await
|
||||||
|
.expect_err("initial reload must report the controlled config parse failure");
|
||||||
|
assert!(err.to_string().contains("tierConfigInit: no data"), "unexpected startup failure: {err}");
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(manager.read().await.tiers.is_empty(), "failed startup must not publish the candidate");
|
||||||
|
let blocked = match TierConfigMgr::acquire_operation_lease(&manager, tier_name).await {
|
||||||
|
Ok(_) => panic!("failed startup must retain the recovered committed fence"),
|
||||||
|
Err(err) => err,
|
||||||
|
};
|
||||||
|
assert!(TierConfigMgr::operation_lease_blocked_by_mutation(&blocked));
|
||||||
|
|
||||||
|
// Restore only the durable bytes. Recovery must come from the worker
|
||||||
|
// started by init, after the test-only handle scope has already ended.
|
||||||
|
com::save_config(store.clone(), &config_path, config_bytes)
|
||||||
|
.await
|
||||||
|
.expect("restoring committed config bytes should remove the startup failure");
|
||||||
|
// Reload only publishes this Add into an empty manager, so it has
|
||||||
|
// no replaced backend to construct or probe. Only the lease check
|
||||||
|
// below needs a driver; keep its factory on the observing task.
|
||||||
|
let driver_factory: TierDriverTestFactory = Arc::new(move |_| Ok(Box::new(backend.clone())));
|
||||||
|
TIER_DRIVER_TEST_FACTORY
|
||||||
|
.scope(driver_factory, async {
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
loop {
|
||||||
|
match TierConfigMgr::acquire_operation_lease(&manager, tier_name).await {
|
||||||
|
Ok(lease) => {
|
||||||
|
drop(lease);
|
||||||
|
match load_tier_mutation_intent_record(store.clone(), mutation_id).await {
|
||||||
|
Err(Error::ConfigNotFound) => break,
|
||||||
|
Ok(retained) => assert_eq!(retained, intent),
|
||||||
|
Err(err) => panic!("committed recovery intent should remain readable: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => assert!(
|
||||||
|
TierConfigMgr::operation_lease_blocked_by_mutation(&err),
|
||||||
|
"recovery must retain the fence until the tier becomes available: {err}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("startup worker must publish the tier and clean its fence without another mutation");
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let recovered = manager.read().await;
|
||||||
|
assert_eq!(
|
||||||
|
tier_config_candidate_digest(&recovered).expect("recovered config digest should build"),
|
||||||
|
candidate_digest,
|
||||||
|
"startup recovery must publish the committed configuration"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial(storage_class_env)]
|
#[serial_test::serial(storage_class_env)]
|
||||||
|
|||||||
@@ -762,13 +762,7 @@ impl ECStore {
|
|||||||
self.pools
|
self.pools
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter(|(pool_index, _)| {
|
.filter(|(pool_index, _)| pool_meta.scanner_pause_backlog_pool_writable(*pool_index))
|
||||||
!pool_meta.pools.get(*pool_index).is_some_and(|pool| {
|
|
||||||
pool.decommission
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|info| info.has_decommission_state() && !info.failed && !info.canceled)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.flat_map(|(_, pool)| pool.disk_set.iter().cloned())
|
.flat_map(|(_, pool)| pool.disk_set.iter().cloned())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ use crate::bucket::object_lock::objectlock_sys::{
|
|||||||
};
|
};
|
||||||
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
||||||
use crate::bucket::versioning::VersioningApi;
|
use crate::bucket::versioning::VersioningApi;
|
||||||
use crate::core::pools::{DecommissionCapacityOwner, ensure_decommission_capacity_mutation_id};
|
use crate::core::pools::{DecommissionCapacityAdmission, DecommissionCapacityOwner, ensure_decommission_capacity_mutation_id};
|
||||||
use crate::disk::OldCurrentSize;
|
use crate::disk::OldCurrentSize;
|
||||||
use crate::object_api::{
|
use crate::object_api::{
|
||||||
NamespaceLockFence, ObjectLockConfigSnapshot, ScannerPublicationCommitScopeGuard, ScannerPublicationCommitState,
|
NamespaceLockFence, ObjectLockConfigSnapshot, ScannerPublicationCommitScopeGuard, ScannerPublicationCommitState,
|
||||||
@@ -3060,6 +3060,68 @@ impl ECStore {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Publish a native scanner replica without allowing stale pool selection
|
||||||
|
/// to race retirement. Failed/canceled membership repair remains permitted.
|
||||||
|
/// A canceled waiter cannot release publication fences from an in-flight write.
|
||||||
|
pub async fn save_scanner_pause_backlog_replica(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
pool_index: usize,
|
||||||
|
set_index: usize,
|
||||||
|
data: Vec<u8>,
|
||||||
|
preconditions: crate::storage_api_contracts::object::HTTPPreconditions,
|
||||||
|
) -> Result<ObjectInfo> {
|
||||||
|
let set = self
|
||||||
|
.pools
|
||||||
|
.get(pool_index)
|
||||||
|
.and_then(|pool| pool.disk_set.get(set_index))
|
||||||
|
.ok_or_else(|| Error::InvalidArgument("scanner-backlog".into(), "replica".into(), "unknown pool or set".into()))?;
|
||||||
|
let set = Arc::clone(set);
|
||||||
|
let store = Arc::clone(self);
|
||||||
|
let write = async move {
|
||||||
|
let object = "buckets/.scanner-pause-backlog.json";
|
||||||
|
let mut opts = ObjectOptions {
|
||||||
|
max_parity: true,
|
||||||
|
http_preconditions: Some(preconditions),
|
||||||
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// Match migration: fixed object namespace -> durable pool metadata ->
|
||||||
|
// actual replica namespace. The replica need not be the hash-routed set.
|
||||||
|
let object_guard = if store.single_pool() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
store
|
||||||
|
.acquire_object_write_lock("scanner_backlog_replica", RUSTFS_META_BUCKET, object)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let capacity_guard = if let Some(guard) = object_guard.as_ref() {
|
||||||
|
guard.add_namespace_lock_fence(&mut opts);
|
||||||
|
opts.no_lock = match store.pools.first().and_then(|pool| pool.disk_set.first()) {
|
||||||
|
Some(fixed) => fixed.shares_namespace_lock_domain(&set).await,
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
let capacity_guard = store
|
||||||
|
.acquire_external_decommission_capacity_fence(&[pool_index], DecommissionCapacityAdmission::ScannerBacklog)
|
||||||
|
.await?;
|
||||||
|
opts.add_namespace_lock_guard(&capacity_guard);
|
||||||
|
Some(capacity_guard)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let result = set
|
||||||
|
.put_object(RUSTFS_META_BUCKET, object, &mut PutObjReader::from_vec(data), &opts)
|
||||||
|
.await;
|
||||||
|
drop(capacity_guard);
|
||||||
|
drop(object_guard);
|
||||||
|
result
|
||||||
|
};
|
||||||
|
// The set layer may detach its rename owner, even for full-tail writes.
|
||||||
|
// Keep these outer guards alive until that owner finishes if scanner exits.
|
||||||
|
tokio::spawn(write).await.map_err(Error::from)?
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn run_external_decommission_capacity_object_mutation<T, F, Fut>(
|
pub(super) async fn run_external_decommission_capacity_object_mutation<T, F, Fut>(
|
||||||
&self,
|
&self,
|
||||||
target_pool_idx: usize,
|
target_pool_idx: usize,
|
||||||
@@ -3129,7 +3191,10 @@ impl ECStore {
|
|||||||
let (capacity_guard, has_active_decommission) = if capacity_releasing {
|
let (capacity_guard, has_active_decommission) = if capacity_releasing {
|
||||||
self.acquire_decommission_capacity_release_fence_with_active_source().await?
|
self.acquire_decommission_capacity_release_fence_with_active_source().await?
|
||||||
} else {
|
} else {
|
||||||
self.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], "mutation")
|
self.acquire_external_decommission_capacity_fence_with_active_source(
|
||||||
|
&[target_pool_idx],
|
||||||
|
DecommissionCapacityAdmission::Mutation,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
let (capacity_guard, object_guard) = if has_active_decommission && !opts.no_lock {
|
let (capacity_guard, object_guard) = if has_active_decommission && !opts.no_lock {
|
||||||
@@ -3146,7 +3211,7 @@ impl ECStore {
|
|||||||
let capacity_guard = if capacity_releasing {
|
let capacity_guard = if capacity_releasing {
|
||||||
self.acquire_decommission_capacity_release_fence_with_active_source().await?.0
|
self.acquire_decommission_capacity_release_fence_with_active_source().await?.0
|
||||||
} else {
|
} else {
|
||||||
self.acquire_external_decommission_capacity_fence(&[target_pool_idx], "mutation")
|
self.acquire_external_decommission_capacity_fence(&[target_pool_idx], DecommissionCapacityAdmission::Mutation)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
(capacity_guard, Some(guard))
|
(capacity_guard, Some(guard))
|
||||||
@@ -3176,9 +3241,10 @@ impl ECStore {
|
|||||||
bucket: &str,
|
bucket: &str,
|
||||||
object: &str,
|
object: &str,
|
||||||
no_lock: bool,
|
no_lock: bool,
|
||||||
|
admission: DecommissionCapacityAdmission,
|
||||||
) -> Result<(Option<ObjectLockDiagGuard>, bool, Option<rustfs_lock::NamespaceLockGuard>)> {
|
) -> Result<(Option<ObjectLockDiagGuard>, bool, Option<rustfs_lock::NamespaceLockGuard>)> {
|
||||||
let (capacity_guard, has_active_decommission) = self
|
let (capacity_guard, has_active_decommission) = self
|
||||||
.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], "mutation")
|
.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], admission)
|
||||||
.await?;
|
.await?;
|
||||||
if !has_active_decommission {
|
if !has_active_decommission {
|
||||||
// Keep the read probe through the staged commit. This closes the
|
// Keep the read probe through the staged commit. This closes the
|
||||||
@@ -3227,7 +3293,10 @@ impl ECStore {
|
|||||||
return operation(opts).await;
|
return operation(opts).await;
|
||||||
}
|
}
|
||||||
let (capacity_guard, has_active_decommission) = self
|
let (capacity_guard, has_active_decommission) = self
|
||||||
.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], "heal")
|
.acquire_external_decommission_capacity_fence_with_active_source(
|
||||||
|
&[target_pool_idx],
|
||||||
|
DecommissionCapacityAdmission::Heal,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let (capacity_guard, object_guard) = if has_active_decommission && !opts.no_lock {
|
let (capacity_guard, object_guard) = if has_active_decommission && !opts.no_lock {
|
||||||
// Active migration acquires the object namespace before its capacity
|
// Active migration acquires the object namespace before its capacity
|
||||||
@@ -3248,7 +3317,7 @@ impl ECStore {
|
|||||||
None => false,
|
None => false,
|
||||||
};
|
};
|
||||||
let capacity_guard = self
|
let capacity_guard = self
|
||||||
.acquire_external_decommission_capacity_fence(&[target_pool_idx], "heal")
|
.acquire_external_decommission_capacity_fence(&[target_pool_idx], DecommissionCapacityAdmission::Heal)
|
||||||
.await?;
|
.await?;
|
||||||
opts.no_lock = target_lock_covered;
|
opts.no_lock = target_lock_covered;
|
||||||
(capacity_guard, Some(guard))
|
(capacity_guard, Some(guard))
|
||||||
@@ -5093,9 +5162,14 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _capacity_fence = if !self.single_pool() && latest_marker_objects.iter().any(|creates_marker| *creates_marker) {
|
let _capacity_fence = if !self.single_pool() && latest_marker_objects.iter().any(|creates_marker| *creates_marker) {
|
||||||
let target_pool_indices = (0..self.pools.len()).collect::<Vec<_>>();
|
// Only marker destinations can grow. Other pools participate in
|
||||||
|
// exact deletion under the same metadata read fence and must not
|
||||||
|
// be treated as publication targets merely because they retire.
|
||||||
|
let mut target_pool_indices = marker_target_pool_indices.iter().flatten().copied().collect::<Vec<_>>();
|
||||||
|
target_pool_indices.sort_unstable();
|
||||||
|
target_pool_indices.dedup();
|
||||||
match self
|
match self
|
||||||
.acquire_external_decommission_capacity_fence(&target_pool_indices, "batch_delete")
|
.acquire_external_decommission_capacity_fence(&target_pool_indices, DecommissionCapacityAdmission::BatchDelete)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(fence) => Some(fence),
|
Ok(fence) => Some(fence),
|
||||||
|
|||||||
@@ -851,7 +851,7 @@ impl ErasureSetHealer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if failed_objects == 0 && skipped_objects == 0 && failed_buckets == 0 {
|
if failed_objects == 0 && skipped_objects == 0 && failed_buckets == 0 {
|
||||||
self.heal_replacement_pool_metadata(
|
self.heal_pool_metadata(
|
||||||
set_disk_id,
|
set_disk_id,
|
||||||
&mut ErasureSetPassCounters {
|
&mut ErasureSetPassCounters {
|
||||||
processed_objects: &mut processed_objects,
|
processed_objects: &mut processed_objects,
|
||||||
@@ -950,27 +950,44 @@ impl ErasureSetHealer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn heal_replacement_pool_metadata(
|
async fn heal_pool_metadata(
|
||||||
&self,
|
&self,
|
||||||
set_disk_id: &str,
|
set_disk_id: &str,
|
||||||
counters: &mut ErasureSetPassCounters<'_>,
|
counters: &mut ErasureSetPassCounters<'_>,
|
||||||
resume_manager: &ResumeManager,
|
resume_manager: &ResumeManager,
|
||||||
checkpoint_manager: &CheckpointManager,
|
checkpoint_manager: &CheckpointManager,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let target_endpoints = if self.pool_metadata_target_endpoints.is_empty() {
|
let mut metadata_opts = self.heal_opts;
|
||||||
|
metadata_opts.remove = false;
|
||||||
|
metadata_opts.no_lock = false;
|
||||||
|
if self.replacement_task_id.is_none() {
|
||||||
|
let (pool_index, set_index) = crate::heal::utils::parse_set_disk_id(set_disk_id)?;
|
||||||
|
if metadata_opts.pool.is_some_and(|pool| pool != pool_index) || metadata_opts.set.is_some_and(|set| set != set_index)
|
||||||
|
{
|
||||||
|
return Err(Error::TaskExecutionFailed {
|
||||||
|
message: format!("Pool metadata scope does not match resumed set {set_disk_id}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
metadata_opts.pool = Some(pool_index);
|
||||||
|
metadata_opts.set = Some(set_index);
|
||||||
|
}
|
||||||
|
let target_endpoints = if self.replacement_task_id.is_some() || self.pool_metadata_target_endpoints.is_empty() {
|
||||||
self.target_endpoints.as_ref()
|
self.target_endpoints.as_ref()
|
||||||
} else {
|
} else {
|
||||||
self.pool_metadata_target_endpoints.as_ref()
|
self.pool_metadata_target_endpoints.as_ref()
|
||||||
};
|
};
|
||||||
let target_scoped_recreate = !self.heal_opts.dry_run && self.heal_opts.recreate && !target_endpoints.is_empty();
|
let target_scoped_recreate = !metadata_opts.dry_run && metadata_opts.recreate && !target_endpoints.is_empty();
|
||||||
if self.replacement_task_id.is_none() && !target_scoped_recreate {
|
let ordinary_heal = self.replacement_task_id.is_none() && !target_scoped_recreate;
|
||||||
return Ok(());
|
if !ordinary_heal {
|
||||||
}
|
|
||||||
if target_endpoints.is_empty() {
|
if target_endpoints.is_empty() {
|
||||||
return Err(Error::TaskExecutionFailed {
|
return Err(Error::TaskExecutionFailed {
|
||||||
message: "Replacement pool metadata heal requires target endpoints".to_string(),
|
message: "Replacement pool metadata heal requires target endpoints".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if !self.storage.replacement_pool_metadata_applies(&metadata_opts).await? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let object_key = format!("{RUSTFS_META_BUCKET}/{POOL_META_NAME}");
|
let object_key = format!("{RUSTFS_META_BUCKET}/{POOL_META_NAME}");
|
||||||
let checkpoint_key = compose_key(&object_key, None);
|
let checkpoint_key = compose_key(&object_key, None);
|
||||||
@@ -988,16 +1005,42 @@ impl ErasureSetHealer {
|
|||||||
.set_current_item(Some(RUSTFS_META_BUCKET.to_string()), Some(POOL_META_NAME.to_string()))
|
.set_current_item(Some(RUSTFS_META_BUCKET.to_string()), Some(POOL_META_NAME.to_string()))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let result = match self
|
let result = if ordinary_heal {
|
||||||
|
match self.storage.heal_pool_metadata(&metadata_opts).await {
|
||||||
|
Ok(results) if results.is_empty() => return Ok(()),
|
||||||
|
Ok(results) => {
|
||||||
|
let [result] = results.as_slice() else {
|
||||||
|
return Err(Error::TaskExecutionFailed {
|
||||||
|
message: format!("Pool metadata returned multiple replicas for set {set_disk_id}"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
(result_object_size_u64(result), Ok(()))
|
||||||
|
}
|
||||||
|
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
|
||||||
|
Err(err) => match Self::classify_heal_object_error(&err) {
|
||||||
|
HealObjectOutcome::Absent | HealObjectOutcome::Transient => {
|
||||||
|
(0, Err(Error::transient_skip(format!("Pool metadata heal must be retried: {err}"))))
|
||||||
|
}
|
||||||
|
HealObjectOutcome::Failed => (0, Err(err)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match self
|
||||||
.storage
|
.storage
|
||||||
.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &self.heal_opts)
|
.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &metadata_opts)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((result, None)) if target_outcomes_complete(&result, target_endpoints) => {
|
Ok((result, None)) if target_outcomes_complete(&result, target_endpoints) => {
|
||||||
let object_size = result_object_size_u64(&result);
|
let object_size = result_object_size_u64(&result);
|
||||||
match self
|
match self
|
||||||
.storage
|
.storage
|
||||||
.replacement_targets_have_version(RUSTFS_META_BUCKET, POOL_META_NAME, None, &self.heal_opts, target_endpoints)
|
.replacement_targets_have_version(
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
POOL_META_NAME,
|
||||||
|
None,
|
||||||
|
&metadata_opts,
|
||||||
|
target_endpoints,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(true) => (object_size, Ok(())),
|
Ok(true) => (object_size, Ok(())),
|
||||||
@@ -1043,6 +1086,7 @@ impl ErasureSetHealer {
|
|||||||
),
|
),
|
||||||
HealObjectOutcome::Failed => (0, Err(err)),
|
HealObjectOutcome::Failed => (0, Err(err)),
|
||||||
},
|
},
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (object_size, result) = result;
|
let (object_size, result) = result;
|
||||||
@@ -1061,7 +1105,7 @@ impl ErasureSetHealer {
|
|||||||
bucket = RUSTFS_META_BUCKET,
|
bucket = RUSTFS_META_BUCKET,
|
||||||
object = POOL_META_NAME,
|
object = POOL_META_NAME,
|
||||||
state = "healed",
|
state = "healed",
|
||||||
"Replacement pool metadata healed"
|
"Pool metadata healed"
|
||||||
);
|
);
|
||||||
CheckpointObjectOutcome::Processed
|
CheckpointObjectOutcome::Processed
|
||||||
}
|
}
|
||||||
@@ -1078,7 +1122,7 @@ impl ErasureSetHealer {
|
|||||||
object = POOL_META_NAME,
|
object = POOL_META_NAME,
|
||||||
state = "transient_skip",
|
state = "transient_skip",
|
||||||
error = %message,
|
error = %message,
|
||||||
"Replacement pool metadata heal skipped due to transient error"
|
"Pool metadata heal skipped due to transient error"
|
||||||
);
|
);
|
||||||
CheckpointObjectOutcome::Skipped
|
CheckpointObjectOutcome::Skipped
|
||||||
}
|
}
|
||||||
@@ -1095,7 +1139,7 @@ impl ErasureSetHealer {
|
|||||||
object = POOL_META_NAME,
|
object = POOL_META_NAME,
|
||||||
state = "failed",
|
state = "failed",
|
||||||
error = %err,
|
error = %err,
|
||||||
"Replacement pool metadata heal failed"
|
"Pool metadata heal failed"
|
||||||
);
|
);
|
||||||
CheckpointObjectOutcome::Failed
|
CheckpointObjectOutcome::Failed
|
||||||
}
|
}
|
||||||
@@ -1536,7 +1580,9 @@ impl ErasureSetHealer {
|
|||||||
);
|
);
|
||||||
CheckpointObjectOutcome::Processed
|
CheckpointObjectOutcome::Processed
|
||||||
}
|
}
|
||||||
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
|
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
Err(Error::TransientSkip { message }) => {
|
Err(Error::TransientSkip { message }) => {
|
||||||
telemetry_unknown |= !increment_counter(skipped_objects);
|
telemetry_unknown |= !increment_counter(skipped_objects);
|
||||||
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
|
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
|
||||||
@@ -2038,6 +2084,8 @@ mod resume_loop_tests {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
enum HealOutcome {
|
enum HealOutcome {
|
||||||
Ok,
|
Ok,
|
||||||
|
/// The object has no metadata on any disk in the selected set.
|
||||||
|
FileNotFound,
|
||||||
/// The version vanished before heal ran (deleted mid-heal).
|
/// The version vanished before heal ran (deleted mid-heal).
|
||||||
VersionNotFound,
|
VersionNotFound,
|
||||||
/// A transient infrastructure condition (offline disk / unmet quorum):
|
/// A transient infrastructure condition (offline disk / unmet quorum):
|
||||||
@@ -2063,6 +2111,10 @@ mod resume_loop_tests {
|
|||||||
/// Target-specific physical readback evidence per `compose_key`; the
|
/// Target-specific physical readback evidence per `compose_key`; the
|
||||||
/// fake models a healthy backend unless a test explicitly revokes it.
|
/// fake models a healthy backend unless a test explicitly revokes it.
|
||||||
replacement_commit_evidence: Mutex<HashMap<String, ReplacementCommitEvidence>>,
|
replacement_commit_evidence: Mutex<HashMap<String, ReplacementCommitEvidence>>,
|
||||||
|
ordinary_pool_metadata_required: AtomicBool,
|
||||||
|
pool_metadata_opts: Mutex<Vec<HealOpts>>,
|
||||||
|
pool_metadata_not_applicable: AtomicBool,
|
||||||
|
fail_pool_metadata_scope: AtomicBool,
|
||||||
lifecycle_expired: Mutex<HashSet<String>>,
|
lifecycle_expired: Mutex<HashSet<String>>,
|
||||||
/// every heal_object call recorded as (name, version_id)
|
/// every heal_object call recorded as (name, version_id)
|
||||||
heal_calls: Mutex<Vec<(String, Option<String>)>>,
|
heal_calls: Mutex<Vec<(String, Option<String>)>>,
|
||||||
@@ -2151,11 +2203,14 @@ mod resume_loop_tests {
|
|||||||
}
|
}
|
||||||
async fn heal_object(
|
async fn heal_object(
|
||||||
&self,
|
&self,
|
||||||
_bucket: &str,
|
bucket: &str,
|
||||||
object: &str,
|
object: &str,
|
||||||
version_id: Option<&str>,
|
version_id: Option<&str>,
|
||||||
_opts: &HealOpts,
|
opts: &HealOpts,
|
||||||
) -> Result<(HealResultItem, Option<Error>)> {
|
) -> Result<(HealResultItem, Option<Error>)> {
|
||||||
|
if bucket == RUSTFS_META_BUCKET && object == POOL_META_NAME {
|
||||||
|
self.pool_metadata_opts.lock().expect("metadata options").push(*opts);
|
||||||
|
}
|
||||||
self.heal_calls
|
self.heal_calls
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -2164,6 +2219,7 @@ mod resume_loop_tests {
|
|||||||
let outcome = self.outcomes.lock().unwrap().get(&key).cloned().unwrap_or(HealOutcome::Ok);
|
let outcome = self.outcomes.lock().unwrap().get(&key).cloned().unwrap_or(HealOutcome::Ok);
|
||||||
match outcome {
|
match outcome {
|
||||||
HealOutcome::Ok => Ok((self.results.lock().unwrap().get(&key).cloned().unwrap_or_default(), None)),
|
HealOutcome::Ok => Ok((self.results.lock().unwrap().get(&key).cloned().unwrap_or_default(), None)),
|
||||||
|
HealOutcome::FileNotFound => Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::FileNotFound)))),
|
||||||
HealOutcome::VersionNotFound => {
|
HealOutcome::VersionNotFound => {
|
||||||
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::FileVersionNotFound))))
|
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::FileVersionNotFound))))
|
||||||
}
|
}
|
||||||
@@ -2177,6 +2233,30 @@ mod resume_loop_tests {
|
|||||||
async fn heal_format(&self, _dry: bool) -> Result<(HealResultItem, Option<Error>)> {
|
async fn heal_format(&self, _dry: bool) -> Result<(HealResultItem, Option<Error>)> {
|
||||||
Ok((HealResultItem::default(), None))
|
Ok((HealResultItem::default(), None))
|
||||||
}
|
}
|
||||||
|
async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
|
||||||
|
if !self.ordinary_pool_metadata_required.load(Ordering::SeqCst) {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
if !self.replacement_pool_metadata_applies(opts).await? {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let (result, error) = self.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, opts).await?;
|
||||||
|
if let Some(error) = error {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Ok(vec![result])
|
||||||
|
}
|
||||||
|
async fn replacement_pool_metadata_applies(&self, opts: &HealOpts) -> Result<bool> {
|
||||||
|
if self.fail_pool_metadata_scope.load(Ordering::SeqCst) {
|
||||||
|
return Err(Error::other("injected pool metadata scope failure"));
|
||||||
|
}
|
||||||
|
if self.pool_metadata_not_applicable.load(Ordering::SeqCst) {
|
||||||
|
assert_eq!(opts.pool, Some(0));
|
||||||
|
assert_eq!(opts.set, Some(1));
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
async fn replacement_targets_have_version(
|
async fn replacement_targets_have_version(
|
||||||
&self,
|
&self,
|
||||||
_bucket: &str,
|
_bucket: &str,
|
||||||
@@ -2668,6 +2748,115 @@ mod resume_loop_tests {
|
|||||||
assert!(state.completed, "successful data heal must be persisted before cleanup is attempted");
|
assert!(state.completed, "successful data heal must be persisted before cleanup is attempted");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ordinary_set_heals_pool_metadata_without_replacement_generation_or_targets() {
|
||||||
|
let env = make_env().await;
|
||||||
|
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
|
||||||
|
env.storage
|
||||||
|
.set_result(POOL_META_NAME, None, replacement_target_ok_result("metadata-disk", POOL_META_NAME));
|
||||||
|
assert!(env.healer.replacement_task_id.is_none());
|
||||||
|
assert!(env.healer.target_endpoints.is_empty());
|
||||||
|
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
|
||||||
|
.await
|
||||||
|
.expect("ordinary set recovery should repair metadata even without user buckets");
|
||||||
|
|
||||||
|
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
|
||||||
|
{
|
||||||
|
let opts = env.storage.pool_metadata_opts.lock().expect("metadata options");
|
||||||
|
assert_eq!(opts.len(), 1);
|
||||||
|
assert_eq!((opts[0].pool, opts[0].set), (Some(0), Some(0)));
|
||||||
|
}
|
||||||
|
let state = env.resume.get_state().await;
|
||||||
|
assert!(state.completed);
|
||||||
|
assert_eq!(state.successful_objects, 1, "metadata must enter durable completion counters");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ordinary_set_pool_metadata_respects_non_owner_and_dry_run() {
|
||||||
|
let mut env = make_env().await;
|
||||||
|
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
|
||||||
|
env.storage.pool_metadata_not_applicable.store(true, Ordering::SeqCst);
|
||||||
|
env.healer.heal_opts.pool = Some(0);
|
||||||
|
env.healer.heal_opts.set = Some(1);
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&[], "pool_0_set_1", &env.resume, &env.checkpoint)
|
||||||
|
.await
|
||||||
|
.expect("a valid non-owner set must not invent a metadata replica");
|
||||||
|
assert!(env.storage.calls().is_empty());
|
||||||
|
assert_eq!(env.resume.get_state().await.successful_objects, 0);
|
||||||
|
|
||||||
|
let mut env = make_env().await;
|
||||||
|
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
|
||||||
|
env.healer.heal_opts.dry_run = true;
|
||||||
|
env.healer.heal_opts.remove = true;
|
||||||
|
env.healer.heal_opts.no_lock = true;
|
||||||
|
env.storage.set_replacement_commit_evidence(POOL_META_NAME, None, false);
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
|
||||||
|
.await
|
||||||
|
.expect("ordinary dry-run metadata work must not require a replacement commit");
|
||||||
|
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
|
||||||
|
let opts = env.storage.pool_metadata_opts.lock().expect("metadata options");
|
||||||
|
assert_eq!(opts.len(), 1);
|
||||||
|
assert!(opts[0].dry_run);
|
||||||
|
assert!(!opts[0].remove);
|
||||||
|
assert!(!opts[0].no_lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ordinary_set_missing_pool_metadata_preserves_retry_state() {
|
||||||
|
let env = make_env().await;
|
||||||
|
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
|
||||||
|
env.storage.set_outcome(POOL_META_NAME, None, HealOutcome::FileNotFound);
|
||||||
|
|
||||||
|
let error = env
|
||||||
|
.healer
|
||||||
|
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
|
||||||
|
.await
|
||||||
|
.expect_err("missing required pool metadata must prevent ordinary set completion");
|
||||||
|
|
||||||
|
assert!(matches!(error, Error::TransientSkip { .. }));
|
||||||
|
let state = env.resume.get_state().await;
|
||||||
|
assert!(!state.completed);
|
||||||
|
assert_eq!(state.retry_count, 1);
|
||||||
|
assert!(CheckpointManager::has_checkpoint(&env.healer.disk, &env.task_id).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ordinary_set_pool_metadata_timeout_keeps_control_error() {
|
||||||
|
let env = make_env().await;
|
||||||
|
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
|
||||||
|
env.storage.set_outcome(POOL_META_NAME, None, HealOutcome::Timeout);
|
||||||
|
|
||||||
|
let error = env
|
||||||
|
.healer
|
||||||
|
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
|
||||||
|
.await
|
||||||
|
.expect_err("metadata timeout must abort the ordinary set pass");
|
||||||
|
|
||||||
|
assert!(matches!(error, Error::TaskTimeout));
|
||||||
|
assert!(!env.resume.get_state().await.completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ordinary_set_pool_metadata_rejects_mismatched_explicit_scope() {
|
||||||
|
let mut env = make_env().await;
|
||||||
|
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
|
||||||
|
env.healer.heal_opts.pool = Some(1);
|
||||||
|
|
||||||
|
let error = env
|
||||||
|
.healer
|
||||||
|
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
|
||||||
|
.await
|
||||||
|
.expect_err("explicit metadata scope must agree with the resumed set");
|
||||||
|
|
||||||
|
assert!(matches!(error, Error::TaskExecutionFailed { .. }));
|
||||||
|
assert!(env.storage.calls().is_empty(), "scope mismatch must fail before metadata mutation");
|
||||||
|
assert!(!env.resume.get_state().await.completed);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn replacement_completion_keeps_resume_artifacts_until_marker_cleanup() {
|
async fn replacement_completion_keeps_resume_artifacts_until_marker_cleanup() {
|
||||||
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||||
@@ -2722,6 +2911,111 @@ mod resume_loop_tests {
|
|||||||
drop(checkpoint);
|
drop(checkpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replacement_pool_metadata_non_owner_completes_but_missing_owner_retries() {
|
||||||
|
for owns_pool_metadata in [false, true] {
|
||||||
|
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||||
|
let replacement_task_id = ResumeUtils::generate_task_id();
|
||||||
|
let set_index = usize::from(!owns_pool_metadata);
|
||||||
|
let set_disk_id = format!("pool_0_set_{set_index}");
|
||||||
|
ResumeManager::new_replacement_intent(
|
||||||
|
env.healer.disk.clone(),
|
||||||
|
replacement_task_id.clone(),
|
||||||
|
set_disk_id.clone(),
|
||||||
|
vec!["b".to_string()],
|
||||||
|
vec!["replacement-a".to_string()],
|
||||||
|
vec![crate::heal::resume::ReplacementTargetIdentity {
|
||||||
|
endpoint: "replacement-a".to_string(),
|
||||||
|
canonical_path: "/mnt/replacement-a".to_string(),
|
||||||
|
physical_device_ids: vec!["device-a".to_string()],
|
||||||
|
filesystem_identity: "1:2:3".to_string(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("replacement intent should persist");
|
||||||
|
env.storage
|
||||||
|
.pool_metadata_not_applicable
|
||||||
|
.store(!owns_pool_metadata, Ordering::SeqCst);
|
||||||
|
env.storage.set_outcome(POOL_META_NAME, None, HealOutcome::FileNotFound);
|
||||||
|
let healer = ErasureSetHealer::new(
|
||||||
|
env.storage.clone(),
|
||||||
|
Arc::new(RwLock::new(HealProgress::new())),
|
||||||
|
CancellationToken::new(),
|
||||||
|
env.healer.disk.clone(),
|
||||||
|
HealOpts {
|
||||||
|
pool: Some(0),
|
||||||
|
set: Some(set_index),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
HealRequestSource::AutoHeal,
|
||||||
|
)
|
||||||
|
.with_replacement_targets(vec!["replacement-a".to_string()], Some(replacement_task_id.clone()));
|
||||||
|
|
||||||
|
let result = healer.heal_erasure_set(&["b".to_string()], &set_disk_id).await;
|
||||||
|
let state = ResumeManager::load_replacement_intent(env.healer.disk.clone(), &replacement_task_id)
|
||||||
|
.await
|
||||||
|
.expect("replacement state must remain until marker cleanup")
|
||||||
|
.get_state()
|
||||||
|
.await;
|
||||||
|
if owns_pool_metadata {
|
||||||
|
let error = result.expect_err("missing metadata in the owner set must keep replacement incomplete");
|
||||||
|
assert!(error.to_string().contains("Replacement erasure set heal incomplete"));
|
||||||
|
assert!(!state.completed);
|
||||||
|
assert_eq!(state.replacement_phase, crate::heal::resume::ReplacementPhase::Intent);
|
||||||
|
assert_eq!(state.retry_count, 1);
|
||||||
|
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
|
||||||
|
} else {
|
||||||
|
result.expect("a non-owner set must complete without a pool metadata replica");
|
||||||
|
assert!(state.completed);
|
||||||
|
assert_eq!(state.replacement_phase, crate::heal::resume::ReplacementPhase::Verified);
|
||||||
|
assert_eq!(state.retry_count, 0);
|
||||||
|
assert!(env.storage.calls().is_empty(), "non-owner sets must not attempt pool metadata repair");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replacement_pool_metadata_unknown_scope_cannot_complete() {
|
||||||
|
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||||
|
let healer = ErasureSetHealer::new(
|
||||||
|
env.storage.clone(),
|
||||||
|
Arc::new(RwLock::new(HealProgress::new())),
|
||||||
|
CancellationToken::new(),
|
||||||
|
env.healer.disk.clone(),
|
||||||
|
HealOpts {
|
||||||
|
pool: Some(0),
|
||||||
|
set: Some(0),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
HealRequestSource::AutoHeal,
|
||||||
|
)
|
||||||
|
.with_replacement_targets(vec!["replacement-a".to_string()], Some("generation-a".to_string()));
|
||||||
|
env.storage.fail_pool_metadata_scope.store(true, Ordering::SeqCst);
|
||||||
|
env.storage
|
||||||
|
.set_result(POOL_META_NAME, None, replacement_target_ok_result("replacement-a", POOL_META_NAME));
|
||||||
|
let mut processed_objects = 0;
|
||||||
|
let mut successful_objects = 0;
|
||||||
|
let mut failed_objects = 0;
|
||||||
|
let mut skipped_objects = 0;
|
||||||
|
let error = healer
|
||||||
|
.heal_pool_metadata(
|
||||||
|
"pool_0_set_0",
|
||||||
|
&mut super::ErasureSetPassCounters {
|
||||||
|
processed_objects: &mut processed_objects,
|
||||||
|
successful_objects: &mut successful_objects,
|
||||||
|
failed_objects: &mut failed_objects,
|
||||||
|
skipped_objects: &mut skipped_objects,
|
||||||
|
},
|
||||||
|
&env.resume,
|
||||||
|
&env.checkpoint,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("unknown metadata placement must keep replacement incomplete");
|
||||||
|
assert!(error.to_string().contains("injected pool metadata scope failure"));
|
||||||
|
assert!(env.storage.calls().is_empty());
|
||||||
|
assert_eq!((processed_objects, successful_objects, failed_objects, skipped_objects), (0, 0, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn replacement_pool_metadata_readback_failure_schedules_retry() {
|
async fn replacement_pool_metadata_readback_failure_schedules_retry() {
|
||||||
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||||
@@ -2781,8 +3075,8 @@ mod resume_loop_tests {
|
|||||||
env.healer.disk.clone(),
|
env.healer.disk.clone(),
|
||||||
HealOpts {
|
HealOpts {
|
||||||
recreate: true,
|
recreate: true,
|
||||||
pool: Some(0),
|
remove: true,
|
||||||
set: Some(0),
|
no_lock: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
HealRequestSource::Admin,
|
HealRequestSource::Admin,
|
||||||
@@ -2798,6 +3092,43 @@ mod resume_loop_tests {
|
|||||||
|
|
||||||
assert!(env.resume.get_state().await.completed);
|
assert!(env.resume.get_state().await.completed);
|
||||||
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
|
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
|
||||||
|
let opts = env.storage.pool_metadata_opts.lock().expect("metadata options");
|
||||||
|
assert_eq!(opts.len(), 1);
|
||||||
|
assert_eq!((opts[0].pool, opts[0].set), (Some(0), Some(0)));
|
||||||
|
assert!(opts[0].recreate);
|
||||||
|
assert!(!opts[0].remove);
|
||||||
|
assert!(!opts[0].no_lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_recreate_pool_metadata_validates_owner_scope_before_io() {
|
||||||
|
for (non_owner, unknown_scope, pool) in [(true, false, None), (false, true, None), (false, false, Some(1))] {
|
||||||
|
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||||
|
env.storage.pool_metadata_not_applicable.store(non_owner, Ordering::SeqCst);
|
||||||
|
env.storage.fail_pool_metadata_scope.store(unknown_scope, Ordering::SeqCst);
|
||||||
|
let healer = ErasureSetHealer::new(
|
||||||
|
env.storage.clone(),
|
||||||
|
Arc::new(RwLock::new(HealProgress::new())),
|
||||||
|
CancellationToken::new(),
|
||||||
|
env.healer.disk.clone(),
|
||||||
|
HealOpts {
|
||||||
|
recreate: true,
|
||||||
|
pool,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
HealRequestSource::Admin,
|
||||||
|
)
|
||||||
|
.with_pool_metadata_targets(vec!["replacement-a".to_string()]);
|
||||||
|
|
||||||
|
let set_disk_id = if non_owner { "pool_0_set_1" } else { "pool_0_set_0" };
|
||||||
|
let result = healer
|
||||||
|
.execute_heal_with_resume(&[], set_disk_id, &env.resume, &env.checkpoint)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result.is_ok(), non_owner, "only a known non-owner may skip metadata: {result:?}");
|
||||||
|
assert!(env.storage.calls().is_empty(), "scope validation must precede metadata I/O");
|
||||||
|
assert_eq!(env.resume.get_state().await.completed, non_owner);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -555,6 +555,10 @@ async fn completed_retention_scheduler_preserves_progress_aliases_and_atomic_han
|
|||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl HealStorageAPI for MockStorage {
|
impl HealStorageAPI for MockStorage {
|
||||||
|
async fn heal_pool_metadata(&self, _opts: &HealOpts) -> Result<Vec<HealResultItem>> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_object_meta(&self, _bucket: &str, _object: &str) -> Result<Option<HealObjectInfo>> {
|
async fn get_object_meta(&self, _bucket: &str, _object: &str) -> Result<Option<HealObjectInfo>> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
@@ -1869,7 +1873,7 @@ async fn test_submit_heal_request_returns_merged_for_duplicate() {
|
|||||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||||
let manager = HealManager::new(storage, None);
|
let manager = HealManager::new(storage, None);
|
||||||
|
|
||||||
let request = HealRequest::new(
|
let mut request = HealRequest::new(
|
||||||
HealType::Object {
|
HealType::Object {
|
||||||
bucket: "bucket".to_string(),
|
bucket: "bucket".to_string(),
|
||||||
object: "object".to_string(),
|
object: "object".to_string(),
|
||||||
@@ -1886,6 +1890,7 @@ async fn test_submit_heal_request_returns_merged_for_duplicate() {
|
|||||||
.expect("first request should be accepted"),
|
.expect("first request should be accepted"),
|
||||||
HealAdmissionResult::Accepted
|
HealAdmissionResult::Accepted
|
||||||
);
|
);
|
||||||
|
request.id = uuid::Uuid::new_v4().to_string();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manager
|
manager
|
||||||
.submit_heal_request(request)
|
.submit_heal_request(request)
|
||||||
@@ -3725,7 +3730,7 @@ async fn test_submit_heal_request_returns_merged_before_full_for_duplicate() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
let request = HealRequest::new(
|
let mut request = HealRequest::new(
|
||||||
HealType::Object {
|
HealType::Object {
|
||||||
bucket: "bucket".to_string(),
|
bucket: "bucket".to_string(),
|
||||||
object: "object".to_string(),
|
object: "object".to_string(),
|
||||||
@@ -3742,6 +3747,7 @@ async fn test_submit_heal_request_returns_merged_before_full_for_duplicate() {
|
|||||||
.expect("first request should be accepted"),
|
.expect("first request should be accepted"),
|
||||||
HealAdmissionResult::Accepted
|
HealAdmissionResult::Accepted
|
||||||
);
|
);
|
||||||
|
request.id = uuid::Uuid::new_v4().to_string();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manager
|
manager
|
||||||
.submit_heal_request(request)
|
.submit_heal_request(request)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ use crate::heal::manager::{HealManager, MrfRepairNoticeTarget};
|
|||||||
use metrics::{counter, gauge};
|
use metrics::{counter, gauge};
|
||||||
use rustfs_common::mrf_channel::{MRF_MAX_ATTEMPTS, MrfDurableRepairAnchor, MrfIngressResult, MrfIntent};
|
use rustfs_common::mrf_channel::{MRF_MAX_ATTEMPTS, MrfDurableRepairAnchor, MrfIngressResult, MrfIntent};
|
||||||
use rustfs_heal_contracts::heal_channel::{HealAdmissionDropReason, HealAdmissionResult};
|
use rustfs_heal_contracts::heal_channel::{HealAdmissionDropReason, HealAdmissionResult};
|
||||||
use std::collections::{HashSet, VecDeque};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
@@ -134,7 +134,10 @@ struct MrfQueueKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn queue_key(intent: &MrfIntent) -> MrfQueueKey {
|
fn queue_key(intent: &MrfIntent) -> MrfQueueKey {
|
||||||
let version_id = intent.version_id.filter(|bytes| *bytes != [0; 16]);
|
let version_id = (!matches!(intent.kind, rustfs_common::mrf_channel::MrfKind::MetadataCorruption))
|
||||||
|
.then_some(intent.version_id)
|
||||||
|
.flatten()
|
||||||
|
.filter(|bytes| *bytes != [0; 16]);
|
||||||
let scope = (!matches!(intent.kind, rustfs_common::mrf_channel::MrfKind::MetadataCorruption))
|
let scope = (!matches!(intent.kind, rustfs_common::mrf_channel::MrfKind::MetadataCorruption))
|
||||||
.then_some(intent.scope)
|
.then_some(intent.scope)
|
||||||
.flatten();
|
.flatten();
|
||||||
@@ -539,6 +542,10 @@ struct MrfRuntime {
|
|||||||
/// Partial-write responsibilities accepted from replay and waiting for an
|
/// Partial-write responsibilities accepted from replay and waiting for an
|
||||||
/// exact storage-owned proof before the startup journal can be deleted.
|
/// exact storage-owned proof before the startup journal can be deleted.
|
||||||
durable_replay_anchors: Vec<MrfDurableRepairAnchor>,
|
durable_replay_anchors: Vec<MrfDurableRepairAnchor>,
|
||||||
|
/// Startup responsibilities remain in every successor snapshot until an
|
||||||
|
/// exact verified repair discharges them. Live admissions never grow this
|
||||||
|
/// set, so its size is bounded by the decoded startup journal.
|
||||||
|
retained_replay_intents: HashMap<MrfQueueKey, MrfIntent>,
|
||||||
/// Startup replay source to remove after the retained replay
|
/// Startup replay source to remove after the retained replay
|
||||||
/// responsibilities are discharged. `None` means the runtime only needs
|
/// responsibilities are discharged. `None` means the runtime only needs
|
||||||
/// the legacy journal cleanup path for snapshots it wrote itself.
|
/// the legacy journal cleanup path for snapshots it wrote itself.
|
||||||
@@ -550,24 +557,92 @@ struct MrfRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MrfRuntime {
|
impl MrfRuntime {
|
||||||
fn snapshot(&self) -> (Vec<u8>, Vec<u8>) {
|
fn enqueue_batch(&mut self, intents: impl IntoIterator<Item = MrfIntent>) -> usize {
|
||||||
|
// Reserve retained startup work before admitting live hints. Computing
|
||||||
|
// the union once per batch avoids scanning it for every incoming hint.
|
||||||
|
let mut snapshot_count = self.queue.depth();
|
||||||
|
let mut snapshot_bytes = self.queue.bytes();
|
||||||
|
for (key, intent) in &self.retained_replay_intents {
|
||||||
|
if !self.queue.pending_keys.contains(key) {
|
||||||
|
snapshot_count = snapshot_count.saturating_add(1);
|
||||||
|
snapshot_bytes = snapshot_bytes.saturating_add(intent.estimated_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut enqueued = 0;
|
||||||
|
for intent in intents {
|
||||||
|
let key = queue_key(&intent);
|
||||||
|
let retained = self.retained_replay_intents.get(&key);
|
||||||
|
let additional = retained.is_none() && !self.queue.pending_keys.contains(&key);
|
||||||
|
let next_count = snapshot_count.saturating_add(usize::from(additional));
|
||||||
|
let next_bytes = snapshot_bytes.saturating_add(if additional { intent.estimated_bytes() } else { 0 });
|
||||||
|
let result = if retained.is_some_and(|retained| retained.lease != intent.lease)
|
||||||
|
|| next_count > self.queue.capacity
|
||||||
|
|| next_bytes > self.queue.byte_budget
|
||||||
|
{
|
||||||
|
counter!("rustfs_heal_mrf_dropped_total", "reason" => "queue_overflow").increment(1);
|
||||||
|
MrfQueuePushResult::Rejected
|
||||||
|
} else {
|
||||||
|
self.queue.try_push_typed(intent.clone())
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
MrfQueuePushResult::Enqueued => {
|
||||||
|
snapshot_count = next_count;
|
||||||
|
snapshot_bytes = next_bytes;
|
||||||
|
enqueued += 1;
|
||||||
|
self.new_since_flush += 1;
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
MrfQueuePushResult::Coalesced | MrfQueuePushResult::Rejected => {
|
||||||
|
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enqueued
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot(&self) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||||
let mut authoritative = Vec::new();
|
let mut authoritative = Vec::new();
|
||||||
let mut legacy = Vec::new();
|
let mut legacy = Vec::new();
|
||||||
for intent in self.queue.intents() {
|
let mut encoded = HashSet::new();
|
||||||
|
for intent in self.queue.intents().chain(self.retained_replay_intents.values()) {
|
||||||
|
let key = queue_key(intent);
|
||||||
|
if self
|
||||||
|
.retained_replay_intents
|
||||||
|
.get(&key)
|
||||||
|
.is_some_and(|retained| retained.lease != intent.lease)
|
||||||
|
{
|
||||||
|
// Legacy records cannot distinguish concurrent responsibilities
|
||||||
|
// with different leases. Preserve the existing disk anchor.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !encoded.insert(key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if encoded.len() > self.queue.capacity {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let scoped_identity =
|
let scoped_identity =
|
||||||
!matches!(intent.kind, rustfs_common::mrf_channel::MrfKind::MetadataCorruption) && intent.scope.is_some();
|
!matches!(intent.kind, rustfs_common::mrf_channel::MrfKind::MetadataCorruption) && intent.scope.is_some();
|
||||||
if !encode_intent(intent, &mut authoritative) {
|
if !encode_intent(intent, &mut authoritative) {
|
||||||
counter!("rustfs_heal_mrf_dropped_total", "reason" => "journal_identity_oversized").increment(1);
|
counter!("rustfs_heal_mrf_dropped_total", "reason" => "journal_identity_oversized").increment(1);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if authoritative.len() > self.queue.byte_budget {
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
if !scoped_identity && !encode_intent(intent, &mut legacy) {
|
if !scoped_identity && !encode_intent(intent, &mut legacy) {
|
||||||
counter!("rustfs_heal_mrf_dropped_total", "reason" => "journal_identity_oversized").increment(1);
|
counter!("rustfs_heal_mrf_dropped_total", "reason" => "journal_identity_oversized").increment(1);
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(authoritative, legacy)
|
Some((authoritative, legacy))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn flush(&mut self) {
|
async fn flush(&mut self) {
|
||||||
let (authoritative, legacy) = self.snapshot();
|
let Some((authoritative, legacy)) = self.snapshot() else {
|
||||||
|
self.dirty = true;
|
||||||
|
return;
|
||||||
|
};
|
||||||
let (committed_persisted, committed_on_disk) = if authoritative.is_empty() {
|
let (committed_persisted, committed_on_disk) = if authoritative.is_empty() {
|
||||||
(true, false)
|
(true, false)
|
||||||
} else {
|
} else {
|
||||||
@@ -634,11 +709,31 @@ impl MrfRuntime {
|
|||||||
// attempts counter) changes the encoded snapshot; mark it dirty
|
// attempts counter) changes the encoded snapshot; mark it dirty
|
||||||
// either way.
|
// either way.
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
|
let replay_key = queue_key(&intent);
|
||||||
|
let replayed = self
|
||||||
|
.retained_replay_intents
|
||||||
|
.get(&replay_key)
|
||||||
|
.is_some_and(|retained| retained.lease == intent.lease);
|
||||||
|
if replayed {
|
||||||
|
if !matches!(
|
||||||
|
rustfs_common::mrf_channel::try_rearm_mrf_replay_intent(&mut intent),
|
||||||
|
MrfIngressResult::Enqueued
|
||||||
|
) {
|
||||||
|
self.retain_replay_journal = true;
|
||||||
|
self.queue.push_back(intent);
|
||||||
|
self.backoff_until = Some(tokio::time::Instant::now() + self.config.admission_backoff);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
self.retained_replay_intents.insert(replay_key, intent.clone());
|
||||||
|
}
|
||||||
match submit_mrf_heal_request(manager, &intent).await {
|
match submit_mrf_heal_request(manager, &intent).await {
|
||||||
// Accepted intents leave the pending set; the next flush persists the
|
// Admission removes executable work from the pending queue,
|
||||||
// smaller snapshot. This is not a durable successor receipt and
|
// but startup responsibilities still require an exact proof.
|
||||||
// does not discharge the producer's existing retry hints.
|
Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => {
|
||||||
Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => {}
|
if replayed && let Some(anchor) = manager.durable_mrf_repair_anchor(&intent).await {
|
||||||
|
self.durable_replay_anchors.push(anchor);
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(HealAdmissionResult::Full) | Ok(HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull)) => {
|
Ok(HealAdmissionResult::Full) | Ok(HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull)) => {
|
||||||
intent.attempts = intent.attempts.saturating_add(1);
|
intent.attempts = intent.attempts.saturating_add(1);
|
||||||
if intent.attempts >= MRF_MAX_ATTEMPTS {
|
if intent.attempts >= MRF_MAX_ATTEMPTS {
|
||||||
@@ -672,7 +767,7 @@ impl MrfRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn retained_replay_journal(&self) -> bool {
|
fn retained_replay_journal(&self) -> bool {
|
||||||
self.retain_replay_journal || !self.durable_replay_anchors.is_empty()
|
self.retain_replay_journal || !self.retained_replay_intents.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replay_cleanup_to_delete(&self) -> Option<ReplayCleanup> {
|
fn replay_cleanup_to_delete(&self) -> Option<ReplayCleanup> {
|
||||||
@@ -718,6 +813,7 @@ impl MrfRuntime {
|
|||||||
if self.durable_replay_anchors.is_empty() {
|
if self.durable_replay_anchors.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let mut discharged_leases: HashSet<_> = self.durable_replay_anchors.iter().map(|anchor| anchor.lease).collect();
|
||||||
let mut buckets: Vec<Arc<str>> = self
|
let mut buckets: Vec<Arc<str>> = self
|
||||||
.durable_replay_anchors
|
.durable_replay_anchors
|
||||||
.iter()
|
.iter()
|
||||||
@@ -731,6 +827,13 @@ impl MrfRuntime {
|
|||||||
&mut self.durable_replay_anchors,
|
&mut self.durable_replay_anchors,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for anchor in &self.durable_replay_anchors {
|
||||||
|
discharged_leases.remove(&anchor.lease);
|
||||||
|
}
|
||||||
|
let before = self.retained_replay_intents.len();
|
||||||
|
self.retained_replay_intents
|
||||||
|
.retain(|_, intent| !intent.lease.is_some_and(|lease| discharged_leases.contains(&lease)));
|
||||||
|
self.dirty |= self.retained_replay_intents.len() != before;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,6 +886,7 @@ struct ReplayOutcome {
|
|||||||
journal_on_disk: bool,
|
journal_on_disk: bool,
|
||||||
retain_journal_for_replay: bool,
|
retain_journal_for_replay: bool,
|
||||||
durable_replay_anchors: Vec<MrfDurableRepairAnchor>,
|
durable_replay_anchors: Vec<MrfDurableRepairAnchor>,
|
||||||
|
retained_replay_intents: HashMap<MrfQueueKey, MrfIntent>,
|
||||||
cleanup: Option<ReplayCleanup>,
|
cleanup: Option<ReplayCleanup>,
|
||||||
next_checkpoint_sequence: u64,
|
next_checkpoint_sequence: u64,
|
||||||
}
|
}
|
||||||
@@ -870,6 +974,7 @@ async fn replay_into(
|
|||||||
journal_on_disk: false,
|
journal_on_disk: false,
|
||||||
retain_journal_for_replay: false,
|
retain_journal_for_replay: false,
|
||||||
durable_replay_anchors: Vec::new(),
|
durable_replay_anchors: Vec::new(),
|
||||||
|
retained_replay_intents: HashMap::new(),
|
||||||
cleanup: None,
|
cleanup: None,
|
||||||
next_checkpoint_sequence: 1,
|
next_checkpoint_sequence: 1,
|
||||||
};
|
};
|
||||||
@@ -885,6 +990,7 @@ async fn replay_into(
|
|||||||
journal_on_disk: true,
|
journal_on_disk: true,
|
||||||
retain_journal_for_replay: true,
|
retain_journal_for_replay: true,
|
||||||
durable_replay_anchors: Vec::new(),
|
durable_replay_anchors: Vec::new(),
|
||||||
|
retained_replay_intents: HashMap::new(),
|
||||||
cleanup: None,
|
cleanup: None,
|
||||||
next_checkpoint_sequence: 1,
|
next_checkpoint_sequence: 1,
|
||||||
};
|
};
|
||||||
@@ -929,6 +1035,7 @@ async fn replay_into(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut retained_replay_intents: HashMap<_, _> = queue.intents().map(|intent| (queue_key(intent), intent.clone())).collect();
|
||||||
|
|
||||||
// Drain the replayed intents immediately; whatever the manager refuses
|
// Drain the replayed intents immediately; whatever the manager refuses
|
||||||
// stays armed in `queue` for the consumer's retry loop.
|
// stays armed in `queue` for the consumer's retry loop.
|
||||||
@@ -943,6 +1050,7 @@ async fn replay_into(
|
|||||||
*backoff_until = Some(tokio::time::Instant::now());
|
*backoff_until = Some(tokio::time::Instant::now());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
retained_replay_intents.insert(queue_key(&intent), intent.clone());
|
||||||
match submit_mrf_heal_request(manager, &intent).await {
|
match submit_mrf_heal_request(manager, &intent).await {
|
||||||
Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => {
|
Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => {
|
||||||
if let Some(anchor) = manager.durable_mrf_repair_anchor(&intent).await {
|
if let Some(anchor) = manager.durable_mrf_repair_anchor(&intent).await {
|
||||||
@@ -964,6 +1072,7 @@ async fn replay_into(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Ok(HealAdmissionResult::Dropped(_)) => {
|
Ok(HealAdmissionResult::Dropped(_)) => {
|
||||||
|
retained_replay_intents.remove(&queue_key(&intent));
|
||||||
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -998,6 +1107,7 @@ async fn replay_into(
|
|||||||
journal_on_disk,
|
journal_on_disk,
|
||||||
retain_journal_for_replay,
|
retain_journal_for_replay,
|
||||||
durable_replay_anchors,
|
durable_replay_anchors,
|
||||||
|
retained_replay_intents,
|
||||||
cleanup: journal_on_disk.then_some(cleanup),
|
cleanup: journal_on_disk.then_some(cleanup),
|
||||||
next_checkpoint_sequence,
|
next_checkpoint_sequence,
|
||||||
}
|
}
|
||||||
@@ -1017,6 +1127,7 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
|||||||
journal_on_disk: false,
|
journal_on_disk: false,
|
||||||
retain_replay_journal: false,
|
retain_replay_journal: false,
|
||||||
durable_replay_anchors: Vec::new(),
|
durable_replay_anchors: Vec::new(),
|
||||||
|
retained_replay_intents: HashMap::new(),
|
||||||
replay_cleanup: None,
|
replay_cleanup: None,
|
||||||
runtime_checkpoint: None,
|
runtime_checkpoint: None,
|
||||||
backoff_until: None,
|
backoff_until: None,
|
||||||
@@ -1028,6 +1139,7 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
|||||||
runtime.journal_on_disk = replay.journal_on_disk;
|
runtime.journal_on_disk = replay.journal_on_disk;
|
||||||
runtime.retain_replay_journal = replay.retain_journal_for_replay;
|
runtime.retain_replay_journal = replay.retain_journal_for_replay;
|
||||||
runtime.durable_replay_anchors = replay.durable_replay_anchors;
|
runtime.durable_replay_anchors = replay.durable_replay_anchors;
|
||||||
|
runtime.retained_replay_intents = replay.retained_replay_intents;
|
||||||
runtime.replay_cleanup = replay.cleanup;
|
runtime.replay_cleanup = replay.cleanup;
|
||||||
runtime.next_checkpoint_sequence = replay.next_checkpoint_sequence;
|
runtime.next_checkpoint_sequence = replay.next_checkpoint_sequence;
|
||||||
// Anything still pending (e.g. the manager was full and backoff armed)
|
// Anything still pending (e.g. the manager was full and backoff armed)
|
||||||
@@ -1056,17 +1168,7 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for intent in batch.drain(..) {
|
runtime.enqueue_batch(batch.drain(..));
|
||||||
match runtime.queue.try_push_typed(intent.clone()) {
|
|
||||||
MrfQueuePushResult::Enqueued => {
|
|
||||||
runtime.new_since_flush += 1;
|
|
||||||
runtime.dirty = true;
|
|
||||||
}
|
|
||||||
MrfQueuePushResult::Coalesced | MrfQueuePushResult::Rejected => {
|
|
||||||
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
runtime.dispatch(manager.as_ref()).await;
|
runtime.dispatch(manager.as_ref()).await;
|
||||||
if runtime.new_since_flush >= runtime.config.flush_threshold {
|
if runtime.new_since_flush >= runtime.config.flush_threshold {
|
||||||
runtime.flush().await;
|
runtime.flush().await;
|
||||||
@@ -1225,6 +1327,7 @@ mod tests {
|
|||||||
journal_on_disk: true,
|
journal_on_disk: true,
|
||||||
retain_replay_journal: false,
|
retain_replay_journal: false,
|
||||||
durable_replay_anchors: vec![anchor],
|
durable_replay_anchors: vec![anchor],
|
||||||
|
retained_replay_intents: HashMap::from([(queue_key(&intent), intent.clone())]),
|
||||||
replay_cleanup: Some(cleanup),
|
replay_cleanup: Some(cleanup),
|
||||||
runtime_checkpoint: None,
|
runtime_checkpoint: None,
|
||||||
backoff_until: None,
|
backoff_until: None,
|
||||||
@@ -1244,6 +1347,11 @@ mod tests {
|
|||||||
runtime.retained_replay_journal(),
|
runtime.retained_replay_journal(),
|
||||||
"anchor must retain the startup journal before proof is consumed"
|
"anchor must retain the startup journal before proof is consumed"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_journal(&runtime.snapshot().expect("retained snapshot").0).0.len(),
|
||||||
|
1,
|
||||||
|
"an admitted responsibility must remain in the successor before proof"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
runtime.replay_cleanup_to_delete(),
|
runtime.replay_cleanup_to_delete(),
|
||||||
None,
|
None,
|
||||||
@@ -1254,6 +1362,8 @@ mod tests {
|
|||||||
!runtime.retained_replay_journal(),
|
!runtime.retained_replay_journal(),
|
||||||
"matching verified proof discharges the durable replay anchor"
|
"matching verified proof discharges the durable replay anchor"
|
||||||
);
|
);
|
||||||
|
assert!(runtime.dirty, "proof removal must be persisted by the next flush");
|
||||||
|
assert!(runtime.snapshot().expect("discharged snapshot").0.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
runtime.replay_cleanup_to_delete(),
|
runtime.replay_cleanup_to_delete(),
|
||||||
Some(cleanup),
|
Some(cleanup),
|
||||||
@@ -1262,6 +1372,197 @@ mod tests {
|
|||||||
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_snapshot_retains_admitted_replay_and_pending_successor() {
|
||||||
|
let accepted = intent("snapshot-bucket", "accepted", 0);
|
||||||
|
let pending = intent("snapshot-bucket", "pending", 2);
|
||||||
|
let mut queue = MrfQueue::new(4, 4096);
|
||||||
|
assert!(queue.try_push(pending.clone()));
|
||||||
|
let runtime = MrfRuntime {
|
||||||
|
queue,
|
||||||
|
config: MrfConsumerConfig::default(),
|
||||||
|
checkpoint_owner: Uuid::new_v4(),
|
||||||
|
next_checkpoint_sequence: 1,
|
||||||
|
new_since_flush: 0,
|
||||||
|
dirty: true,
|
||||||
|
journal_on_disk: true,
|
||||||
|
retain_replay_journal: true,
|
||||||
|
durable_replay_anchors: Vec::new(),
|
||||||
|
retained_replay_intents: HashMap::from([
|
||||||
|
(queue_key(&accepted), accepted),
|
||||||
|
(queue_key(&pending), intent("snapshot-bucket", "pending", 0)),
|
||||||
|
]),
|
||||||
|
replay_cleanup: None,
|
||||||
|
runtime_checkpoint: None,
|
||||||
|
backoff_until: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (authoritative, legacy) = runtime.snapshot().expect("complete successor should fit");
|
||||||
|
for snapshot in [authoritative, legacy] {
|
||||||
|
let (recovered, truncated) = decode_journal(&snapshot);
|
||||||
|
assert_eq!(truncated, 0);
|
||||||
|
assert_eq!(recovered.len(), 2, "admission must not discard an unproven startup responsibility");
|
||||||
|
assert!(recovered.iter().any(|intent| intent.object.as_ref() == "accepted"));
|
||||||
|
assert!(
|
||||||
|
recovered
|
||||||
|
.iter()
|
||||||
|
.any(|intent| intent.object.as_ref() == "pending" && intent.attempts == 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_snapshot_preserves_anchor_when_successor_exceeds_budget_or_changes_lease() {
|
||||||
|
let mut retained = intent("bounded-bucket", "retained", 0);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_common::mrf_channel::try_rearm_mrf_replay_intent(&mut retained),
|
||||||
|
MrfIngressResult::Enqueued
|
||||||
|
);
|
||||||
|
let mut queue = MrfQueue::new(1, 4096);
|
||||||
|
assert!(queue.try_push(intent("bounded-bucket", "pending", 0)));
|
||||||
|
let mut runtime = MrfRuntime {
|
||||||
|
queue,
|
||||||
|
config: MrfConsumerConfig::default(),
|
||||||
|
checkpoint_owner: Uuid::new_v4(),
|
||||||
|
next_checkpoint_sequence: 1,
|
||||||
|
new_since_flush: 0,
|
||||||
|
dirty: true,
|
||||||
|
journal_on_disk: true,
|
||||||
|
retain_replay_journal: false,
|
||||||
|
durable_replay_anchors: Vec::new(),
|
||||||
|
retained_replay_intents: HashMap::from([(queue_key(&retained), retained.clone())]),
|
||||||
|
replay_cleanup: None,
|
||||||
|
runtime_checkpoint: None,
|
||||||
|
backoff_until: None,
|
||||||
|
};
|
||||||
|
assert!(runtime.snapshot().is_none(), "combined count must honor the queue ceiling");
|
||||||
|
runtime.queue.capacity = 2;
|
||||||
|
runtime.queue.byte_budget = 1;
|
||||||
|
assert!(runtime.snapshot().is_none(), "oversized successor must not replace the startup journal");
|
||||||
|
|
||||||
|
runtime.queue = MrfQueue::new(2, 4096);
|
||||||
|
let mut newer = retained.clone();
|
||||||
|
newer.lease = None;
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_common::mrf_channel::try_rearm_mrf_replay_intent(&mut newer),
|
||||||
|
MrfIngressResult::Enqueued
|
||||||
|
);
|
||||||
|
assert_ne!(retained.lease, newer.lease);
|
||||||
|
assert!(runtime.queue.try_push(newer));
|
||||||
|
assert!(
|
||||||
|
runtime.snapshot().is_none(),
|
||||||
|
"legacy encoding cannot conflate distinct responsibilities for one object"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_admission_reserves_replay_budget_until_verified_repair() {
|
||||||
|
for count_limited in [true, false] {
|
||||||
|
let mut retained = intent("reserved-bucket", "retained", 0);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_common::mrf_channel::try_rearm_mrf_replay_intent(&mut retained),
|
||||||
|
MrfIngressResult::Enqueued
|
||||||
|
);
|
||||||
|
let incarnation = Uuid::new_v4();
|
||||||
|
let anchor = MrfDurableRepairAnchor::from_intent(&retained, incarnation).expect("replay anchor");
|
||||||
|
let mut runtime = MrfRuntime {
|
||||||
|
queue: MrfQueue::new(if count_limited { 1 } else { 2 }, retained.estimated_bytes()),
|
||||||
|
config: MrfConsumerConfig::default(),
|
||||||
|
checkpoint_owner: Uuid::new_v4(),
|
||||||
|
next_checkpoint_sequence: 1,
|
||||||
|
new_since_flush: 0,
|
||||||
|
dirty: false,
|
||||||
|
journal_on_disk: true,
|
||||||
|
retain_replay_journal: false,
|
||||||
|
durable_replay_anchors: vec![anchor],
|
||||||
|
retained_replay_intents: HashMap::from([(queue_key(&retained), retained.clone())]),
|
||||||
|
replay_cleanup: None,
|
||||||
|
runtime_checkpoint: None,
|
||||||
|
backoff_until: None,
|
||||||
|
};
|
||||||
|
if count_limited {
|
||||||
|
runtime.queue.byte_budget = 4096;
|
||||||
|
}
|
||||||
|
let pending = intent("reserved-bucket", "pending", 0);
|
||||||
|
assert_eq!(runtime.enqueue_batch([pending.clone()]), 0, "retained work consumes admission budget");
|
||||||
|
assert_eq!(runtime.queue.depth(), 0);
|
||||||
|
let (snapshot, _) = runtime.snapshot().expect("rejection must leave a writable retained snapshot");
|
||||||
|
let (recovered, truncated) = decode_journal(&snapshot);
|
||||||
|
assert_eq!(truncated, 0);
|
||||||
|
assert_eq!(recovered.len(), 1);
|
||||||
|
assert_eq!(recovered[0].object.as_ref(), "retained");
|
||||||
|
|
||||||
|
rustfs_common::mrf_channel::note_mrf_verified_repair(MrfVerifiedRepairEvent {
|
||||||
|
kind: retained.kind,
|
||||||
|
bucket: retained.bucket.clone(),
|
||||||
|
object: retained.object.clone(),
|
||||||
|
version_id: retained.version_id,
|
||||||
|
scope: retained.scope,
|
||||||
|
lease: retained.lease,
|
||||||
|
bucket_incarnation_id: incarnation,
|
||||||
|
disposition: MrfVerifiedRepairDisposition::Repaired,
|
||||||
|
});
|
||||||
|
runtime.discharge_durable_replay_anchors();
|
||||||
|
assert_eq!(runtime.enqueue_batch([pending]), 1, "proof must release admission capacity for retry");
|
||||||
|
let (snapshot, _) = runtime.snapshot().expect("new admitted work must be persistable");
|
||||||
|
let (recovered, truncated) = decode_journal(&snapshot);
|
||||||
|
assert_eq!(truncated, 0);
|
||||||
|
assert_eq!(recovered.len(), 1);
|
||||||
|
assert_eq!(recovered[0].object.as_ref(), "pending");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_admission_rejects_new_lease_without_blocking_other_successors() {
|
||||||
|
let mut retained = intent("lease-bucket", "retained", 0);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_common::mrf_channel::try_rearm_mrf_replay_intent(&mut retained),
|
||||||
|
MrfIngressResult::Enqueued
|
||||||
|
);
|
||||||
|
let incarnation = Uuid::new_v4();
|
||||||
|
let anchor = MrfDurableRepairAnchor::from_intent(&retained, incarnation).expect("replay anchor");
|
||||||
|
let mut runtime = MrfRuntime {
|
||||||
|
queue: MrfQueue::new(2, 4096),
|
||||||
|
config: MrfConsumerConfig::default(),
|
||||||
|
checkpoint_owner: Uuid::new_v4(),
|
||||||
|
next_checkpoint_sequence: 1,
|
||||||
|
new_since_flush: 0,
|
||||||
|
dirty: false,
|
||||||
|
journal_on_disk: true,
|
||||||
|
retain_replay_journal: false,
|
||||||
|
durable_replay_anchors: vec![anchor],
|
||||||
|
retained_replay_intents: HashMap::from([(queue_key(&retained), retained.clone())]),
|
||||||
|
replay_cleanup: None,
|
||||||
|
runtime_checkpoint: None,
|
||||||
|
backoff_until: None,
|
||||||
|
};
|
||||||
|
let mut newer = retained.clone();
|
||||||
|
newer.lease = None;
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_common::mrf_channel::try_rearm_mrf_replay_intent(&mut newer),
|
||||||
|
MrfIngressResult::Enqueued
|
||||||
|
);
|
||||||
|
assert_ne!(newer.lease, retained.lease);
|
||||||
|
assert_eq!(runtime.enqueue_batch([newer.clone(), intent("lease-bucket", "pending", 0)]), 1);
|
||||||
|
assert_eq!(runtime.queue.intents().next().expect("unrelated successor").object.as_ref(), "pending");
|
||||||
|
assert_eq!(decode_journal(&runtime.snapshot().expect("unblocked successor").0).0.len(), 2);
|
||||||
|
|
||||||
|
rustfs_common::mrf_channel::note_mrf_verified_repair(MrfVerifiedRepairEvent {
|
||||||
|
kind: retained.kind,
|
||||||
|
bucket: retained.bucket.clone(),
|
||||||
|
object: retained.object.clone(),
|
||||||
|
version_id: retained.version_id,
|
||||||
|
scope: retained.scope,
|
||||||
|
lease: retained.lease,
|
||||||
|
bucket_incarnation_id: incarnation,
|
||||||
|
disposition: MrfVerifiedRepairDisposition::Repaired,
|
||||||
|
});
|
||||||
|
runtime.discharge_durable_replay_anchors();
|
||||||
|
assert_eq!(runtime.enqueue_batch([newer.clone()]), 1);
|
||||||
|
assert!(runtime.queue.intents().any(|intent| intent.lease == newer.lease));
|
||||||
|
assert_eq!(decode_journal(&runtime.snapshot().expect("new lease successor").0).0.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_cleanup_defaults_to_legacy_for_runtime_written_journals() {
|
fn runtime_cleanup_defaults_to_legacy_for_runtime_written_journals() {
|
||||||
let runtime = MrfRuntime {
|
let runtime = MrfRuntime {
|
||||||
@@ -1274,6 +1575,7 @@ mod tests {
|
|||||||
journal_on_disk: true,
|
journal_on_disk: true,
|
||||||
retain_replay_journal: false,
|
retain_replay_journal: false,
|
||||||
durable_replay_anchors: Vec::new(),
|
durable_replay_anchors: Vec::new(),
|
||||||
|
retained_replay_intents: HashMap::new(),
|
||||||
replay_cleanup: None,
|
replay_cleanup: None,
|
||||||
runtime_checkpoint: None,
|
runtime_checkpoint: None,
|
||||||
backoff_until: None,
|
backoff_until: None,
|
||||||
@@ -1320,6 +1622,50 @@ mod tests {
|
|||||||
rustfs_common::mrf_channel::release_mrf_intent(&replay);
|
rustfs_common::mrf_channel::release_mrf_intent(&replay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_replay_canonicalization_preserves_one_bounded_responsibility() {
|
||||||
|
let mut legacy = intent("metadata-replay-bucket", "object", 0);
|
||||||
|
legacy.kind = MrfKind::MetadataCorruption;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
assert!(encode_intent(&legacy, &mut bytes));
|
||||||
|
let (mut decoded, truncated) = decode_journal(&bytes);
|
||||||
|
assert_eq!(truncated, 0);
|
||||||
|
let mut replay = decoded.pop().expect("legacy metadata record");
|
||||||
|
assert!(replay.version_id.is_some(), "the legacy wire record carries an ignored version");
|
||||||
|
let original_key = queue_key(&replay);
|
||||||
|
let mut retained_replay_intents = HashMap::from([(original_key.clone(), replay.clone())]);
|
||||||
|
assert_eq!(
|
||||||
|
rustfs_common::mrf_channel::try_rearm_mrf_replay_intent(&mut replay),
|
||||||
|
MrfIngressResult::Enqueued
|
||||||
|
);
|
||||||
|
assert!(replay.version_id.is_none());
|
||||||
|
assert_eq!(queue_key(&replay), original_key, "rearm must not create another retained key");
|
||||||
|
retained_replay_intents.insert(queue_key(&replay), replay);
|
||||||
|
assert_eq!(retained_replay_intents.len(), 1);
|
||||||
|
let runtime = MrfRuntime {
|
||||||
|
queue: MrfQueue::new(1, 4096),
|
||||||
|
config: MrfConsumerConfig::default(),
|
||||||
|
checkpoint_owner: Uuid::new_v4(),
|
||||||
|
next_checkpoint_sequence: 1,
|
||||||
|
new_since_flush: 0,
|
||||||
|
dirty: false,
|
||||||
|
journal_on_disk: true,
|
||||||
|
retain_replay_journal: true,
|
||||||
|
durable_replay_anchors: Vec::new(),
|
||||||
|
retained_replay_intents,
|
||||||
|
replay_cleanup: None,
|
||||||
|
runtime_checkpoint: None,
|
||||||
|
backoff_until: None,
|
||||||
|
};
|
||||||
|
let (snapshot, _) = runtime
|
||||||
|
.snapshot()
|
||||||
|
.expect("canonical metadata fits the original one-record budget");
|
||||||
|
let (recovered, truncated) = decode_journal(&snapshot);
|
||||||
|
assert_eq!(truncated, 0);
|
||||||
|
assert_eq!(recovered.len(), 1);
|
||||||
|
assert!(recovered[0].version_id.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_can_arm_more_records_than_live_queue_budget() {
|
fn replay_can_arm_more_records_than_live_queue_budget() {
|
||||||
let mut queue = MrfQueue::new(1, intent("bucket", "object-0", 0).estimated_bytes());
|
let mut queue = MrfQueue::new(1, intent("bucket", "object-0", 0).estimated_bytes());
|
||||||
|
|||||||
@@ -436,6 +436,23 @@ pub trait HealStorageAPI: Send + Sync {
|
|||||||
Err(Error::other("target-scoped replacement format is unsupported"))
|
Err(Error::other("target-scoped replacement format is unsupported"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Heal each pool metadata replica owned by the selected live scope.
|
||||||
|
///
|
||||||
|
/// A successful result requires every applicable owner to finish; an empty
|
||||||
|
/// result is valid only for a known scope with no metadata replica. Backends
|
||||||
|
/// without pool metadata must explicitly implement that empty result.
|
||||||
|
async fn heal_pool_metadata(&self, _opts: &HealOpts) -> Result<Vec<HealResultItem>> {
|
||||||
|
Err(Error::other("pool metadata healing is unsupported"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the selected replacement set owns the pool metadata replica.
|
||||||
|
///
|
||||||
|
/// Only a topology-aware backend may exempt a valid non-owner set. The
|
||||||
|
/// conservative default requires the existing repair and readback checks.
|
||||||
|
async fn replacement_pool_metadata_applies(&self, _opts: &HealOpts) -> Result<bool> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Read target-specific physical evidence for one replacement version.
|
/// Read target-specific physical evidence for one replacement version.
|
||||||
///
|
///
|
||||||
/// This is only used by automatic replacement healing after the normal
|
/// This is only used by automatic replacement healing after the normal
|
||||||
@@ -1268,6 +1285,22 @@ impl HealStorageAPI for ECStoreHealStorage {
|
|||||||
.map_err(Error::Storage)
|
.map_err(Error::Storage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
|
||||||
|
self.ecstore.heal_pool_metadata(opts).await.map_err(Error::Storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn replacement_pool_metadata_applies(&self, opts: &HealOpts) -> Result<bool> {
|
||||||
|
let pool_index = opts
|
||||||
|
.pool
|
||||||
|
.ok_or_else(|| Error::other("replacement pool metadata is missing pool scope"))?;
|
||||||
|
let set_index = opts
|
||||||
|
.set
|
||||||
|
.ok_or_else(|| Error::other("replacement pool metadata is missing set scope"))?;
|
||||||
|
self.ecstore
|
||||||
|
.replacement_pool_metadata_applies(pool_index, set_index)
|
||||||
|
.map_err(Error::Storage)
|
||||||
|
}
|
||||||
|
|
||||||
async fn replacement_targets_have_version(
|
async fn replacement_targets_have_version(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ use tokio::sync::RwLock;
|
|||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::{BUCKET_META_PREFIX, DATA_USAGE_CACHE_NAME, POOL_META_NAME, RUSTFS_META_BUCKET};
|
use super::{BUCKET_META_PREFIX, DATA_USAGE_CACHE_NAME, RUSTFS_META_BUCKET};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) struct OutcomeFinishTestHook {
|
pub(crate) struct OutcomeFinishTestHook {
|
||||||
|
|||||||
@@ -328,6 +328,23 @@ impl HealTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !self.options.dry_run {
|
||||||
|
let metadata_opts = HealOpts {
|
||||||
|
dry_run: self.options.dry_run,
|
||||||
|
recreate: self.options.recreate_missing,
|
||||||
|
scan_mode: self.options.scan_mode,
|
||||||
|
pool: self.options.pool_index,
|
||||||
|
set: self.options.set_index,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for result in self
|
||||||
|
.await_with_control(self.storage.heal_pool_metadata(&metadata_opts))
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
self.record_result_item(result).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if failed > 0 {
|
if failed > 0 {
|
||||||
let failure = BatchHealFailure {
|
let failure = BatchHealFailure {
|
||||||
scope: "cluster".to_string(),
|
scope: "cluster".to_string(),
|
||||||
@@ -340,88 +357,9 @@ impl HealTask {
|
|||||||
return Err(self.record_batch_failure(failure).await);
|
return Err(self.record_batch_failure(failure).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.options.recreate_missing && !self.options.dry_run {
|
|
||||||
self.heal_cluster_pool_metadata().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn heal_cluster_pool_metadata(&self) -> Result<()> {
|
|
||||||
let heal_opts = HealOpts {
|
|
||||||
recursive: false,
|
|
||||||
dry_run: self.options.dry_run,
|
|
||||||
remove: false,
|
|
||||||
recreate: self.options.recreate_missing,
|
|
||||||
scan_mode: self.options.scan_mode,
|
|
||||||
update_parity: self.options.update_parity,
|
|
||||||
no_lock: self.options.no_lock,
|
|
||||||
read_repair: false,
|
|
||||||
pool: self.options.pool_index,
|
|
||||||
set: self.options.set_index,
|
|
||||||
};
|
|
||||||
|
|
||||||
let heal_result = self
|
|
||||||
.await_with_control(self.storage.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &heal_opts))
|
|
||||||
.await;
|
|
||||||
match heal_result {
|
|
||||||
Ok((result, None)) => {
|
|
||||||
debug!(
|
|
||||||
target: "rustfs::heal::task",
|
|
||||||
event = EVENT_HEAL_BUCKET_RESULT,
|
|
||||||
component = LOG_COMPONENT_HEAL,
|
|
||||||
subsystem = LOG_SUBSYSTEM_TASK,
|
|
||||||
task_id = %self.id,
|
|
||||||
bucket = RUSTFS_META_BUCKET,
|
|
||||||
object = POOL_META_NAME,
|
|
||||||
drives_healed = result.drives_healed(),
|
|
||||||
drives_total = result.drives_reported(),
|
|
||||||
result = "pool_metadata_ok",
|
|
||||||
"Heal cluster pool metadata repaired"
|
|
||||||
);
|
|
||||||
self.record_result_item(result).await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Ok((result, Some(err))) => {
|
|
||||||
self.record_result_item(result).await;
|
|
||||||
warn!(
|
|
||||||
target: "rustfs::heal::task",
|
|
||||||
event = EVENT_HEAL_BUCKET_RESULT,
|
|
||||||
component = LOG_COMPONENT_HEAL,
|
|
||||||
subsystem = LOG_SUBSYSTEM_TASK,
|
|
||||||
task_id = %self.id,
|
|
||||||
bucket = RUSTFS_META_BUCKET,
|
|
||||||
object = POOL_META_NAME,
|
|
||||||
result = "pool_metadata_failed",
|
|
||||||
error = %err,
|
|
||||||
"Heal cluster pool metadata failed"
|
|
||||||
);
|
|
||||||
Err(Error::TaskExecutionFailed {
|
|
||||||
message: format!("Failed to heal cluster pool metadata: {err}"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(Error::TaskCancelled) => Err(Error::TaskCancelled),
|
|
||||||
Err(Error::TaskTimeout) => Err(Error::TaskTimeout),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
target: "rustfs::heal::task",
|
|
||||||
event = EVENT_HEAL_BUCKET_RESULT,
|
|
||||||
component = LOG_COMPONENT_HEAL,
|
|
||||||
subsystem = LOG_SUBSYSTEM_TASK,
|
|
||||||
task_id = %self.id,
|
|
||||||
bucket = RUSTFS_META_BUCKET,
|
|
||||||
object = POOL_META_NAME,
|
|
||||||
result = "pool_metadata_failed",
|
|
||||||
error = %err,
|
|
||||||
"Heal cluster pool metadata failed"
|
|
||||||
);
|
|
||||||
Err(Error::TaskExecutionFailed {
|
|
||||||
message: format!("Failed to heal cluster pool metadata: {err}"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn heal_prefix(&self, bucket: &str, prefix: &str) -> Result<()> {
|
pub(super) async fn heal_prefix(&self, bucket: &str, prefix: &str) -> Result<()> {
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::task",
|
target: "rustfs::heal::task",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
use super::super::{DiskOption, DiskStore, Endpoint, new_disk};
|
use super::super::{DiskOption, DiskStore, Endpoint, new_disk};
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::heal::POOL_META_NAME;
|
||||||
use crate::heal::storage::HealStorageObjectResult;
|
use crate::heal::storage::HealStorageObjectResult;
|
||||||
|
|
||||||
mod deferred_retry;
|
mod deferred_retry;
|
||||||
@@ -42,6 +43,7 @@ mod canonical_outcome {
|
|||||||
#[tokio::test(start_paused = true)]
|
#[tokio::test(start_paused = true)]
|
||||||
async fn cluster_retries_only_the_failed_listing_page() {
|
async fn cluster_retries_only_the_failed_listing_page() {
|
||||||
let storage = Arc::new(MockStorage {
|
let storage = Arc::new(MockStorage {
|
||||||
|
pool_metadata_required: true,
|
||||||
recoverable_second_page_failures: Mutex::new(Some(1)),
|
recoverable_second_page_failures: Mutex::new(Some(1)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
@@ -1325,6 +1327,7 @@ struct MockStorage {
|
|||||||
retry_test_events: Mutex<Vec<String>>,
|
retry_test_events: Mutex<Vec<String>>,
|
||||||
listed: Mutex<bool>,
|
listed: Mutex<bool>,
|
||||||
list_each_bucket: bool,
|
list_each_bucket: bool,
|
||||||
|
pool_metadata_required: bool,
|
||||||
fail_second_listing_page: bool,
|
fail_second_listing_page: bool,
|
||||||
recoverable_second_page_failures: Mutex<Option<usize>>,
|
recoverable_second_page_failures: Mutex<Option<usize>>,
|
||||||
listing_tokens: Mutex<Vec<Option<String>>>,
|
listing_tokens: Mutex<Vec<Option<String>>>,
|
||||||
@@ -1339,6 +1342,10 @@ struct MockStorage {
|
|||||||
heal_object_outcomes: Mutex<HashMap<String, VecDeque<MockHealObjectOutcome>>>,
|
heal_object_outcomes: Mutex<HashMap<String, VecDeque<MockHealObjectOutcome>>>,
|
||||||
heal_object_receipts: Mutex<HashMap<String, VecDeque<HealObjectReceipt>>>,
|
heal_object_receipts: Mutex<HashMap<String, VecDeque<HealObjectReceipt>>>,
|
||||||
bucket_incarnation_id: Mutex<Option<Uuid>>,
|
bucket_incarnation_id: Mutex<Option<Uuid>>,
|
||||||
|
bucket_incarnation_calls: AtomicU64,
|
||||||
|
bucket_incarnation_error: Mutex<Option<Error>>,
|
||||||
|
block_bucket_incarnation: bool,
|
||||||
|
bucket_incarnation_started: tokio::sync::Notify,
|
||||||
bucket_incarnation_after_object_heal: Mutex<Option<Uuid>>,
|
bucket_incarnation_after_object_heal: Mutex<Option<Uuid>>,
|
||||||
bucket_incarnation_unavailable: Mutex<bool>,
|
bucket_incarnation_unavailable: Mutex<bool>,
|
||||||
format_no_heal_required: Mutex<bool>,
|
format_no_heal_required: Mutex<bool>,
|
||||||
@@ -1482,7 +1489,7 @@ async fn object_heal_records_matching_positive_storage_receipt() {
|
|||||||
});
|
});
|
||||||
let task = HealTask::from_request(
|
let task = HealTask::from_request(
|
||||||
HealRequest::object("bucket-a".to_string(), "object-a".to_string(), Some("version-a".to_string())),
|
HealRequest::object("bucket-a".to_string(), "object-a".to_string(), Some("version-a".to_string())),
|
||||||
storage,
|
storage.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
task.execute().await.expect("mock object heal should complete");
|
task.execute().await.expect("mock object heal should complete");
|
||||||
@@ -1495,6 +1502,114 @@ async fn object_heal_records_matching_positive_storage_receipt() {
|
|||||||
assert_eq!(object.identity.version_id.as_deref(), Some("version-a"));
|
assert_eq!(object.identity.version_id.as_deref(), Some("version-a"));
|
||||||
assert!(object.identity.bucket_incarnation_id.is_some());
|
assert!(object.identity.bucket_incarnation_id.is_some());
|
||||||
assert_eq!(object.disposition, HealObjectDisposition::Repaired);
|
assert_eq!(object.disposition, HealObjectDisposition::Repaired);
|
||||||
|
assert_eq!(
|
||||||
|
storage.bucket_incarnation_calls.load(Ordering::Relaxed),
|
||||||
|
1,
|
||||||
|
"latch the owner exactly once before repair"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn object_heal_owner_lookup_failure_preserves_unverified_repair() {
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
bucket_incarnation_error: Mutex::new(Some(Error::other("owner metadata unavailable"))),
|
||||||
|
heal_object_receipts: Mutex::new(HashMap::from([(
|
||||||
|
"object-a".to_string(),
|
||||||
|
VecDeque::from([object_receipt(
|
||||||
|
"object-a",
|
||||||
|
None,
|
||||||
|
HealObjectDisposition::Repaired,
|
||||||
|
Uuid::new_v4(),
|
||||||
|
)]),
|
||||||
|
)])),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let task = HealTask::from_request(HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None), storage.clone());
|
||||||
|
|
||||||
|
task.execute().await.expect("missing receipt owner must not prevent repair");
|
||||||
|
|
||||||
|
assert_eq!(storage.heal_object_calls.lock().expect("heal calls").as_slice(), ["object-a"]);
|
||||||
|
let outcome = task.get_outcome().await;
|
||||||
|
assert_eq!(outcome.counters.healed, 0);
|
||||||
|
assert_eq!(outcome.counters.unknown, 1);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.objects.front().expect("unverified outcome").disposition,
|
||||||
|
HealObjectDisposition::Unknown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn object_heal_dry_run_skips_owner_lookup_and_positive_receipts() {
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
bucket_incarnation_error: Mutex::new(Some(Error::other("dry-run must not query the receipt owner"))),
|
||||||
|
heal_object_receipts: Mutex::new(HashMap::from([(
|
||||||
|
"object-a".to_string(),
|
||||||
|
VecDeque::from([object_receipt(
|
||||||
|
"object-a",
|
||||||
|
None,
|
||||||
|
HealObjectDisposition::Repaired,
|
||||||
|
Uuid::new_v4(),
|
||||||
|
)]),
|
||||||
|
)])),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut request = HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None);
|
||||||
|
request.options.dry_run = true;
|
||||||
|
let task = HealTask::from_request(request, storage.clone());
|
||||||
|
|
||||||
|
task.execute().await.expect("dry-run should complete without owner metadata");
|
||||||
|
|
||||||
|
assert!(storage.object_heal_opts.lock().expect("heal options")[0].dry_run);
|
||||||
|
assert_eq!(storage.bucket_incarnation_calls.load(Ordering::Relaxed), 0);
|
||||||
|
let outcome = task.get_outcome().await;
|
||||||
|
assert_eq!(outcome.counters.healed, 0);
|
||||||
|
assert_eq!(outcome.counters.unknown, 0);
|
||||||
|
assert_eq!(outcome.counters.skipped, 1);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.objects.front().expect("dry-run outcome").disposition,
|
||||||
|
HealObjectDisposition::DryRunObserved
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn object_heal_owner_lookup_obeys_task_timeout() {
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
block_bucket_incarnation: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut request = HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None);
|
||||||
|
request.options.timeout = Some(Duration::from_secs(5));
|
||||||
|
let task = HealTask::from_request(request, storage.clone());
|
||||||
|
|
||||||
|
let result = tokio::time::timeout(Duration::from_secs(60), task.execute())
|
||||||
|
.await
|
||||||
|
.expect("owner lookup must honor the task deadline");
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(Error::TaskTimeout)));
|
||||||
|
assert!(storage.heal_object_calls.lock().expect("heal calls").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn object_heal_owner_lookup_obeys_cancellation() {
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
block_bucket_incarnation: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut request = HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None);
|
||||||
|
request.options.timeout = None;
|
||||||
|
let task = HealTask::from_request(request, storage.clone());
|
||||||
|
|
||||||
|
let (result, ()) = tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
tokio::join!(task.execute(), async {
|
||||||
|
storage.bucket_incarnation_started.notified().await;
|
||||||
|
task.cancel().await.expect("cancel pending owner lookup");
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("cancellation must interrupt owner lookup");
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(Error::TaskCancelled)));
|
||||||
|
assert!(storage.heal_object_calls.lock().expect("heal calls").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1832,6 +1947,34 @@ impl HealStorageAPI for MockStorage {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
|
||||||
|
if !self.pool_metadata_required {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let scopes = self.erasure_set_scopes.lock().expect("metadata scopes").clone();
|
||||||
|
let scopes = if scopes.is_empty() {
|
||||||
|
vec![(opts.pool.unwrap_or(0), opts.set.unwrap_or(0))]
|
||||||
|
} else {
|
||||||
|
scopes
|
||||||
|
};
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for (pool, set) in scopes {
|
||||||
|
let scoped_opts = HealOpts {
|
||||||
|
pool: Some(pool),
|
||||||
|
set: Some(set),
|
||||||
|
..*opts
|
||||||
|
};
|
||||||
|
let (result, error) = self
|
||||||
|
.heal_object(RUSTFS_META_BUCKET, crate::heal::POOL_META_NAME, None, &scoped_opts)
|
||||||
|
.await?;
|
||||||
|
if let Some(error) = error {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
async fn object_exists(&self, _bucket: &str, object: &str) -> Result<bool> {
|
async fn object_exists(&self, _bucket: &str, object: &str) -> Result<bool> {
|
||||||
if let Some(result) = self.object_exists_by_name.lock().unwrap().get(object).copied() {
|
if let Some(result) = self.object_exists_by_name.lock().unwrap().get(object).copied() {
|
||||||
return match result {
|
return match result {
|
||||||
@@ -1844,6 +1987,14 @@ impl HealStorageAPI for MockStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn bucket_incarnation_id(&self, _bucket: &str) -> Result<Option<Uuid>> {
|
async fn bucket_incarnation_id(&self, _bucket: &str) -> Result<Option<Uuid>> {
|
||||||
|
self.bucket_incarnation_calls.fetch_add(1, Ordering::Relaxed);
|
||||||
|
self.bucket_incarnation_started.notify_one();
|
||||||
|
if self.block_bucket_incarnation {
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
}
|
||||||
|
if let Some(error) = self.bucket_incarnation_error.lock().expect("owner lookup error").take() {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
if *self.bucket_incarnation_unavailable.lock().unwrap() {
|
if *self.bucket_incarnation_unavailable.lock().unwrap() {
|
||||||
return Err(Error::Other("bucket incarnation unavailable".to_string()));
|
return Err(Error::Other("bucket incarnation unavailable".to_string()));
|
||||||
}
|
}
|
||||||
@@ -2564,9 +2715,187 @@ async fn test_recursive_bucket_heal_treats_missing_continuation_token_as_end() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_heal_restores_pool_metadata_without_user_buckets() {
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
pool_metadata_required: true,
|
||||||
|
listed_buckets: Mutex::new(Some(Vec::new())),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
assert!(storage.pool_metadata_required);
|
||||||
|
let task = HealTask::from_request(
|
||||||
|
HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::Normal),
|
||||||
|
storage.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
task.execute().await.expect("root heal should restore required pool metadata");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
storage.heal_object_calls.lock().expect("heal calls").as_slice(),
|
||||||
|
[crate::heal::POOL_META_NAME]
|
||||||
|
);
|
||||||
|
assert!(matches!(task.get_status().await, HealTaskStatus::Completed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_heal_pool_metadata_cannot_hide_a_later_owner_failure() {
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
pool_metadata_required: true,
|
||||||
|
listed_buckets: Mutex::new(Some(Vec::new())),
|
||||||
|
erasure_set_scopes: Mutex::new(vec![(0, 0), (1, 1)]),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
storage.heal_object_outcomes.lock().expect("metadata outcomes").insert(
|
||||||
|
crate::heal::POOL_META_NAME.to_string(),
|
||||||
|
VecDeque::from([
|
||||||
|
MockHealObjectOutcome::UnavailableDrive(DriveState::Ok),
|
||||||
|
MockHealObjectOutcome::OkWithReadQuorum,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
let task = HealTask::from_request(
|
||||||
|
HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::Normal),
|
||||||
|
storage.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = task
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.expect_err("one healthy owner cannot satisfy another owner's recovery");
|
||||||
|
|
||||||
|
assert!(matches!(error, Error::Storage(EcstoreError::InsufficientReadQuorum(_, _))));
|
||||||
|
{
|
||||||
|
let opts = storage.object_heal_opts.lock().expect("owner options");
|
||||||
|
assert_eq!(
|
||||||
|
opts.iter().map(|opts| (opts.pool, opts.set)).collect::<Vec<_>>(),
|
||||||
|
vec![(Some(0), Some(0)), (Some(1), Some(1))]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_heal_pool_metadata_does_not_inherit_remove_or_no_lock() {
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
pool_metadata_required: true,
|
||||||
|
listed_buckets: Mutex::new(Some(Vec::new())),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let task = HealTask::from_request(
|
||||||
|
HealRequest::new(
|
||||||
|
HealType::Cluster,
|
||||||
|
HealOptions {
|
||||||
|
remove_corrupted: true,
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
HealPriority::Normal,
|
||||||
|
),
|
||||||
|
storage.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
task.execute()
|
||||||
|
.await
|
||||||
|
.expect("metadata repair should retain its write fence and reject destructive options");
|
||||||
|
|
||||||
|
let opts = storage.object_heal_opts.lock().expect("metadata options");
|
||||||
|
assert_eq!(opts.len(), 1, "an empty user namespace must still inspect metadata");
|
||||||
|
assert!(!opts[0].dry_run);
|
||||||
|
assert!(!opts[0].remove);
|
||||||
|
assert!(!opts[0].no_lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_heal_pool_metadata_failure_does_not_prevent_user_repairs() {
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
pool_metadata_required: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
storage.heal_object_outcomes.lock().expect("metadata outcome").insert(
|
||||||
|
crate::heal::POOL_META_NAME.to_string(),
|
||||||
|
VecDeque::from([MockHealObjectOutcome::OkWithReadQuorum]),
|
||||||
|
);
|
||||||
|
let task = HealTask::from_request(
|
||||||
|
HealRequest::new(
|
||||||
|
HealType::Cluster,
|
||||||
|
HealOptions {
|
||||||
|
recursive: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
HealPriority::Normal,
|
||||||
|
),
|
||||||
|
storage.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = task
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.expect_err("unrecovered metadata must still fail root completion");
|
||||||
|
|
||||||
|
assert!(matches!(error, Error::Storage(EcstoreError::InsufficientReadQuorum(_, _))));
|
||||||
|
assert_eq!(
|
||||||
|
storage.heal_object_calls.lock().expect("heal calls").as_slice(),
|
||||||
|
["object-a", "object-b", crate::heal::POOL_META_NAME]
|
||||||
|
);
|
||||||
|
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_heal_pool_metadata_preserves_typed_quorum_failure() {
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
pool_metadata_required: true,
|
||||||
|
listed_buckets: Mutex::new(Some(Vec::new())),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
storage.heal_object_outcomes.lock().expect("metadata outcome").insert(
|
||||||
|
crate::heal::POOL_META_NAME.to_string(),
|
||||||
|
VecDeque::from([MockHealObjectOutcome::OkWithReadQuorum]),
|
||||||
|
);
|
||||||
|
let task = HealTask::from_request(HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::Normal), storage);
|
||||||
|
|
||||||
|
let error = task
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.expect_err("metadata quorum failure must prevent root completion");
|
||||||
|
|
||||||
|
assert!(matches!(error, Error::Storage(EcstoreError::InsufficientReadQuorum(_, _))));
|
||||||
|
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn root_heal_pool_metadata_obeys_task_timeout() {
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
pool_metadata_required: true,
|
||||||
|
listed_buckets: Mutex::new(Some(Vec::new())),
|
||||||
|
retry_test_delays: HashMap::from([(crate::heal::POOL_META_NAME.to_string(), Duration::from_secs(10))]),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let task = HealTask::from_request(
|
||||||
|
HealRequest::new(
|
||||||
|
HealType::Cluster,
|
||||||
|
HealOptions {
|
||||||
|
timeout: Some(Duration::from_millis(10)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
HealPriority::Normal,
|
||||||
|
),
|
||||||
|
storage,
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = task
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.expect_err("metadata work must stay inside the root task budget");
|
||||||
|
|
||||||
|
assert!(matches!(error, Error::TaskTimeout));
|
||||||
|
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_cluster_heal_visits_bucket_objects() {
|
async fn test_cluster_heal_visits_bucket_objects() {
|
||||||
let storage = Arc::new(MockStorage::default());
|
let storage = Arc::new(MockStorage {
|
||||||
|
pool_metadata_required: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
let request = HealRequest::new(
|
let request = HealRequest::new(
|
||||||
HealType::Cluster,
|
HealType::Cluster,
|
||||||
HealOptions {
|
HealOptions {
|
||||||
@@ -2589,7 +2918,10 @@ async fn test_cluster_heal_visits_bucket_objects() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cluster_recreate_heals_pool_metadata_after_user_buckets() {
|
async fn cluster_recreate_heals_pool_metadata_after_user_buckets() {
|
||||||
let storage = Arc::new(MockStorage::default());
|
let storage = Arc::new(MockStorage {
|
||||||
|
pool_metadata_required: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
let request = HealRequest::new(
|
let request = HealRequest::new(
|
||||||
HealType::Cluster,
|
HealType::Cluster,
|
||||||
HealOptions {
|
HealOptions {
|
||||||
@@ -2616,7 +2948,10 @@ async fn cluster_recreate_heals_pool_metadata_after_user_buckets() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
|
async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
|
||||||
let storage = Arc::new(MockStorage::default());
|
let storage = Arc::new(MockStorage {
|
||||||
|
pool_metadata_required: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
storage.heal_object_outcomes.lock().expect("object outcomes").insert(
|
storage.heal_object_outcomes.lock().expect("object outcomes").insert(
|
||||||
POOL_META_NAME.to_string(),
|
POOL_META_NAME.to_string(),
|
||||||
VecDeque::from([MockHealObjectOutcome::ErrOther("pool metadata missing")]),
|
VecDeque::from([MockHealObjectOutcome::ErrOther("pool metadata missing")]),
|
||||||
@@ -2638,7 +2973,7 @@ async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
|
|||||||
.await
|
.await
|
||||||
.expect_err("cluster recreate heal must not hide pool metadata failure");
|
.expect_err("cluster recreate heal must not hide pool metadata failure");
|
||||||
|
|
||||||
assert!(matches!(err, Error::TaskExecutionFailed { .. }));
|
assert!(matches!(err, Error::Other(message) if message == "pool metadata missing"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
storage.heal_object_calls.lock().expect("object calls").as_slice(),
|
storage.heal_object_calls.lock().expect("object calls").as_slice(),
|
||||||
["object-a".to_string(), "object-b".to_string(), POOL_META_NAME.to_string()]
|
["object-a".to_string(), "object-b".to_string(), POOL_META_NAME.to_string()]
|
||||||
@@ -2647,7 +2982,10 @@ async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cluster_dry_run_does_not_heal_pool_metadata() {
|
async fn cluster_dry_run_does_not_heal_pool_metadata() {
|
||||||
let storage = Arc::new(MockStorage::default());
|
let storage = Arc::new(MockStorage {
|
||||||
|
pool_metadata_required: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
let request = HealRequest::new(
|
let request = HealRequest::new(
|
||||||
HealType::Cluster,
|
HealType::Cluster,
|
||||||
HealOptions {
|
HealOptions {
|
||||||
|
|||||||
@@ -666,7 +666,8 @@ fn mrf_successor_flush_child_process_fixture() {
|
|||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
mrf_queue::spawn_mrf_consumer(manager.clone());
|
mrf_queue::spawn_mrf_consumer(manager.clone());
|
||||||
let expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
|
let mut expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
|
||||||
|
expected_successor.extend(journal_record(1, "successor-bucket", "first-object", None, 0));
|
||||||
let flushed = wait_until(Duration::from_secs(10), || async {
|
let flushed = wait_until(Duration::from_secs(10), || async {
|
||||||
manager.operations_snapshot().await.queued_by_source.mrf == 1
|
manager.operations_snapshot().await.queued_by_source.mrf == 1
|
||||||
&& committed_checkpoint_matches_on_all_disks(&disk_paths, 2, &expected_successor)
|
&& committed_checkpoint_matches_on_all_disks(&disk_paths, 2, &expected_successor)
|
||||||
@@ -714,7 +715,8 @@ fn mrf_successor_flush_waiting_child_process_fixture() {
|
|||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
mrf_queue::spawn_mrf_consumer(manager.clone());
|
mrf_queue::spawn_mrf_consumer(manager.clone());
|
||||||
let expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
|
let mut expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
|
||||||
|
expected_successor.extend(journal_record(1, "service-kill-bucket", "first-object", None, 0));
|
||||||
let flushed = wait_until(Duration::from_secs(10), || async {
|
let flushed = wait_until(Duration::from_secs(10), || async {
|
||||||
manager.operations_snapshot().await.queued_by_source.mrf == 1
|
manager.operations_snapshot().await.queued_by_source.mrf == 1
|
||||||
&& committed_checkpoint_matches_on_all_disks(&disk_paths, 2, &expected_successor)
|
&& committed_checkpoint_matches_on_all_disks(&disk_paths, 2, &expected_successor)
|
||||||
@@ -755,7 +757,8 @@ fn mrf_authoritative_fsync_waiting_child_process_fixture() {
|
|||||||
write_journal_path_to_disks(&disk_paths, SCOPED_JOURNAL_REL, &startup);
|
write_journal_path_to_disks(&disk_paths, SCOPED_JOURNAL_REL, &startup);
|
||||||
write_journal_path_to_disks(&disk_paths, JOURNAL_REL, &startup);
|
write_journal_path_to_disks(&disk_paths, JOURNAL_REL, &startup);
|
||||||
|
|
||||||
let successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
|
let mut successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
|
||||||
|
successor.extend(journal_record(1, "fsync-kill-bucket", "first-object", None, 0));
|
||||||
write_journal_path_to_disks_synced(&disk_paths, SCOPED_JOURNAL_REL, &successor);
|
write_journal_path_to_disks_synced(&disk_paths, SCOPED_JOURNAL_REL, &successor);
|
||||||
assert!(
|
assert!(
|
||||||
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &successor)
|
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &successor)
|
||||||
@@ -812,9 +815,8 @@ async fn journal_replay_retains_child_process_anchor_when_manager_is_full() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If a process crashes after flushing a smaller successor snapshot but before
|
/// A successor flush must preserve both pending work and accepted work whose
|
||||||
/// deleting the startup anchor, the restarted process must replay the
|
/// repair has not been proven when the process restarts.
|
||||||
/// successor tail rather than losing it or merging it with stale records.
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn journal_replay_survives_successor_flush_before_delete() {
|
async fn journal_replay_survives_successor_flush_before_delete() {
|
||||||
@@ -829,7 +831,8 @@ async fn journal_replay_survives_successor_flush_before_delete() {
|
|||||||
assert_eq!(status.code(), Some(78), "child process did not reach the successor flush boundary");
|
assert_eq!(status.code(), Some(78), "child process did not reach the successor flush boundary");
|
||||||
|
|
||||||
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
|
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
|
||||||
let expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
|
let mut expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
|
||||||
|
expected_successor.extend(journal_record(1, "successor-bucket", "first-object", None, 0));
|
||||||
assert!(
|
assert!(
|
||||||
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor),
|
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor),
|
||||||
"restarted process must see the pending successor snapshot"
|
"restarted process must see the pending successor snapshot"
|
||||||
@@ -837,11 +840,11 @@ async fn journal_replay_survives_successor_flush_before_delete() {
|
|||||||
|
|
||||||
let restarted = make_manager(storage);
|
let restarted = make_manager(storage);
|
||||||
let replayed = mrf_queue::replay_journal_once(&restarted).await;
|
let replayed = mrf_queue::replay_journal_once(&restarted).await;
|
||||||
assert_eq!(replayed, 1, "restart after successor flush must replay only the still-pending tail");
|
assert_eq!(replayed, 2, "restart must replay both the admitted and pending responsibilities");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
restarted.operations_snapshot().await.queued_by_source.mrf,
|
restarted.operations_snapshot().await.queued_by_source.mrf,
|
||||||
1,
|
2,
|
||||||
"the successor tail must be accepted after restart"
|
"both unproven successor responsibilities must be accepted after restart"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
disk_paths.iter().all(|path| {
|
disk_paths.iter().all(|path| {
|
||||||
@@ -853,8 +856,8 @@ async fn journal_replay_survives_successor_flush_before_delete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A service-style hard kill after successor flush must be equivalent to a
|
/// A service-style hard kill after successor flush must be equivalent to a
|
||||||
/// crash at the flush-before-delete boundary: restart may replay the smaller
|
/// crash at the flush-before-delete boundary: restart must recover every
|
||||||
/// successor snapshot, but must not lose or merge stale startup records.
|
/// unproven responsibility from the successor snapshot.
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
#[serial]
|
#[serial]
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -882,7 +885,8 @@ async fn journal_replay_survives_service_kill_after_successor_flush() {
|
|||||||
assert!(!status.success(), "child fixture must be terminated instead of exiting cleanly");
|
assert!(!status.success(), "child fixture must be terminated instead of exiting cleanly");
|
||||||
|
|
||||||
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
|
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
|
||||||
let expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
|
let mut expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
|
||||||
|
expected_successor.extend(journal_record(1, "service-kill-bucket", "first-object", None, 0));
|
||||||
assert!(
|
assert!(
|
||||||
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor),
|
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor),
|
||||||
"restarted process must see the successor snapshot produced before the kill"
|
"restarted process must see the successor snapshot produced before the kill"
|
||||||
@@ -890,11 +894,11 @@ async fn journal_replay_survives_service_kill_after_successor_flush() {
|
|||||||
|
|
||||||
let restarted = make_manager(storage);
|
let restarted = make_manager(storage);
|
||||||
let replayed = mrf_queue::replay_journal_once(&restarted).await;
|
let replayed = mrf_queue::replay_journal_once(&restarted).await;
|
||||||
assert_eq!(replayed, 1, "restart after service kill must replay only the still-pending tail");
|
assert_eq!(replayed, 2, "service-kill restart must preserve every unproven responsibility");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
restarted.operations_snapshot().await.queued_by_source.mrf,
|
restarted.operations_snapshot().await.queued_by_source.mrf,
|
||||||
1,
|
2,
|
||||||
"the successor tail must be accepted after service kill restart"
|
"both unproven responsibilities must be accepted after service kill restart"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
disk_paths.iter().all(|path| {
|
disk_paths.iter().all(|path| {
|
||||||
@@ -906,9 +910,9 @@ async fn journal_replay_survives_service_kill_after_successor_flush() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A hard kill between the authoritative successor fsync and the legacy mirror
|
/// A hard kill between the authoritative successor fsync and the legacy mirror
|
||||||
/// rewrite must prefer the canonical successor tail over the stale legacy
|
/// rewrite must prefer the canonical successor over the stale legacy startup
|
||||||
/// startup epoch. This models the mixed-version boundary conservatively: new
|
/// epoch while retaining every unproven responsibility. New readers must not
|
||||||
/// readers must not merge epochs, while the old mirror remains crash-visible.
|
/// merge epochs, while the old mirror remains crash-visible.
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
#[serial]
|
#[serial]
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -936,7 +940,8 @@ async fn journal_replay_survives_sigkill_after_authoritative_successor_fsync_bef
|
|||||||
assert!(!status.success(), "child fixture must be terminated instead of exiting cleanly");
|
assert!(!status.success(), "child fixture must be terminated instead of exiting cleanly");
|
||||||
|
|
||||||
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
|
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
|
||||||
let expected_successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
|
let mut expected_successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
|
||||||
|
expected_successor.extend(journal_record(1, "fsync-kill-bucket", "first-object", None, 0));
|
||||||
let stale_startup = {
|
let stale_startup = {
|
||||||
let mut startup = journal_record(1, "fsync-kill-bucket", "first-object", None, 0);
|
let mut startup = journal_record(1, "fsync-kill-bucket", "first-object", None, 0);
|
||||||
startup.extend(journal_record(1, "fsync-kill-bucket", "second-object", None, 0));
|
startup.extend(journal_record(1, "fsync-kill-bucket", "second-object", None, 0));
|
||||||
@@ -953,11 +958,11 @@ async fn journal_replay_survives_sigkill_after_authoritative_successor_fsync_bef
|
|||||||
|
|
||||||
let restarted = make_manager(storage);
|
let restarted = make_manager(storage);
|
||||||
let replayed = mrf_queue::replay_journal_once(&restarted).await;
|
let replayed = mrf_queue::replay_journal_once(&restarted).await;
|
||||||
assert_eq!(replayed, 1, "new reader must replay only the authoritative successor tail");
|
assert_eq!(replayed, 2, "new reader must recover every responsibility in the authoritative successor");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
restarted.operations_snapshot().await.queued_by_source.mrf,
|
restarted.operations_snapshot().await.queued_by_source.mrf,
|
||||||
1,
|
2,
|
||||||
"the successor tail must be accepted after the fsync-boundary restart"
|
"both responsibilities must be accepted after the fsync-boundary restart"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
disk_paths.iter().all(|path| {
|
disk_paths.iter().all(|path| {
|
||||||
|
|||||||
@@ -62,6 +62,51 @@ pub fn record_contention_event() {
|
|||||||
counter!("rustfs_lock_contentions").increment(1);
|
counter!("rustfs_lock_contentions").increment(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record a remote lock RPC that exceeded its caller's deadline.
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn record_remote_lock_rpc_timeout(peer: &str, op: &'static str) {
|
||||||
|
use metrics::counter;
|
||||||
|
counter!("rustfs_remote_lock_rpc_timeouts_total", "peer" => peer.to_string(), "op" => op).increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the cached lock channel to `peer` being evicted after an RPC failure.
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn record_remote_lock_channel_eviction(peer: &str, trigger: &'static str) {
|
||||||
|
use metrics::counter;
|
||||||
|
counter!("rustfs_remote_lock_channel_evictions_total", "peer" => peer.to_string(), "trigger" => trigger).increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record an RPC failure that did not evict the cached lock channel to `peer`
|
||||||
|
/// because the peer recently served a request or was re-dialed too recently.
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn record_remote_lock_channel_eviction_suppressed(peer: &str, verdict: &'static str) {
|
||||||
|
use metrics::counter;
|
||||||
|
counter!("rustfs_remote_lock_channel_evictions_suppressed_total", "peer" => peer.to_string(), "verdict" => verdict)
|
||||||
|
.increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a timed-out lock RPC that was left running (`detached`) or cancelled
|
||||||
|
/// because the per-peer detached budget was exhausted (`aborted`).
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn record_remote_lock_rpc_detached(op: &'static str, outcome: &'static str) {
|
||||||
|
use metrics::counter;
|
||||||
|
counter!("rustfs_remote_lock_rpc_detached_total", "op" => op, "outcome" => outcome).increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record how a detached lock RPC eventually ended.
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn record_remote_lock_rpc_late_completion(op: &'static str, outcome: &'static str) {
|
||||||
|
use metrics::counter;
|
||||||
|
counter!("rustfs_remote_lock_rpc_late_completions_total", "op" => op, "outcome" => outcome).increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the release of a lock that was granted after its caller timed out.
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn record_remote_lock_late_release(outcome: &'static str) {
|
||||||
|
use metrics::counter;
|
||||||
|
counter!("rustfs_remote_lock_late_releases_total", "outcome" => outcome).increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
/// Record object namespace lock diagnostics being enabled.
|
/// Record object namespace lock diagnostics being enabled.
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
pub fn record_object_lock_diag_enabled(enabled: bool) {
|
pub fn record_object_lock_diag_enabled(enabled: bool) {
|
||||||
@@ -183,6 +228,12 @@ mod tests {
|
|||||||
record_lock_hold_time(Duration::from_millis(100));
|
record_lock_hold_time(Duration::from_millis(100));
|
||||||
record_early_release();
|
record_early_release();
|
||||||
record_contention_event();
|
record_contention_event();
|
||||||
|
record_remote_lock_rpc_timeout("http://peer:9000", "lock");
|
||||||
|
record_remote_lock_channel_eviction("http://peer:9000", "timeout");
|
||||||
|
record_remote_lock_channel_eviction_suppressed("http://peer:9000", "peer_recently_served");
|
||||||
|
record_remote_lock_rpc_detached("lock", "detached");
|
||||||
|
record_remote_lock_rpc_late_completion("lock", "success");
|
||||||
|
record_remote_lock_late_release("released");
|
||||||
});
|
});
|
||||||
|
|
||||||
let emitted: std::collections::HashSet<String> = snapshotter
|
let emitted: std::collections::HashSet<String> = snapshotter
|
||||||
@@ -199,6 +250,12 @@ mod tests {
|
|||||||
"rustfs_lock_hold_time_secs",
|
"rustfs_lock_hold_time_secs",
|
||||||
"rustfs_lock_early_releases",
|
"rustfs_lock_early_releases",
|
||||||
"rustfs_lock_contentions",
|
"rustfs_lock_contentions",
|
||||||
|
"rustfs_remote_lock_rpc_timeouts_total",
|
||||||
|
"rustfs_remote_lock_channel_evictions_total",
|
||||||
|
"rustfs_remote_lock_channel_evictions_suppressed_total",
|
||||||
|
"rustfs_remote_lock_rpc_detached_total",
|
||||||
|
"rustfs_remote_lock_rpc_late_completions_total",
|
||||||
|
"rustfs_remote_lock_late_releases_total",
|
||||||
] {
|
] {
|
||||||
assert!(emitted.contains(expected), "{expected} must be emitted by its record helper");
|
assert!(emitted.contains(expected), "{expected} must be emitted by its record helper");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1233,6 +1233,9 @@ impl LocalKmsClient {
|
|||||||
async fn decode_stored_key(&self, key_id: &str) -> Result<(StoredMasterKey, Vec<u8>)> {
|
async fn decode_stored_key(&self, key_id: &str) -> Result<(StoredMasterKey, Vec<u8>)> {
|
||||||
let key_path = self.master_key_path(key_id)?;
|
let key_path = self.master_key_path(key_id)?;
|
||||||
if !fs::try_exists(&key_path).await? {
|
if !fs::try_exists(&key_path).await? {
|
||||||
|
// Only an accessible key store can establish that a single key is
|
||||||
|
// missing; a directory outage must retain its filesystem error.
|
||||||
|
let _ = fs::read_dir(&self.config.key_dir).await?;
|
||||||
return Err(KmsError::key_not_found(key_id));
|
return Err(KmsError::key_not_found(key_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2095,11 +2098,7 @@ impl KmsBackend for LocalKmsBackend {
|
|||||||
let _write_guard = self.client.lock_key_for_write(key_id).await;
|
let _write_guard = self.client.lock_key_for_write(key_id).await;
|
||||||
|
|
||||||
// First, load the key from disk to get the master key
|
// First, load the key from disk to get the master key
|
||||||
let mut master_key = self
|
let mut master_key = self.client.load_master_key(key_id).await?;
|
||||||
.client
|
|
||||||
.load_master_key(key_id)
|
|
||||||
.await
|
|
||||||
.map_err(|_| KmsError::key_not_found(format!("Key {key_id} not found")))?;
|
|
||||||
|
|
||||||
let (deletion_date_str, deletion_date_dt) = if request.force_immediate.unwrap_or(false) {
|
let (deletion_date_str, deletion_date_dt) = if request.force_immediate.unwrap_or(false) {
|
||||||
// Tombstone first: mark the record Deleted before removing the
|
// Tombstone first: mark the record Deleted before removing the
|
||||||
@@ -2202,11 +2201,7 @@ impl KmsBackend for LocalKmsBackend {
|
|||||||
let _write_guard = self.client.lock_key_for_write(key_id).await;
|
let _write_guard = self.client.lock_key_for_write(key_id).await;
|
||||||
|
|
||||||
// Load the key from disk to get the master key
|
// Load the key from disk to get the master key
|
||||||
let mut master_key = self
|
let mut master_key = self.client.load_master_key(key_id).await?;
|
||||||
.client
|
|
||||||
.load_master_key(key_id)
|
|
||||||
.await
|
|
||||||
.map_err(|_| KmsError::key_not_found(format!("Key {key_id} not found")))?;
|
|
||||||
|
|
||||||
if master_key.status != KeyStatus::PendingDeletion {
|
if master_key.status != KeyStatus::PendingDeletion {
|
||||||
return Err(KmsError::invalid_key_state(format!("Key {key_id} is not pending deletion")));
|
return Err(KmsError::invalid_key_state(format!("Key {key_id} is not pending deletion")));
|
||||||
@@ -2387,6 +2382,76 @@ mod tests {
|
|||||||
(client, temp_dir)
|
(client, temp_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_key_directory_outage_is_io_error_and_recovers_original_key() {
|
||||||
|
let root = TempDir::new().expect("create isolated key store");
|
||||||
|
let key_dir = root.path().join("keys");
|
||||||
|
let unavailable_dir = root.path().join("keys-unavailable");
|
||||||
|
let config = KmsConfig::local(key_dir.clone()).with_insecure_development_defaults();
|
||||||
|
let backend = LocalKmsBackend::new(config).await.expect("start Local KMS");
|
||||||
|
let key_id = "directory-outage-key";
|
||||||
|
backend
|
||||||
|
.create_key(CreateKeyRequest {
|
||||||
|
key_name: Some(key_id.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create the original key");
|
||||||
|
let request = |key_id: &str| GenerateDataKeyRequest {
|
||||||
|
key_id: key_id.to_string(),
|
||||||
|
key_spec: KeySpec::Aes256,
|
||||||
|
encryption_context: HashMap::new(),
|
||||||
|
};
|
||||||
|
let before = backend
|
||||||
|
.generate_data_key(request(key_id))
|
||||||
|
.await
|
||||||
|
.expect("generate a data key before the outage");
|
||||||
|
let missing_key = backend.generate_data_key(request("no-such-key")).await;
|
||||||
|
let key_path = key_dir.join(format!("{key_id}.key"));
|
||||||
|
let original_record = fs::read(&key_path).await.expect("read the original key record");
|
||||||
|
|
||||||
|
fs::rename(&key_dir, &unavailable_dir)
|
||||||
|
.await
|
||||||
|
.expect("make the key directory unavailable");
|
||||||
|
let unavailable = backend.generate_data_key(request(key_id)).await;
|
||||||
|
// Restore before checking the error so the failing regression leaves no
|
||||||
|
// orphaned key store; both paths also belong to the same temporary root.
|
||||||
|
fs::rename(&unavailable_dir, &key_dir)
|
||||||
|
.await
|
||||||
|
.expect("restore the original key directory");
|
||||||
|
|
||||||
|
let after = backend
|
||||||
|
.generate_data_key(request(key_id))
|
||||||
|
.await
|
||||||
|
.expect("generate a data key after directory restoration");
|
||||||
|
for data_key in [&before, &after] {
|
||||||
|
let decrypted = backend
|
||||||
|
.decrypt(DecryptRequest {
|
||||||
|
ciphertext: data_key.ciphertext_blob.clone(),
|
||||||
|
encryption_context: HashMap::new(),
|
||||||
|
grant_tokens: Vec::new(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("the original master key must decrypt both data keys");
|
||||||
|
assert!(
|
||||||
|
decrypted.plaintext == data_key.plaintext_key,
|
||||||
|
"directory restoration must preserve the original key material"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
fs::read(&key_path).await.expect("read the restored key record") == original_record,
|
||||||
|
"reads and recovery must not rewrite the key record"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(missing_key, Err(KmsError::KeyNotFound { key_id }) if key_id == "no-such-key"),
|
||||||
|
"a missing key in a readable directory must remain KeyNotFound"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(unavailable, Err(KmsError::IoError { .. })),
|
||||||
|
"an unavailable key directory must remain an I/O error, not KeyNotFound"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// With the AAD write switch on, the Local backend seals the stored
|
/// With the AAD write switch on, the Local backend seals the stored
|
||||||
/// encryption context into the wrap exactly like KV2: the bound envelope
|
/// encryption context into the wrap exactly like KV2: the bound envelope
|
||||||
/// round-trips, a rewritten stored context fails authentication even with
|
/// round-trips, a rewritten stored context fails authentication even with
|
||||||
@@ -2965,6 +3030,111 @@ mod tests {
|
|||||||
assert!(matches!(error, KmsError::InvalidKey { .. }));
|
assert!(matches!(error, KmsError::InvalidKey { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_key_preserves_directory_io_error() {
|
||||||
|
let (client, temp_dir) = create_dev_mode_client().await;
|
||||||
|
client.create_key("existing-key", "AES_256", None).await.expect("create key");
|
||||||
|
let backend = LocalKmsBackend { client };
|
||||||
|
let offline_dir = TempDir::new().expect("create offline directory");
|
||||||
|
let offline_key_dir = offline_dir.path().join("keys");
|
||||||
|
fs::rename(temp_dir.path(), &offline_key_dir)
|
||||||
|
.await
|
||||||
|
.expect("move key directory offline");
|
||||||
|
fs::write(temp_dir.path(), b"not a directory")
|
||||||
|
.await
|
||||||
|
.expect("replace key directory with a file");
|
||||||
|
|
||||||
|
let error = backend
|
||||||
|
.delete_key(DeleteKeyRequest {
|
||||||
|
key_id: "existing-key".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("unreadable storage must prevent scheduling deletion");
|
||||||
|
|
||||||
|
fs::remove_file(temp_dir.path()).await.expect("remove replacement file");
|
||||||
|
fs::rename(&offline_key_dir, temp_dir.path())
|
||||||
|
.await
|
||||||
|
.expect("restore key directory");
|
||||||
|
assert!(matches!(error, KmsError::IoError { .. }), "got {error:?}");
|
||||||
|
let key = backend
|
||||||
|
.client
|
||||||
|
.load_master_key("existing-key")
|
||||||
|
.await
|
||||||
|
.expect("read retained key");
|
||||||
|
assert_eq!(key.status, KeyStatus::Active, "failed deletion must not mutate key state");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancel_key_deletion_preserves_directory_io_error() {
|
||||||
|
let (client, temp_dir) = create_dev_mode_client().await;
|
||||||
|
client.create_key("existing-key", "AES_256", None).await.expect("create key");
|
||||||
|
let backend = LocalKmsBackend { client };
|
||||||
|
backend
|
||||||
|
.delete_key(DeleteKeyRequest {
|
||||||
|
key_id: "existing-key".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("schedule key deletion");
|
||||||
|
let offline_dir = TempDir::new().expect("create offline directory");
|
||||||
|
let offline_key_dir = offline_dir.path().join("keys");
|
||||||
|
fs::rename(temp_dir.path(), &offline_key_dir)
|
||||||
|
.await
|
||||||
|
.expect("move key directory offline");
|
||||||
|
fs::write(temp_dir.path(), b"not a directory")
|
||||||
|
.await
|
||||||
|
.expect("replace key directory with a file");
|
||||||
|
|
||||||
|
let error = backend
|
||||||
|
.cancel_key_deletion(CancelKeyDeletionRequest {
|
||||||
|
key_id: "existing-key".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect_err("unreadable storage must prevent cancelling deletion");
|
||||||
|
|
||||||
|
fs::remove_file(temp_dir.path()).await.expect("remove replacement file");
|
||||||
|
fs::rename(&offline_key_dir, temp_dir.path())
|
||||||
|
.await
|
||||||
|
.expect("restore key directory");
|
||||||
|
assert!(matches!(error, KmsError::IoError { .. }), "got {error:?}");
|
||||||
|
let key = backend
|
||||||
|
.client
|
||||||
|
.load_master_key("existing-key")
|
||||||
|
.await
|
||||||
|
.expect("read retained key");
|
||||||
|
assert_eq!(
|
||||||
|
key.status,
|
||||||
|
KeyStatus::PendingDeletion,
|
||||||
|
"failed cancellation must retain the deletion state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_load_master_key_directory_replaced_by_file_is_io_error() {
|
||||||
|
let (client, temp_dir) = create_dev_mode_client().await;
|
||||||
|
client.create_key("existing-key", "AES_256", None).await.expect("create key");
|
||||||
|
let offline_dir = TempDir::new().expect("create offline directory");
|
||||||
|
let offline_key_dir = offline_dir.path().join("keys");
|
||||||
|
fs::rename(temp_dir.path(), &offline_key_dir)
|
||||||
|
.await
|
||||||
|
.expect("move key directory offline");
|
||||||
|
fs::write(temp_dir.path(), b"not a directory")
|
||||||
|
.await
|
||||||
|
.expect("replace key directory with a file");
|
||||||
|
|
||||||
|
let error = client
|
||||||
|
.load_master_key("existing-key")
|
||||||
|
.await
|
||||||
|
.expect_err("a file in place of the key directory must fail");
|
||||||
|
|
||||||
|
fs::remove_file(temp_dir.path()).await.expect("remove replacement file");
|
||||||
|
fs::rename(&offline_key_dir, temp_dir.path())
|
||||||
|
.await
|
||||||
|
.expect("restore key directory");
|
||||||
|
assert!(matches!(error, KmsError::IoError { .. }), "got {error:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_load_master_key_accepts_legacy_rfc3339_timestamp() {
|
async fn test_load_master_key_accepts_legacy_rfc3339_timestamp() {
|
||||||
let (client, _temp_dir) = create_dev_mode_client().await;
|
let (client, _temp_dir) = create_dev_mode_client().await;
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
const UNLOCK_RETRY_ATTEMPTS: usize = 3;
|
const UNLOCK_RETRY_ATTEMPTS: usize = 3;
|
||||||
const UNLOCK_RETRY_BACKOFF: Duration = Duration::from_millis(100);
|
const UNLOCK_RETRY_BACKOFF: Duration = Duration::from_millis(100);
|
||||||
|
/// Slow retry schedule for unlocks that survive the fast retry loop. Lock RPC
|
||||||
|
/// timeouts under load are transient (issue #7363); giving up after three
|
||||||
|
/// quick attempts left orphaned entries for the server lease to expire.
|
||||||
|
const DEFERRED_UNLOCK_BACKOFF: [Duration; 5] = [
|
||||||
|
Duration::from_secs(1),
|
||||||
|
Duration::from_secs(2),
|
||||||
|
Duration::from_secs(4),
|
||||||
|
Duration::from_secs(8),
|
||||||
|
Duration::from_secs(16),
|
||||||
|
];
|
||||||
const LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF: Duration = Duration::from_millis(250);
|
const LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF: Duration = Duration::from_millis(250);
|
||||||
const LOCK_ACQUIRE_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(1);
|
const LOCK_ACQUIRE_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(1);
|
||||||
const LOCK_ACQUIRE_SPARE_HEDGES: usize = 1;
|
const LOCK_ACQUIRE_SPARE_HEDGES: usize = 1;
|
||||||
@@ -719,6 +729,31 @@ impl DistributedLock {
|
|||||||
let mut pending = entries;
|
let mut pending = entries;
|
||||||
|
|
||||||
for attempt in 1..=UNLOCK_RETRY_ATTEMPTS {
|
for attempt in 1..=UNLOCK_RETRY_ATTEMPTS {
|
||||||
|
pending = Self::release_pending_once(pending, attempt, context).await;
|
||||||
|
if pending.is_empty() {
|
||||||
|
debug!(attempt, context, "distributed unlock completed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt < UNLOCK_RETRY_ATTEMPTS {
|
||||||
|
tokio::time::sleep(UNLOCK_RETRY_BACKOFF * attempt as u32).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
warn!(
|
||||||
|
remaining = pending.len(),
|
||||||
|
attempts = UNLOCK_RETRY_ATTEMPTS,
|
||||||
|
context,
|
||||||
|
"distributed unlock left unreleased entries after retry; continuing with deferred retries"
|
||||||
|
);
|
||||||
|
Self::release_entries_deferred(pending, context).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_pending_once(
|
||||||
|
pending: Vec<(LockId, Arc<dyn LockClient>)>,
|
||||||
|
attempt: usize,
|
||||||
|
context: &'static str,
|
||||||
|
) -> Vec<(LockId, Arc<dyn LockClient>)> {
|
||||||
let release_results = join_all(pending.into_iter().map(|(lock_id, client)| async move {
|
let release_results = join_all(pending.into_iter().map(|(lock_id, client)| async move {
|
||||||
match client.release(&lock_id).await {
|
match client.release(&lock_id).await {
|
||||||
Ok(true) => None,
|
Ok(true) => None,
|
||||||
@@ -734,22 +769,29 @@ impl DistributedLock {
|
|||||||
}))
|
}))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
pending = release_results.into_iter().flatten().collect();
|
release_results.into_iter().flatten().collect()
|
||||||
if pending.is_empty() {
|
|
||||||
debug!(attempt, context, "distributed unlock completed");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if attempt < UNLOCK_RETRY_ATTEMPTS {
|
/// Bounded slow retries for entries the fast loop could not release. Every
|
||||||
tokio::time::sleep(UNLOCK_RETRY_BACKOFF * attempt as u32).await;
|
/// caller runs on a background task, so waiting here blocks nobody; after
|
||||||
|
/// the schedule is exhausted the server lease reclaims the entry.
|
||||||
|
async fn release_entries_deferred(mut pending: Vec<(LockId, Arc<dyn LockClient>)>, context: &'static str) {
|
||||||
|
let mut attempt = UNLOCK_RETRY_ATTEMPTS;
|
||||||
|
for delay in DEFERRED_UNLOCK_BACKOFF {
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
attempt += 1;
|
||||||
|
pending = Self::release_pending_once(pending, attempt, context).await;
|
||||||
|
if pending.is_empty() {
|
||||||
|
debug!(attempt, context, "deferred distributed unlock converged");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
warn!(
|
warn!(
|
||||||
remaining = pending.len(),
|
remaining = pending.len(),
|
||||||
attempts = UNLOCK_RETRY_ATTEMPTS,
|
attempts = attempt,
|
||||||
context,
|
context,
|
||||||
"distributed unlock left unreleased entries after retry"
|
"distributed unlock abandoned entries after deferred retry; the server lease will expire them"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,7 +837,9 @@ impl DistributedLock {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
Self::release_entries(vec![(lock_id, client.clone())], context).await;
|
// Deferred retries may wait tens of seconds; never hold up the
|
||||||
|
// next late completion behind them.
|
||||||
|
drop(tokio::spawn(Self::release_entries(vec![(lock_id, client.clone())], context)));
|
||||||
}
|
}
|
||||||
Ok((idx, Ok(resp))) => {
|
Ok((idx, Ok(resp))) => {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -1198,8 +1242,8 @@ fn record_lock_held_release(lock_type: LockType) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
DistributedLock, LOCK_ACQUIRE_ATTEMPT_TIMEOUT, LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF, LockAcquireFailureKind,
|
DEFERRED_UNLOCK_BACKOFF, DistributedLock, LOCK_ACQUIRE_ATTEMPT_TIMEOUT, LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF,
|
||||||
LockLostSignal, is_remote_lock_rpc_failure, should_warn_lock_failure,
|
LockAcquireFailureKind, LockLostSignal, UNLOCK_RETRY_ATTEMPTS, is_remote_lock_rpc_failure, should_warn_lock_failure,
|
||||||
};
|
};
|
||||||
use crate::{LockError, LockId, LockInfo, LockRequest, LockResponse, LockStats, LockType, ObjectKey, client::LockClient};
|
use crate::{LockError, LockId, LockInfo, LockRequest, LockResponse, LockStats, LockType, ObjectKey, client::LockClient};
|
||||||
use rand::{SeedableRng as _, TryRng, rngs::StdRng};
|
use rand::{SeedableRng as _, TryRng, rngs::StdRng};
|
||||||
@@ -1692,6 +1736,94 @@ mod tests {
|
|||||||
drop(guard);
|
drop(guard);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fails `release` a fixed number of times before succeeding, mimicking a
|
||||||
|
/// peer whose lock RPCs time out under load and then recover.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct FlakyReleaseClient {
|
||||||
|
failures_left: AtomicUsize,
|
||||||
|
release_calls: Arc<AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl LockClient for FlakyReleaseClient {
|
||||||
|
async fn acquire_lock(&self, _request: &LockRequest) -> crate::Result<LockResponse> {
|
||||||
|
Ok(LockResponse::failure("unused", Duration::ZERO))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release(&self, _lock_id: &LockId) -> crate::Result<bool> {
|
||||||
|
self.release_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if self.failures_left.load(Ordering::SeqCst) > 0 {
|
||||||
|
self.failures_left.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
return Err(LockError::internal("remote lock rpc timed out: release"));
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh(&self, _lock_id: &LockId) -> crate::Result<bool> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn force_release(&self, _lock_id: &LockId) -> crate::Result<bool> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_status(&self, _lock_id: &LockId) -> crate::Result<Option<LockInfo>> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_stats(&self) -> crate::Result<LockStats> {
|
||||||
|
Ok(LockStats::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close(&self) -> crate::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_online(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_local(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn release_entries_keeps_retrying_transient_failures_after_the_fast_loop() {
|
||||||
|
let release_calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let client: Arc<dyn LockClient> = Arc::new(FlakyReleaseClient {
|
||||||
|
failures_left: AtomicUsize::new(UNLOCK_RETRY_ATTEMPTS + 2),
|
||||||
|
release_calls: release_calls.clone(),
|
||||||
|
});
|
||||||
|
let lock_id = LockId::new_unique(&ObjectKey::new("bucket", "object"));
|
||||||
|
|
||||||
|
DistributedLock::release_entries(vec![(lock_id, client)], "test_deferred_unlock").await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
release_calls.load(Ordering::SeqCst),
|
||||||
|
UNLOCK_RETRY_ATTEMPTS + 3,
|
||||||
|
"two deferred attempts fail, the third releases the entry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn release_entries_gives_up_after_the_deferred_schedule() {
|
||||||
|
let release_calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let client: Arc<dyn LockClient> = Arc::new(FlakyReleaseClient {
|
||||||
|
failures_left: AtomicUsize::new(usize::MAX),
|
||||||
|
release_calls: release_calls.clone(),
|
||||||
|
});
|
||||||
|
let lock_id = LockId::new_unique(&ObjectKey::new("bucket", "object"));
|
||||||
|
|
||||||
|
DistributedLock::release_entries(vec![(lock_id, client)], "test_deferred_unlock_abandoned").await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
release_calls.load(Ordering::SeqCst),
|
||||||
|
UNLOCK_RETRY_ATTEMPTS + DEFERRED_UNLOCK_BACKOFF.len(),
|
||||||
|
"the retry budget is bounded; the server lease reclaims what remains"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ResponseClient {
|
struct ResponseClient {
|
||||||
response: LockResponse,
|
response: LockResponse,
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ use super::ScannerCycleOutcome;
|
|||||||
use crate::data_usage_define::DataUsageCacheRevision;
|
use crate::data_usage_define::DataUsageCacheRevision;
|
||||||
use crate::storage_api::ScannerStorage;
|
use crate::storage_api::ScannerStorage;
|
||||||
use crate::storage_api::owner::ObjectIO as _;
|
use crate::storage_api::owner::ObjectIO as _;
|
||||||
use crate::{
|
use crate::{BUCKET_META_PREFIX, ECStore, EcstoreError, RUSTFS_META_BUCKET, ScannerObjectOptions, SetDisks};
|
||||||
BUCKET_META_PREFIX, ECStore, EcstoreError, RUSTFS_META_BUCKET, ScannerObjectOptions, SetDisks, save_config_with_preconditions,
|
|
||||||
};
|
|
||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -1195,13 +1193,14 @@ where
|
|||||||
};
|
};
|
||||||
let revision = revisions.get(&id).cloned();
|
let revision = revisions.get(&id).cloned();
|
||||||
let data = data.clone();
|
let data = data.clone();
|
||||||
|
let storeapi = storeapi.clone();
|
||||||
async move {
|
async move {
|
||||||
let Some(revision) = revision else {
|
let Some(revision) = revision else {
|
||||||
return (id, Err("replica revision is unavailable".to_string()));
|
return (id, Err("replica revision is unavailable".to_string()));
|
||||||
};
|
};
|
||||||
let result = save_config_with_preconditions(set, SCANNER_PAUSE_BACKLOG_PATH.as_str(), data, revision.preconditions())
|
let result = storeapi
|
||||||
|
.save_scanner_pause_backlog_replica(id.pool_index, id.set_index, data, revision.preconditions())
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|err| err.to_string());
|
.map_err(|err| err.to_string());
|
||||||
(id, result)
|
(id, result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,49 @@ fn run_data_scanner_keeps_its_two_argument_api() {
|
|||||||
assert_run_data_scanner_signature(run_data_scanner);
|
assert_run_data_scanner_signature(run_data_scanner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn native_backlog_replica_writes_preserve_cas_across_writer_restart() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store_with_pool_count(false, 2).await;
|
||||||
|
let now = scanner_pause_backlog_now();
|
||||||
|
let mut stale = ScannerPauseBacklogController::claim(store.clone(), now)
|
||||||
|
.await
|
||||||
|
.expect("the original scanner writer must publish to both pools");
|
||||||
|
let original = scanner_pause_backlog_status(store.clone()).await;
|
||||||
|
assert!(original.durable);
|
||||||
|
assert_eq!(original.healthy_replicas, 2);
|
||||||
|
let restarted = restart_scanner_cycle_store_from(&store).await;
|
||||||
|
let _replacement = ScannerPauseBacklogController::claim(restarted.clone(), now.saturating_add(1))
|
||||||
|
.await
|
||||||
|
.expect("the restarted scanner must claim the surviving native replicas");
|
||||||
|
let claimed = scanner_pause_backlog_status(restarted.clone()).await;
|
||||||
|
assert!(claimed.writer_epoch > original.writer_epoch);
|
||||||
|
assert_eq!(claimed.healthy_replicas, 2);
|
||||||
|
|
||||||
|
stale
|
||||||
|
.observe(ScannerPauseBacklogObservation {
|
||||||
|
now_unix_secs: now.saturating_add(2),
|
||||||
|
paused: true,
|
||||||
|
movement_generation: store.scanner_data_movement_generation().saturating_add(1),
|
||||||
|
movement_work_items: 1,
|
||||||
|
pause_started_at_unix_secs: now.saturating_add(2),
|
||||||
|
dirty_usage_buckets: 0,
|
||||||
|
discovered_expiry_items: 0,
|
||||||
|
discovered_transition_items: 0,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let retained = scanner_pause_backlog_status(restarted.clone()).await;
|
||||||
|
assert_eq!(retained.writer_epoch, claimed.writer_epoch);
|
||||||
|
assert_eq!(retained.generation, claimed.generation);
|
||||||
|
assert_eq!(retained.phase, ScannerPauseBacklogPhase::Idle);
|
||||||
|
assert_eq!(retained.healthy_replicas, 2);
|
||||||
|
assert_eq!(retained.stale_or_unavailable_replicas, 0);
|
||||||
|
let _recovered = ScannerPauseBacklogController::claim(restarted.clone(), now.saturating_add(3))
|
||||||
|
.await
|
||||||
|
.expect("a fresh writer must still recover after the stale CAS failure");
|
||||||
|
assert!(scanner_pause_backlog_status(restarted).await.error.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn restarted_main_loop_completes_durable_pause_backlog_catch_up() {
|
async fn restarted_main_loop_completes_durable_pause_backlog_catch_up() {
|
||||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||||
|
|||||||
@@ -355,6 +355,13 @@ pub(crate) trait ScannerStorage:
|
|||||||
async fn list_bucket_for_scanner(&self, opts: &storage_contracts::BucketOptions) -> EcstoreResultType<ScannerBucketListing>;
|
async fn list_bucket_for_scanner(&self, opts: &storage_contracts::BucketOptions) -> EcstoreResultType<ScannerBucketListing>;
|
||||||
fn all_set_disks(&self) -> Vec<Arc<EcstoreSetDisks>>;
|
fn all_set_disks(&self) -> Vec<Arc<EcstoreSetDisks>>;
|
||||||
async fn scanner_pause_backlog_writable_set_disks(&self) -> Vec<Arc<EcstoreSetDisks>>;
|
async fn scanner_pause_backlog_writable_set_disks(&self) -> Vec<Arc<EcstoreSetDisks>>;
|
||||||
|
async fn save_scanner_pause_backlog_replica(
|
||||||
|
self: Arc<Self>,
|
||||||
|
pool_index: usize,
|
||||||
|
set_index: usize,
|
||||||
|
data: Vec<u8>,
|
||||||
|
preconditions: storage_contracts::HTTPPreconditions,
|
||||||
|
) -> EcstoreResultType<()>;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn scanner_observed_probe_store_key(&self) -> usize;
|
fn scanner_observed_probe_store_key(&self) -> usize;
|
||||||
}
|
}
|
||||||
@@ -417,6 +424,18 @@ impl ScannerStorage for EcstoreStore {
|
|||||||
EcstoreStore::scanner_pause_backlog_writable_set_disks(self).await
|
EcstoreStore::scanner_pause_backlog_writable_set_disks(self).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn save_scanner_pause_backlog_replica(
|
||||||
|
self: Arc<Self>,
|
||||||
|
pool_index: usize,
|
||||||
|
set_index: usize,
|
||||||
|
data: Vec<u8>,
|
||||||
|
preconditions: storage_contracts::HTTPPreconditions,
|
||||||
|
) -> EcstoreResultType<()> {
|
||||||
|
EcstoreStore::save_scanner_pause_backlog_replica(&self, pool_index, set_index, data, preconditions)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn scanner_observed_probe_store_key(&self) -> usize {
|
fn scanner_observed_probe_store_key(&self) -> usize {
|
||||||
std::ptr::from_ref(self).cast::<()>() as usize
|
std::ptr::from_ref(self).cast::<()>() as usize
|
||||||
@@ -577,6 +596,20 @@ mod tests {
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn save_scanner_pause_backlog_replica(
|
||||||
|
self: Arc<Self>,
|
||||||
|
_pool_index: usize,
|
||||||
|
_set_index: usize,
|
||||||
|
_data: Vec<u8>,
|
||||||
|
_preconditions: storage_contracts::HTTPPreconditions,
|
||||||
|
) -> EcstoreResultType<()> {
|
||||||
|
Err(EcstoreErrorType::InvalidArgument(
|
||||||
|
"scanner-backlog".into(),
|
||||||
|
"replica".into(),
|
||||||
|
"fake storage has no writable replicas".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn scanner_observed_probe_store_key(&self) -> usize {
|
fn scanner_observed_probe_store_key(&self) -> usize {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
RustFS supports queued multi-pool decommission start requests on multi-pool deployments. The admin handler accepts the MinIO-compatible request shape, including comma-separated pool targets. An empty target list is rejected; single-pool deployments reject decommission because there is no destination pool; on multi-pool deployments one or more valid target pools are accepted as a single queued operation.
|
RustFS supports queued multi-pool decommission start requests on multi-pool deployments. The admin handler accepts the MinIO-compatible request shape, including comma-separated pool targets. An empty target list is rejected; single-pool deployments reject decommission because there is no destination pool; on multi-pool deployments one or more valid target pools are accepted as a single queued operation.
|
||||||
|
|
||||||
|
Deterministic request rejections (unsupported single-pool operations, missing or terminal targets, an empty start request, removing the last active pool, and clearing unresolved recovery entries) retain the typed `InvalidArgument` error and its actionable reason. Active-operation conflicts retain their existing `InvalidRequest` or `OperationAborted` contract. Storage, quorum, and fleet-proof failures are not converted into argument errors.
|
||||||
|
|
||||||
### Request Semantics
|
### Request Semantics
|
||||||
|
|
||||||
`POST /v3/pools/decommission` with comma-separated pool targets is a queue submission:
|
`POST /v3/pools/decommission` with comma-separated pool targets is a queue submission:
|
||||||
@@ -15,7 +17,8 @@ RustFS supports queued multi-pool decommission start requests on multi-pool depl
|
|||||||
- reject duplicate target pools in the same request;
|
- reject duplicate target pools in the same request;
|
||||||
- reject active or queued target pools;
|
- reject active or queued target pools;
|
||||||
- reject completed decommission targets, because completion means the pool can be removed from the deployment configuration;
|
- reject completed decommission targets, because completion means the pool can be removed from the deployment configuration;
|
||||||
- allow failed or canceled targets to be retried;
|
- require failed or canceled targets to be cleared before restarting, except
|
||||||
|
when unresolved listing entries require an explicit recovery retry;
|
||||||
- persist queued metadata before starting workers;
|
- persist queued metadata before starting workers;
|
||||||
- start only the local-leader prefix of the queue on the receiving node.
|
- start only the local-leader prefix of the queue on the receiving node.
|
||||||
|
|
||||||
@@ -56,6 +59,29 @@ Cancel separates active and queued behavior:
|
|||||||
|
|
||||||
Cancel requests can be accepted on non-leader nodes as remote cancel intent; the leader observes the pending cancel and applies it to the active worker.
|
Cancel requests can be accepted on non-leader nodes as remote cancel intent; the leader observes the pending cancel and applies it to the active worker.
|
||||||
|
|
||||||
|
`queuedBuckets` retains the unfinished work inventory after cancellation. It is
|
||||||
|
not evidence of active scheduling: `queued` is false and `startTime` is absent.
|
||||||
|
Operators and tests must inspect the terminal flags, peer state and progress
|
||||||
|
stability instead of requiring the historical inventory to be empty. A normal
|
||||||
|
canceled entry remains blocked until clear; unresolved listing entries instead
|
||||||
|
retain the explicit retry path that can re-observe or resolve those entries.
|
||||||
|
|
||||||
|
### Publication On Retiring Pools
|
||||||
|
|
||||||
|
Ordinary publication rechecks the selected pool against the durable pool metadata under its existing read fence. Selection may have happened before retirement, or on a node whose local pool state has not been refreshed. A staged new PUT must return `SlowDown` instead of publishing into a pool that has since become suspended. The staged input is not automatically replayed into another pool.
|
||||||
|
|
||||||
|
Running, queued, failed, canceled, and completed decommission states exclude the source from new ordinary publication, including new multipart uploads. Previously created multipart uploads retain their drain path while the source remains non-terminal; terminal source states reject further multipart publication. Failed and canceled entries become writable for new ordinary publication only after an allowed clear operation removes that state. This check does not change repair admission or the separate fence for operations that only release capacity.
|
||||||
|
|
||||||
|
For mixed batch deletes, only the pools selected to receive new delete markers are publication targets. Exact-version deletions on other pools remain protected by the same pool metadata read fence, without treating the retiring source or an unrelated reserved target as a destination for those markers.
|
||||||
|
|
||||||
|
### Shared Capacity On Healthy Targets
|
||||||
|
|
||||||
|
Ordinary publication into a healthy target is not rejected solely because that pool has an active decommission reservation. This follows the MinIO decommission write-routing contract: the retiring source stops accepting new writes, while the remaining pools share physical capacity between foreground requests and migration. A reservation remains a migration budget and recovery ledger, not an exclusive foreground-write quota. Repair retains its existing conservative reservation admission policy.
|
||||||
|
|
||||||
|
The existing durable metadata fence, valid active reservation checks, owner/mutation identity, pending-intent recovery, target write quorum and source-cleanup preflight remain required. Foreground writes do not acquire the mover's target I/O lock or settle its pending intent. Capacity estimates, including filesystem free-space deltas observed during migration, may include concurrent unrelated I/O; they are not proof of exclusive space or of a committed target object. Actual write failures and identity/quorum checks remain authoritative. Space loss can stop migration with the source retained, including after a target copy has committed. Capacity exhaustion can also fail foreground writes; this policy does not guarantee foreground priority or success. RustFS retains its existing capacity-blocked state and recovery behavior rather than changing terminal-state or retry semantics here.
|
||||||
|
|
||||||
|
The native regression overlaps public PUT and multipart create/part replacement/complete/abort operations with a paused target rename on another node context, checks that foreground publication leaves the pending migration ledger unchanged, and then checks both sufficient-capacity cleanup and injected capacity loss with byte-for-byte retained source and target data. Mixed batch deletion covers marker publication on both reserved and unreserved healthy targets together with exact-version removal on the retiring source. Capacity is injected deterministically; the object and metadata operations use real temporary disks, not a physical disk-exhaustion test.
|
||||||
|
|
||||||
### Status Response Shape
|
### Status Response Shape
|
||||||
|
|
||||||
`GET /v3/pools/list` and `GET /v3/pools/status?pool=...` expose per-pool machine-readable decommission state. The `status` field can report `active`, `running`, `queued`, `complete`, `failed`, or `canceled`.
|
`GET /v3/pools/list` and `GET /v3/pools/status?pool=...` expose per-pool machine-readable decommission state. The `status` field can report `active`, `running`, `queued`, `complete`, `failed`, or `canceled`.
|
||||||
@@ -70,6 +96,40 @@ When decommission metadata is present, `decommissionInfo` includes:
|
|||||||
|
|
||||||
This makes queued pools and stalled metadata visible without requiring operators to inspect pool metadata files directly.
|
This makes queued pools and stalled metadata visible without requiring operators to inspect pool metadata files directly.
|
||||||
|
|
||||||
|
### Scanner Backlog Replica Conflicts
|
||||||
|
|
||||||
|
Native scanner CAS publication uses the storage-owned replica write path, not a
|
||||||
|
direct write to a set selected from node-local pool state. On multi-pool stores,
|
||||||
|
the fixed object namespace precedes the durable pool metadata read fence and
|
||||||
|
the actual replica-set namespace. Admission excludes running, queued and
|
||||||
|
completed sources; failed/canceled sources retain the scanner's existing
|
||||||
|
membership-repair behavior. Missing pool metadata does not authorize a replica.
|
||||||
|
Healthy reserved targets remain writable under the shared-capacity contract.
|
||||||
|
|
||||||
|
The replica writer retains both outer guards in an owned task and waits for the
|
||||||
|
rename tail, including when its caller is canceled. Lock-loss signals remain
|
||||||
|
attached to the set commit. This does not require every disk to succeed or alter
|
||||||
|
write quorum/fsync policy. Replica writes for this one internal key serialize
|
||||||
|
through its fixed namespace; ordinary PUT/GET do not enter this writer. The
|
||||||
|
scanner still requires CAS success on every surviving set before acknowledging
|
||||||
|
a ledger generation, and retains its partial-commit recovery protocol.
|
||||||
|
Older scanner writers still use direct set CAS; this source-publication fence
|
||||||
|
requires updating every scanner-capable node. No new on-disk or wire format is
|
||||||
|
introduced.
|
||||||
|
|
||||||
|
The exact internal object `.rustfs.sys/buckets/.scanner-pause-backlog.json` is
|
||||||
|
published with CAS to surviving sets. Its replica-local object modification
|
||||||
|
times are not scanner ledger generations. A cross-pool migration receiving
|
||||||
|
`PreconditionFailed` can therefore accept an existing unversioned replica with
|
||||||
|
an identical known ETag, payload identity and metadata even when its write time
|
||||||
|
differs. This exception does not apply to other keys, versioned objects, delete
|
||||||
|
markers, missing identity evidence, or a different older ledger payload.
|
||||||
|
|
||||||
|
The source is still revalidated under its mutation fence before migration.
|
||||||
|
Existing capacity-owner and mutation checks reconcile the pending intent before
|
||||||
|
source cleanup; the replica exception does not clear an unknown intent, rewrite
|
||||||
|
the native target, or change the scanner's committed-membership selection.
|
||||||
|
|
||||||
## MinIO Divergence Decisions
|
## MinIO Divergence Decisions
|
||||||
|
|
||||||
Behavior that is close to MinIO but not byte-for-byte identical. Changing either decision requires an operator compatibility note and updated characterization tests.
|
Behavior that is close to MinIO but not byte-for-byte identical. Changing either decision requires an operator compatibility note and updated characterization tests.
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Lock RPC storm protection
|
||||||
|
|
||||||
|
**Use this when:** a slow lock endpoint turns into cluster-wide `Remote lock RPC timed out`, `Evicting cached remote lock connection`, and `GOAWAY too_many_resets` log floods, or when you tune how the remote lock client reacts to per-request deadlines (rustfs#7363).
|
||||||
|
|
||||||
|
## What the client does on a failed lock RPC
|
||||||
|
|
||||||
|
Every remote lock call (`lock`, `lock_batch`, `release`, `refresh`, `force_release`, `check_status`, and the readiness `ping`) runs under the deadline from `RUSTFS_OBJECT_LOCK_RPC_TIMEOUT_MS` (readiness uses `RUSTFS_HEALTH_LOCK_ONLINE_TIMEOUT_MS`). A deadline only says that one stream was slow; it says nothing about the shared HTTP/2 channel it ran on. The client therefore keeps a small per-peer history and decides per failure:
|
||||||
|
|
||||||
|
| Failure | Verdict | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Deadline expired, peer completed any lock RPC within two deadlines | `peer_recently_served` | Channel kept. The peer is slow, not gone. |
|
||||||
|
| Deadline expired, peer quiet for longer than two deadlines | `evict` | Cached channel evicted once, then the next request re-dials. |
|
||||||
|
| Any failure while the last eviction is younger than the cooldown | `cooling_down` | Channel kept so the fresh dial can prove itself; no re-dial burst. |
|
||||||
|
| Transport failure (refused, reset, `GOAWAY`) outside the cooldown | `evict` | Cached channel evicted once. |
|
||||||
|
|
||||||
|
A timed-out request is no longer cancelled. Cancelling sends `RST_STREAM`, and enough resets against a server that is slow to accept streams make it answer `GOAWAY too_many_resets`, which kills every stream on the connection and restarts the loop. Instead the stream is detached: it keeps running in the background (bounded by the internode RPC timeout), the caller still gets its timeout error, and if the peer grants a lock after the caller gave up the client releases it immediately instead of leaving an orphan for the lease to expire.
|
||||||
|
|
||||||
|
Unlocks that fail three quick retries no longer stop there. The background task continues with a deferred schedule (1s, 2s, 4s, 8s, 16s) before it gives up and leaves the entry to the server-side lease.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Environment variable | Default | Behavior |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `RUSTFS_OBJECT_LOCK_RPC_TIMEOUT_MS` | `3000` | Per-request deadline for remote lock RPCs. |
|
||||||
|
| `RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS` | `5000` | Minimum interval between channel evictions per peer. `0` restores eviction on every qualifying failure. |
|
||||||
|
| `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT` | `256` | How many timed-out lock RPCs per peer may keep running in the background. Beyond the budget a timed-out stream is cancelled as before. |
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
| Metric | Labels | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `rustfs_remote_lock_rpc_timeouts_total` | `peer`, `op` | Remote lock RPCs that exceeded their deadline. |
|
||||||
|
| `rustfs_remote_lock_channel_evictions_total` | `peer`, `trigger` | Cached channel evictions; `trigger` is `timeout` or `transport`. |
|
||||||
|
| `rustfs_remote_lock_channel_evictions_suppressed_total` | `peer`, `verdict` | Failures that kept the channel; `verdict` is `peer_recently_served` or `cooling_down`. |
|
||||||
|
| `rustfs_remote_lock_rpc_detached_total` | `op`, `outcome` | Timed-out RPCs left running (`detached`) or cancelled for budget (`aborted`). |
|
||||||
|
| `rustfs_remote_lock_rpc_late_completions_total` | `op`, `outcome` | How detached RPCs ended (`success`, `error`, `join_error`). |
|
||||||
|
| `rustfs_remote_lock_late_releases_total` | `outcome` | Releases of locks granted after their caller timed out (`released`, `partial`, `failed`). |
|
||||||
|
|
||||||
|
## Reading an incident
|
||||||
|
|
||||||
|
A healthy-but-slow endpoint now shows a rising `rustfs_remote_lock_rpc_timeouts_total{peer}` with `evictions_suppressed_total{verdict="peer_recently_served"}` and at most one eviction per cooldown. A dead endpoint shows `evictions_total{trigger="transport"}` once per cooldown while the connection re-dials. Sustained `GOAWAY too_many_resets` in the server log means detached streams are being cancelled, which only happens once `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT` is exhausted; raise the limit or fix the slow lock service (`http_request_inflight_slow` on `NodeService/Lock` names the endpoint).
|
||||||
|
|
||||||
|
The client code lives in `crates/ecstore/src/cluster/rpc/remote_locker.rs`; the deferred unlock schedule lives in `crates/lock/src/distributed_lock.rs`.
|
||||||
@@ -43,17 +43,18 @@ Promotion rule: never promote a report-only lane to required from one green run.
|
|||||||
| PR, non-doc change | `End-to-End Tests` | `ci.yml` `e2e-tests` | Report-only | `cargo nextest run --profile e2e-smoke -p e2e_test`, then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>`; membership guards `scripts/check_test_wiring.py --check-profile e2e-smoke <listing.json>` and `scripts/check_security_smoke_count.sh check <listing.json>` |
|
| PR, non-doc change | `End-to-End Tests` | `ci.yml` `e2e-tests` | Report-only | `cargo nextest run --profile e2e-smoke -p e2e_test`, then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>`; membership guards `scripts/check_test_wiring.py --check-profile e2e-smoke <listing.json>` and `scripts/check_security_smoke_count.sh check <listing.json>` |
|
||||||
| PR, non-doc change | `S3 Implemented Tests` | `ci.yml` `s3-implemented-tests` | Report-only | build `rustfs`, then `scripts/s3-tests/run.sh` with the job's `DEPLOY_MODE` / `TEST_MODE` / `MAXFAIL` env |
|
| PR, non-doc change | `S3 Implemented Tests` | `ci.yml` `s3-implemented-tests` | Report-only | build `rustfs`, then `scripts/s3-tests/run.sh` with the job's `DEPLOY_MODE` / `TEST_MODE` / `MAXFAIL` env |
|
||||||
| PR, non-doc change | `S3 Lifecycle Behavior Tests` | `ci.yml` `s3-lifecycle-behavior-tests` | Report-only | `scripts/s3-tests/run.sh` with the job's accelerated-scanner env |
|
| PR, non-doc change | `S3 Lifecycle Behavior Tests` | `ci.yml` `s3-lifecycle-behavior-tests` | Report-only | `scripts/s3-tests/run.sh` with the job's accelerated-scanner env |
|
||||||
| PR touching `paths` in `audit.yml` | `Cargo Deny`, `Workflow Pin Report`, `Dependency Review` | `audit.yml` `cargo-deny`, `workflow-pin-report`, `dependency-review` | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
| PR to `main` or `release` touching `paths` in `audit.yml` | `Cargo Deny`, `Workflow Pin Report`, `Dependency Review` | `audit.yml` `cargo-deny`, `workflow-pin-report`, `dependency-review` | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
||||||
|
| Push to `main` or `release` touching `paths` in `audit.yml` | `Cargo Deny`, `Workflow Pin Report` | `audit.yml` `cargo-deny`, `workflow-pin-report` | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
||||||
| PR touching `paths` in `architecture-migration-rules.yml` | `Architecture Migration Rules` | `architecture-migration-rules.yml` `architecture-migration-rules` | Report-only | `scripts/check_architecture_migration_rules.sh` |
|
| PR touching `paths` in `architecture-migration-rules.yml` | `Architecture Migration Rules` | `architecture-migration-rules.yml` `architecture-migration-rules` | Report-only | `scripts/check_architecture_migration_rules.sh` |
|
||||||
| PR touching `paths` in `nix.yml` | `Nix Build & Check` | `nix.yml` `nix-validation` | Report-only | `nix flake check` |
|
| PR touching `paths` in `nix.yml` | `Nix Build & Check` | `nix.yml` `nix-validation` | Report-only | `nix flake check` |
|
||||||
| PR touching `paths` in `fuzz.yml` | `Build Fuzz Harness`, `Smoke / <target>` | `fuzz.yml` `fuzz-build`, `pr-fuzz-smoke` | Report-only | `MAX_TOTAL_TIME=60 ./scripts/fuzz/run.sh` |
|
| PR touching `paths` in `fuzz.yml` | `Build Fuzz Harness`, `Smoke / <target>` | `fuzz.yml` `fuzz-build`, `pr-fuzz-smoke` | Report-only | `MAX_TOTAL_TIME=60 ./scripts/fuzz/run.sh` |
|
||||||
| PR touching `paths` in `windows-filesystem.yml` | `Rename Safety` | `windows-filesystem.yml` `rename-safety` | Report-only | the `cargo test -p rustfs-ecstore --lib <filter>` commands in the job, on Windows |
|
| PR touching `paths` in `windows-filesystem.yml` | `Rename Safety` | `windows-filesystem.yml` `rename-safety` | Report-only | the `cargo test -p rustfs-ecstore --lib <filter>` commands in the job, on Windows |
|
||||||
| PR touching `paths` in `coverage.yml` | `Workspace line coverage` | `coverage.yml` `coverage` | Report-only | `make coverage`; `python3 scripts/check_security_coverage.py target/llvm-cov/coverage.json` |
|
| PR touching `paths` in `coverage.yml` | `Workspace line coverage` | `coverage.yml` `coverage` | Report-only | `make coverage`; `python3 scripts/check_security_coverage.py target/llvm-cov/coverage.json` |
|
||||||
| PR touching `paths` in `e2e-upgrade.yml` | `Direct upgrade from the previous release`, `Mixed-version rolling upgrade from the previous release`, `Bucket configuration survives the upgrade`, `Rollback reads current bucket metadata` | `e2e-upgrade.yml` `upgrade` matrix | Report-only | the `cargo test --locked -p e2e_test` command in the job with `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous release (`UPGRADE_SOURCE_VERSION`) |
|
| PR touching `paths` in `e2e-upgrade.yml` | `Direct upgrade from the previous release`, `Mixed-version rolling upgrade from the previous release`, `Bucket configuration survives the upgrade`, `Rollback reads current bucket metadata`, `ODM configuration recovery after rc.5 rollback`, `Multipart layouts survive the rc.5 upgrade`, `rc.5 multipart replication baseline` | `e2e-upgrade.yml` `upgrade` matrix | Report-only | the `cargo test --locked -p e2e_test` command in the job with `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous release (`UPGRADE_SOURCE_VERSION`) |
|
||||||
| PR touching `paths` in `oidc-keycloak.yml` | `OIDC Keycloak live gate` | `oidc-keycloak.yml` `oidc-keycloak-live` | Report-only | `cargo build --locked -p rustfs --bin rustfs`, then `bash scripts/test/oidc_keycloak_live.sh ./target/debug/rustfs` |
|
| PR touching `paths` in `oidc-keycloak.yml` | `OIDC Keycloak live gate` | `oidc-keycloak.yml` `oidc-keycloak-live` | Report-only | `cargo build --locked -p rustfs --bin rustfs`, then `bash scripts/test/oidc_keycloak_live.sh ./target/debug/rustfs` |
|
||||||
| PR touching `paths` in `targets-integration.yml` | `PostgreSQL, MySQL, AMQP, and NATS` | `targets-integration.yml` `targets-live` | Report-only | start the containers as in the job, export the `RUSTFS_TEST_*` DSNs, then the job's `cargo test --locked -p rustfs-targets --test <name> -- --ignored --test-threads=1` commands |
|
| PR touching `paths` in `targets-integration.yml` | `PostgreSQL, MySQL, AMQP, and NATS` | `targets-integration.yml` `targets-live` | Report-only | start the containers as in the job, export the `RUSTFS_TEST_*` DSNs, then the job's `cargo test --locked -p rustfs-targets --test <name> -- --ignored --test-threads=1` commands |
|
||||||
| PR limited to main-CI-excluded paths | `Quick Checks`, `Test and Lint` | `ci-docs-only.yml` `quick-checks`, `test-and-lint` | Required | `git diff --check`; `make doc-paths-check`; `scripts/check_no_planning_docs.sh` |
|
| PR limited to main-CI-excluded paths | `Quick Checks`, `Test and Lint` | `ci-docs-only.yml` `quick-checks`, `test-and-lint` | Required | `git diff --check`; `make doc-paths-check`; `scripts/check_no_planning_docs.sh` |
|
||||||
| `merge_group`; push to `main` | `End-to-End Tests (full merge gate)` | `ci.yml` `e2e-full` | Report-only | `cargo nextest run --profile e2e-full -p e2e_test` |
|
| `merge_group`; push to `main` or `release` | `End-to-End Tests (full merge gate)` | `ci.yml` `e2e-full` | Report-only | `cargo nextest run --profile e2e-full -p e2e_test` |
|
||||||
|
|
||||||
e2e filters live in `.config/nextest.toml`; extend a profile instead of adding a second selector. Before a profile runs, `scripts/check_test_wiring.py` compares its listing to the committed digest in `.config/e2e-<profile>-selection.txt`, so a silent test drop fails closed.
|
e2e filters live in `.config/nextest.toml`; extend a profile instead of adding a second selector. Before a profile runs, `scripts/check_test_wiring.py` compares its listing to the committed digest in `.config/e2e-<profile>-selection.txt`, so a silent test drop fails closed.
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ cost. `data_usage_test` runs in the PR `e2e-smoke` lane so changes that affect
|
|||||||
authoritative scanner usage publication, quota-visible usage, or admin usage
|
authoritative scanner usage publication, quota-visible usage, or admin usage
|
||||||
snapshots get an end-to-end signal before merge review. `heal_erasure_disk_rebuild_test`
|
snapshots get an end-to-end signal before merge review. `heal_erasure_disk_rebuild_test`
|
||||||
runs in `e2e-full` so core erasure heal rebuild regressions are caught no later
|
runs in `e2e-full` so core erasure heal rebuild regressions are caught no later
|
||||||
than the merge queue or `main` push lane; it also remains in `e2e-nightly` with
|
than the merge queue or `main`/`release` push lane; it also remains in `e2e-nightly` with
|
||||||
the serialized cluster fault-domain suites for scheduled soak signal.
|
the serialized cluster fault-domain suites for scheduled soak signal.
|
||||||
|
|
||||||
## Scheduled validation
|
## Scheduled validation
|
||||||
@@ -85,7 +86,7 @@ Scheduled lanes never block a PR. Their workflow-local gate fails the run, sched
|
|||||||
| `mint.yml` (weekly) | `mint` | report-only by design; per-suite PASS/FAIL/NA and raw `log.json` | yes | pinned Docker sequence in the workflow |
|
| `mint.yml` (weekly) | `mint` | report-only by design; per-suite PASS/FAIL/NA and raw `log.json` | yes | pinned Docker sequence in the workflow |
|
||||||
| `coverage.yml` (weekly) | `coverage` | report-only trend; lcov and JSON artifact | yes | `make coverage` |
|
| `coverage.yml` (weekly) | `coverage` | report-only trend; lcov and JSON artifact | yes | `make coverage` |
|
||||||
| `runner-hygiene.yml` (monthly) | `check-ephemerality` | runner ephemerality | yes | dispatch |
|
| `runner-hygiene.yml` (monthly) | `check-ephemerality` | runner ephemerality | yes | dispatch |
|
||||||
| `e2e-upgrade.yml` (weekly) | `upgrade` (4-case matrix) | upgrade and rollback gate; server logs | no | see the PR row |
|
| `e2e-upgrade.yml` (weekly) | `upgrade` (7-case matrix) | upgrade and rollback gate; server logs | no | see the PR row |
|
||||||
| `oidc-keycloak.yml` (weekly) | `oidc-keycloak-live` | live OIDC gate | no | see the PR row |
|
| `oidc-keycloak.yml` (weekly) | `oidc-keycloak-live` | live OIDC gate | no | see the PR row |
|
||||||
| `targets-integration.yml` (nightly) | `targets-live` | live target gate; container logs | no | see the PR row |
|
| `targets-integration.yml` (nightly) | `targets-live` | live target gate; container logs | no | see the PR row |
|
||||||
| `scheduled-validation-freshness.yml` (nightly) | `check-freshness` | fails on a never-created or stale schedule | n/a | dispatch |
|
| `scheduled-validation-freshness.yml` (nightly) | `check-freshness` | fails on a never-created or stale schedule | n/a | dispatch |
|
||||||
|
|||||||
@@ -12,7 +12,52 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn git(root: &Path, args: &[&str]) -> Option<String> {
|
||||||
|
let output = Command::new("git").args(args).current_dir(root).output().ok()?;
|
||||||
|
output
|
||||||
|
.status
|
||||||
|
.success()
|
||||||
|
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> shadow_rs::SdResult<()> {
|
fn main() -> shadow_rs::SdResult<()> {
|
||||||
|
let manifest = std::env::var_os("CARGO_MANIFEST_DIR").ok_or("missing CARGO_MANIFEST_DIR")?;
|
||||||
|
let root = Path::new(&manifest).join("..");
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
// Match the E2E build identity's source and configuration inputs.
|
||||||
|
for path in [
|
||||||
|
"crates",
|
||||||
|
"rustfs",
|
||||||
|
"Cargo.toml",
|
||||||
|
"Cargo.lock",
|
||||||
|
"rust-toolchain.toml",
|
||||||
|
".cargo",
|
||||||
|
".config",
|
||||||
|
] {
|
||||||
|
let path = root.join(path);
|
||||||
|
if path.exists() {
|
||||||
|
println!("cargo:rerun-if-changed={}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Resolve worktree-local HEAD/index and common refs through Git. Watching
|
||||||
|
// refs also covers tags and a new loose ref created from packed refs.
|
||||||
|
for name in ["HEAD", "index", "refs", "packed-refs"] {
|
||||||
|
if let Some(path) = git(&root, &["rev-parse", "--git-path", name]) {
|
||||||
|
let path = Path::new(&path);
|
||||||
|
let path = if path.is_absolute() {
|
||||||
|
path.to_owned()
|
||||||
|
} else {
|
||||||
|
root.join(path)
|
||||||
|
};
|
||||||
|
if path.exists() {
|
||||||
|
println!("cargo:rerun-if-changed={}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
println!("cargo:rerun-if-env-changed=RUSTFS_BUILD_VERSION");
|
println!("cargo:rerun-if-env-changed=RUSTFS_BUILD_VERSION");
|
||||||
if let Ok(version) = std::env::var("RUSTFS_BUILD_VERSION")
|
if let Ok(version) = std::env::var("RUSTFS_BUILD_VERSION")
|
||||||
&& !version.is_empty()
|
&& !version.is_empty()
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
10|crates/ecstore/src/cluster/rpc/remote_disk.rs
|
10|crates/ecstore/src/cluster/rpc/remote_disk.rs
|
||||||
6|crates/ecstore/src/config/com.rs
|
6|crates/ecstore/src/config/com.rs
|
||||||
14|crates/ecstore/src/config/storageclass.rs
|
14|crates/ecstore/src/config/storageclass.rs
|
||||||
180|crates/ecstore/src/core/pools.rs
|
178|crates/ecstore/src/core/pools.rs
|
||||||
7|crates/ecstore/src/data_movement/mod.rs
|
7|crates/ecstore/src/data_movement/mod.rs
|
||||||
2|crates/ecstore/src/data_usage/local_snapshot.rs
|
2|crates/ecstore/src/data_usage/local_snapshot.rs
|
||||||
12|crates/ecstore/src/data_usage/mod.rs
|
12|crates/ecstore/src/data_usage/mod.rs
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
1|crates/ecstore/src/disk/mod.rs
|
1|crates/ecstore/src/disk/mod.rs
|
||||||
5|crates/ecstore/src/erasure/codec/bridge.rs
|
5|crates/ecstore/src/erasure/codec/bridge.rs
|
||||||
1|crates/ecstore/src/erasure/coding/decode_reader.rs
|
1|crates/ecstore/src/erasure/coding/decode_reader.rs
|
||||||
10|crates/ecstore/src/erasure/coding/encode.rs
|
8|crates/ecstore/src/erasure/coding/encode.rs
|
||||||
25|crates/ecstore/src/erasure/coding/erasure.rs
|
25|crates/ecstore/src/erasure/coding/erasure.rs
|
||||||
3|crates/ecstore/src/layout/disks_layout.rs
|
3|crates/ecstore/src/layout/disks_layout.rs
|
||||||
2|crates/ecstore/src/layout/endpoint.rs
|
2|crates/ecstore/src/layout/endpoint.rs
|
||||||
|
|||||||
Reference in New Issue
Block a user