mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-06 03:59:14 +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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user