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:
@@ -118,7 +118,7 @@ pub mod bucket {
|
||||
}
|
||||
|
||||
pub mod tier_last_day_stats {
|
||||
pub use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats};
|
||||
pub use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats, TierDailyStatsWire};
|
||||
}
|
||||
|
||||
pub mod tier_sweeper {
|
||||
@@ -472,7 +472,7 @@ pub mod notification {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use crate::services::notification_sys::rotate_cross_pool_fence_fleet_proof_for_test;
|
||||
pub use crate::services::notification_sys::{
|
||||
CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
|
||||
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
|
||||
acquire_cross_pool_fence_fleet_proof, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
|
||||
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#![allow(clippy::all)]
|
||||
|
||||
use rustfs_data_usage::TierStats;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Sub;
|
||||
@@ -27,7 +28,24 @@ use tracing::{error, warn};
|
||||
|
||||
pub type DailyAllTierStats = HashMap<String, LastDayTierStats>;
|
||||
|
||||
#[derive(Clone)]
|
||||
/// One bin per hour of the rolling day. The bin index is the UTC hour, so the
|
||||
/// array is a ring the writer ages forward rather than a queue.
|
||||
pub const TIER_DAILY_STATS_BINS: usize = 24;
|
||||
|
||||
/// Interchange form of [`LastDayTierStats`] for the internode tier-stats RPC.
|
||||
///
|
||||
/// The in-memory type keeps its bins private because the ring is only
|
||||
/// meaningful together with `updated_at`; this type carries both across the
|
||||
/// wire and is validated back into the ring by [`LastDayTierStats::from_wire`].
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TierDailyStatsWire {
|
||||
pub bins: Vec<TierStats>,
|
||||
/// Seconds since the Unix epoch. Bins are hour-resolution, so a coarser
|
||||
/// timestamp than the in-memory `OffsetDateTime` loses nothing.
|
||||
pub updated_at_unix_secs: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LastDayTierStats {
|
||||
bins: [TierStats; 24],
|
||||
updated_at: OffsetDateTime,
|
||||
@@ -80,11 +98,57 @@ impl LastDayTierStats {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "asserted by this file's tests; the lib target cannot see test-only consumers (backlog#1823)"
|
||||
)]
|
||||
fn merge(&self, m: LastDayTierStats) -> LastDayTierStats {
|
||||
/// The rolling ring as observed, without aging it forward.
|
||||
///
|
||||
/// Only meaningful together with [`LastDayTierStats::updated_at`]: a bin
|
||||
/// belongs to the hour of its index within the day that ends at
|
||||
/// `updated_at`.
|
||||
pub fn bins(&self) -> &[TierStats; TIER_DAILY_STATS_BINS] {
|
||||
&self.bins
|
||||
}
|
||||
|
||||
pub fn updated_at(&self) -> OffsetDateTime {
|
||||
self.updated_at
|
||||
}
|
||||
|
||||
pub fn to_wire(&self) -> TierDailyStatsWire {
|
||||
TierDailyStatsWire {
|
||||
bins: self.bins.to_vec(),
|
||||
updated_at_unix_secs: self.updated_at.unix_timestamp(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the ring from a peer's response.
|
||||
///
|
||||
/// A ring of the wrong width or an unrepresentable timestamp is corrupt
|
||||
/// peer input, not a zero sample: it returns an error so the caller can
|
||||
/// report the node as non-reporting instead of merging a plausible but
|
||||
/// wrong day into a cluster total.
|
||||
pub fn from_wire(wire: TierDailyStatsWire) -> Result<Self, std::io::Error> {
|
||||
let bins: [TierStats; TIER_DAILY_STATS_BINS] = wire.bins.try_into().map_err(|bins: Vec<TierStats>| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("tier daily stats must carry {TIER_DAILY_STATS_BINS} bins, got {}", bins.len()),
|
||||
)
|
||||
})?;
|
||||
let updated_at = OffsetDateTime::from_unix_timestamp(wire.updated_at_unix_secs).map_err(|err| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("tier daily stats carry an unrepresentable timestamp: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Self { bins, updated_at })
|
||||
}
|
||||
|
||||
/// Combine two independently observed rings.
|
||||
///
|
||||
/// Each node counts only the transitions it completed itself, so summing
|
||||
/// bins across nodes is a cluster total rather than a double count. The
|
||||
/// older ring is aged forward to the newer one's clock first, so a node
|
||||
/// that stopped transitioning hours ago contributes its still-current
|
||||
/// bins and not its expired ones.
|
||||
pub fn merge(&self, m: LastDayTierStats) -> LastDayTierStats {
|
||||
let mut cl = self.clone();
|
||||
let mut cm = m;
|
||||
let mut merged = LastDayTierStats::default();
|
||||
@@ -108,6 +172,7 @@ impl LastDayTierStats {
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use time::Duration;
|
||||
|
||||
#[test]
|
||||
fn total_sums_all_recorded_stats() {
|
||||
@@ -132,4 +197,83 @@ mod test {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn sample(total_size: u64) -> TierStats {
|
||||
TierStats {
|
||||
total_size,
|
||||
num_versions: 1,
|
||||
num_objects: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_round_trip_preserves_the_ring_and_its_clock() {
|
||||
let mut stats = LastDayTierStats::default();
|
||||
stats.add_stats(sample(10));
|
||||
|
||||
let restored = LastDayTierStats::from_wire(stats.to_wire()).expect("a ring this node produced must decode");
|
||||
|
||||
assert_eq!(restored.bins(), stats.bins(), "every bin must survive the wire");
|
||||
assert_eq!(
|
||||
restored.updated_at().unix_timestamp(),
|
||||
stats.updated_at().unix_timestamp(),
|
||||
"the ring's clock must survive the wire"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ring_of_the_wrong_width_is_rejected() {
|
||||
let mut wire = LastDayTierStats::default().to_wire();
|
||||
wire.bins.pop();
|
||||
|
||||
let err = LastDayTierStats::from_wire(wire).expect_err("a short ring must not decode as a zero day");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unrepresentable_clock_is_rejected() {
|
||||
let mut wire = LastDayTierStats::default().to_wire();
|
||||
wire.updated_at_unix_secs = i64::MIN;
|
||||
|
||||
let err = LastDayTierStats::from_wire(wire).expect_err("an unrepresentable clock must not decode");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_sums_two_nodes_that_transitioned_in_the_same_hour() {
|
||||
let mut left = LastDayTierStats::default();
|
||||
left.add_stats(sample(10));
|
||||
let mut right = LastDayTierStats::default();
|
||||
right.add_stats(sample(20));
|
||||
|
||||
assert_eq!(
|
||||
left.merge(right).total(),
|
||||
TierStats {
|
||||
total_size: 30,
|
||||
num_versions: 2,
|
||||
num_objects: 2,
|
||||
},
|
||||
"each node counts only its own completions, so a merge is a cluster total"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_ages_out_a_peer_ring_older_than_a_day() {
|
||||
let mut stale = LastDayTierStats::default();
|
||||
stale.add_stats(sample(10));
|
||||
stale.updated_at -= Duration::days(2);
|
||||
|
||||
let mut fresh = LastDayTierStats::default();
|
||||
fresh.add_stats(sample(20));
|
||||
|
||||
assert_eq!(
|
||||
fresh.merge(stale).total(),
|
||||
TierStats {
|
||||
total_size: 20,
|
||||
num_versions: 1,
|
||||
num_objects: 1,
|
||||
},
|
||||
"a node that stopped transitioning more than a day ago must not keep contributing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::storage_api_contracts::internode::{
|
||||
SCANNER_ACTIVITY_V6_PROTOCOL_VERSION,
|
||||
};
|
||||
use crate::{
|
||||
bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats, TierDailyStatsWire},
|
||||
bucket::replication::BucketStats,
|
||||
disk::disk_store::{get_drive_active_check_interval, get_drive_active_check_timeout},
|
||||
layout::endpoints::EndpointServerPools,
|
||||
@@ -48,7 +49,7 @@ use rustfs_protos::proto_gen::node_service::{
|
||||
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ReplacementRecoveryStatusRequest,
|
||||
ScannerActivityRequest, ScannerActivityResponse, ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseRequest,
|
||||
ScannerPublicationLeaseResponse, ServerInfoRequest, SignalServiceRequest, SignalServiceResponse, StartDecommissionRequest,
|
||||
StartProfilingRequest, StopRebalanceRequest, TierMutationAbortRequest, TierMutationCommitRequest,
|
||||
StartProfilingRequest, StopRebalanceRequest, TierDailyStatsRequest, TierMutationAbortRequest, TierMutationCommitRequest,
|
||||
TierMutationControlResponse, TierMutationFailureClass, TierMutationPeerState, TierMutationPrepareRequest,
|
||||
node_service_client::NodeServiceClient, tier_mutation_control_service_client::TierMutationControlServiceClient,
|
||||
};
|
||||
@@ -986,6 +987,40 @@ impl PeerRestClient {
|
||||
Ok(cpus)
|
||||
}
|
||||
|
||||
/// This peer's own rolling-day transition counters, per remote tier.
|
||||
///
|
||||
/// The response is untrusted peer input: a ring of the wrong width or an
|
||||
/// unrepresentable clock is rejected here rather than merged, so a corrupt
|
||||
/// answer makes the node non-reporting instead of silently shifting a
|
||||
/// cluster total.
|
||||
pub async fn tier_daily_stats(&self) -> Result<DailyAllTierStats> {
|
||||
self.finalize_result(self.tier_daily_stats_inner().await).await
|
||||
}
|
||||
|
||||
async fn tier_daily_stats_inner(&self) -> Result<DailyAllTierStats> {
|
||||
let mut client = self.get_client().await?;
|
||||
let request = Request::new(TierDailyStatsRequest {});
|
||||
|
||||
let response = client.tier_daily_stats(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("tier_daily_stats", None));
|
||||
}
|
||||
|
||||
let mut buf = Deserializer::new(Cursor::new(response.tier_daily_stats));
|
||||
let wire: HashMap<String, TierDailyStatsWire> = Deserialize::deserialize(&mut buf)?;
|
||||
|
||||
wire.into_iter()
|
||||
.map(|(tier, stats)| {
|
||||
LastDayTierStats::from_wire(stats)
|
||||
.map(|stats| (tier, stats))
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn get_net_info(&self) -> Result<NetInfo> {
|
||||
self.finalize_result(self.get_net_info_inner().await).await
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::bucket::lifecycle::tier_last_day_stats::DailyAllTierStats;
|
||||
use crate::cluster::rpc::{PeerRestClient, ScannerPeerActivity, ScannerPublicationLease, TierConfigReloadOutcome};
|
||||
use crate::diagnostics::admin_server_info::get_commit_id;
|
||||
use crate::disk::DiskAPI;
|
||||
@@ -50,6 +51,7 @@ const LOG_SUBSYSTEM_NOTIFICATION: &str = "notification";
|
||||
const EVENT_NOTIFICATION_PEER_PROPAGATION: &str = "notification_peer_propagation";
|
||||
const EVENT_NOTIFICATION_CAPABILITY_PROBE: &str = "notification_capability_probe";
|
||||
const SCANNER_ACTIVITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const TIER_DAILY_STATS_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const TIER_CONFIG_RELOAD_RETRY_BASE: Duration = Duration::from_millis(100);
|
||||
const TIER_CONFIG_RELOAD_RETRY_CAP: Duration = Duration::from_secs(5);
|
||||
const REMOTE_VERSION_STATE_PROBE_INTERVAL: Duration = Duration::from_secs(10);
|
||||
@@ -973,6 +975,120 @@ impl NotificationSys {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolling tier activity summed over every cluster member that answered, with
|
||||
/// the reporting coverage behind the sum.
|
||||
///
|
||||
/// The coverage is part of the result rather than a log line: a sum over a
|
||||
/// subset of the cluster is not a cluster total, and a caller that renders it
|
||||
/// as one is the defect this type exists to prevent.
|
||||
pub struct ClusterTierDailyStats {
|
||||
pub stats: DailyAllTierStats,
|
||||
/// Members whose rolling day is included, always at least this node.
|
||||
pub nodes_reporting: usize,
|
||||
/// Members this deployment expects to hear from, including this node.
|
||||
pub nodes_expected: usize,
|
||||
/// Members that could not be asked, could not answer, or answered with a
|
||||
/// ring this build refuses to merge. Sorted, and named by grid host.
|
||||
pub unavailable_nodes: Vec<String>,
|
||||
}
|
||||
|
||||
impl ClusterTierDailyStats {
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.unavailable_nodes.is_empty() && self.nodes_reporting == self.nodes_expected
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold one member's rolling day into the running cluster total.
|
||||
///
|
||||
/// Merging (rather than adding totals) ages each member's ring to the newer
|
||||
/// clock first, so a member that stopped transitioning yesterday contributes
|
||||
/// only the hours still inside the rolling day.
|
||||
fn merge_tier_daily_stats(into: &mut DailyAllTierStats, from: DailyAllTierStats) {
|
||||
for (tier, stats) in from {
|
||||
match into.remove(&tier) {
|
||||
Some(existing) => {
|
||||
into.insert(tier, existing.merge(stats));
|
||||
}
|
||||
None => {
|
||||
into.insert(tier, stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationSys {
|
||||
/// Sum this node's rolling tier activity with every reachable peer's.
|
||||
///
|
||||
/// Each node records only the transitions it completed itself, so the sum
|
||||
/// is a cluster total and a retried transition is counted once, by the
|
||||
/// node that finally committed it. Peers are probed concurrently under a
|
||||
/// per-peer deadline so one black-holed member cannot hold the admin
|
||||
/// request open.
|
||||
pub async fn tier_daily_stats(&self, local: DailyAllTierStats) -> ClusterTierDailyStats {
|
||||
let mut stats = local;
|
||||
let nodes_expected = self.peer_clients.len() + 1;
|
||||
let mut nodes_reporting = 1;
|
||||
let mut unavailable_nodes = Vec::new();
|
||||
|
||||
let mut probes = Vec::with_capacity(self.peer_clients.len());
|
||||
for (idx, client) in self.peer_clients.iter().enumerate() {
|
||||
let host = self.tier_daily_stats_peer_host(idx, client.as_ref());
|
||||
probes.push(async move {
|
||||
let Some(client) = client.as_ref() else {
|
||||
return (host, Err(Error::other("peer is not reachable")));
|
||||
};
|
||||
// The peer is already named by the caller's `peer` log field
|
||||
// and by `unavailable_nodes`, so the deadline is reported as
|
||||
// the typed variant rather than a formatted fragment.
|
||||
let result = timeout(TIER_DAILY_STATS_PROBE_TIMEOUT, client.tier_daily_stats())
|
||||
.await
|
||||
.unwrap_or(Err(Error::Timeout));
|
||||
(host, result)
|
||||
});
|
||||
}
|
||||
|
||||
for (host, result) in join_all(probes).await {
|
||||
match result {
|
||||
Ok(peer_stats) => {
|
||||
nodes_reporting += 1;
|
||||
merge_tier_daily_stats(&mut stats, peer_stats);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event = EVENT_NOTIFICATION_CAPABILITY_PROBE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
|
||||
peer = host,
|
||||
error = %err,
|
||||
"tier daily stats peer did not report"
|
||||
);
|
||||
unavailable_nodes.push(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unavailable_nodes.sort();
|
||||
ClusterTierDailyStats {
|
||||
stats,
|
||||
nodes_reporting,
|
||||
nodes_expected,
|
||||
unavailable_nodes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Name a peer slot even when no client was ever built for it, so an
|
||||
/// unreachable member is reported by host instead of disappearing.
|
||||
fn tier_daily_stats_peer_host(&self, idx: usize, client: Option<&PeerRestClient>) -> String {
|
||||
if let Some(client) = client {
|
||||
return client.grid_host.clone();
|
||||
}
|
||||
self.peer_topology_hosts
|
||||
.get(idx)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("peer[{idx}]"))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NotificationPeerErr {
|
||||
pub host: String,
|
||||
pub err: Option<Error>,
|
||||
@@ -2935,6 +3051,65 @@ fn aggregate_scanner_dirty_usage_acknowledgement_results(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bucket::lifecycle::tier_last_day_stats::LastDayTierStats;
|
||||
use rustfs_data_usage::TierStats;
|
||||
|
||||
fn ring(total_size: u64) -> LastDayTierStats {
|
||||
let mut stats = LastDayTierStats::default();
|
||||
stats.add_stats(TierStats {
|
||||
total_size,
|
||||
num_versions: 1,
|
||||
num_objects: 1,
|
||||
});
|
||||
stats
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_peer_rings_sums_each_tier_without_double_counting() {
|
||||
let mut cluster = DailyAllTierStats::from([("WARM".to_string(), ring(10))]);
|
||||
|
||||
merge_tier_daily_stats(
|
||||
&mut cluster,
|
||||
DailyAllTierStats::from([("WARM".to_string(), ring(20)), ("COLD".to_string(), ring(5))]),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
cluster.get("WARM").expect("the shared tier must survive the merge").total(),
|
||||
TierStats {
|
||||
total_size: 30,
|
||||
num_versions: 2,
|
||||
num_objects: 2,
|
||||
},
|
||||
"both nodes' completions belong in the cluster total"
|
||||
);
|
||||
assert_eq!(
|
||||
cluster.get("COLD").expect("a tier only one node saw must be kept").total(),
|
||||
TierStats {
|
||||
total_size: 5,
|
||||
num_versions: 1,
|
||||
num_objects: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_member_result_is_complete_and_a_missing_peer_is_not() {
|
||||
let complete = ClusterTierDailyStats {
|
||||
stats: DailyAllTierStats::new(),
|
||||
nodes_reporting: 1,
|
||||
nodes_expected: 1,
|
||||
unavailable_nodes: Vec::new(),
|
||||
};
|
||||
assert!(complete.is_complete(), "a single-member deployment reports its whole cluster");
|
||||
|
||||
let partial = ClusterTierDailyStats {
|
||||
stats: DailyAllTierStats::new(),
|
||||
nodes_reporting: 1,
|
||||
nodes_expected: 2,
|
||||
unavailable_nodes: vec!["node-b:9000".to_string()],
|
||||
};
|
||||
assert!(!partial.is_complete(), "a silent member must make the sum partial");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_pool_policy_versions_authorize_only_their_supported_protocols() {
|
||||
|
||||
@@ -38,7 +38,7 @@ const WASABI_ALTERNATIVE_ENDPOINTS: &[(&str, &str)] = &[
|
||||
("eu-south-1", "https://s3.it-1.wasabisys.com"),
|
||||
];
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
|
||||
pub enum TierType {
|
||||
#[default]
|
||||
Unsupported,
|
||||
|
||||
@@ -43,6 +43,7 @@ use rustfs_s3_client::{
|
||||
api_put_object::{AdvancedPutOptions, PutObjectOptions},
|
||||
transition_api::{ReadCloser, ReaderImpl},
|
||||
};
|
||||
use rustfs_scanner_metrics::metrics::{TierRequestOperation, TierRequestOutcome, global_metrics};
|
||||
use rustfs_utils::egress::validate_outbound_url;
|
||||
use rustfs_utils::http::headers::{
|
||||
CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, HeaderExt as _,
|
||||
@@ -346,6 +347,110 @@ pub(crate) fn optimal_part_size(object_size: i64, min_part_size: i64) -> Result<
|
||||
Ok(part_size)
|
||||
}
|
||||
|
||||
/// Counts every remote-tier request exactly once, whatever backend performs it.
|
||||
///
|
||||
/// The counters are the only production update path for the `tier` request
|
||||
/// metrics, and they must not grow with tier names, endpoints or object keys,
|
||||
/// so the wrapper deliberately records nothing but the fixed operation and
|
||||
/// outcome labels. Wrapping here rather than inside each provider keeps a new
|
||||
/// backend counted by construction, and keeps one call per request: the
|
||||
/// overridden `remove_exact` delegates to the inner backend, so the inner
|
||||
/// default's own `remove` cannot count the same request a second time.
|
||||
struct MeteredWarmBackend {
|
||||
inner: WarmBackendImpl,
|
||||
}
|
||||
|
||||
impl MeteredWarmBackend {
|
||||
fn record<T>(operation: TierRequestOperation, result: Result<T, std::io::Error>) -> Result<T, std::io::Error> {
|
||||
let outcome = match &result {
|
||||
Ok(_) => TierRequestOutcome::Success,
|
||||
Err(err) => TierRequestOutcome::from_error(err),
|
||||
};
|
||||
global_metrics().record_tier_request(operation, outcome);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl WarmBackend for MeteredWarmBackend {
|
||||
/// Delegated without a counter: only one backend issues a remote request
|
||||
/// here, and every other one takes the trait default, so a `validate`
|
||||
/// counter would mostly record requests that never happened.
|
||||
async fn validate(&self) -> Result<(), std::io::Error> {
|
||||
self.inner.validate().await
|
||||
}
|
||||
|
||||
/// A local check of a value this process already holds; it issues no
|
||||
/// remote request, so it is delegated without a counter.
|
||||
fn validate_remote_version_id(&self, remote_version_id: &str) -> Result<(), std::io::Error> {
|
||||
self.inner.validate_remote_version_id(remote_version_id)
|
||||
}
|
||||
|
||||
async fn put(&self, object: &str, r: ReaderImpl, length: i64) -> Result<String, std::io::Error> {
|
||||
Self::record(TierRequestOperation::Put, self.inner.put(object, r, length).await)
|
||||
}
|
||||
|
||||
async fn put_with_meta(
|
||||
&self,
|
||||
object: &str,
|
||||
r: ReaderImpl,
|
||||
length: i64,
|
||||
meta: HashMap<String, String>,
|
||||
) -> Result<String, std::io::Error> {
|
||||
Self::record(TierRequestOperation::Put, self.inner.put_with_meta(object, r, length, meta).await)
|
||||
}
|
||||
|
||||
async fn get(&self, object: &str, rv: &str, opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error> {
|
||||
Self::record(TierRequestOperation::Get, self.inner.get(object, rv, opts).await)
|
||||
}
|
||||
|
||||
async fn remove(&self, object: &str, rv: &str) -> Result<(), std::io::Error> {
|
||||
Self::record(TierRequestOperation::Remove, self.inner.remove(object, rv).await)
|
||||
}
|
||||
|
||||
async fn remove_exact(&self, object: &str, rv: &str) -> Result<(), std::io::Error> {
|
||||
Self::record(TierRequestOperation::Remove, self.inner.remove_exact(object, rv).await)
|
||||
}
|
||||
|
||||
async fn probe_transition_candidate(&self, object: &str) -> Result<TransitionCandidateProbe, std::io::Error> {
|
||||
let result = self.inner.probe_transition_candidate(object).await;
|
||||
// `Unsupported` is the trait default: the backend issued no request,
|
||||
// so counting it would inflate the probe counter on every backend that
|
||||
// does not implement probing.
|
||||
if matches!(result, Ok(TransitionCandidateProbe::Unsupported)) {
|
||||
return result;
|
||||
}
|
||||
Self::record(TierRequestOperation::Probe, result)
|
||||
}
|
||||
|
||||
async fn in_use(&self) -> Result<bool, std::io::Error> {
|
||||
Self::record(TierRequestOperation::InUse, self.inner.in_use().await)
|
||||
}
|
||||
}
|
||||
|
||||
/// The reconciler is a second remote probe path, reached from recovery rather
|
||||
/// than from the `WarmBackend` handle, so it needs its own counter: a `probe`
|
||||
/// counter that saw only one of the two paths would read as a complete count
|
||||
/// while missing every recovery probe.
|
||||
struct MeteredTransitionCandidateReconciler {
|
||||
inner: Box<dyn TransitionCandidateReconciler + Send + Sync + 'static>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TransitionCandidateReconciler for MeteredTransitionCandidateReconciler {
|
||||
async fn probe_transition_candidate_for(
|
||||
&self,
|
||||
object: &str,
|
||||
identity: TransitionCandidateIdentity,
|
||||
) -> Result<TransitionCandidateProbe, std::io::Error> {
|
||||
let result = self.inner.probe_transition_candidate_for(object, identity).await;
|
||||
if matches!(result, Ok(TransitionCandidateProbe::Unsupported)) {
|
||||
return result;
|
||||
}
|
||||
MeteredWarmBackend::record(TierRequestOperation::Probe, result)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_warm_backend(w: Option<&WarmBackendImpl>) -> Result<(), AdminError> {
|
||||
let w = w.ok_or_else(|| ERR_TIER_NOT_FOUND.clone())?;
|
||||
w.validate().await.map_err(|_| ERR_TIER_INVALID_CONFIG.clone())?;
|
||||
@@ -593,6 +698,8 @@ pub async fn new_warm_backend(tier: &TierConfig, probe: bool) -> Result<WarmBack
|
||||
status_code: StatusCode::BAD_REQUEST,
|
||||
})?;
|
||||
|
||||
let d: WarmBackendImpl = Box::new(MeteredWarmBackend { inner: d });
|
||||
|
||||
if probe {
|
||||
d.validate().await.map_err(|_| ERR_TIER_INVALID_CONFIG.clone())?;
|
||||
}
|
||||
@@ -641,7 +748,7 @@ pub(crate) async fn new_transition_candidate_reconciler(
|
||||
),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
Ok(Some(reconciler))
|
||||
Ok(Some(Box::new(MeteredTransitionCandidateReconciler { inner: reconciler })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -655,6 +762,156 @@ mod tests {
|
||||
|
||||
const PROBE_VERSION: &str = "remote-v2";
|
||||
|
||||
struct CountingBackend {
|
||||
put_result: fn() -> Result<String, std::io::Error>,
|
||||
removes: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl WarmBackend for CountingBackend {
|
||||
async fn put(&self, _object: &str, _r: ReaderImpl, _length: i64) -> Result<String, std::io::Error> {
|
||||
(self.put_result)()
|
||||
}
|
||||
|
||||
async fn put_with_meta(
|
||||
&self,
|
||||
object: &str,
|
||||
r: ReaderImpl,
|
||||
length: i64,
|
||||
_meta: HashMap<String, String>,
|
||||
) -> Result<String, std::io::Error> {
|
||||
self.put(object, r, length).await
|
||||
}
|
||||
|
||||
async fn get(&self, _object: &str, _rv: &str, _opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error> {
|
||||
Err(std::io::Error::other("unused"))
|
||||
}
|
||||
|
||||
async fn remove(&self, _object: &str, _rv: &str) -> Result<(), std::io::Error> {
|
||||
self.removes.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn in_use(&self) -> Result<bool, std::io::Error> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn tier_cell(operation: TierRequestOperation, outcome: TierRequestOutcome) -> u64 {
|
||||
global_metrics()
|
||||
.tier_request_counts()
|
||||
.into_iter()
|
||||
.find(|count| count.operation == operation && count.outcome == outcome)
|
||||
.map(|count| count.count)
|
||||
.expect("every operation/outcome cell must be reported")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_metered_put_counts_its_outcome_once() {
|
||||
let before_success = tier_cell(TierRequestOperation::Put, TierRequestOutcome::Success);
|
||||
let before_failure = tier_cell(TierRequestOperation::Put, TierRequestOutcome::BackendError);
|
||||
let backend = MeteredWarmBackend {
|
||||
inner: Box::new(CountingBackend {
|
||||
put_result: || Ok("remote-v1".to_string()),
|
||||
removes: Arc::new(AtomicUsize::new(0)),
|
||||
}),
|
||||
};
|
||||
|
||||
backend
|
||||
.put("object", ReaderImpl::Body(Bytes::from_static(b"payload")), 7)
|
||||
.await
|
||||
.expect("the fake backend accepts the put");
|
||||
|
||||
assert_eq!(
|
||||
tier_cell(TierRequestOperation::Put, TierRequestOutcome::Success),
|
||||
before_success + 1,
|
||||
"an acknowledged put must be counted once"
|
||||
);
|
||||
assert_eq!(
|
||||
tier_cell(TierRequestOperation::Put, TierRequestOutcome::BackendError),
|
||||
before_failure,
|
||||
"a success must not also increment a failure cell"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_metered_put_failure_is_classified_by_error_kind() {
|
||||
let before_timeout = tier_cell(TierRequestOperation::Put, TierRequestOutcome::Timeout);
|
||||
let backend = MeteredWarmBackend {
|
||||
inner: Box::new(CountingBackend {
|
||||
put_result: || Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "endpoint stalled")),
|
||||
removes: Arc::new(AtomicUsize::new(0)),
|
||||
}),
|
||||
};
|
||||
|
||||
backend
|
||||
.put("object", ReaderImpl::Body(Bytes::from_static(b"payload")), 7)
|
||||
.await
|
||||
.expect_err("the fake backend rejects the put");
|
||||
|
||||
assert_eq!(
|
||||
tier_cell(TierRequestOperation::Put, TierRequestOutcome::Timeout),
|
||||
before_timeout + 1,
|
||||
"a timed-out request must land in the timeout cell"
|
||||
);
|
||||
}
|
||||
|
||||
fn tier_probe_total() -> u64 {
|
||||
global_metrics()
|
||||
.tier_request_counts()
|
||||
.into_iter()
|
||||
.filter(|count| count.operation == TierRequestOperation::Probe)
|
||||
.map(|count| count.count)
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unsupported_probe_is_not_counted_as_a_request() {
|
||||
let before = tier_probe_total();
|
||||
let backend = MeteredWarmBackend {
|
||||
inner: Box::new(CountingBackend {
|
||||
put_result: || Ok(String::new()),
|
||||
removes: Arc::new(AtomicUsize::new(0)),
|
||||
}),
|
||||
};
|
||||
|
||||
let probe = backend
|
||||
.probe_transition_candidate("object")
|
||||
.await
|
||||
.expect("the trait default answers without a remote request");
|
||||
|
||||
assert_eq!(probe, TransitionCandidateProbe::Unsupported);
|
||||
assert_eq!(
|
||||
tier_probe_total(),
|
||||
before,
|
||||
"a backend that issues no probe request must not appear in the probe counters"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_exact_remove_is_counted_once_not_twice() {
|
||||
let before = tier_cell(TierRequestOperation::Remove, TierRequestOutcome::Success);
|
||||
let removes = Arc::new(AtomicUsize::new(0));
|
||||
let backend = MeteredWarmBackend {
|
||||
inner: Box::new(CountingBackend {
|
||||
put_result: || Ok(String::new()),
|
||||
removes: Arc::clone(&removes),
|
||||
}),
|
||||
};
|
||||
|
||||
backend
|
||||
.remove_exact("object", "remote-v1")
|
||||
.await
|
||||
.expect("the fake backend accepts the remove");
|
||||
|
||||
assert_eq!(removes.load(Ordering::SeqCst), 1, "the inner backend performs one request");
|
||||
assert_eq!(
|
||||
tier_cell(TierRequestOperation::Remove, TierRequestOutcome::Success),
|
||||
before + 1,
|
||||
"the default remove_exact must not count its own inner remove a second time"
|
||||
);
|
||||
}
|
||||
|
||||
struct RejectingValidationBackend {
|
||||
validations: Arc<AtomicUsize>,
|
||||
puts: Arc<AtomicUsize>,
|
||||
|
||||
Reference in New Issue
Block a user