fix(health): report storage quorum in node readiness (#7566)

This commit is contained in:
cxymds
2026-09-09 16:41:11 +08:00
committed by GitHub
parent 51e6733a9c
commit 1076e2fb4f
10 changed files with 676 additions and 45 deletions
@@ -17,6 +17,7 @@ use aws_sdk_s3::Client;
use aws_sdk_s3::error::SdkError;
use bytes::Bytes;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Barrier;
use tracing::{info, warn};
@@ -175,6 +176,9 @@ async fn test_concurrent_cluster_overwrites_do_not_fail_namespace_lock_quorum()
// Keep the regression focused on false quorum-loss errors, not ordinary lock
// wait exhaustion under a heavily contended same-key overwrite workload.
cluster.set_env("RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT", "20");
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:2");
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
cluster.set_env("RUSTFS_HEALTH_MINIMAL_RESPONSE_ENABLE", "false");
cluster.start().await?;
cluster.create_test_bucket(BUCKET).await?;
@@ -233,6 +237,181 @@ async fn test_concurrent_cluster_overwrites_do_not_fail_namespace_lock_quorum()
);
clients[0].delete_object().bucket(BUCKET).key(KEY).send().await?;
assert_node_readiness_tracks_quorum(&mut cluster).await?;
Ok(())
}
async fn assert_node_readiness_tracks_quorum(cluster: &mut RustFSTestClusterEnvironment) -> TestResult {
let clients: Vec<_> = cluster
.create_all_clients()?
.into_iter()
.map(|client| {
Client::from_conf(
client
.config()
.to_builder()
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
.build(),
)
})
.collect();
let http = reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(3))
.build()?;
let seed_key = "readiness-seed";
let seed_body = b"readiness quorum regression";
clients[0]
.put_object()
.bucket(BUCKET)
.key(seed_key)
.body(Bytes::from_static(seed_body).into())
.send()
.await?;
for (phase, survivors) in [4, 3, 2, 1, 4].into_iter().enumerate() {
if phase == 4 {
cluster.stop();
cluster.start().await?;
} else if survivors < 4 {
cluster.stop_node(survivors)?;
}
let write_ready = survivors >= 3;
let read_quorum = survivors >= 2;
let expected_status = if write_ready { 200 } else { 503 };
for (idx, client) in clients.iter().enumerate().take(survivors) {
let url = &cluster.nodes[idx].url;
let deadline = Instant::now() + Duration::from_secs(30);
// Poll health before issuing S3 I/O: idle remote disk handles must
// not remain evidence of quorum after their host becomes unreachable.
let payload = loop {
let response = http.get(format!("{url}/health/ready")).send().await?;
let status = response.status().as_u16();
let payload: serde_json::Value = response.json().await?;
if status == expected_status
&& payload["ready"] == write_ready
&& payload["details"]["storage"]["ready"] == write_ready
&& payload["details"]["storage"]["readQuorum"] == read_quorum
&& payload["details"]["storage"]["writeQuorum"] == write_ready
&& payload["details"]["poolMetadata"]["ready"] == true
&& payload["details"]["iam"]["ready"] == true
&& payload["details"]["lock"]["ready"] == write_ready
{
break payload;
}
assert!(Instant::now() < deadline, "node {idx}, survivors={survivors}: HTTP {status}, {payload}");
tokio::time::sleep(Duration::from_millis(200)).await;
};
assert_eq!(payload["details"]["storage"]["readinessScope"], "write_quorum_and_pool_metadata");
assert_eq!(payload["details"]["storage"]["source"], "local_runtime");
assert_eq!(
payload["details"]["storage"]["status"],
if write_ready { "connected" } else { "disconnected" }
);
if !write_ready {
assert!(
payload["degradedReasons"]
.as_array()
.expect("degraded reasons")
.iter()
.any(|reason| reason == "storage_and_lock_unavailable")
);
}
for path in ["/health/ready", "/minio/health/ready"] {
let head = http.head(format!("{url}{path}")).send().await?;
assert_eq!(head.status().as_u16(), expected_status, "HEAD {path}, survivors={survivors}");
assert!(head.bytes().await?.is_empty());
let response = http.get(format!("{url}{path}")).send().await?;
assert_eq!(response.status().as_u16(), expected_status);
let body: serde_json::Value = response.json().await?;
assert_eq!(body["details"]["storage"], payload["details"]["storage"]);
assert_eq!(body["details"]["poolMetadata"], payload["details"]["poolMetadata"]);
}
let live = http.get(format!("{url}/health/live")).send().await?;
assert_eq!(live.status().as_u16(), 200);
assert!(live.json::<serde_json::Value>().await?.get("details").is_none());
for (path, storage_ready, scope) in [
("/minio/health/cluster", write_ready, "write_quorum_and_pool_metadata"),
("/minio/health/cluster/read", read_quorum, "read_quorum"),
] {
let deadline = Instant::now() + Duration::from_secs(30);
// Cluster read/write reports have independent caches; allow
// each observation to expire before comparing stable states.
let body = loop {
let response = http.get(format!("{url}{path}")).send().await?;
let status = response.status().as_u16();
let body: serde_json::Value = response.json().await?;
if status == expected_status
&& body["details"]["storage"]["ready"] == storage_ready
&& body["details"]["lock"]["ready"] == write_ready
{
break body;
}
assert!(Instant::now() < deadline, "{path}, survivors={survivors}: HTTP {status}, {body}");
tokio::time::sleep(Duration::from_millis(200)).await;
};
assert_eq!(body["details"]["storage"]["readinessScope"], scope);
}
let put = client
.put_object()
.bucket(BUCKET)
.key(format!("readiness-phase-{phase}-node-{idx}"))
.body(Bytes::from_static(seed_body).into())
.send()
.await;
let put_status = if write_ready {
put.expect("a ready node must accept the PUT");
200
} else {
let error = put.expect_err("subquorum node must reject PUT");
assert!(
error.raw_response().is_some_and(|response| response.status().as_u16() >= 500),
"unexpected PUT failure: {error:?}"
);
error.raw_response().expect("PUT error response").status().as_u16()
};
let get = client.get_object().bucket(BUCKET).key(seed_key).send().await;
let get_status = match get {
Ok(object) => {
assert_eq!(object.body.collect().await?.into_bytes().as_ref(), seed_body);
200
}
Err(error) => {
assert!(!write_ready, "GET must succeed on a ready cluster: {error:?}");
let status = error
.raw_response()
.expect("GET should have an HTTP response")
.status()
.as_u16();
assert!(status >= 500, "unexpected GET failure: {error:?}");
status
}
};
let list = client.list_objects_v2().bucket(BUCKET).send().await;
let list_status = match list {
Ok(result) => {
assert!(result.contents().iter().any(|object| object.key() == Some(seed_key)));
200
}
Err(error) => {
assert!(!write_ready, "listing must succeed on a ready cluster: {error:?}");
let status = error
.raw_response()
.expect("LIST should have an HTTP response")
.status()
.as_u16();
assert!(status >= 500, "unexpected listing failure: {error:?}");
status
}
};
eprintln!(
"readiness matrix: survivors={survivors}, node={idx}, ready={write_ready}, read_quorum={read_quorum}, PUT={put_status}, GET={get_status}, LIST={list_status}"
);
}
}
cluster.stop();
Ok(())
}
+29 -3
View File
@@ -39,7 +39,7 @@ an unknown or unsupported peer-health snapshot degrades readiness with
- Liveness reports process availability and must not depend on storage, IAM,
lock quorum, or peer health.
- Node readiness reports local dependency readiness.
- Node readiness reports the node's observed storage write quorum, local pool metadata write gate, IAM, and lock quorum. Runtime storage diagnostics do not change the existing startup `FullReady` publication gate or S3 request admission.
- A blocked pool metadata writer degrades node and cluster-write readiness with
`pool_meta_write_blocked`. Metadata save-gate inspection is bounded to 100 ms;
contention reports `pool_metadata_check_timeout` without installing a block.
@@ -49,11 +49,37 @@ an unknown or unsupported peer-health snapshot degrades readiness with
decision. Runtime readiness and gate status are separate bounded observations.
- Cluster write readiness requires write quorum and the runtime dependency
readiness used by `FullReady`.
- Cluster read readiness may use the read-quorum path and cluster-health
timeout behavior.
- Cluster read readiness uses the storage read-quorum path and cluster-health timeout behavior. Its lock dependency still uses the per-set majority/write-lock health check. Actual shared namespace locks require `ceil(lock_clients / 2)`, so the cluster read probe is conservative: a four-client set can still admit some reads with two clients while the probe returns 503. This diagnostic change does not lower that probe's lock threshold.
- `HEAD` health probes keep header/status semantics and do not require response
bodies.
### Storage Detail Contract
The existing `details.storage.ready` boolean and `connected` / `disconnected` status values are retained. `readinessScope` states the condition they summarize:
| Probe | `readinessScope` | `source` |
| --- | --- | --- |
| `/health/ready`, `/minio/health/ready` | `write_quorum_and_pool_metadata` | `local_runtime` |
| `/minio/health/cluster` | `write_quorum_and_pool_metadata` | `storage_inventory` |
| `/minio/health/cluster/read` | `read_quorum` | `storage_inventory` |
Node readiness additionally reports `details.storage.readQuorum`, `details.storage.writeQuorum`, and `details.poolMetadata.ready`. The metadata component's status is `writable` or `unavailable`; existing typed degradation reasons distinguish a write block from an inspection timeout. A healthy metadata writer alone no longer makes the storage component ready.
Node storage quorum uses configured drives per set, all configured pools/sets, and their Standard storage-class data/parity layout. Missing, duplicate, unreachable, or unhealthy disk observations cannot supply extra quorum votes. The read quorum is the data-drive count; the write quorum is that count plus one when data and parity counts are equal. These are observations of available storage slots, not guarantees that a particular object's metadata, shards, or required locks are available.
For a healthy IAM and metadata writer in a four-node, one-drive-per-node EC 2+2 set:
| Surviving nodes | Storage read quorum | Storage write quorum | Pool metadata ready | Node HTTP / top-level ready |
| --- | --- | --- | --- | --- |
| 4 or 3 | true | true | true | 200 / true |
| 2 | true | false | true | 503 / false |
| 1 | false | false | true | 503 / false |
| All restored | true | true | true | 200 / true |
The node path reads local disk-handle health and reuses the same reachable-host observation as its lock dependency, including the existing `RUSTFS_HEALTH_READINESS_CACHE_TTL_MS` cache. Only `Online` drives count; a reachable host with a `Returning` drive does not yet prove data I/O has recovered. It does not call cluster `storage_info`, local `disk_info`, or add disk-info RPCs. The entire storage inventory snapshot has a separate 100 ms wait budget; expiry reports `storage_readiness_check_timeout` and fails closed. Pool metadata inspection retains its own 100 ms budget. These observations are not an atomic cluster snapshot and do not bypass the existing lock-probe timing or cache policy.
The new fields are additive. Their absence in an older response is not evidence of storage quorum. Minimal responses still contain only the existing top-level fields, liveness remains dependency-independent, and HEAD responses remain bodyless.
## Preservation Rules
- Do not move peer-health checks into the S3 data hot path.
+2 -2
View File
@@ -56,8 +56,8 @@ When the whole cluster (or several nodes) went down and nodes are brought back o
| `startup_finalization` | Last startup steps are being published. |
2. Logs say what the node waits for. The IAM recovery loop retries with backoff and logs `event="iam_bootstrap_retry_failed"` with an actionable `hint` field (for example, "storage read quorum not met yet; waiting for enough cluster nodes/disks to come online"). After repeated failures the level escalates from WARN to ERROR; this still does not kill the process.
3. Recovery is automatic. As soon as enough peers are online for the storage read quorum, the pending nodes finish IAM bootstrap on the next retry and flip `/health/ready` to `200` on their own. Restarting them does not speed anything up.
4. Check readiness detail while waiting. `/health/ready` (and `/minio/health/ready`) return per-dependency detail during degradation; the `details` object shows `storage` / `iam` / `lock` readiness and `degradedReasons` lists machine-readable causes such as `storage_quorum_unavailable` or `lock_quorum_unavailable`:
3. Recovery is automatic. Once storage read quorum is available, pending nodes can finish IAM bootstrap on the next retry. `/health/ready` returns `200` when storage write quorum, the metadata write gate, IAM, and lock readiness are satisfied. Restarting pending nodes does not speed this up.
4. Check readiness detail while waiting. `/health/ready` (and `/minio/health/ready`) separate `storage` / `poolMetadata` / `iam` / `lock` readiness. `storage.ready` summarizes write quorum plus the metadata write gate; `storage.readQuorum` and `storage.writeQuorum` show the separate quorum observations. A healthy `poolMetadata.ready` does not imply storage quorum. `degradedReasons` lists machine-readable causes such as `storage_quorum_unavailable`, `storage_and_lock_unavailable`, or `pool_metadata_check_timeout`. See the [storage detail contract](../architecture/readiness-matrix.md#storage-detail-contract) for probe scopes and sampling limits:
```bash
curl -s http://<node>:9000/health/ready | jq
+8
View File
@@ -211,6 +211,7 @@ mod tests {
peer_health_ready: true,
},
degraded_reasons: vec![crate::shared_types::ReadinessDegradedReason::StorageQuorumUnavailable],
storage_details: None,
};
let parts = build_health_response_parts(
Method::GET,
@@ -233,6 +234,7 @@ mod tests {
peer_health_ready: true,
},
degraded_reasons: Vec::new(),
storage_details: None,
};
let parts = build_health_response_parts(
Method::GET,
@@ -255,6 +257,7 @@ mod tests {
peer_health_ready: true,
},
degraded_reasons: vec![crate::shared_types::ReadinessDegradedReason::StorageAndIamUnavailable],
storage_details: None,
};
let parts = build_health_response_parts(
Method::GET,
@@ -284,6 +287,7 @@ mod tests {
peer_health_ready: true,
},
degraded_reasons: vec![crate::shared_types::ReadinessDegradedReason::LockQuorumUnavailable],
storage_details: None,
};
let liveness = build_health_response_parts(
@@ -328,6 +332,7 @@ mod tests {
peer_health_ready: true,
},
degraded_reasons: vec![crate::shared_types::ReadinessDegradedReason::StorageAndIamUnavailable],
storage_details: None,
};
let parts = build_health_response_parts(
Method::HEAD,
@@ -398,6 +403,7 @@ mod tests {
peer_health_ready: true,
},
degraded_reasons: Vec::new(),
storage_details: None,
};
let parts =
build_health_response_parts(Method::HEAD, HealthProbe::Readiness, Some(&report), "rustfs-endpoint", None, None);
@@ -417,6 +423,7 @@ mod tests {
peer_health_ready: true,
},
degraded_reasons: vec![crate::shared_types::ReadinessDegradedReason::StorageQuorumUnavailable],
storage_details: None,
};
let parts =
build_health_response_parts(Method::GET, HealthProbe::Readiness, Some(&report), "rustfs-endpoint", None, None);
@@ -440,6 +447,7 @@ mod tests {
peer_health_ready: true,
},
degraded_reasons: Vec::new(),
storage_details: None,
};
let parts = build_health_response_parts(
Method::GET,
+3
View File
@@ -277,6 +277,7 @@ mod tests {
peer_health_ready: true,
},
degraded_reasons: Vec::new(),
storage_details: None,
});
assert_eq!(ready.state, ClusterRuntimeReadinessState::Ready);
@@ -288,6 +289,7 @@ mod tests {
peer_health_ready: true,
},
degraded_reasons: vec![ReadinessDegradedReason::StorageQuorumUnavailable],
storage_details: None,
});
assert_eq!(degraded.state, ClusterRuntimeReadinessState::Degraded);
assert_eq!(degraded.degraded_reasons, vec![ReadinessDegradedReason::StorageQuorumUnavailable]);
@@ -304,6 +306,7 @@ mod tests {
peer_health_ready: true,
},
degraded_reasons: vec![ReadinessDegradedReason::StorageAndLockUnavailable],
storage_details: None,
});
let snapshot = cluster_read_only_snapshot_from_endpoint_pools(&endpoint_pools, runtime_status);
+98 -2
View File
@@ -343,7 +343,7 @@ pub(crate) fn build_health_response_parts(
let payload = if method == Method::HEAD {
None
} else {
Some(build_health_payload(HealthPayloadContext {
let mut payload = build_health_payload(HealthPayloadContext {
probe,
health,
storage_ready,
@@ -354,7 +354,18 @@ pub(crate) fn build_health_response_parts(
uptime,
kms_ready,
include_dependency_details,
}))
});
if let Some(details) = readiness_report.and_then(|report| report.storage_details)
&& payload.get("details").is_some()
{
payload["details"]["storage"]["readQuorum"] = json!(details.read_quorum_ready);
payload["details"]["storage"]["writeQuorum"] = json!(details.write_quorum_ready);
payload["details"]["poolMetadata"] = json!({
"ready": details.pool_metadata_write_ready,
"status": if details.pool_metadata_write_ready { "writable" } else { "unavailable" },
});
}
Some(payload)
};
HealthResponseParts {
@@ -390,6 +401,14 @@ pub(crate) fn build_health_payload(ctx: HealthPayloadContext<'_>) -> Value {
if ctx.include_dependency_details {
payload["details"] = build_component_details(ctx.storage_ready, ctx.iam_ready, ctx.lock_quorum_ready, ctx.kms_ready);
payload["details"]["storage"]["readinessScope"] = json!(match ctx.probe {
HealthProbe::ClusterRead => "read_quorum",
_ => "write_quorum_and_pool_metadata",
});
payload["details"]["storage"]["source"] = json!(match ctx.probe {
HealthProbe::Readiness => "local_runtime",
_ => "storage_inventory",
});
payload["degradedReasons"] = build_degraded_reasons(ctx.degraded_reasons);
}
@@ -432,9 +451,86 @@ mod tests {
peer_health_ready: true,
},
degraded_reasons: Vec::new(),
storage_details: None,
}
}
#[test]
#[serial]
fn node_storage_details_separate_quorum_from_pool_metadata() {
with_var(rustfs_config::ENV_HEALTH_MINIMAL_RESPONSE_ENABLE, Some("false"), || {
for (read_quorum, write_quorum, metadata_ready, lock_ready) in [
(true, true, true, true),
(true, false, true, false),
(false, false, true, false),
(true, true, false, true),
(true, true, true, true),
] {
let mut report = ready_report();
report.readiness.storage_ready = write_quorum && metadata_ready;
report.readiness.lock_quorum_ready = lock_ready;
report.storage_details = Some(crate::shared_types::StorageReadinessDetails {
read_quorum_ready: read_quorum,
write_quorum_ready: write_quorum,
pool_metadata_write_ready: metadata_ready,
});
let parts = build_health_response_parts(Method::GET, HealthProbe::Readiness, Some(&report), "rustfs", None, None);
let expected_ready = write_quorum && metadata_ready && lock_ready;
assert_eq!(
parts.status_code,
if expected_ready {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
}
);
let payload = parts.payload.expect("GET readiness body");
assert_eq!(payload["ready"], expected_ready);
assert_eq!(payload["details"]["storage"]["ready"], write_quorum && metadata_ready);
assert_eq!(
payload["details"]["storage"]["status"],
if write_quorum && metadata_ready {
"connected"
} else {
"disconnected"
}
);
assert_eq!(payload["details"]["storage"]["readQuorum"], read_quorum);
assert_eq!(payload["details"]["storage"]["writeQuorum"], write_quorum);
assert_eq!(payload["details"]["poolMetadata"]["ready"], metadata_ready);
assert_eq!(payload["details"]["storage"]["source"], "local_runtime");
assert_eq!(payload["details"]["storage"]["readinessScope"], "write_quorum_and_pool_metadata");
assert!(
build_health_response_parts(Method::HEAD, HealthProbe::Readiness, Some(&report), "rustfs", None, None)
.payload
.is_none()
);
}
});
}
#[test]
#[serial]
fn node_storage_details_do_not_expand_minimal_or_liveness_payloads() {
let mut report = ready_report();
report.storage_details = Some(crate::shared_types::StorageReadinessDetails {
read_quorum_ready: true,
write_quorum_ready: true,
pool_metadata_write_ready: true,
});
with_var(rustfs_config::ENV_HEALTH_MINIMAL_RESPONSE_ENABLE, Some("true"), || {
let parts = build_health_response_parts(Method::GET, HealthProbe::Readiness, Some(&report), "rustfs", None, None);
assert_eq!(parts.payload, Some(json!({ "status": "ok", "ready": true })));
});
with_var(rustfs_config::ENV_HEALTH_MINIMAL_RESPONSE_ENABLE, Some("false"), || {
let parts = build_health_response_parts(Method::GET, HealthProbe::Liveness, Some(&report), "rustfs", None, None);
assert_eq!(parts.status_code, StatusCode::OK);
let payload = parts.payload.expect("liveness GET body");
assert!(payload.get("details").is_none());
assert!(payload.get("ready").is_none());
});
}
#[tokio::test]
async fn readiness_collects_object_stalls_and_recovers_on_completion() {
let object_traffic_health = ObjectTrafficHealth::enabled_for_test(Duration::ZERO);
+337 -37
View File
@@ -17,8 +17,8 @@ use crate::server::{ServiceState, ServiceStateManager};
use crate::server::{has_path_prefix, is_table_catalog_path};
use crate::storage_api::cluster::control_plane::ClusterControlPlane;
use crate::storage_api::error::StorageError;
use crate::storage_api::server::readiness::contract::admin::StorageAdminApi;
use crate::storage_api::server::readiness::{Endpoint, EndpointServerPools, is_dist_erasure};
use crate::storage_api::server::readiness::contract::admin::{DiskSetSelector, StorageAdminApi};
use crate::storage_api::server::readiness::{DiskStore, Endpoint, EndpointServerPools, disk_endpoint_snapshot, is_dist_erasure};
#[cfg(test)]
use crate::storage_api::server::readiness::{Endpoints, PoolEndpoints};
use crate::storage_api::startup::shutdown::mark_get_metadata_read_version_coalescing_service_ready;
@@ -30,7 +30,7 @@ use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use metrics::{counter, gauge};
use rustfs_common::GlobalReadiness;
use rustfs_madmin::{Disk, StorageInfo};
use rustfs_madmin::{BackendInfo, Disk, StorageInfo};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
@@ -76,7 +76,7 @@ fn startup_runtime_readiness_max_wait() -> Duration {
const METRIC_RUNTIME_READINESS_READY: &str = "rustfs_runtime_readiness_ready";
const METRIC_RUNTIME_READINESS_DEGRADED_TOTAL: &str = "rustfs_runtime_readiness_degraded_total";
pub use crate::shared_types::{DependencyReadiness, DependencyReadinessReport, ReadinessDegradedReason};
pub use crate::shared_types::{DependencyReadiness, DependencyReadinessReport, ReadinessDegradedReason, StorageReadinessDetails};
/// ReadinessGateLayer ensures that the system components (IAM, Storage)
/// are fully initialized before allowing any request to proceed.
@@ -288,10 +288,16 @@ fn pool_metadata_write_readiness(result: Result<(), StorageError>) -> StorageWri
}
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone)]
struct LockQuorumCacheEntry {
captured_at: Instant,
observation: LockQuorumObservation,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct LockQuorumObservation {
status: LockQuorumStatus,
online_hosts: HashSet<String>,
}
#[derive(Debug, Clone)]
@@ -317,6 +323,7 @@ pub struct LockQuorumStatus {
const DISK_STATE_OK: &str = "ok";
const DISK_STATE_UNFORMATTED: &str = "unformatted";
const RUNTIME_STATE_RETURNING: &str = "returning";
const NODE_STORAGE_READINESS_TIMEOUT: Duration = Duration::from_millis(100);
fn health_readiness_cache_ttl() -> Duration {
Duration::from_millis(rustfs_utils::get_env_u64(
@@ -439,7 +446,7 @@ async fn update_storage_readiness_cache(status: StorageWriteReadinessStatus) {
});
}
async fn load_cached_lock_quorum_status() -> Option<LockQuorumStatus> {
async fn load_cached_lock_quorum_status() -> Option<LockQuorumObservation> {
let ttl = health_readiness_cache_ttl();
if ttl.is_zero() {
return None;
@@ -448,13 +455,13 @@ async fn load_cached_lock_quorum_status() -> Option<LockQuorumStatus> {
let cache = lock_quorum_status_cache().lock().await;
let entry = cache.as_ref()?;
if entry.captured_at.elapsed() <= ttl {
return Some(entry.status);
return Some(entry.observation.clone());
}
None
}
async fn update_lock_quorum_status_cache(status: LockQuorumStatus) {
async fn update_lock_quorum_status_cache(observation: LockQuorumObservation) {
if health_readiness_cache_ttl().is_zero() {
return;
}
@@ -462,7 +469,7 @@ async fn update_lock_quorum_status_cache(status: LockQuorumStatus) {
let mut cache = lock_quorum_status_cache().lock().await;
*cache = Some(LockQuorumCacheEntry {
captured_at: Instant::now(),
status,
observation,
});
}
@@ -730,6 +737,7 @@ fn dependency_readiness_report_from_readiness(readiness: DependencyReadiness) ->
DependencyReadinessReport {
degraded_reasons: degraded_reasons(readiness),
readiness,
storage_details: None,
}
}
@@ -740,6 +748,7 @@ fn dependency_readiness_report_from_write_status(
DependencyReadinessReport {
degraded_reasons: degraded_reasons_with_pool_meta_status(readiness, storage.pool_metadata_reason),
readiness,
storage_details: None,
}
}
@@ -774,18 +783,95 @@ pub async fn collect_cluster_read_health_report() -> DependencyReadinessReport {
}
pub async fn collect_node_readiness_report() -> DependencyReadinessReport {
let storage = node_pool_meta_write_readiness().await;
let lock_observation = collect_lock_quorum_observation().await;
let mut storage = StorageWriteReadinessStatus::default();
let mut details = StorageReadinessDetails::default();
let mut storage_check_timed_out = false;
if let Some(store) = runtime_sources::current_object_store_handle() {
storage = pool_metadata_write_readiness(store.pool_meta_write_status().await);
details.pool_metadata_write_ready = storage.ready;
match node_storage_snapshot(store.as_ref(), &lock_observation.online_hosts).await {
Ok(info) => {
details.read_quorum_ready = storage_read_ready_from_runtime_state(&info);
details.write_quorum_ready = storage_ready_from_runtime_state(&info);
}
Err(StorageError::Timeout) => storage_check_timed_out = true,
Err(_) => {}
}
}
storage.ready &= details.write_quorum_ready;
let readiness = DependencyReadiness {
storage_ready: storage.ready,
iam_ready: runtime_sources::current_iam_ready(),
lock_quorum_ready: collect_lock_quorum_status().await.ready,
lock_quorum_ready: lock_observation.status.ready,
peer_health_ready: collect_peer_health_readiness(),
};
let report = dependency_readiness_report_from_write_status(readiness, storage);
let mut report = dependency_readiness_report_from_write_status(readiness, storage);
report.storage_details = Some(details);
if storage_check_timed_out {
report
.degraded_reasons
.push(ReadinessDegradedReason::StorageReadinessCheckTimeout);
}
record_readiness_report(&report);
report
}
async fn node_storage_snapshot<S>(store: &S, online_hosts: &HashSet<String>) -> Result<StorageInfo, StorageError>
where
S: StorageAdminApi<BackendInfo = BackendInfo, Disk = DiskStore, Error = StorageError>,
{
tokio::time::timeout(NODE_STORAGE_READINESS_TIMEOUT, async {
let mut info = StorageInfo {
backend: store.backend_info().await,
..Default::default()
};
if configured_readiness_topology(&info).is_none() {
return Ok(info);
}
// Inventory and runtime health are local snapshots. Reuse the lock probe's
// peer reachability so an idle remote disk cannot outlive its failed host.
// Do not perform disk-info RPCs or filesystem probes in node readiness.
for (pool_idx, &set_count) in info.backend.total_sets.iter().enumerate() {
for set_idx in 0..set_count {
for disk in store
.disk_set_inventory(DiskSetSelector::new(pool_idx, set_idx))
.await?
.into_iter()
.flatten()
{
info.disks.push(node_disk_snapshot(
disk_endpoint_snapshot(&disk),
disk.runtime_state().as_str(),
online_hosts,
));
}
}
}
Ok(info)
})
.await
.map_err(|_| StorageError::Timeout)?
}
fn node_disk_snapshot(endpoint: Endpoint, runtime_state: &str, online_hosts: &HashSet<String>) -> Disk {
let reachable = endpoint.is_local || online_hosts.contains(&endpoint.host_port());
// Returning drives can still reject data I/O as faulty. Without a fresh
// disk-info probe, only an Online runtime observation can supply quorum.
let online = reachable && runtime_state == rustfs_madmin::ITEM_ONLINE;
Disk {
endpoint: endpoint.to_string(),
drive_path: endpoint.get_file_path(),
pool_index: endpoint.pool_idx,
set_index: endpoint.set_idx,
disk_index: endpoint.disk_idx,
state: if online { DISK_STATE_OK } else { "offline" }.to_string(),
runtime_state: Some(runtime_state.to_string()),
..Default::default()
}
}
async fn collect_cluster_health_report_with<LoadFn, Fut>(
kind: ClusterHealthProbeKind,
mut load_report: LoadFn,
@@ -820,11 +906,22 @@ fn cluster_health_timeout_report() -> DependencyReadinessReport {
peer_health_ready: false,
},
degraded_reasons: vec![ReadinessDegradedReason::ClusterHealthTimeout],
storage_details: None,
}
}
async fn collect_node_readiness() -> DependencyReadiness {
collect_node_readiness_report().await.readiness
// Startup publication retains its local dependency gate. Runtime probes
// additionally report storage quorum without changing S3 admission.
let storage = node_pool_meta_write_readiness().await;
let readiness = DependencyReadiness {
storage_ready: storage.ready,
iam_ready: runtime_sources::current_iam_ready(),
lock_quorum_ready: collect_lock_quorum_status().await.ready,
peer_health_ready: collect_peer_health_readiness(),
};
record_readiness_report(&dependency_readiness_report_from_write_status(readiness, storage));
readiness
}
pub async fn collect_cluster_read_dependency_readiness_report() -> DependencyReadinessReport {
@@ -856,11 +953,15 @@ pub(crate) async fn snapshot_dependency_readiness_report() -> DependencyReadines
}
async fn collect_lock_quorum_status() -> LockQuorumStatus {
collect_lock_quorum_observation().await.status
}
async fn collect_lock_quorum_observation() -> LockQuorumObservation {
if let Some(cached) = load_cached_lock_quorum_status().await {
cached
} else {
let computed = collect_lock_quorum_status_uncached().await;
update_lock_quorum_status_cache(computed).await;
let computed = collect_lock_quorum_observation_uncached().await;
update_lock_quorum_status_cache(computed.clone()).await;
computed
}
}
@@ -972,20 +1073,27 @@ fn aggregate_lock_quorum_status(pool_endpoints: &EndpointServerPools, online_hos
}
async fn collect_lock_quorum_status_uncached() -> LockQuorumStatus {
collect_lock_quorum_observation_uncached().await.status
}
async fn collect_lock_quorum_observation_uncached() -> LockQuorumObservation {
if !is_dist_erasure().await {
return LockQuorumStatus {
ready: true,
connected_clients: 1,
total_clients: 1,
required_quorum: 1,
return LockQuorumObservation {
status: LockQuorumStatus {
ready: true,
connected_clients: 1,
total_clients: 1,
required_quorum: 1,
},
online_hosts: HashSet::new(),
};
}
let Some(pool_endpoints) = runtime_sources::current_endpoints_handle() else {
return LockQuorumStatus::default();
return LockQuorumObservation::default();
};
let Some(lock_clients) = runtime_sources::current_lock_clients_handle() else {
return LockQuorumStatus::default();
return LockQuorumObservation::default();
};
let online_hosts = futures::future::join_all(lock_clients.iter().map(|(host, client)| {
@@ -998,7 +1106,10 @@ async fn collect_lock_quorum_status_uncached() -> LockQuorumStatus {
.filter_map(|(host, online)| online.then_some(host))
.collect::<HashSet<_>>();
aggregate_lock_quorum_status(&pool_endpoints, &online_hosts)
LockQuorumObservation {
status: aggregate_lock_quorum_status(&pool_endpoints, &online_hosts),
online_hosts,
}
}
pub async fn wait_for_runtime_readiness_with<F, Fut, ReadyFn>(
@@ -1048,12 +1159,206 @@ where
#[cfg(test)]
mod tests {
use super::*;
use crate::storage_api::server::readiness::{DiskOption, new_disk};
use rustfs_madmin::{BackendInfo, Disk};
use serial_test::serial;
use std::future;
use std::sync::atomic::{AtomicUsize, Ordering};
use temp_env::{async_with_vars, with_var};
#[derive(Debug, Default)]
struct RuntimeInventory {
backend: BackendInfo,
disks: HashMap<DiskSetSelector, Vec<Option<DiskStore>>>,
pending_set: Option<DiskSetSelector>,
failed_set: Option<DiskSetSelector>,
}
#[async_trait::async_trait]
impl StorageAdminApi for RuntimeInventory {
type BackendInfo = BackendInfo;
type StorageInfo = StorageInfo;
type Disk = DiskStore;
type Error = StorageError;
async fn backend_info(&self) -> BackendInfo {
self.backend.clone()
}
async fn storage_info(&self) -> StorageInfo {
panic!("node readiness must not perform a cluster storage-info probe")
}
async fn local_storage_info(&self) -> StorageInfo {
panic!("node readiness must not perform local disk-info I/O")
}
async fn disk_set_inventory(&self, selector: DiskSetSelector) -> Result<Vec<Option<DiskStore>>, StorageError> {
if self.pending_set == Some(selector) {
return future::pending().await;
}
if self.failed_set == Some(selector) {
return Err(StorageError::other("inventory unavailable"));
}
Ok(self.disks.get(&selector).cloned().unwrap_or_default())
}
fn set_drive_counts(&self) -> Vec<usize> {
self.backend.drives_per_set.clone()
}
}
async fn runtime_inventory(layouts: &[(usize, usize, usize)]) -> RuntimeInventory {
let mut store = RuntimeInventory::default();
for (pool_idx, &(set_count, drive_count, parity)) in layouts.iter().enumerate() {
store.backend.total_sets.push(set_count);
store.backend.drives_per_set.push(drive_count);
store.backend.standard_sc_data.push(drive_count - parity);
store.backend.standard_sc_parities.push(parity);
for set_idx in 0..set_count {
let mut disks = Vec::new();
for disk_idx in 0..drive_count {
let endpoint = Endpoint {
url: url::Url::parse(&format!("http://node-{disk_idx}:9000/pool-{pool_idx}-set-{set_idx}"))
.expect("valid test endpoint"),
is_local: false,
pool_idx: i32::try_from(pool_idx).expect("test pool index"),
set_idx: i32::try_from(set_idx).expect("test set index"),
disk_idx: i32::try_from(disk_idx).expect("test disk index"),
};
disks.push(Some(
new_disk(&endpoint, &DiskOption::default())
.await
.expect("create runtime disk handle"),
));
}
store.disks.insert(DiskSetSelector::new(pool_idx, set_idx), disks);
}
}
store
}
#[tokio::test]
async fn node_storage_snapshot_tracks_read_write_quorum_and_recovery() {
for (drive_count, parity) in [(4, 2), (4, 1), (8, 2)] {
let store = runtime_inventory(&[(1, drive_count, parity)]).await;
let data = drive_count - parity;
let write_quorum = data + usize::from(data == parity);
for survivors in (0..=drive_count).rev().chain(std::iter::once(drive_count)) {
let online_hosts = (0..survivors).map(|idx| format!("node-{idx}:9000")).collect();
let info = node_storage_snapshot(&store, &online_hosts)
.await
.expect("read local runtime inventory");
assert_eq!(info.disks.len(), drive_count, "offline members retain their topology slots");
assert_eq!(
storage_read_ready_from_runtime_state(&info),
survivors >= data,
"layout={drive_count}/{parity}, survivors={survivors}"
);
assert_eq!(
storage_ready_from_runtime_state(&info),
survivors >= write_quorum,
"layout={drive_count}/{parity}, survivors={survivors}"
);
}
}
}
#[test]
fn node_disk_snapshot_requires_online_runtime_before_counting_recovered_drives() {
for is_local in [false, true] {
for reachable in [false, true] {
let online_hosts = if reachable {
HashSet::from(["node-0:9000".to_string()])
} else {
HashSet::new()
};
for runtime_state in ["online", "suspect", "offline", "returning", "unknown"] {
let endpoint = Endpoint {
url: url::Url::parse("http://node-0:9000/data").expect("valid test endpoint"),
is_local,
pool_idx: 0,
set_idx: 0,
disk_idx: 2,
};
let mut disks = online_readiness_disks(0, 2);
disks.push(node_disk_snapshot(endpoint, runtime_state, &online_hosts));
let info = StorageInfo {
backend: BackendInfo {
total_sets: vec![1],
drives_per_set: vec![4],
standard_sc_data: vec![2],
standard_sc_parities: vec![2],
..Default::default()
},
disks,
..Default::default()
};
assert!(storage_read_ready_from_runtime_state(&info));
assert_eq!(
storage_ready_from_runtime_state(&info),
runtime_state == "online" && (is_local || reachable),
"runtime_state={runtime_state}, local={is_local}, reachable={reachable}"
);
}
}
}
}
#[tokio::test]
async fn node_storage_snapshot_requires_each_configured_set_and_distinct_drives() {
let mut store = runtime_inventory(&[(2, 4, 2), (1, 8, 2)]).await;
let online_hosts = (0..8).map(|idx| format!("node-{idx}:9000")).collect();
let info = node_storage_snapshot(&store, &online_hosts)
.await
.expect("healthy mixed layout");
assert!(storage_ready_from_runtime_state(&info));
let selector = DiskSetSelector::new(0, 1);
let original = store.disks.remove(&selector).expect("second configured set");
let info = node_storage_snapshot(&store, &online_hosts)
.await
.expect("missing set snapshot");
assert!(!storage_read_ready_from_runtime_state(&info));
assert!(!storage_ready_from_runtime_state(&info));
store.disks.insert(selector, vec![original[0].clone(); 4]);
let info = node_storage_snapshot(&store, &online_hosts)
.await
.expect("duplicate drive snapshot");
assert!(!storage_read_ready_from_runtime_state(&info));
assert!(!storage_ready_from_runtime_state(&info));
store.disks.insert(selector, original);
assert!(storage_ready_from_runtime_state(
&node_storage_snapshot(&store, &online_hosts)
.await
.expect("restored inventory")
));
}
#[tokio::test]
async fn node_storage_snapshot_bounds_waits_and_propagates_inventory_failure() {
let mut store = runtime_inventory(&[(1, 4, 2)]).await;
let online_hosts = (0..4).map(|idx| format!("node-{idx}:9000")).collect();
let selector = DiskSetSelector::new(0, 0);
store.pending_set = Some(selector);
let result = tokio::time::timeout(Duration::from_secs(1), node_storage_snapshot(&store, &online_hosts))
.await
.expect("node storage inspection must honor its own 100 ms budget");
assert!(matches!(result, Err(StorageError::Timeout)));
store.pending_set = None;
store.failed_set = Some(selector);
assert!(node_storage_snapshot(&store, &online_hosts).await.is_err());
store.failed_set = None;
assert!(storage_ready_from_runtime_state(
&node_storage_snapshot(&store, &online_hosts)
.await
.expect("inspection recovered")
));
}
fn online_readiness_disks(set_idx: i32, count: i32) -> Vec<Disk> {
(0..count)
.map(|disk_index| Disk {
@@ -1157,6 +1462,7 @@ mod tests {
peer_health_ready: true,
},
degraded_reasons: Vec::new(),
storage_details: None,
};
let first_calls = calls.clone();
@@ -1213,6 +1519,7 @@ mod tests {
peer_health_ready: true,
},
degraded_reasons: Vec::new(),
storage_details: None,
}
})
.await;
@@ -2013,24 +2320,17 @@ mod tests {
*guard = None;
}
update_lock_quorum_status_cache(LockQuorumStatus {
ready: true,
connected_clients: 2,
total_clients: 3,
required_quorum: 2,
})
.await;
let cached = load_cached_lock_quorum_status().await;
assert_eq!(
cached,
Some(LockQuorumStatus {
let observation = LockQuorumObservation {
status: LockQuorumStatus {
ready: true,
connected_clients: 2,
total_clients: 3,
required_quorum: 2,
})
);
},
online_hosts: HashSet::from(["node-a:9000".to_owned(), "node-b:9000".to_owned()]),
};
update_lock_quorum_status_cache(observation.clone()).await;
assert_eq!(load_cached_lock_quorum_status().await, Some(observation));
})
.await;
}
+10
View File
@@ -45,6 +45,7 @@ pub enum ReadinessDegradedReason {
ObjectWriteStalled,
PoolMetaWriteBlocked,
PoolMetadataCheckTimeout,
StorageReadinessCheckTimeout,
ClusterHealthTimeout,
PeerHealthUnavailable,
StartupFinalizationPending,
@@ -65,6 +66,7 @@ impl ReadinessDegradedReason {
ReadinessDegradedReason::ObjectWriteStalled => "object_write_stalled",
ReadinessDegradedReason::PoolMetaWriteBlocked => "pool_meta_write_blocked",
ReadinessDegradedReason::PoolMetadataCheckTimeout => "pool_metadata_check_timeout",
ReadinessDegradedReason::StorageReadinessCheckTimeout => "storage_readiness_check_timeout",
ReadinessDegradedReason::ClusterHealthTimeout => "cluster_health_timeout",
ReadinessDegradedReason::PeerHealthUnavailable => "peer_health_unavailable",
ReadinessDegradedReason::StartupFinalizationPending => "startup_finalization_pending",
@@ -80,6 +82,14 @@ impl ReadinessDegradedReason {
pub struct DependencyReadinessReport {
pub readiness: DependencyReadiness,
pub degraded_reasons: Vec<ReadinessDegradedReason>,
pub storage_details: Option<StorageReadinessDetails>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct StorageReadinessDetails {
pub read_quorum_ready: bool,
pub write_quorum_ready: bool,
pub pool_metadata_write_ready: bool,
}
pub(crate) fn convert_ecstore_object_info(object: StorageObjectInfo) -> NotifyObjectInfo {
+2
View File
@@ -475,6 +475,8 @@ pub(crate) mod ecstore_disk {
RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, SnapshotLeaseToken,
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, get_object_disk_read_timeout, validate_batch_read_version_item_count,
};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::disk::{DiskOption, new_disk};
pub(crate) use rustfs_ecstore::api::disk::{endpoint, error, error_reduce};
}
+8 -1
View File
@@ -211,12 +211,19 @@ pub(crate) mod server {
pub(crate) mod readiness {
pub(crate) mod contract {
pub(crate) mod admin {
pub(crate) use super::super::super::super::storage_contracts::StorageAdminApi;
pub(crate) use super::super::super::super::storage_contracts::{DiskSetSelector, StorageAdminApi};
}
}
pub(crate) use crate::storage::storage_api::ecstore_disk::DiskStore;
pub(crate) use crate::storage::storage_api::{Endpoint, EndpointServerPools, is_dist_erasure};
pub(crate) fn disk_endpoint_snapshot(disk: &DiskStore) -> Endpoint {
crate::storage::storage_api::ecstore_disk::DiskAPI::endpoint(disk.as_ref())
}
#[cfg(test)]
pub(crate) use crate::storage::storage_api::ecstore_disk::{DiskOption, new_disk};
#[cfg(test)]
pub(crate) use crate::storage::storage_api::{Endpoints, PoolEndpoints};
}