mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-07 12:35:54 +00:00
feat(tier): report cluster tier stats and count tier requests (#7110)
`GET /v3/tier-stats` answered from whichever process received the request, returning that node's rolling 24-hour transition counters as if they were cluster totals, and the `TierRequestsSuccess` and `TierRequestsFailure` metric names had no producer at all. The body now separates the two quantities a tier carries. Stored inventory comes from the persisted scanner usage snapshot, which is already cluster-wide; rolling activity is summed over every member through a new read-only `TierDailyStats` peer RPC. Rings are merged rather than added, so an idle node's expired hours age out, and each node counts only its own committed transitions, so a retry is counted once. Coverage travels with the numbers: `activity.status` names the reporting members and the ones that could not be asked, timed out, or answered with a ring this build refuses to merge, and per-tier inventory is absent rather than zero when the snapshot has no accounting. The version 1 body stays reachable at `?format=legacy`. Tier request counters are recorded at the two seams every remote request passes through, so a new provider is counted by construction, with a closed operation/outcome label set that can never grow a tier name, endpoint or object key. Closes rustfs/backlog#2207 Co-authored-by: cxymds <cxymds@gmail.com>
This commit is contained in:
@@ -25,7 +25,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
|
||||
| **policy** | [`src/policy/`](src/policy), `existing_object_tag_policy_test`, `bucket_policy_check_test`, `anonymous_access_test`, `security_boundary_test`, `multipart_auth_test` | IAM / bucket-policy / STS session policy, policy variables, anonymous access, DoS/SSRF boundaries. Own guide: [`src/policy/README.md`](src/policy/README.md) |
|
||||
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
|
||||
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
|
||||
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
||||
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
||||
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
||||
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
||||
|
||||
|
||||
@@ -218,6 +218,10 @@ mod cluster_multidrive_pool_test;
|
||||
#[cfg(test)]
|
||||
mod inline_fast_path_cluster_test;
|
||||
|
||||
// backlog#2207: two-node gate for the cluster-authoritative tier stats contract.
|
||||
#[cfg(test)]
|
||||
mod tier_stats_cluster_test;
|
||||
|
||||
// PutObject / MultipartUpload with checksum (Content-MD5, x-amz-checksum-*)
|
||||
#[cfg(test)]
|
||||
mod checksum_upload_test;
|
||||
|
||||
@@ -904,6 +904,13 @@ impl NodeService for MinimalLockNodeService {
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::LoadTransitionTierConfigResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn tier_daily_stats(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::TierDailyStatsRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::TierDailyStatsResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a gRPC lock server on a random port
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Two-node gate for the tier stats wire contract (rustfs/backlog#2207).
|
||||
//!
|
||||
//! Before this contract, `GET /v3/tier-stats` answered from the process that
|
||||
//! happened to receive the request, so the same query returned different
|
||||
//! numbers depending on which node a client reached, with nothing in the body
|
||||
//! saying so. These tests pin the two properties that fix costs: the answer is
|
||||
//! node-independent, and it states how much of the cluster it covers.
|
||||
|
||||
use crate::common::{RustFSTestClusterEnvironment, admin_request, init_logging};
|
||||
use http::Method;
|
||||
use http::StatusCode;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use tokio::time::{Instant, sleep};
|
||||
|
||||
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
const TIER_STATS_PATH: &str = "/rustfs/admin/v3/tier-stats";
|
||||
const PEER_CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
async fn tier_stats(cluster: &RustFSTestClusterEnvironment, node: usize, query: &str) -> TestResult<(StatusCode, String)> {
|
||||
admin_request(
|
||||
&cluster.nodes[node].url,
|
||||
Method::GET,
|
||||
&format!("{TIER_STATS_PATH}{query}"),
|
||||
None,
|
||||
&cluster.access_key,
|
||||
&cluster.secret_key,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn tier_stats_json(cluster: &RustFSTestClusterEnvironment, node: usize) -> TestResult<Value> {
|
||||
let (status, body) = tier_stats(cluster, node, "").await?;
|
||||
assert_eq!(status, StatusCode::OK, "node {node} must answer tier-stats: {body}");
|
||||
Ok(serde_json::from_str(&body)?)
|
||||
}
|
||||
|
||||
/// Wait until every node reports the whole cluster.
|
||||
///
|
||||
/// Peer clients are established during startup, so a query issued in the first
|
||||
/// moments can legitimately see a peer as unavailable. Polling separates that
|
||||
/// startup window from the failure this test is about: an answer that stays
|
||||
/// partial because the peer never reports at all.
|
||||
async fn wait_for_complete_activity(cluster: &RustFSTestClusterEnvironment) -> TestResult<Vec<Value>> {
|
||||
let deadline = Instant::now() + PEER_CONVERGENCE_TIMEOUT;
|
||||
loop {
|
||||
let mut bodies = Vec::with_capacity(cluster.nodes.len());
|
||||
for node in 0..cluster.nodes.len() {
|
||||
bodies.push(tier_stats_json(cluster, node).await?);
|
||||
}
|
||||
|
||||
if bodies.iter().all(|body| body["activity"]["status"] == "complete") {
|
||||
return Ok(bodies);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!("tier-stats activity never became complete on every node: {bodies:?}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_stats_answers_the_same_cluster_result_from_either_node() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
|
||||
cluster.start().await?;
|
||||
|
||||
let bodies = wait_for_complete_activity(&cluster).await?;
|
||||
let (first, second) = (&bodies[0], &bodies[1]);
|
||||
|
||||
for (node, body) in bodies.iter().enumerate() {
|
||||
assert_eq!(body["contractVersion"], 2, "node {node} must name the current contract version");
|
||||
assert_eq!(
|
||||
body["activity"]["nodesExpected"], 2,
|
||||
"node {node} must expect both cluster members, not only itself"
|
||||
);
|
||||
assert_eq!(
|
||||
body["activity"]["nodesReporting"], 2,
|
||||
"node {node} must include its peer's rolling window in the sum"
|
||||
);
|
||||
assert_eq!(
|
||||
body["activity"]["unavailableNodes"],
|
||||
serde_json::json!([]),
|
||||
"node {node} reported a complete result while naming an unavailable member"
|
||||
);
|
||||
}
|
||||
|
||||
// A fixture cluster has no remote tier, so this pair is equal over an
|
||||
// empty list; `nodesReporting` above is what proves the peer answered.
|
||||
// The per-tier summing itself is pinned by the aggregator unit tests.
|
||||
assert_eq!(
|
||||
first["tiers"], second["tiers"],
|
||||
"the same query must not depend on which node received it"
|
||||
);
|
||||
assert_eq!(
|
||||
first["inventory"]["status"], second["inventory"]["status"],
|
||||
"both nodes read the same persisted usage snapshot"
|
||||
);
|
||||
|
||||
cluster.stop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_stats_keeps_the_legacy_body_reachable_and_rejects_unknown_formats() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
|
||||
cluster.start().await?;
|
||||
|
||||
let (status, body) = tier_stats(&cluster, 0, "?format=legacy").await?;
|
||||
assert_eq!(status, StatusCode::OK, "the pinned legacy body must stay reachable: {body}");
|
||||
let legacy: Value = serde_json::from_str(&body)?;
|
||||
assert!(
|
||||
legacy.is_object() && legacy.get("contractVersion").is_none(),
|
||||
"the legacy body is the bare tier map, not the current envelope: {legacy}"
|
||||
);
|
||||
|
||||
let (status, body) = tier_stats(&cluster, 0, "?format=v3").await?;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"an unknown format must be rejected rather than answered in another shape: {body}"
|
||||
);
|
||||
|
||||
cluster.stop();
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user