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:
Zhengchao An
2026-09-04 09:40:03 +08:00
committed by GitHub
parent 7dfc2ee5f0
commit 9863f4848d
27 changed files with 1844 additions and 46 deletions
+534 -22
View File
@@ -16,10 +16,10 @@
use crate::admin::runtime_sources::object_store_from_extensions;
use crate::admin::storage_api::runtime_sources::TierConfigMgr;
use crate::admin::storage_api::tier::{
AdminError, DailyAllTierStats, ERR_TIER_ALREADY_EXISTS, ERR_TIER_BACKEND_IN_USE, ERR_TIER_BACKEND_NOT_EMPTY,
ERR_TIER_CONNECT_ERR, ERR_TIER_INVALID_CONFIG, ERR_TIER_INVALID_CREDENTIALS, ERR_TIER_MISSING_CREDENTIALS,
ERR_TIER_NAME_NOT_UPPERCASE, ERR_TIER_NOT_FOUND, ERR_TIER_RESERVED_NAME, TierConfig, TierConfigUpdateError, TierCreds,
TierType,
AdminError, ClusterTierDailyStats, DailyAllTierStats, ECStore, ERR_TIER_ALREADY_EXISTS, ERR_TIER_BACKEND_IN_USE,
ERR_TIER_BACKEND_NOT_EMPTY, ERR_TIER_CONNECT_ERR, ERR_TIER_INVALID_CONFIG, ERR_TIER_INVALID_CREDENTIALS,
ERR_TIER_MISSING_CREDENTIALS, ERR_TIER_NAME_NOT_UPPERCASE, ERR_TIER_NOT_FOUND, ERR_TIER_RESERVED_NAME, TierConfig,
TierConfigUpdateError, TierCreds, TierType,
};
use crate::{
admin::runtime_sources::{current_daily_tier_stats, current_notification_system, current_tier_config_handle},
@@ -42,7 +42,8 @@ use s3s::{
s3_error,
};
use serde_urlencoded::from_bytes;
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::Arc;
use time::OffsetDateTime;
use tracing::{debug, warn};
@@ -174,7 +175,7 @@ fn resolve_tier_name(uri: &Uri, params: &Params<'_, '_>) -> S3Result<String> {
AddTierQuery::default()
};
Ok(require_tier_name(&query)?.to_string())
Ok(require_tier_name(query.tier.as_deref())?.to_string())
}
pub fn register_tier_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
@@ -654,6 +655,107 @@ impl Operation for VerifyTier {
}
}
/// Version of the `GET /v3/tier-stats` response body.
///
/// Version 1 was a bare `{"<TIER>": {"total_size": ...}}` map holding the
/// answering process's rolling 24-hour transition counters, with nothing in
/// the body saying that it was neither a cluster total nor an inventory.
/// Version 2 separates the two quantities and carries the reporting coverage
/// behind each. Callers pinned to version 1 request it with `?format=legacy`.
const TIER_STATS_CONTRACT_VERSION: u32 = 2;
const TIER_STATS_FORMAT_LEGACY: &str = "legacy";
#[derive(Debug, Clone, serde::Deserialize, Default)]
pub struct TierStatsQuery {
pub tier: Option<String>,
/// `legacy` returns the version 1 body. Any other value is rejected rather
/// than silently answered in the current format.
pub format: Option<String>,
}
/// Counters shared by the inventory and rolling-activity views.
///
/// The field names are the camelCase spelling admin clients expect from the
/// `madmin` tier stats shape (`totalSize`, `numVersions`, `numObjects`), not
/// the Rust field names the version 1 body leaked.
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct TierStatsBody {
total_size: u64,
num_versions: u64,
num_objects: u64,
}
impl From<TierStats> for TierStatsBody {
fn from(stats: TierStats) -> Self {
Self {
total_size: stats.total_size,
num_versions: stats.num_versions,
num_objects: stats.num_objects,
}
}
}
/// Where the stored-inventory numbers came from.
///
/// `accounted` is the only status whose per-tier `inventory` values are
/// present; the other two say why they are absent instead of reporting a
/// plausible zero (`crates/data-usage/src/data_usage.rs` documents that an
/// absent per-tier accounting means "not accounted", never "zero").
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct TierInventoryStatusBody {
status: &'static str,
/// Scanner snapshot time the inventory was taken from, RFC 3339.
#[serde(skip_serializing_if = "Option::is_none")]
updated_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
}
/// How much of the cluster the rolling activity counters cover.
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct TierActivityStatusBody {
status: &'static str,
nodes_reporting: usize,
nodes_expected: usize,
unavailable_nodes: Vec<String>,
}
#[derive(Debug, PartialEq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct TierInfoBody {
name: String,
/// The configured remote tier type. Absent when the name carries stats but
/// is not a configured remote tier: a local storage class the scanner
/// accounts for, or a tier removed since the snapshot was taken.
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
tier_type: Option<TierType>,
/// Cluster-wide stored inventory for this tier. Absent whenever
/// `inventory.status` is not `accounted`.
#[serde(skip_serializing_if = "Option::is_none")]
inventory: Option<TierStatsBody>,
/// Transitions this cluster completed into the tier during the rolling
/// 24 hours, summed over the reporting nodes. Each node counts only its
/// own completions, so a transition retried across nodes is counted once,
/// by the node that committed it.
transitions_last24h: TierStatsBody,
/// Newest hour boundary the merged rolling ring has been aged to, RFC 3339.
#[serde(skip_serializing_if = "Option::is_none")]
transitions_updated_at: Option<String>,
}
#[derive(Debug, PartialEq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct TierStatsBodyV2 {
contract_version: u32,
inventory: TierInventoryStatusBody,
activity: TierActivityStatusBody,
tiers: Vec<TierInfoBody>,
}
pub struct GetTierInfo {}
#[async_trait::async_trait]
impl Operation for GetTierInfo {
@@ -666,23 +768,27 @@ impl Operation for GetTierInfo {
let query = {
if let Some(query) = req.uri.query() {
let input: AddTierQuery =
let input: TierStatsQuery =
from_bytes(query.as_bytes()).map_err(|_e| s3_error!(InvalidArgument, "failed to decode query"))?;
input
} else {
AddTierQuery::default()
TierStatsQuery::default()
}
};
let tier_name = if query.tier.is_some() {
Some(require_tier_name(&query)?)
Some(require_tier_name(query.tier.as_deref())?)
} else {
None
};
let info = filter_tier_stats(current_daily_tier_stats(), tier_name);
let data = serde_json::to_vec(&info)
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("marshal tier err {e}")))?;
let data = if tier_stats_wants_legacy_format(&query)? {
serde_json::to_vec(&filter_tier_stats(current_daily_tier_stats(), tier_name))
} else {
let store = object_store_from_extensions(&req.extensions);
serde_json::to_vec(&tier_stats_body(store, tier_name).await)
}
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("marshal tier err {e}")))?;
let mut header = HeaderMap::new();
header.insert(CONTENT_TYPE, "application/json".parse().expect("valid header value"));
@@ -691,12 +797,171 @@ impl Operation for GetTierInfo {
}
}
fn optional_tier_name(query: &AddTierQuery) -> Option<&str> {
query.tier.as_deref().map(str::trim).filter(|tier| !tier.is_empty())
/// Assemble the version 2 body from the two independent sources.
///
/// The stored inventory comes from the persisted scanner snapshot, which is
/// already cluster-wide; the rolling activity is per node and is summed over
/// the members that answer. Neither source can stand in for the other, so a
/// failure in one leaves the other's fields populated and says so in its own
/// status.
async fn tier_stats_body(store: Option<Arc<ECStore>>, tier_name: Option<&str>) -> TierStatsBodyV2 {
let (inventory_status, inventory) = tier_inventory(store).await;
let activity = cluster_tier_daily_stats().await;
let tier_types = {
let tier_config_mgr_handle = current_tier_config_handle();
let tier_config_mgr = tier_config_mgr_handle.read().await;
tier_config_mgr
.list_tiers()
.into_iter()
.map(|tier| (tier.name, tier.tier_type))
.collect()
};
assemble_tier_stats_body(tier_types, inventory_status, inventory, activity, tier_name)
}
fn require_tier_name(query: &AddTierQuery) -> S3Result<&str> {
optional_tier_name(query).ok_or_else(|| s3_error!(InvalidArgument, "tier is required"))
/// Join the configured tiers, the stored inventory and the rolling activity
/// into one body.
///
/// A tier is listed when any of the three sources knows it: a configured tier
/// with no data yet must still appear, and a tier that carries data but no
/// configuration must not be dropped just because it cannot be typed.
fn assemble_tier_stats_body(
tier_types: HashMap<String, TierType>,
inventory_status: TierInventoryStatusBody,
inventory: HashMap<String, TierStats>,
activity: ClusterTierDailyStats,
tier_name: Option<&str>,
) -> TierStatsBodyV2 {
let mut names: BTreeSet<&str> = tier_types.keys().map(String::as_str).collect();
names.extend(inventory.keys().map(String::as_str));
names.extend(activity.stats.keys().map(String::as_str));
let tiers = names
.into_iter()
.filter(|name| tier_name.is_none_or(|requested| name.eq_ignore_ascii_case(requested)))
.map(|name| {
let daily = activity.stats.get(name);
TierInfoBody {
name: name.to_string(),
tier_type: tier_types.get(name).cloned(),
inventory: inventory.get(name).copied().map(TierStatsBody::from),
transitions_last24h: daily.map(|stats| stats.total()).unwrap_or_default().into(),
transitions_updated_at: daily.and_then(|stats| format_rfc3339(stats.updated_at())),
}
})
.collect();
TierStatsBodyV2 {
contract_version: TIER_STATS_CONTRACT_VERSION,
inventory: inventory_status,
activity: TierActivityStatusBody {
status: if activity.is_complete() { "complete" } else { "partial" },
nodes_reporting: activity.nodes_reporting,
nodes_expected: activity.nodes_expected,
unavailable_nodes: activity.unavailable_nodes,
},
tiers,
}
}
/// Resolve the requested body version.
///
/// An unrecognized value is rejected rather than answered in the current
/// format: a caller that asked for a shape it can parse must not receive a
/// different one with a 200.
fn tier_stats_wants_legacy_format(query: &TierStatsQuery) -> S3Result<bool> {
match query.format.as_deref().map(str::trim).filter(|format| !format.is_empty()) {
None => Ok(false),
Some(TIER_STATS_FORMAT_LEGACY) => Ok(true),
Some(_) => Err(invalid_tier_query("unsupported tier-stats format")),
}
}
/// The rolling ring of every cluster member that answers.
///
/// Without a notification system this process is the whole cluster it can
/// speak for, so its own ring is reported as a complete single-member result
/// rather than as a cluster total it cannot prove.
async fn cluster_tier_daily_stats() -> ClusterTierDailyStats {
let local = current_daily_tier_stats();
match current_notification_system() {
Some(notification_sys) => notification_sys.tier_daily_stats(local).await,
None => ClusterTierDailyStats {
stats: local,
nodes_reporting: 1,
nodes_expected: 1,
unavailable_nodes: Vec::new(),
},
}
}
async fn tier_inventory(store: Option<Arc<ECStore>>) -> (TierInventoryStatusBody, HashMap<String, TierStats>) {
let Some(store) = store else {
return (
TierInventoryStatusBody {
status: "unavailable",
updated_at: None,
detail: Some("object store is not initialized".to_string()),
},
HashMap::new(),
);
};
match crate::admin::storage_api::data_usage::load_admin_data_usage_from_backend_cached(store).await {
Err(err) => (
TierInventoryStatusBody {
status: "unavailable",
updated_at: None,
detail: Some(format!("usage snapshot could not be read: {err}")),
},
HashMap::new(),
),
Ok(usage) => {
let updated_at = usage.last_update.and_then(|updated| {
OffsetDateTime::from(updated)
.format(&time::format_description::well_known::Rfc3339)
.ok()
});
match usage.tier_stats {
Some(tier_stats) => (
TierInventoryStatusBody {
status: "accounted",
updated_at,
detail: None,
},
tier_stats.tiers,
),
None => (
TierInventoryStatusBody {
status: "not-accounted",
updated_at,
detail: Some("the persisted usage snapshot carries no per-tier accounting".to_string()),
},
HashMap::new(),
),
}
}
}
}
fn format_rfc3339(at: OffsetDateTime) -> Option<String> {
at.format(&time::format_description::well_known::Rfc3339).ok()
}
/// One constructor for every tier query rejection, so the mutation handlers
/// and the stats handler cannot drift into different faults for the same
/// class of bad request.
fn invalid_tier_query(message: &str) -> S3Error {
s3_error!(InvalidArgument, "{message}")
}
fn optional_tier_name(tier: Option<&str>) -> Option<&str> {
tier.map(str::trim).filter(|tier| !tier.is_empty())
}
fn require_tier_name(tier: Option<&str>) -> S3Result<&str> {
optional_tier_name(tier).ok_or_else(|| invalid_tier_query("tier is required"))
}
fn filter_tier_stats(daily_stats: DailyAllTierStats, tier_name: Option<&str>) -> HashMap<String, TierStats> {
@@ -951,7 +1216,7 @@ mod tests {
#[test]
fn require_tier_name_rejects_missing_value() {
let err = require_tier_name(&AddTierQuery::default()).expect_err("missing tier should return an error");
let err = require_tier_name(None).expect_err("missing tier should return an error");
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
assert_eq!(err.message(), Some("tier is required"));
@@ -959,11 +1224,7 @@ mod tests {
#[test]
fn require_tier_name_rejects_empty_value() {
let err = require_tier_name(&AddTierQuery {
tier: Some(" ".to_string()),
..Default::default()
})
.expect_err("empty tier should return an error");
let err = require_tier_name(Some(" ")).expect_err("empty tier should return an error");
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
assert_eq!(err.message(), Some("tier is required"));
@@ -1127,6 +1388,257 @@ mod tests {
assert_eq!(query.force, "true");
}
fn accounted_inventory() -> TierInventoryStatusBody {
TierInventoryStatusBody {
status: "accounted",
updated_at: None,
detail: None,
}
}
fn complete_activity(stats: DailyAllTierStats) -> ClusterTierDailyStats {
ClusterTierDailyStats {
stats,
nodes_reporting: 2,
nodes_expected: 2,
unavailable_nodes: Vec::new(),
}
}
#[test]
fn tier_stats_body_separates_inventory_from_rolling_activity() {
let inventory = HashMap::from([(
"WARM".to_string(),
TierStats {
total_size: 4096,
num_versions: 8,
num_objects: 8,
},
)]);
let body = assemble_tier_stats_body(
HashMap::from([("WARM".to_string(), TierType::S3)]),
accounted_inventory(),
inventory,
complete_activity(sample_daily_stats()),
Some("WARM"),
);
let warm = body.tiers.first().expect("the requested tier must be present");
assert_eq!(
warm.inventory,
Some(TierStatsBody {
total_size: 4096,
num_versions: 8,
num_objects: 8,
}),
"inventory must come from the stored accounting, not the rolling window"
);
assert_eq!(
warm.transitions_last24h,
TierStatsBody {
total_size: 15,
num_versions: 3,
num_objects: 1,
},
"rolling activity must come from the transition ring, not the inventory"
);
assert_eq!(warm.tier_type, Some(TierType::S3));
}
#[test]
fn a_configured_tier_without_data_is_still_listed() {
let body = assemble_tier_stats_body(
HashMap::from([("COLD".to_string(), TierType::S3)]),
accounted_inventory(),
HashMap::new(),
complete_activity(DailyAllTierStats::new()),
None,
);
let cold = body
.tiers
.first()
.expect("a configured tier must be listed before it has data");
assert_eq!(cold.name, "COLD");
assert_eq!(cold.inventory, None, "an unaccounted tier must not report a zero inventory");
assert_eq!(
cold.transitions_last24h,
TierStatsBody {
total_size: 0,
num_versions: 0,
num_objects: 0,
}
);
assert_eq!(cold.transitions_updated_at, None);
}
#[test]
fn a_tier_with_activity_but_no_configuration_keeps_its_counters_untyped() {
let body = assemble_tier_stats_body(
HashMap::new(),
accounted_inventory(),
HashMap::new(),
complete_activity(sample_daily_stats()),
Some("ARCHIVE"),
);
let archive = body
.tiers
.first()
.expect("a tier with data must be listed without a configuration");
assert_eq!(archive.tier_type, None, "an unconfigured name must not be given a type");
assert_eq!(
archive.transitions_last24h,
TierStatsBody {
total_size: 9,
num_versions: 1,
num_objects: 1,
}
);
}
#[test]
fn a_restarted_cluster_keeps_its_inventory_and_loses_only_the_rolling_window() {
// The rolling window lives in each process's memory and the inventory
// lives in the persisted usage snapshot, so a restart empties one and
// leaves the other intact. Reporting the emptied window as the tier's
// contents is the confusion the two fields exist to prevent.
let inventory = HashMap::from([(
"WARM".to_string(),
TierStats {
total_size: 4096,
num_versions: 8,
num_objects: 8,
},
)]);
let body = assemble_tier_stats_body(
HashMap::from([("WARM".to_string(), TierType::S3)]),
accounted_inventory(),
inventory,
complete_activity(DailyAllTierStats::new()),
Some("WARM"),
);
let warm = body.tiers.first().expect("the tier must survive a restart");
assert_eq!(
warm.inventory,
Some(TierStatsBody {
total_size: 4096,
num_versions: 8,
num_objects: 8,
}),
"a restart must not empty the stored inventory"
);
assert_eq!(
warm.transitions_last24h,
TierStatsBody {
total_size: 0,
num_versions: 0,
num_objects: 0,
},
"a restart empties the rolling window, and that must stay visible as zero activity"
);
}
#[test]
fn an_unreachable_node_makes_the_activity_partial() {
let body = assemble_tier_stats_body(
HashMap::new(),
accounted_inventory(),
HashMap::new(),
ClusterTierDailyStats {
stats: sample_daily_stats(),
nodes_reporting: 1,
nodes_expected: 2,
unavailable_nodes: vec!["10.0.0.2:9000".to_string()],
},
None,
);
assert_eq!(
body.activity.status, "partial",
"a sum over part of the cluster must not be presented as a cluster total"
);
assert_eq!(body.activity.nodes_reporting, 1);
assert_eq!(body.activity.nodes_expected, 2);
assert_eq!(body.activity.unavailable_nodes, vec!["10.0.0.2:9000".to_string()]);
}
#[test]
fn an_unavailable_inventory_omits_the_per_tier_values() {
let body = assemble_tier_stats_body(
HashMap::from([("WARM".to_string(), TierType::S3)]),
TierInventoryStatusBody {
status: "unavailable",
updated_at: None,
detail: Some("usage snapshot could not be read".to_string()),
},
HashMap::new(),
complete_activity(sample_daily_stats()),
Some("WARM"),
);
assert_eq!(body.inventory.status, "unavailable");
assert_eq!(
body.tiers.first().expect("the tier must still be listed").inventory,
None,
"an unreadable snapshot must not be rendered as an empty tier"
);
}
#[test]
fn the_body_names_its_own_contract_version() {
let body = assemble_tier_stats_body(
HashMap::new(),
accounted_inventory(),
HashMap::new(),
complete_activity(DailyAllTierStats::new()),
None,
);
let encoded = serde_json::to_value(&body).expect("the body must serialize");
assert_eq!(encoded["contractVersion"], 2);
assert!(
encoded["activity"]["nodesExpected"].is_number(),
"the activity coverage must reach the wire"
);
}
#[test]
fn tier_stats_counters_use_the_madmin_field_spelling() {
let encoded = serde_json::to_value(TierStatsBody::from(TierStats {
total_size: 1,
num_versions: 2,
num_objects: 3,
}))
.expect("counters must serialize");
assert_eq!(encoded["totalSize"], 1);
assert_eq!(encoded["numVersions"], 2);
assert_eq!(encoded["numObjects"], 3);
}
#[test]
fn the_legacy_format_is_opt_in_and_unknown_formats_are_rejected() {
assert!(!tier_stats_wants_legacy_format(&TierStatsQuery::default()).expect("no format is the current contract"));
assert!(
tier_stats_wants_legacy_format(&TierStatsQuery {
tier: None,
format: Some("legacy".to_string()),
})
.expect("legacy must stay reachable")
);
let err = tier_stats_wants_legacy_format(&TierStatsQuery {
tier: None,
format: Some("v3".to_string()),
})
.expect_err("an unknown format must not be answered in another shape");
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
}
fn sample_daily_stats() -> DailyAllTierStats {
let mut warm = LastDayTierStats::default();
warm.add_stats(TierStats {
+6 -5
View File
@@ -65,7 +65,7 @@ mod ecstore_metrics {
mod ecstore_notification {
pub(crate) use crate::storage::storage_api::ecstore_notification::{
CrossPoolFenceFleetProofToken, NotificationSys, acquire_cross_pool_fence_fleet_proof,
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, NotificationSys, acquire_cross_pool_fence_fleet_proof,
};
}
@@ -102,6 +102,7 @@ pub(crate) type ECStore = ecstore_storage::ECStore;
pub(crate) type EndpointServerPools = ecstore_layout::EndpointServerPools;
pub(crate) type MetricType = ecstore_metrics::MetricType;
pub(crate) type NotificationSys = ecstore_notification::NotificationSys;
pub(crate) type ClusterTierDailyStats = ecstore_notification::ClusterTierDailyStats;
pub(crate) type PeerRestClient = ecstore_rpc::PeerRestClient;
pub(crate) type RebalSaveOpt = ecstore_rebalance::RebalSaveOpt;
pub(crate) type RebalanceCleanupWarnings = ecstore_rebalance::RebalanceCleanupWarnings;
@@ -1031,9 +1032,9 @@ pub(crate) mod s3 {
pub(crate) mod tier {
pub(crate) use super::{
AdminError, DailyAllTierStats, ERR_TIER_ALREADY_EXISTS, ERR_TIER_BACKEND_IN_USE, ERR_TIER_BACKEND_NOT_EMPTY,
ERR_TIER_CONNECT_ERR, ERR_TIER_INVALID_CONFIG, ERR_TIER_INVALID_CREDENTIALS, ERR_TIER_MISSING_CREDENTIALS,
ERR_TIER_NAME_NOT_UPPERCASE, ERR_TIER_NOT_FOUND, ERR_TIER_RESERVED_NAME, TierConfig, TierConfigUpdateError, TierCreds,
TierType,
AdminError, ClusterTierDailyStats, DailyAllTierStats, ECStore, ERR_TIER_ALREADY_EXISTS, ERR_TIER_BACKEND_IN_USE,
ERR_TIER_BACKEND_NOT_EMPTY, ERR_TIER_CONNECT_ERR, ERR_TIER_INVALID_CONFIG, ERR_TIER_INVALID_CREDENTIALS,
ERR_TIER_MISSING_CREDENTIALS, ERR_TIER_NAME_NOT_UPPERCASE, ERR_TIER_NOT_FOUND, ERR_TIER_RESERVED_NAME, TierConfig,
TierConfigUpdateError, TierCreds, TierType,
};
}
+7
View File
@@ -2497,6 +2497,13 @@ impl Node for NodeService {
}
}
async fn tier_daily_stats(
&self,
request: Request<TierDailyStatsRequest>,
) -> Result<Response<TierDailyStatsResponse>, Status> {
self.handle_tier_daily_stats(request).await
}
async fn load_transition_tier_config(
&self,
request: Request<LoadTransitionTierConfigRequest>,
+35 -1
View File
@@ -14,11 +14,14 @@
use super::NodeService;
use crate::storage::rpc::encode_msgpack_map;
use crate::storage::storage_api::rpc_consumer::node_service::{CollectMetricsOpts, MetricType, collect_local_metrics};
use crate::storage::storage_api::rpc_consumer::node_service::{
CollectMetricsOpts, MetricType, TierDailyStatsWire, collect_local_metrics, get_global_transition_state,
};
use bytes::Bytes;
use rmp_serde::Deserializer;
use rustfs_protos::proto_gen::node_service::*;
use serde::Deserialize;
use std::collections::HashMap;
use std::io::Cursor;
use tonic::{Request, Response, Status};
use tracing::error;
@@ -72,4 +75,35 @@ impl NodeService {
})),
}
}
/// This node's own rolling-day transition counters.
///
/// Only this node's completions are reported; the caller sums the rings of
/// every member, so answering with anything wider would double count.
pub(super) async fn handle_tier_daily_stats(
&self,
_request: Request<TierDailyStatsRequest>,
) -> Result<Response<TierDailyStatsResponse>, Status> {
let stats = get_global_transition_state()
.get_daily_all_tier_stats()
.into_iter()
.map(|(tier, stats)| (tier, stats.to_wire()))
.collect::<HashMap<String, TierDailyStatsWire>>();
match encode_msgpack_map(&stats) {
Ok(buf) => Ok(Response::new(TierDailyStatsResponse {
success: true,
tier_daily_stats: buf.into(),
error_info: None,
})),
Err(err) => {
error!(error = %err, "failed to serialize tier daily stats");
Ok(Response::new(TierDailyStatsResponse {
success: false,
tier_daily_stats: Bytes::new(),
error_info: Some(err.to_string()),
}))
}
}
}
}
+5 -4
View File
@@ -261,9 +261,9 @@ pub(crate) mod rpc_consumer {
ECStore, Error, FileInfoVersions, KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, MetricType, PEER_RESTDRY_RUN,
PEER_RESTSIGNAL, PEER_RESTSUB_SYS, ReadMultipleReq, ReadMultipleResp, ReadOptions, SCANNER_PUBLICATION_LEASE_TTL_MS,
SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC, StorageDiskRpcExt, StoragePeerS3ClientExt,
UpdateMetadataOpts, all_local_disk_path, collect_local_metrics, find_local_disk_by_ref, get_local_server_property,
reload_bucket_metadata, reload_transition_tier_config, remove_bucket_metadata,
validate_batch_read_version_item_count,
TierDailyStatsWire, UpdateMetadataOpts, all_local_disk_path, collect_local_metrics, find_local_disk_by_ref,
get_global_transition_state, get_local_server_property, reload_bucket_metadata, reload_transition_tier_config,
remove_bucket_metadata, validate_batch_read_version_item_count,
};
pub(crate) type StorageResult<T> = super::super::Result<T>;
@@ -511,7 +511,7 @@ pub(crate) mod ecstore_notification {
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::notification::rotate_cross_pool_fence_fleet_proof_for_test;
pub(crate) use rustfs_ecstore::api::notification::{
CrossPoolFenceFleetProofToken, NotificationSys, acquire_cross_pool_fence_fleet_proof,
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, NotificationSys, acquire_cross_pool_fence_fleet_proof,
cross_pool_fence_fleet_proof_matches, get_global_notification_sys, new_global_notification_sys,
start_remote_version_state_fleet_probe,
};
@@ -662,6 +662,7 @@ pub(crate) type BucketBandwidthMonitor = ecstore_bucket::bandwidth::monitor::Mon
pub(crate) type CheckPartsResp = ecstore_disk::CheckPartsResp;
pub(crate) type CollectMetricsOpts = ecstore_metrics::CollectMetricsOpts;
pub(crate) type DailyAllTierStats = ecstore_bucket::lifecycle::tier_last_day_stats::DailyAllTierStats;
pub(crate) type TierDailyStatsWire = ecstore_bucket::lifecycle::tier_last_day_stats::TierDailyStatsWire;
pub(crate) type DeleteOptions = ecstore_disk::DeleteOptions;
pub(crate) type DiskError = ecstore_disk::error::DiskError;
pub(crate) type DiskInfo = ecstore_disk::DiskInfo;