mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-11 23:56:53 +00:00
fix: report stalled object traffic as unready (#5936)
* fix: report stalled object traffic as unready * fix: track fully received PUT storage progress
This commit is contained in:
@@ -97,6 +97,14 @@ Current guidance:
|
||||
- enables minimal payload mode for GET health responses (`status`, `ready` only).
|
||||
- `RUSTFS_HEALTH_READINESS_CACHE_TTL_MS`
|
||||
- TTL for readiness cache evaluation.
|
||||
- `RUSTFS_HEALTH_OBJECT_PROGRESS_ENABLE`
|
||||
- withdraws readiness when bounded object read/write stages stop completing while requests remain active.
|
||||
- default is `true`.
|
||||
- `RUSTFS_HEALTH_OBJECT_PROGRESS_TIMEOUT_MS`
|
||||
- maximum time without completion in a bounded object stage before readiness is withdrawn.
|
||||
- default is `30000`; `0` uses the default.
|
||||
- the effective value is at least 5 seconds longer than `RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT`.
|
||||
- this readiness SLO is independent of disk read/write failure deadlines and may withdraw traffic before those deadlines expire.
|
||||
- `RUSTFS_HEALTH_COMPAT_BUSY_CHECK_ENABLE`
|
||||
- enables busy protection behavior for health probes.
|
||||
- default is `false`.
|
||||
|
||||
@@ -22,6 +22,19 @@ pub const DEFAULT_HEALTH_ENDPOINT_ENABLE: bool = true;
|
||||
pub const ENV_HEALTH_READINESS_CACHE_TTL_MS: &str = "RUSTFS_HEALTH_READINESS_CACHE_TTL_MS";
|
||||
pub const DEFAULT_HEALTH_READINESS_CACHE_TTL_MS: u64 = 1000;
|
||||
|
||||
/// Enable readiness withdrawal when bounded object read/write stages stop
|
||||
/// completing while requests remain active.
|
||||
pub const ENV_HEALTH_OBJECT_PROGRESS_ENABLE: &str = "RUSTFS_HEALTH_OBJECT_PROGRESS_ENABLE";
|
||||
pub const DEFAULT_HEALTH_OBJECT_PROGRESS_ENABLE: bool = true;
|
||||
|
||||
/// Requested time without completion in a bounded object stage before local
|
||||
/// readiness is withdrawn (milliseconds). A value of `0` uses the default;
|
||||
/// runtime adds a safety floor based on the object-lock acquisition timeout.
|
||||
pub const ENV_HEALTH_OBJECT_PROGRESS_TIMEOUT_MS: &str = "RUSTFS_HEALTH_OBJECT_PROGRESS_TIMEOUT_MS";
|
||||
pub const DEFAULT_HEALTH_OBJECT_PROGRESS_TIMEOUT_MS: u64 = 30_000;
|
||||
/// Additional time beyond the configured object-lock acquisition deadline.
|
||||
pub const HEALTH_OBJECT_PROGRESS_LOCK_MARGIN_MS: u64 = 5_000;
|
||||
|
||||
/// Timeout for cluster health readiness collectors (milliseconds).
|
||||
/// This bounds expensive storage and lock quorum checks used by cluster probes.
|
||||
pub const ENV_HEALTH_CLUSTER_TIMEOUT_MS: &str = "RUSTFS_HEALTH_CLUSTER_TIMEOUT_MS";
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::server::{
|
||||
};
|
||||
use crate::version::build;
|
||||
use axum::{
|
||||
Json, Router,
|
||||
Extension, Json, Router,
|
||||
body::Body,
|
||||
extract::Request,
|
||||
middleware,
|
||||
@@ -632,13 +632,22 @@ fn setup_console_middleware_stack(
|
||||
/// # Returns:
|
||||
/// - A `Response` containing the health check result.
|
||||
#[instrument]
|
||||
async fn health_check(method: Method, uri: Uri) -> Response {
|
||||
async fn health_check(
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
server_ctx: Option<Extension<Arc<crate::runtime_sources::ServerContextSlot>>>,
|
||||
) -> Response {
|
||||
let probe = if uri.path().strip_prefix(CONSOLE_PREFIX) == Some(HEALTH_READY_PATH) {
|
||||
HealthProbe::Readiness
|
||||
} else {
|
||||
HealthProbe::Liveness
|
||||
};
|
||||
let readiness_report = collect_probe_readiness(probe).await;
|
||||
let app_context = match server_ctx {
|
||||
Some(Extension(server_ctx)) => server_ctx.installed_app_context(),
|
||||
None => crate::runtime_sources::current_app_context(),
|
||||
};
|
||||
let object_traffic_health = app_context.map(|context| context.object_traffic_health());
|
||||
let readiness_report = collect_probe_readiness(probe, object_traffic_health.as_deref()).await;
|
||||
let uptime = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
@@ -919,6 +928,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn console_readiness_uses_the_request_server_object_progress() {
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_HEALTH_MINIMAL_RESPONSE_ENABLE, Some("false"))], async {
|
||||
let object_traffic_health =
|
||||
Arc::new(crate::app::object_traffic_health::ObjectTrafficHealth::enabled_for_test(Duration::ZERO));
|
||||
let stalled = object_traffic_health
|
||||
.track_write_storage()
|
||||
.expect("write tracking must be enabled");
|
||||
let app_context =
|
||||
crate::app::gating_test_env::app_context_with_object_traffic_health(Arc::clone(&object_traffic_health)).await;
|
||||
let server_ctx = crate::runtime_sources::ServerContextSlot::new();
|
||||
assert!(server_ctx.install(app_context));
|
||||
|
||||
let response = health_check(
|
||||
Method::GET,
|
||||
format!("{CONSOLE_PREFIX}{HEALTH_READY_PATH}")
|
||||
.parse()
|
||||
.expect("console readiness URI"),
|
||||
Some(Extension(server_ctx)),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("console readiness body")
|
||||
.to_bytes();
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("console readiness JSON");
|
||||
assert_eq!(payload["ready"], false);
|
||||
assert_eq!(payload["degradedReasons"], serde_json::json!(["object_write_stalled"]));
|
||||
drop(stalled);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// setup_console_middleware_stack reads ENV_HEALTH_ENDPOINT_ENABLE (see above).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
use super::profile::{TriggerProfileCPU, TriggerProfileMemory};
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::admin::runtime_sources::app_context_from_req;
|
||||
use crate::server::{
|
||||
HEALTH_PREFIX, HEALTH_READY_PATH, PROFILE_CPU_PATH, PROFILE_MEMORY_PATH, build_health_response_parts,
|
||||
collect_probe_readiness, probe_from_path,
|
||||
@@ -51,6 +52,7 @@ pub struct HealthCheckHandler {}
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for HealthCheckHandler {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let object_traffic_health = app_context_from_req(&req).map(|context| context.object_traffic_health());
|
||||
// Extract the original HTTP Method (encapsulated by s3s into S3Request)
|
||||
let method = req.method;
|
||||
|
||||
@@ -66,7 +68,7 @@ impl Operation for HealthCheckHandler {
|
||||
}
|
||||
|
||||
let probe = probe_from_path(req.uri.path());
|
||||
let readiness_report = collect_probe_readiness(probe).await;
|
||||
let readiness_report = collect_probe_readiness(probe, object_traffic_health.as_deref()).await;
|
||||
|
||||
let response_parts =
|
||||
build_health_response_parts(method.clone(), probe, readiness_report.as_ref(), "rustfs-endpoint", None, None);
|
||||
|
||||
@@ -34,6 +34,7 @@ use super::interfaces::{
|
||||
ScannerMetricsInterface, ServerConfigInterface, StorageClassInterface, TierConfigInterface, TransitionStateInterface,
|
||||
};
|
||||
use crate::app::object_data_cache::ObjectDataCacheAdapter;
|
||||
use crate::app::object_traffic_health::ObjectTrafficHealth;
|
||||
use rustfs_iam::{federation::FederatedIdentityService, store::object::ObjectStore, sys::IamSys};
|
||||
use rustfs_kms::KmsServiceManager;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
@@ -74,6 +75,7 @@ pub struct AppContext {
|
||||
storage_class: Arc<dyn StorageClassInterface>,
|
||||
buffer_config: Arc<dyn BufferConfigInterface>,
|
||||
object_data_cache: Arc<ObjectDataCacheAdapter>,
|
||||
object_traffic_health: Arc<ObjectTrafficHealth>,
|
||||
}
|
||||
|
||||
impl AppContext {
|
||||
@@ -122,6 +124,7 @@ impl AppContext {
|
||||
storage_class: default_storage_class_interface(),
|
||||
buffer_config: default_buffer_config_interface(),
|
||||
object_data_cache,
|
||||
object_traffic_health: Arc::new(ObjectTrafficHealth::from_env()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +140,10 @@ impl AppContext {
|
||||
self.object_store.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn object_traffic_health(&self) -> Arc<ObjectTrafficHealth> {
|
||||
Arc::clone(&self.object_traffic_health)
|
||||
}
|
||||
|
||||
pub fn iam(&self) -> Arc<dyn IamInterface> {
|
||||
self.iam.clone()
|
||||
}
|
||||
@@ -342,9 +349,15 @@ impl AppContext {
|
||||
storage_class: interfaces.storage_class,
|
||||
buffer_config: interfaces.buffer_config,
|
||||
object_data_cache: ObjectDataCacheAdapter::disabled_arc(),
|
||||
object_traffic_health: Arc::new(ObjectTrafficHealth::from_env()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_test_object_traffic_health(mut self, object_traffic_health: Arc<ObjectTrafficHealth>) -> Self {
|
||||
self.object_traffic_health = object_traffic_health;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_test_runtime_config_interfaces(
|
||||
mut self,
|
||||
server_config: Arc<dyn ServerConfigInterface>,
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
use super::storage_api::test::bucket::metadata_sys;
|
||||
use super::storage_api::test::contract::bucket::{BucketOperations, BucketOptions};
|
||||
use super::storage_api::test::{ECStore, Endpoint, EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use super::{context::AppContext, object_traffic_health::ObjectTrafficHealth};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tempfile::TempDir;
|
||||
@@ -32,6 +33,7 @@ use tokio::fs;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
static SHARED_GATING_ENV: OnceLock<(Vec<PathBuf>, Arc<ECStore>, TempDir)> = OnceLock::new();
|
||||
static SHARED_GATING_INIT: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// Return a shared 4-disk `ECStore` with bucket metadata initialized.
|
||||
///
|
||||
@@ -42,6 +44,10 @@ pub(crate) async fn shared_gating_ecstore() -> Arc<ECStore> {
|
||||
if let Some((_paths, store, _)) = SHARED_GATING_ENV.get() {
|
||||
return store.clone();
|
||||
}
|
||||
let _init_guard = SHARED_GATING_INIT.lock().await;
|
||||
if let Some((_paths, store, _)) = SHARED_GATING_ENV.get() {
|
||||
return store.clone();
|
||||
}
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir for gating test env");
|
||||
let temp_path = temp_dir.path().to_path_buf();
|
||||
@@ -101,6 +107,28 @@ pub(crate) async fn shared_gating_ecstore() -> Arc<ECStore> {
|
||||
ecstore
|
||||
}
|
||||
|
||||
pub(crate) async fn shared_gating_ambient() -> Arc<AppContext> {
|
||||
let store = shared_gating_ecstore().await;
|
||||
if let Some(ambient) = crate::runtime_sources::current_app_context() {
|
||||
return ambient;
|
||||
}
|
||||
|
||||
let _init_guard = SHARED_GATING_INIT.lock().await;
|
||||
if crate::runtime_sources::current_app_context().is_none() {
|
||||
super::runtime_sources::install_test_app_context(Arc::clone(&store)).await;
|
||||
}
|
||||
crate::runtime_sources::current_app_context().expect("object traffic test context must be installed")
|
||||
}
|
||||
|
||||
pub(crate) fn app_context_from_current_environment(ambient: &AppContext) -> AppContext {
|
||||
AppContext::new(ambient.object_store(), ambient.iam(), ambient.kms())
|
||||
}
|
||||
|
||||
pub(crate) async fn app_context_with_object_traffic_health(object_traffic_health: Arc<ObjectTrafficHealth>) -> Arc<AppContext> {
|
||||
let ambient = shared_gating_ambient().await;
|
||||
Arc::new(app_context_from_current_environment(&ambient).with_test_object_traffic_health(object_traffic_health))
|
||||
}
|
||||
|
||||
/// Like [`shared_gating_ecstore`], but also returns the backing disk paths so
|
||||
/// tests can remove on-disk shards and simulate the object data vanishing
|
||||
/// mid-stream.
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod context;
|
||||
pub(crate) mod metadata_route;
|
||||
pub mod multipart_usecase;
|
||||
pub(crate) mod object_data_cache;
|
||||
pub(crate) mod object_traffic_health;
|
||||
pub mod object_usecase;
|
||||
pub(crate) mod runtime_sources;
|
||||
mod select_object;
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
// 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.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(crate) struct ObjectTrafficSnapshot {
|
||||
pub(crate) read_stalled: bool,
|
||||
pub(crate) write_stalled: bool,
|
||||
}
|
||||
|
||||
/// Detects bounded object stages that stop returning from foreground requests.
|
||||
/// Both success and error returns are progress: dependency correctness remains
|
||||
/// the responsibility of the existing readiness checks.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ObjectTrafficHealth {
|
||||
started_at: Instant,
|
||||
stall_after_ms: u64,
|
||||
enabled: bool,
|
||||
read_metadata: OperationProgress,
|
||||
read_storage: OperationProgress,
|
||||
write_storage: OperationProgress,
|
||||
}
|
||||
|
||||
impl ObjectTrafficHealth {
|
||||
pub(crate) fn from_env() -> Self {
|
||||
let enabled = rustfs_utils::get_env_bool(
|
||||
rustfs_config::ENV_HEALTH_OBJECT_PROGRESS_ENABLE,
|
||||
rustfs_config::DEFAULT_HEALTH_OBJECT_PROGRESS_ENABLE,
|
||||
);
|
||||
let configured_timeout_ms = rustfs_utils::get_env_u64(
|
||||
rustfs_config::ENV_HEALTH_OBJECT_PROGRESS_TIMEOUT_MS,
|
||||
rustfs_config::DEFAULT_HEALTH_OBJECT_PROGRESS_TIMEOUT_MS,
|
||||
);
|
||||
let requested_timeout_ms = if configured_timeout_ms == 0 {
|
||||
rustfs_config::DEFAULT_HEALTH_OBJECT_PROGRESS_TIMEOUT_MS
|
||||
} else {
|
||||
configured_timeout_ms
|
||||
};
|
||||
let minimum_timeout_ms = duration_ms_saturating(crate::storage::get_lock_acquire_timeout())
|
||||
.saturating_add(rustfs_config::HEALTH_OBJECT_PROGRESS_LOCK_MARGIN_MS);
|
||||
let stall_after_ms = requested_timeout_ms.max(minimum_timeout_ms);
|
||||
|
||||
Self::new(enabled, stall_after_ms)
|
||||
}
|
||||
|
||||
fn new(enabled: bool, stall_after_ms: u64) -> Self {
|
||||
Self {
|
||||
started_at: Instant::now(),
|
||||
stall_after_ms,
|
||||
enabled,
|
||||
read_metadata: OperationProgress::default(),
|
||||
read_storage: OperationProgress::default(),
|
||||
write_storage: OperationProgress::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn track_read_metadata(&self) -> Option<ObjectTrafficProgressGuard<'_>> {
|
||||
self.track(&self.read_metadata)
|
||||
}
|
||||
|
||||
pub(crate) fn track_read_storage(&self) -> Option<ObjectTrafficProgressGuard<'_>> {
|
||||
self.track(&self.read_storage)
|
||||
}
|
||||
|
||||
pub(crate) fn track_write_storage(&self) -> Option<ObjectTrafficProgressGuard<'_>> {
|
||||
self.track(&self.write_storage)
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot(&self) -> ObjectTrafficSnapshot {
|
||||
if !self.enabled {
|
||||
return ObjectTrafficSnapshot::default();
|
||||
}
|
||||
|
||||
let now_ms = self.now_ms();
|
||||
ObjectTrafficSnapshot {
|
||||
read_stalled: self.read_metadata.is_stalled_at(now_ms, self.stall_after_ms)
|
||||
|| self.read_storage.is_stalled_at(now_ms, self.stall_after_ms),
|
||||
write_stalled: self.write_storage.is_stalled_at(now_ms, self.stall_after_ms),
|
||||
}
|
||||
}
|
||||
|
||||
fn track<'a>(&'a self, progress: &'a OperationProgress) -> Option<ObjectTrafficProgressGuard<'a>> {
|
||||
if !self.enabled || !progress.begin_at(self.now_ms()) {
|
||||
return None;
|
||||
}
|
||||
Some(ObjectTrafficProgressGuard { health: self, progress })
|
||||
}
|
||||
|
||||
fn now_ms(&self) -> u64 {
|
||||
duration_ms_saturating(self.started_at.elapsed())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn enabled_for_test(stall_after: Duration) -> Self {
|
||||
Self::new(true, duration_ms_saturating(stall_after))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn read_storage_stalled_for_test(&self) -> bool {
|
||||
self.read_storage.is_stalled_at(self.now_ms(), self.stall_after_ms)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct OperationProgress {
|
||||
active: AtomicU64,
|
||||
last_progress_ms: AtomicU64,
|
||||
}
|
||||
|
||||
impl OperationProgress {
|
||||
fn begin_at(&self, now_ms: u64) -> bool {
|
||||
let mut active = self.active.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let Some(next) = active.checked_add(1) else {
|
||||
return false;
|
||||
};
|
||||
if active == 0 {
|
||||
self.last_progress_ms.fetch_max(now_ms, Ordering::Relaxed);
|
||||
}
|
||||
match self
|
||||
.active
|
||||
.compare_exchange_weak(active, next, Ordering::Release, Ordering::Relaxed)
|
||||
{
|
||||
Ok(_) => return true,
|
||||
Err(observed) => active = observed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_at(&self, now_ms: u64) {
|
||||
self.last_progress_ms.fetch_max(now_ms, Ordering::Relaxed);
|
||||
let previous = self.active.fetch_sub(1, Ordering::Release);
|
||||
debug_assert!(previous > 0, "object traffic progress guard underflow");
|
||||
}
|
||||
|
||||
fn is_stalled_at(&self, now_ms: u64, stall_after_ms: u64) -> bool {
|
||||
self.active.load(Ordering::Acquire) > 0
|
||||
&& now_ms.saturating_sub(self.last_progress_ms.load(Ordering::Relaxed)) >= stall_after_ms
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use = "dropping the guard records operation completion"]
|
||||
pub(crate) struct ObjectTrafficProgressGuard<'a> {
|
||||
health: &'a ObjectTrafficHealth,
|
||||
progress: &'a OperationProgress,
|
||||
}
|
||||
|
||||
impl Drop for ObjectTrafficProgressGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.progress.complete_at(self.health.now_ms());
|
||||
}
|
||||
}
|
||||
|
||||
fn duration_ms_saturating(duration: Duration) -> u64 {
|
||||
duration
|
||||
.as_secs()
|
||||
.saturating_mul(1_000)
|
||||
.saturating_add(u64::from(duration.subsec_millis()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_active_operation_stalls_at_the_exact_boundary() {
|
||||
let progress = OperationProgress::default();
|
||||
assert!(progress.begin_at(10));
|
||||
|
||||
assert!(!progress.is_stalled_at(39, 30));
|
||||
assert!(progress.is_stalled_at(40, 30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_arrivals_do_not_hide_an_existing_stall() {
|
||||
let progress = OperationProgress::default();
|
||||
assert!(progress.begin_at(10));
|
||||
assert!(progress.begin_at(35));
|
||||
|
||||
assert!(progress.is_stalled_at(40, 30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_completion_resets_progress_until_the_remaining_operation_stalls() {
|
||||
let progress = OperationProgress::default();
|
||||
assert!(progress.begin_at(10));
|
||||
assert!(progress.begin_at(20));
|
||||
|
||||
progress.complete_at(35);
|
||||
|
||||
assert!(!progress.is_stalled_at(64, 30));
|
||||
assert!(progress.is_stalled_at(65, 30));
|
||||
progress.complete_at(65);
|
||||
assert!(!progress.is_stalled_at(u64::MAX, 30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_begin_timestamp_cannot_overwrite_newer_progress() {
|
||||
let progress = OperationProgress::default();
|
||||
assert!(progress.begin_at(10));
|
||||
progress.complete_at(100);
|
||||
|
||||
assert!(progress.begin_at(10));
|
||||
assert!(!progress.is_stalled_at(129, 30));
|
||||
assert!(progress.is_stalled_at(130, 30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn environment_configuration_is_sanitized() {
|
||||
temp_env::with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_HEALTH_OBJECT_PROGRESS_ENABLE, Some("false")),
|
||||
(rustfs_config::ENV_HEALTH_OBJECT_PROGRESS_TIMEOUT_MS, Some("1")),
|
||||
],
|
||||
|| {
|
||||
let minimum_timeout_ms = duration_ms_saturating(crate::storage::get_lock_acquire_timeout())
|
||||
.saturating_add(rustfs_config::HEALTH_OBJECT_PROGRESS_LOCK_MARGIN_MS);
|
||||
let health = ObjectTrafficHealth::from_env();
|
||||
assert!(!health.enabled);
|
||||
assert_eq!(health.stall_after_ms, minimum_timeout_ms);
|
||||
},
|
||||
);
|
||||
|
||||
temp_env::with_vars([(rustfs_config::ENV_HEALTH_OBJECT_PROGRESS_TIMEOUT_MS, Some("0"))], || {
|
||||
let minimum_timeout_ms = duration_ms_saturating(crate::storage::get_lock_acquire_timeout())
|
||||
.saturating_add(rustfs_config::HEALTH_OBJECT_PROGRESS_LOCK_MARGIN_MS);
|
||||
let health = ObjectTrafficHealth::from_env();
|
||||
assert_eq!(
|
||||
health.stall_after_ms,
|
||||
rustfs_config::DEFAULT_HEALTH_OBJECT_PROGRESS_TIMEOUT_MS.max(minimum_timeout_ms)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_and_write_progress_are_independent() {
|
||||
let health = ObjectTrafficHealth::enabled_for_test(Duration::ZERO);
|
||||
let read = health.track_read_storage().expect("read tracking must be enabled");
|
||||
|
||||
assert_eq!(
|
||||
health.snapshot(),
|
||||
ObjectTrafficSnapshot {
|
||||
read_stalled: true,
|
||||
write_stalled: false,
|
||||
}
|
||||
);
|
||||
|
||||
drop(read);
|
||||
let write = health.track_write_storage().expect("write tracking must be enabled");
|
||||
assert_eq!(
|
||||
health.snapshot(),
|
||||
ObjectTrafficSnapshot {
|
||||
read_stalled: false,
|
||||
write_stalled: true,
|
||||
}
|
||||
);
|
||||
drop(write);
|
||||
assert_eq!(health.snapshot(), ObjectTrafficSnapshot::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_tracking_never_withdraws_readiness() {
|
||||
let health = ObjectTrafficHealth::new(false, 0);
|
||||
|
||||
assert!(health.track_read_metadata().is_none());
|
||||
assert!(health.track_read_storage().is_none());
|
||||
assert!(health.track_write_storage().is_none());
|
||||
assert_eq!(health.snapshot(), ObjectTrafficSnapshot::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_completions_do_not_hide_a_storage_stall() {
|
||||
let health = ObjectTrafficHealth::enabled_for_test(Duration::ZERO);
|
||||
let storage = health.track_read_storage().expect("read storage tracking must be enabled");
|
||||
let metadata = health.track_read_metadata().expect("read metadata tracking must be enabled");
|
||||
drop(metadata);
|
||||
|
||||
assert!(health.snapshot().read_stalled);
|
||||
drop(storage);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aborting_a_tracked_future_clears_the_active_operation() {
|
||||
let health = std::sync::Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO));
|
||||
let task_health = std::sync::Arc::clone(&health);
|
||||
let task = tokio::spawn(async move {
|
||||
let _progress = task_health.track_read_storage().expect("read tracking must be enabled");
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
|
||||
if tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !health.snapshot().read_stalled {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
panic!("tracked future did not publish an active operation");
|
||||
}
|
||||
task.abort();
|
||||
assert!(task.await.expect_err("tracked task must be cancelled").is_cancelled());
|
||||
assert!(!health.snapshot().read_stalled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn app_context_honors_the_disabled_progress_environment() {
|
||||
let ambient = crate::app::gating_test_env::shared_gating_ambient().await;
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_HEALTH_OBJECT_PROGRESS_ENABLE, Some("false"))], async {
|
||||
let context = crate::app::gating_test_env::app_context_from_current_environment(&ambient);
|
||||
assert!(context.object_traffic_health().track_read_storage().is_none());
|
||||
})
|
||||
.await;
|
||||
|
||||
let installed = crate::app::runtime_sources::current_app_context().expect("test AppContext must remain installed");
|
||||
assert!(std::sync::Arc::ptr_eq(&ambient, &installed));
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,7 @@ use crate::app::object_data_cache::{
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::app::object_data_cache::{ColdFillRole, ColdFillWaitOutcome, scope_cold_fill_disk_permit_owner_for_test};
|
||||
use crate::app::object_traffic_health::ObjectTrafficHealth;
|
||||
|
||||
type S3StdError = Box<dyn std::error::Error + Send + Sync + 'static>;
|
||||
|
||||
@@ -2951,6 +2952,8 @@ fn normalize_delete_objects_version_id(
|
||||
|
||||
#[cfg(test)]
|
||||
type DeleteSnapshotTestHook = (String, Arc<tokio::sync::Barrier>, Arc<tokio::sync::Barrier>);
|
||||
#[cfg(test)]
|
||||
type PutPostStoreTestHook = (String, Arc<tokio::sync::Barrier>, Arc<tokio::sync::Barrier>);
|
||||
|
||||
#[cfg(test)]
|
||||
static DELETE_SNAPSHOT_TEST_HOOK: OnceLock<Mutex<Option<DeleteSnapshotTestHook>>> = OnceLock::new();
|
||||
@@ -2958,6 +2961,8 @@ static DELETE_SNAPSHOT_TEST_HOOK: OnceLock<Mutex<Option<DeleteSnapshotTestHook>>
|
||||
static DELETE_SOURCE_TEST_HOOK: OnceLock<Mutex<Option<DeleteSnapshotTestHook>>> = OnceLock::new();
|
||||
#[cfg(test)]
|
||||
static DELETE_OBJECTS_AUTH_TEST_HOOK: OnceLock<Mutex<Option<DeleteSnapshotTestHook>>> = OnceLock::new();
|
||||
#[cfg(test)]
|
||||
static PUT_POST_STORE_TEST_HOOK: OnceLock<Mutex<Option<PutPostStoreTestHook>>> = OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_delete_snapshot_test_hook(
|
||||
@@ -3052,6 +3057,33 @@ async fn wait_for_delete_objects_auth_test_hook(bucket: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn install_put_post_store_test_hook(bucket: String, entered: Arc<tokio::sync::Barrier>, resume: Arc<tokio::sync::Barrier>) {
|
||||
*PUT_POST_STORE_TEST_HOOK
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
.lock()
|
||||
.expect("PUT post-store test hook lock should not be poisoned") = Some((bucket, entered, resume));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn wait_for_put_post_store_test_hook(bucket: &str) {
|
||||
let hook = {
|
||||
let mut slot = PUT_POST_STORE_TEST_HOOK
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
.lock()
|
||||
.expect("PUT post-store test hook lock should not be poisoned");
|
||||
if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) {
|
||||
slot.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some((_bucket, entered, resume)) = hook {
|
||||
entered.wait().await;
|
||||
resume.wait().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn build_put_object_expiration_header(event: &lifecycle::Event) -> Option<String> {
|
||||
if !event.action.delete() {
|
||||
return None;
|
||||
@@ -3895,6 +3927,14 @@ pub struct DefaultObjectUsecase {
|
||||
get_object_timeout_policy: Option<GetObjectTimeoutPolicy>,
|
||||
}
|
||||
|
||||
async fn track_object_read_setup<F>(health: Option<&ObjectTrafficHealth>, future: F) -> F::Output
|
||||
where
|
||||
F: std::future::Future,
|
||||
{
|
||||
let _progress = health.and_then(ObjectTrafficHealth::track_read_storage);
|
||||
future.await
|
||||
}
|
||||
|
||||
impl DefaultObjectUsecase {
|
||||
fn should_use_large_put_concurrency_tuning(size: i64) -> bool {
|
||||
size >= DEFAULT_PUT_LARGE_CONCURRENCY_TUNING_MIN_SIZE_BYTES
|
||||
@@ -3951,6 +3991,13 @@ impl DefaultObjectUsecase {
|
||||
current_object_data_cache_for_context(self.context.as_deref())
|
||||
}
|
||||
|
||||
fn object_traffic_health(&self) -> Option<Arc<ObjectTrafficHealth>> {
|
||||
self.context
|
||||
.as_ref()
|
||||
.map(|context| context.object_traffic_health())
|
||||
.or_else(|| current_app_context().map(|context| context.object_traffic_health()))
|
||||
}
|
||||
|
||||
fn base_buffer_size(&self) -> usize {
|
||||
self.context
|
||||
.clone()
|
||||
@@ -4351,6 +4398,7 @@ impl DefaultObjectUsecase {
|
||||
rs: Option<HTTPRangeSpec>,
|
||||
opts: &ObjectOptions,
|
||||
part_number: Option<usize>,
|
||||
object_traffic_health: Option<Arc<ObjectTrafficHealth>>,
|
||||
) -> S3Result<GetObjectPreparedRead> {
|
||||
let read_start = std::time::Instant::now();
|
||||
let read_stage_start = rustfs_io_metrics::get_stage_metrics_enabled().then_some(read_start);
|
||||
@@ -4366,10 +4414,12 @@ impl DefaultObjectUsecase {
|
||||
key,
|
||||
)
|
||||
.await?;
|
||||
let reader = store
|
||||
.get_object_reader(bucket, key, rs.clone(), req.headers.clone(), opts)
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?;
|
||||
let reader = track_object_read_setup(
|
||||
object_traffic_health.as_deref(),
|
||||
store.get_object_reader(bucket, key, rs.clone(), req.headers.clone(), opts),
|
||||
)
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?;
|
||||
let read_setup =
|
||||
Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?;
|
||||
return Ok(GetObjectPreparedRead { io_planning, read_setup });
|
||||
@@ -4390,10 +4440,12 @@ impl DefaultObjectUsecase {
|
||||
.await?,
|
||||
);
|
||||
let mut prepared = Some(
|
||||
store
|
||||
.prepare_get_object_reader(bucket, key, rs.clone(), HeaderMap::new(), opts)
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?,
|
||||
track_object_read_setup(
|
||||
object_traffic_health.as_deref(),
|
||||
store.prepare_get_object_reader(bucket, key, rs.clone(), HeaderMap::new(), opts),
|
||||
)
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?,
|
||||
);
|
||||
let mut cache_fill_allowed = true;
|
||||
let mut legacy_hook_missed = false;
|
||||
@@ -4494,6 +4546,7 @@ impl DefaultObjectUsecase {
|
||||
let headers = &req.headers;
|
||||
let store = &store;
|
||||
let range = &rs;
|
||||
let object_traffic_health = &object_traffic_health;
|
||||
move |producer| {
|
||||
let adapter = Arc::clone(adapter);
|
||||
let engine_plan = engine_plan.clone();
|
||||
@@ -4503,6 +4556,7 @@ impl DefaultObjectUsecase {
|
||||
let bucket = bucket.to_owned();
|
||||
let key = key.to_owned();
|
||||
let opts = opts.clone();
|
||||
let object_traffic_health = object_traffic_health.as_ref().map(Arc::clone);
|
||||
async move {
|
||||
let producer_deadline = producer.deadline();
|
||||
let cancellation = producer.cancellation_token();
|
||||
@@ -4548,7 +4602,10 @@ impl DefaultObjectUsecase {
|
||||
}
|
||||
};
|
||||
|
||||
let prepare = store.prepare_get_object_reader(&bucket, &key, range.clone(), HeaderMap::new(), &opts);
|
||||
let prepare = track_object_read_setup(
|
||||
object_traffic_health.as_deref(),
|
||||
store.prepare_get_object_reader(&bucket, &key, range.clone(), HeaderMap::new(), &opts),
|
||||
);
|
||||
let prepared = match match await_cold_fill_startup(prepare, &cancellation, producer_deadline).await {
|
||||
Ok(result) => result,
|
||||
Err(ColdFillStartupWaitError::Cancelled) => {
|
||||
@@ -4602,7 +4659,8 @@ impl DefaultObjectUsecase {
|
||||
|| {
|
||||
#[cfg(test)]
|
||||
record_cold_fill_reader_open_for_test(&reader_open_plan);
|
||||
prepared.with_headers(h).into_reader()
|
||||
let open_reader = prepared.with_headers(h).into_reader();
|
||||
async move { track_object_read_setup(object_traffic_health.as_deref(), open_reader).await }
|
||||
},
|
||||
ColdFillProducerExecution {
|
||||
expected,
|
||||
@@ -4647,11 +4705,12 @@ impl DefaultObjectUsecase {
|
||||
let io_planning = metadata_admission
|
||||
.take()
|
||||
.ok_or_else(|| s3_error!(InternalError, "prepared metadata admission is unavailable"))?;
|
||||
let reader = prepared
|
||||
.with_headers(req.headers.clone())
|
||||
.into_reader()
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?;
|
||||
let reader = track_object_read_setup(
|
||||
object_traffic_health.as_deref(),
|
||||
prepared.with_headers(req.headers.clone()).into_reader(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?;
|
||||
(io_planning, reader)
|
||||
} else {
|
||||
let io_planning = Self::acquire_get_object_io_planning(
|
||||
@@ -4665,19 +4724,25 @@ impl DefaultObjectUsecase {
|
||||
)
|
||||
.await?;
|
||||
let reader = if legacy_hook_missed {
|
||||
store
|
||||
.prepare_get_object_reader(bucket, key, rs.clone(), HeaderMap::new(), opts)
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?
|
||||
.with_headers(req.headers.clone())
|
||||
.into_reader()
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?
|
||||
let prepared = track_object_read_setup(
|
||||
object_traffic_health.as_deref(),
|
||||
store.prepare_get_object_reader(bucket, key, rs.clone(), HeaderMap::new(), opts),
|
||||
)
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?;
|
||||
track_object_read_setup(
|
||||
object_traffic_health.as_deref(),
|
||||
prepared.with_headers(req.headers.clone()).into_reader(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?
|
||||
} else {
|
||||
store
|
||||
.get_object_reader(bucket, key, rs.clone(), req.headers.clone(), opts)
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?
|
||||
track_object_read_setup(
|
||||
object_traffic_health.as_deref(),
|
||||
store.get_object_reader(bucket, key, rs.clone(), req.headers.clone(), opts),
|
||||
)
|
||||
.await
|
||||
.map_err(map_get_object_reader_error)?
|
||||
};
|
||||
(io_planning, reader)
|
||||
};
|
||||
@@ -5485,8 +5550,8 @@ impl DefaultObjectUsecase {
|
||||
debug!("Zero-copy write enabled for {} byte object (bucket={}, key={})", size, bucket, key);
|
||||
}
|
||||
|
||||
let use_small_eager_put_path =
|
||||
should_use_small_eager_put_path(size, &req.headers, server_side_encryption_requested, should_compress, false);
|
||||
let use_empty_or_small_eager_put_path = size == 0
|
||||
|| should_use_small_eager_put_path(size, &req.headers, server_side_encryption_requested, should_compress, false);
|
||||
let zero_copy_eager_put_path_status =
|
||||
zero_copy_eager_put_path_status(size, &req.headers, server_side_encryption_requested, should_compress, false);
|
||||
let use_zero_copy_eager_put_path = zero_copy_eager_put_path_status == PUT_EAGER_STATUS_ELIGIBLE;
|
||||
@@ -5498,7 +5563,7 @@ impl DefaultObjectUsecase {
|
||||
"stream_compressed"
|
||||
} else if use_zero_copy_eager_put_path {
|
||||
"zero_copy_eager"
|
||||
} else if use_small_eager_put_path {
|
||||
} else if use_empty_or_small_eager_put_path {
|
||||
"small_eager"
|
||||
} else {
|
||||
"streaming"
|
||||
@@ -5712,7 +5777,7 @@ impl DefaultObjectUsecase {
|
||||
let eager_body = read_zero_copy_put_body_exact(body, actual_size as usize).await?;
|
||||
rustfs_io_metrics::record_zero_copy_write(actual_size as usize, zero_copy_start.elapsed().as_secs_f64() * 1000.0);
|
||||
HashReader::from_stream(eager_body, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)?
|
||||
} else if use_small_eager_put_path {
|
||||
} else if use_empty_or_small_eager_put_path {
|
||||
if (actual_size as usize) <= POOL_BYPASS_MAX_SIZE {
|
||||
// Bypass BytesPool for very small objects to avoid Small-tier
|
||||
// Mutex contention under high concurrency. Direct allocation
|
||||
@@ -5866,6 +5931,14 @@ impl DefaultObjectUsecase {
|
||||
}
|
||||
});
|
||||
|
||||
let object_traffic_health = if use_zero_copy_eager_put_path || use_empty_or_small_eager_put_path {
|
||||
self.object_traffic_health()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let object_traffic_progress = object_traffic_health
|
||||
.as_deref()
|
||||
.and_then(ObjectTrafficHealth::track_write_storage);
|
||||
let (obj_info, backfilled_old_current_size) = match store
|
||||
.put_object_with_old_current_size(&bucket, &key, &mut reader, &opts)
|
||||
.await
|
||||
@@ -5912,6 +5985,9 @@ impl DefaultObjectUsecase {
|
||||
return result;
|
||||
}
|
||||
};
|
||||
drop(object_traffic_progress);
|
||||
#[cfg(test)]
|
||||
wait_for_put_post_store_test_hook(&bucket).await;
|
||||
|
||||
maybe_enqueue_transition_immediate(&obj_info, LcEventSrc::S3PutObject).await;
|
||||
let _ = invalidate_object_data_cache_after_put_success(&cache_adapter, &bucket, &key).await;
|
||||
@@ -6352,6 +6428,10 @@ impl DefaultObjectUsecase {
|
||||
// naming nonexistent buckets fail before the versioning lookup in
|
||||
// get_opts. The store comes from the request-bound server context
|
||||
// (backlog#1052 S6), not the process-global handle.
|
||||
let object_traffic_health = self.object_traffic_health();
|
||||
let object_metadata_progress = object_traffic_health
|
||||
.as_deref()
|
||||
.and_then(ObjectTrafficHealth::track_read_metadata);
|
||||
let store_lookup_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
|
||||
let Some(store) = self.object_store() else {
|
||||
lifecycle.finish_err();
|
||||
@@ -6392,6 +6472,7 @@ impl DefaultObjectUsecase {
|
||||
rs,
|
||||
opts,
|
||||
} = request_context;
|
||||
drop(object_metadata_progress);
|
||||
|
||||
let manager = get_concurrency_manager();
|
||||
|
||||
@@ -6407,6 +6488,7 @@ impl DefaultObjectUsecase {
|
||||
rs,
|
||||
&opts,
|
||||
part_number,
|
||||
object_traffic_health,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -11142,6 +11224,278 @@ mod tests {
|
||||
(store, context)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(body_cache_hook)]
|
||||
async fn object_progress_tracks_real_get_and_small_put_lock_waits() {
|
||||
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
|
||||
let object_traffic_health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO));
|
||||
let context = temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_DATA_CACHE_ENABLE, Some("false"))], async {
|
||||
crate::app::gating_test_env::app_context_with_object_traffic_health(Arc::clone(&object_traffic_health)).await
|
||||
})
|
||||
.await;
|
||||
let store = context.object_store();
|
||||
let bucket = format!("object-progress-{}", Uuid::new_v4());
|
||||
let object = "object.bin";
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("object progress bucket must be created");
|
||||
put_real_cold_fill_object(&store, &bucket, object, b"initial").await;
|
||||
|
||||
let metadata_entered = Arc::new(tokio::sync::Barrier::new(2));
|
||||
let metadata_resume = Arc::new(tokio::sync::Barrier::new(2));
|
||||
crate::storage::options::install_versioning_config_test_hook(
|
||||
bucket.clone(),
|
||||
Arc::clone(&metadata_entered),
|
||||
Arc::clone(&metadata_resume),
|
||||
);
|
||||
let metadata_input = GetObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.expect("metadata GET input must build");
|
||||
let metadata_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
|
||||
let metadata_get = tokio::spawn(async move {
|
||||
metadata_usecase
|
||||
.execute_get_object(build_request(metadata_input, Method::GET))
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(2), metadata_entered.wait())
|
||||
.await
|
||||
.expect("GET must enter the bucket metadata stage");
|
||||
assert!(object_traffic_health.snapshot().read_stalled);
|
||||
assert!(!metadata_get.is_finished(), "GET must still be waiting in bucket metadata");
|
||||
metadata_resume.wait().await;
|
||||
let metadata_response = tokio::time::timeout(Duration::from_secs(10), metadata_get)
|
||||
.await
|
||||
.expect("metadata GET must finish after release")
|
||||
.expect("metadata GET task must join")
|
||||
.expect("metadata GET must succeed after release");
|
||||
assert!(!object_traffic_health.snapshot().read_stalled);
|
||||
drop(metadata_response);
|
||||
|
||||
let read_lock = store
|
||||
.new_ns_lock(&bucket, object)
|
||||
.await
|
||||
.expect("read test namespace lock must be created")
|
||||
.get_write_lock(Duration::from_secs(5))
|
||||
.await
|
||||
.expect("read test namespace lock must be held");
|
||||
let get_input = GetObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.build()
|
||||
.expect("GET input must build");
|
||||
let get_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
|
||||
let get = tokio::spawn(async move { get_usecase.execute_get_object(build_request(get_input, Method::GET)).await });
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !object_traffic_health.read_storage_stalled_for_test() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("blocked GET must publish a storage stall");
|
||||
assert!(!get.is_finished(), "GET must still be waiting for the held namespace lock");
|
||||
drop(read_lock);
|
||||
let get_response = tokio::time::timeout(Duration::from_secs(10), get)
|
||||
.await
|
||||
.expect("GET must finish after releasing the lock")
|
||||
.expect("GET task must join")
|
||||
.expect("GET must succeed after releasing the lock");
|
||||
assert!(!object_traffic_health.snapshot().read_stalled);
|
||||
drop(get_response);
|
||||
|
||||
let write_lock = store
|
||||
.new_ns_lock(&bucket, object)
|
||||
.await
|
||||
.expect("write test namespace lock must be created")
|
||||
.get_write_lock(Duration::from_secs(5))
|
||||
.await
|
||||
.expect("write test namespace lock must be held");
|
||||
let post_store_entered = Arc::new(tokio::sync::Barrier::new(2));
|
||||
let post_store_resume = Arc::new(tokio::sync::Barrier::new(2));
|
||||
install_put_post_store_test_hook(bucket.clone(), Arc::clone(&post_store_entered), Arc::clone(&post_store_resume));
|
||||
let payload = Bytes::from_static(b"replacement");
|
||||
let put_input = PutObjectInput::builder()
|
||||
.bucket(bucket)
|
||||
.key(object.to_string())
|
||||
.body(Some(StreamingBlob::from(s3s::Body::from(payload.clone()))))
|
||||
.content_length(Some(i64::try_from(payload.len()).expect("test payload length must fit i64")))
|
||||
.build()
|
||||
.expect("PUT input must build");
|
||||
let put_usecase = DefaultObjectUsecase::with_context(Some(context));
|
||||
let put = tokio::spawn(async move {
|
||||
put_usecase
|
||||
.execute_put_object(&FS::new(), build_request(put_input, Method::PUT))
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !object_traffic_health.snapshot().write_stalled {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("blocked small PUT must publish a storage stall");
|
||||
assert!(!put.is_finished(), "PUT must still be waiting for the held namespace lock");
|
||||
drop(write_lock);
|
||||
tokio::time::timeout(Duration::from_secs(10), post_store_entered.wait())
|
||||
.await
|
||||
.expect("PUT must reach the first post-store hook");
|
||||
assert!(!object_traffic_health.snapshot().write_stalled);
|
||||
assert!(!put.is_finished(), "PUT must remain blocked after the store guard has ended");
|
||||
post_store_resume.wait().await;
|
||||
tokio::time::timeout(Duration::from_secs(10), put)
|
||||
.await
|
||||
.expect("PUT must finish after releasing the lock")
|
||||
.expect("PUT task must join")
|
||||
.expect("PUT must succeed after releasing the lock");
|
||||
let recovered = object_traffic_health.snapshot();
|
||||
assert!(!recovered.read_stalled);
|
||||
assert!(!recovered.write_stalled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_progress_tracks_zero_byte_and_zero_copy_put_lock_waits() {
|
||||
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
|
||||
let object_traffic_health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO));
|
||||
let context =
|
||||
crate::app::gating_test_env::app_context_with_object_traffic_health(Arc::clone(&object_traffic_health)).await;
|
||||
let store = context.object_store();
|
||||
let bucket = format!("progress-buffered-{}", Uuid::new_v4());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("buffered PUT progress bucket must be created");
|
||||
|
||||
let extra_body_object = "zero-byte-extra.bin";
|
||||
let extra_body_input = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(extra_body_object.to_string())
|
||||
.body(Some(StreamingBlob::from(s3s::Body::from(Bytes::from_static(b"x")))))
|
||||
.content_length(Some(88))
|
||||
.build()
|
||||
.expect("zero-byte extra-body PUT input must build");
|
||||
let extra_body_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
|
||||
let mut extra_body_request = build_request(extra_body_input, Method::PUT);
|
||||
extra_body_request.headers = streaming_headers(Some("0"));
|
||||
let extra_body_err = extra_body_usecase
|
||||
.execute_put_object(&FS::new(), extra_body_request)
|
||||
.await
|
||||
.expect_err("decoded zero-byte PUT with body data must fail");
|
||||
assert_eq!(extra_body_err.code(), &S3ErrorCode::UnexpectedContent);
|
||||
assert!(!object_traffic_health.snapshot().write_stalled);
|
||||
let lookup_err = store
|
||||
.get_object_info(&bucket, extra_body_object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("rejected zero-byte PUT must not create an object");
|
||||
assert!(is_err_object_not_found(&lookup_err));
|
||||
|
||||
let zero_object = "zero-byte.bin";
|
||||
let zero_write_lock = store
|
||||
.new_ns_lock(&bucket, zero_object)
|
||||
.await
|
||||
.expect("zero-byte PUT namespace lock must be created")
|
||||
.get_write_lock(Duration::from_secs(30))
|
||||
.await
|
||||
.expect("zero-byte PUT namespace lock must be held");
|
||||
let (body_polled_tx, body_polled_rx) = tokio::sync::oneshot::channel();
|
||||
let (body_release_tx, body_release_rx) = tokio::sync::oneshot::channel();
|
||||
let pending_zero_body = StreamingBlob::wrap(futures::stream::once(async move {
|
||||
body_polled_tx.send(()).expect("zero-byte body poll signal must be received");
|
||||
body_release_rx.await.expect("zero-byte body EOF must be released");
|
||||
Ok::<Bytes, std::io::Error>(Bytes::new())
|
||||
}));
|
||||
let zero_input = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(zero_object.to_string())
|
||||
.body(Some(pending_zero_body))
|
||||
.content_length(Some(87))
|
||||
.build()
|
||||
.expect("zero-byte PUT input must build");
|
||||
let zero_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
|
||||
let mut zero_request = build_request(zero_input, Method::PUT);
|
||||
zero_request.headers = streaming_headers(Some("0"));
|
||||
let zero_put = tokio::spawn(async move { zero_usecase.execute_put_object(&FS::new(), zero_request).await });
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(30), body_polled_rx)
|
||||
.await
|
||||
.expect("zero-byte PUT body must be polled for EOF")
|
||||
.expect("zero-byte PUT body poll signal must be sent");
|
||||
assert!(!object_traffic_health.snapshot().write_stalled);
|
||||
assert!(!zero_put.is_finished(), "zero-byte PUT must still be waiting for request EOF");
|
||||
|
||||
body_release_tx.send(()).expect("zero-byte PUT body EOF must be released");
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
while !object_traffic_health.snapshot().write_stalled {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("fully received zero-byte PUT must publish a storage stall");
|
||||
assert!(!zero_put.is_finished(), "zero-byte PUT must still be waiting for the held namespace lock");
|
||||
|
||||
drop(zero_write_lock);
|
||||
tokio::time::timeout(Duration::from_secs(30), zero_put)
|
||||
.await
|
||||
.expect("zero-byte PUT must finish after releasing the lock")
|
||||
.expect("zero-byte PUT task must join")
|
||||
.expect("zero-byte PUT must succeed after releasing the lock");
|
||||
assert!(!object_traffic_health.snapshot().write_stalled);
|
||||
|
||||
let zero_copy_object = "zero-copy-eager.jpg";
|
||||
let zero_copy_payload = Bytes::from(vec![b'z'; 1024 * 1024 + 1]);
|
||||
let zero_copy_size = i64::try_from(zero_copy_payload.len()).expect("zero-copy payload length must fit i64");
|
||||
let zero_copy_headers = HeaderMap::new();
|
||||
assert!(!is_disk_compressible(&zero_copy_headers, zero_copy_object));
|
||||
assert_eq!(
|
||||
zero_copy_eager_put_path_status(zero_copy_size, &zero_copy_headers, false, false, false),
|
||||
PUT_EAGER_STATUS_ELIGIBLE,
|
||||
"test payload must exercise the production zero-copy eager path",
|
||||
);
|
||||
let zero_copy_write_lock = store
|
||||
.new_ns_lock(&bucket, zero_copy_object)
|
||||
.await
|
||||
.expect("zero-copy PUT namespace lock must be created")
|
||||
.get_write_lock(Duration::from_secs(30))
|
||||
.await
|
||||
.expect("zero-copy PUT namespace lock must be held");
|
||||
let zero_copy_input = PutObjectInput::builder()
|
||||
.bucket(bucket)
|
||||
.key(zero_copy_object.to_string())
|
||||
.body(Some(StreamingBlob::from(s3s::Body::from(zero_copy_payload))))
|
||||
.content_length(Some(zero_copy_size))
|
||||
.build()
|
||||
.expect("zero-copy PUT input must build");
|
||||
let zero_copy_usecase = DefaultObjectUsecase::with_context(Some(context));
|
||||
let zero_copy_put = tokio::spawn(async move {
|
||||
zero_copy_usecase
|
||||
.execute_put_object(&FS::new(), build_request(zero_copy_input, Method::PUT))
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
while !object_traffic_health.snapshot().write_stalled {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("blocked zero-copy eager PUT must publish a storage stall");
|
||||
assert!(
|
||||
!zero_copy_put.is_finished(),
|
||||
"zero-copy PUT must still be waiting for the held namespace lock"
|
||||
);
|
||||
|
||||
drop(zero_copy_write_lock);
|
||||
tokio::time::timeout(Duration::from_secs(30), zero_copy_put)
|
||||
.await
|
||||
.expect("zero-copy PUT must finish after releasing the lock")
|
||||
.expect("zero-copy PUT task must join")
|
||||
.expect("zero-copy PUT must succeed after releasing the lock");
|
||||
assert!(!object_traffic_health.snapshot().write_stalled);
|
||||
}
|
||||
|
||||
async fn put_real_cold_fill_object(store: &Arc<ECStore>, bucket: &str, object: &str, body: &[u8]) -> ObjectInfo {
|
||||
let mut reader = PutObjReader::from_vec(body.to_vec());
|
||||
store
|
||||
|
||||
+139
-8
@@ -12,11 +12,12 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::readiness::{DependencyReadinessReport, ReadinessDegradedReason};
|
||||
use super::readiness::{DependencyReadinessReport, ReadinessDegradedReason, record_readiness_overlay_reason};
|
||||
use super::{
|
||||
HEALTH_READY_PATH, MINIO_HEALTH_CLUSTER_PATH, MINIO_HEALTH_CLUSTER_READ_PATH, MINIO_HEALTH_READY_PATH,
|
||||
collect_cluster_read_health_report, collect_cluster_write_health_report, collect_node_readiness_report,
|
||||
};
|
||||
use crate::app::object_traffic_health::{ObjectTrafficHealth, ObjectTrafficSnapshot};
|
||||
use http::{Method, StatusCode};
|
||||
use rustfs_kms::ProbeStatus;
|
||||
use rustfs_kms::probe::{DEFAULT_PROBE_INTERVAL, ENV_KMS_PROBE_INTERVAL_SECS, MIN_PROBE_INTERVAL};
|
||||
@@ -70,11 +71,33 @@ pub(crate) struct HealthPayloadContext<'a> {
|
||||
pub(crate) include_dependency_details: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn collect_probe_readiness(probe: HealthProbe) -> Option<DependencyReadinessReport> {
|
||||
match readiness_source_for_probe(probe)? {
|
||||
HealthReadinessSource::Node => Some(collect_node_readiness_report().await),
|
||||
HealthReadinessSource::ClusterWrite => Some(collect_cluster_write_health_report().await),
|
||||
HealthReadinessSource::ClusterRead => Some(collect_cluster_read_health_report().await),
|
||||
pub(crate) async fn collect_probe_readiness(
|
||||
probe: HealthProbe,
|
||||
object_traffic_health: Option<&ObjectTrafficHealth>,
|
||||
) -> Option<DependencyReadinessReport> {
|
||||
let mut report = match readiness_source_for_probe(probe)? {
|
||||
HealthReadinessSource::Node => collect_node_readiness_report().await,
|
||||
HealthReadinessSource::ClusterWrite => collect_cluster_write_health_report().await,
|
||||
HealthReadinessSource::ClusterRead => collect_cluster_read_health_report().await,
|
||||
};
|
||||
if probe == HealthProbe::Readiness
|
||||
&& let Some(object_traffic_health) = object_traffic_health
|
||||
{
|
||||
apply_object_traffic_snapshot(&mut report, object_traffic_health.snapshot());
|
||||
}
|
||||
Some(report)
|
||||
}
|
||||
|
||||
fn apply_object_traffic_snapshot(report: &mut DependencyReadinessReport, snapshot: ObjectTrafficSnapshot) {
|
||||
if snapshot.read_stalled {
|
||||
let reason = ReadinessDegradedReason::ObjectReadStalled;
|
||||
report.degraded_reasons.push(reason);
|
||||
record_readiness_overlay_reason(reason);
|
||||
}
|
||||
if snapshot.write_stalled {
|
||||
let reason = ReadinessDegradedReason::ObjectWriteStalled;
|
||||
report.degraded_reasons.push(reason);
|
||||
record_readiness_overlay_reason(reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,13 +323,19 @@ pub(crate) fn build_health_response_parts(
|
||||
),
|
||||
};
|
||||
|
||||
if probe == HealthProbe::Readiness && matches!(kms_ready, Some(false)) {
|
||||
let object_traffic_stalled = degraded_reasons.iter().any(|reason| {
|
||||
matches!(
|
||||
reason,
|
||||
ReadinessDegradedReason::ObjectReadStalled | ReadinessDegradedReason::ObjectWriteStalled
|
||||
)
|
||||
});
|
||||
if probe == HealthProbe::Readiness && (object_traffic_stalled || matches!(kms_ready, Some(false))) {
|
||||
health = HealthCheckState {
|
||||
status_code: StatusCode::SERVICE_UNAVAILABLE,
|
||||
status: "degraded",
|
||||
ready: false,
|
||||
};
|
||||
if !degraded_reasons.contains(&ReadinessDegradedReason::KmsNotReady) {
|
||||
if matches!(kms_ready, Some(false)) && !degraded_reasons.contains(&ReadinessDegradedReason::KmsNotReady) {
|
||||
degraded_reasons.push(ReadinessDegradedReason::KmsNotReady);
|
||||
}
|
||||
}
|
||||
@@ -365,6 +394,8 @@ pub(crate) fn build_health_payload(ctx: HealthPayloadContext<'_>) -> Value {
|
||||
mod tests {
|
||||
use super::super::readiness::DependencyReadiness;
|
||||
use super::*;
|
||||
use metrics_util::MetricKind;
|
||||
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
|
||||
use rustfs_kms::{ProbeFailureKind, ProbeResult};
|
||||
use serial_test::serial;
|
||||
use temp_env::with_var;
|
||||
@@ -394,6 +425,106 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_collects_object_stalls_and_recovers_on_completion() {
|
||||
let object_traffic_health = ObjectTrafficHealth::enabled_for_test(Duration::ZERO);
|
||||
let read = object_traffic_health
|
||||
.track_read_storage()
|
||||
.expect("read tracking must be enabled");
|
||||
let write = object_traffic_health
|
||||
.track_write_storage()
|
||||
.expect("write tracking must be enabled");
|
||||
|
||||
let stalled = collect_probe_readiness(HealthProbe::Readiness, Some(&object_traffic_health))
|
||||
.await
|
||||
.expect("readiness must have a dependency report");
|
||||
assert!(stalled.degraded_reasons.contains(&ReadinessDegradedReason::ObjectReadStalled));
|
||||
assert!(
|
||||
stalled
|
||||
.degraded_reasons
|
||||
.contains(&ReadinessDegradedReason::ObjectWriteStalled)
|
||||
);
|
||||
|
||||
drop(read);
|
||||
drop(write);
|
||||
let recovered = collect_probe_readiness(HealthProbe::Readiness, Some(&object_traffic_health))
|
||||
.await
|
||||
.expect("readiness must have a dependency report");
|
||||
assert!(
|
||||
!recovered
|
||||
.degraded_reasons
|
||||
.contains(&ReadinessDegradedReason::ObjectReadStalled)
|
||||
);
|
||||
assert!(
|
||||
!recovered
|
||||
.degraded_reasons
|
||||
.contains(&ReadinessDegradedReason::ObjectWriteStalled)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn an_object_stall_degrades_readiness_without_changing_dependency_details() {
|
||||
with_var(rustfs_config::ENV_HEALTH_MINIMAL_RESPONSE_ENABLE, Some("false"), || {
|
||||
let mut report = ready_report();
|
||||
report.degraded_reasons.push(ReadinessDegradedReason::ObjectReadStalled);
|
||||
|
||||
let parts =
|
||||
build_health_response_parts(Method::GET, HealthProbe::Readiness, Some(&report), "rustfs-endpoint", None, None);
|
||||
|
||||
assert_eq!(parts.status_code, StatusCode::SERVICE_UNAVAILABLE);
|
||||
let payload = parts.payload.expect("GET should include payload");
|
||||
assert_eq!(payload["ready"], false);
|
||||
assert_eq!(payload["details"]["storage"]["ready"], true);
|
||||
assert_eq!(payload["degradedReasons"], json!(["object_read_stalled"]));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_stalls_do_not_change_liveness() {
|
||||
let mut report = ready_report();
|
||||
report.degraded_reasons.push(ReadinessDegradedReason::ObjectWriteStalled);
|
||||
|
||||
let parts =
|
||||
build_health_response_parts(Method::HEAD, HealthProbe::Liveness, Some(&report), "rustfs-endpoint", None, None);
|
||||
|
||||
assert_eq!(parts.status_code, StatusCode::OK);
|
||||
assert!(parts.payload.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_stall_overlay_records_the_final_readiness_metrics() {
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
let mut report = ready_report();
|
||||
apply_object_traffic_snapshot(
|
||||
&mut report,
|
||||
ObjectTrafficSnapshot {
|
||||
read_stalled: true,
|
||||
write_stalled: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
let entries = snapshotter.snapshot().into_vec();
|
||||
let ready = entries.iter().find_map(|(composite, _, _, value)| {
|
||||
(composite.kind() == MetricKind::Gauge && composite.key().name() == "rustfs_runtime_readiness_ready").then_some(value)
|
||||
});
|
||||
assert!(matches!(ready, Some(DebugValue::Gauge(value)) if value.into_inner() == 0.0));
|
||||
|
||||
let degraded = entries.iter().find_map(|(composite, _, _, value)| {
|
||||
(composite.kind() == MetricKind::Counter
|
||||
&& composite.key().name() == "rustfs_runtime_readiness_degraded_total"
|
||||
&& composite
|
||||
.key()
|
||||
.labels()
|
||||
.any(|label| label.key() == "reason" && label.value() == "object_read_stalled"))
|
||||
.then_some(value)
|
||||
});
|
||||
assert!(matches!(degraded, Some(DebugValue::Counter(1))));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_fresh_successful_round_keeps_the_service_ready() {
|
||||
let round_at = Instant::now();
|
||||
|
||||
@@ -1604,7 +1604,7 @@ fn process_connection(
|
||||
.option_layer(if is_console { Some(RedirectLayer) } else { None })
|
||||
.layer(BodylessStatusFixLayer)
|
||||
.layer(HeadRequestBodyFixLayer)
|
||||
.layer(PublicHealthEndpointLayer)
|
||||
.layer(PublicHealthEndpointLayer::new(Arc::clone(&server_ctx)))
|
||||
.option_layer((!server_domains_configured && !is_console).then_some(VirtualHostStyleHintLayer))
|
||||
.layer(DoubleSlashListBucketsCompatLayer)
|
||||
.service(service)
|
||||
@@ -1701,7 +1701,7 @@ fn process_connection(
|
||||
.option_layer(if is_console { Some(RedirectLayer) } else { None })
|
||||
.layer(BodylessStatusFixLayer)
|
||||
.layer(HeadRequestBodyFixLayer)
|
||||
.layer(PublicHealthEndpointLayer)
|
||||
.layer(PublicHealthEndpointLayer::new(Arc::clone(&server_ctx)))
|
||||
.option_layer((!server_domains_configured && !is_console).then_some(VirtualHostStyleHintLayer))
|
||||
.layer(DoubleSlashListBucketsCompatLayer)
|
||||
.service(service)
|
||||
|
||||
+138
-15
@@ -14,6 +14,7 @@
|
||||
|
||||
use super::runtime_sources;
|
||||
use crate::admin::console::is_console_path;
|
||||
use crate::app::object_traffic_health::ObjectTrafficHealth;
|
||||
use crate::error::ApiError;
|
||||
use crate::server::RemoteAddr;
|
||||
use crate::server::cors;
|
||||
@@ -1238,19 +1239,31 @@ where
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PublicHealthEndpointLayer;
|
||||
pub struct PublicHealthEndpointLayer {
|
||||
server_ctx: Arc<crate::runtime_sources::ServerContextSlot>,
|
||||
}
|
||||
|
||||
impl PublicHealthEndpointLayer {
|
||||
pub fn new(server_ctx: Arc<crate::runtime_sources::ServerContextSlot>) -> Self {
|
||||
Self { server_ctx }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for PublicHealthEndpointLayer {
|
||||
type Service = PublicHealthEndpointService<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
PublicHealthEndpointService { inner }
|
||||
PublicHealthEndpointService {
|
||||
inner,
|
||||
server_ctx: Arc::clone(&self.server_ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PublicHealthEndpointService<S> {
|
||||
inner: S,
|
||||
server_ctx: Arc<crate::runtime_sources::ServerContextSlot>,
|
||||
}
|
||||
|
||||
fn health_endpoint_enabled() -> bool {
|
||||
@@ -1318,6 +1331,7 @@ async fn health_kms_ready() -> bool {
|
||||
async fn build_public_health_http_response<RestBody, GrpcBody>(
|
||||
method: Method,
|
||||
path: String,
|
||||
object_traffic_health: Option<Arc<ObjectTrafficHealth>>,
|
||||
) -> Response<HybridBody<RestBody, GrpcBody>>
|
||||
where
|
||||
RestBody: From<Bytes>,
|
||||
@@ -1342,7 +1356,7 @@ where
|
||||
.expect("failed to build health busy response");
|
||||
}
|
||||
|
||||
let readiness_report = collect_probe_readiness(probe).await;
|
||||
let readiness_report = collect_probe_readiness(probe, object_traffic_health.as_deref()).await;
|
||||
let kms_ready = if probe == HealthProbe::Readiness && health_compat_kms_ready_check_enabled() {
|
||||
Some(health_kms_ready().await)
|
||||
} else {
|
||||
@@ -1388,7 +1402,11 @@ where
|
||||
if is_public_health_endpoint_request(method, path) {
|
||||
let method = method.clone();
|
||||
let path = path.to_owned();
|
||||
return Box::pin(async move { Ok(build_public_health_http_response(method, path).await) });
|
||||
let object_traffic_health = self
|
||||
.server_ctx
|
||||
.installed_app_context()
|
||||
.map(|context| context.object_traffic_health());
|
||||
return Box::pin(async move { Ok(build_public_health_http_response(method, path, object_traffic_health).await) });
|
||||
}
|
||||
|
||||
let mut inner = self.inner.clone();
|
||||
@@ -2185,6 +2203,17 @@ mod tests {
|
||||
use temp_env::{async_with_vars, with_var};
|
||||
use tracing_subscriber::{Registry, fmt::MakeWriter, layer::SubscriberExt};
|
||||
|
||||
fn public_health_layer() -> PublicHealthEndpointLayer {
|
||||
PublicHealthEndpointLayer::new(crate::runtime_sources::ServerContextSlot::new())
|
||||
}
|
||||
|
||||
async fn public_health_layer_with_tracker(object_traffic_health: Arc<ObjectTrafficHealth>) -> PublicHealthEndpointLayer {
|
||||
let app_context = crate::app::gating_test_env::app_context_with_object_traffic_health(object_traffic_health).await;
|
||||
let server_ctx = crate::runtime_sources::ServerContextSlot::new();
|
||||
assert!(server_ctx.install(app_context));
|
||||
PublicHealthEndpointLayer::new(server_ctx)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CaptureService;
|
||||
|
||||
@@ -2651,7 +2680,7 @@ mod tests {
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("true"))], async {
|
||||
let inner = CountingHybridService::default();
|
||||
let calls = inner.calls();
|
||||
let mut service = PublicHealthEndpointLayer.layer(inner);
|
||||
let mut service = public_health_layer().layer(inner);
|
||||
|
||||
let response = service
|
||||
.call(
|
||||
@@ -2846,7 +2875,7 @@ mod tests {
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("true"))], async {
|
||||
let inner = CountingHybridService::default();
|
||||
let calls = inner.calls();
|
||||
let mut service = PublicHealthEndpointLayer.layer(inner);
|
||||
let mut service = public_health_layer().layer(inner);
|
||||
|
||||
let response = service
|
||||
.call(
|
||||
@@ -2874,7 +2903,7 @@ mod tests {
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("true"))], async {
|
||||
let inner = CountingHybridService::default();
|
||||
let calls = inner.calls();
|
||||
let mut service = PublicHealthEndpointLayer.layer(inner);
|
||||
let mut service = public_health_layer().layer(inner);
|
||||
|
||||
let response = service
|
||||
.call(
|
||||
@@ -2899,7 +2928,7 @@ mod tests {
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("true"))], async {
|
||||
let inner = CountingHybridService::default();
|
||||
let calls = inner.calls();
|
||||
let mut service = PublicHealthEndpointLayer.layer(inner);
|
||||
let mut service = public_health_layer().layer(inner);
|
||||
|
||||
let response = service
|
||||
.call(
|
||||
@@ -2924,7 +2953,7 @@ mod tests {
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("true"))], async {
|
||||
let inner = CountingHybridService::default();
|
||||
let calls = inner.calls();
|
||||
let mut service = PublicHealthEndpointLayer.layer(inner);
|
||||
let mut service = public_health_layer().layer(inner);
|
||||
|
||||
let response = service
|
||||
.call(
|
||||
@@ -2946,13 +2975,107 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn public_readiness_aliases_use_the_installed_object_progress() {
|
||||
async_with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("true")),
|
||||
(rustfs_config::ENV_HEALTH_MINIMAL_RESPONSE_ENABLE, Some("false")),
|
||||
],
|
||||
async {
|
||||
let object_traffic_health = Arc::new(ObjectTrafficHealth::enabled_for_test(Duration::ZERO));
|
||||
let stalled = object_traffic_health
|
||||
.track_read_storage()
|
||||
.expect("read tracking must be enabled");
|
||||
let inner = CountingHybridService::default();
|
||||
let calls = inner.calls();
|
||||
let mut service = public_health_layer_with_tracker(Arc::clone(&object_traffic_health))
|
||||
.await
|
||||
.layer(inner);
|
||||
|
||||
let response = service
|
||||
.call(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(HEALTH_READY_PATH)
|
||||
.body(Full::<Bytes>::from(Bytes::new()))
|
||||
.expect("canonical readiness request"),
|
||||
)
|
||||
.await
|
||||
.expect("canonical readiness response");
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = BodyExt::collect(response.into_body())
|
||||
.await
|
||||
.expect("readiness body")
|
||||
.to_bytes();
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("readiness JSON");
|
||||
assert_eq!(payload["ready"], false);
|
||||
assert_eq!(payload["degradedReasons"], serde_json::json!(["object_read_stalled"]));
|
||||
|
||||
let response = service
|
||||
.call(
|
||||
Request::builder()
|
||||
.method(Method::HEAD)
|
||||
.uri(MINIO_HEALTH_READY_PATH)
|
||||
.body(Full::<Bytes>::from(Bytes::new()))
|
||||
.expect("MinIO readiness request"),
|
||||
)
|
||||
.await
|
||||
.expect("MinIO readiness response");
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert!(
|
||||
BodyExt::collect(response.into_body())
|
||||
.await
|
||||
.expect("HEAD body")
|
||||
.to_bytes()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let response = service
|
||||
.call(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(HEALTH_COMPAT_LIVE_PATH)
|
||||
.body(Full::<Bytes>::from(Bytes::new()))
|
||||
.expect("liveness request"),
|
||||
)
|
||||
.await
|
||||
.expect("liveness response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
|
||||
drop(stalled);
|
||||
let response = service
|
||||
.call(
|
||||
Request::builder()
|
||||
.method(Method::HEAD)
|
||||
.uri(HEALTH_READY_PATH)
|
||||
.body(Full::<Bytes>::from(Bytes::new()))
|
||||
.expect("recovered readiness request"),
|
||||
)
|
||||
.await
|
||||
.expect("recovered readiness response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
BodyExt::collect(response.into_body())
|
||||
.await
|
||||
.expect("HEAD body")
|
||||
.to_bytes()
|
||||
.is_empty()
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn public_health_endpoint_layer_handles_minio_health_cluster_before_inner_service() {
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("true"))], async {
|
||||
let inner = CountingHybridService::default();
|
||||
let calls = inner.calls();
|
||||
let mut service = PublicHealthEndpointLayer.layer(inner);
|
||||
let mut service = public_health_layer().layer(inner);
|
||||
|
||||
let response = service
|
||||
.call(
|
||||
@@ -2977,7 +3100,7 @@ mod tests {
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("true"))], async {
|
||||
let inner = CountingHybridService::default();
|
||||
let calls = inner.calls();
|
||||
let mut service = PublicHealthEndpointLayer.layer(inner);
|
||||
let mut service = public_health_layer().layer(inner);
|
||||
|
||||
let response = service
|
||||
.call(
|
||||
@@ -3002,7 +3125,7 @@ mod tests {
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("false"))], async {
|
||||
let inner = CountingHybridService::default();
|
||||
let calls = inner.calls();
|
||||
let mut service = PublicHealthEndpointLayer.layer(inner);
|
||||
let mut service = public_health_layer().layer(inner);
|
||||
|
||||
let response = service
|
||||
.call(
|
||||
@@ -3027,7 +3150,7 @@ mod tests {
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("false"))], async {
|
||||
let inner = CountingHybridService::default();
|
||||
let calls = inner.calls();
|
||||
let mut service = PublicHealthEndpointLayer.layer(inner);
|
||||
let mut service = public_health_layer().layer(inner);
|
||||
|
||||
let response = service
|
||||
.call(
|
||||
@@ -3069,7 +3192,7 @@ mod tests {
|
||||
async_with_vars([(rustfs_config::ENV_HEALTH_ENDPOINT_ENABLE, Some("false"))], async {
|
||||
let inner = CountingHybridService::default();
|
||||
let calls = inner.calls();
|
||||
let mut service = PublicHealthEndpointLayer.layer(inner);
|
||||
let mut service = public_health_layer().layer(inner);
|
||||
|
||||
let response = service
|
||||
.call(
|
||||
@@ -3092,7 +3215,7 @@ mod tests {
|
||||
async fn public_health_endpoint_layer_forwards_non_health_requests() {
|
||||
let inner = CountingHybridService::default();
|
||||
let calls = inner.calls();
|
||||
let mut service = PublicHealthEndpointLayer.layer(inner);
|
||||
let mut service = public_health_layer().layer(inner);
|
||||
|
||||
let response = service
|
||||
.call(
|
||||
|
||||
@@ -88,6 +88,8 @@ pub enum ReadinessDegradedReason {
|
||||
IamNotReady,
|
||||
LockQuorumUnavailable,
|
||||
KmsNotReady,
|
||||
ObjectReadStalled,
|
||||
ObjectWriteStalled,
|
||||
ClusterHealthTimeout,
|
||||
PeerHealthUnavailable,
|
||||
StorageAndIamUnavailable,
|
||||
@@ -103,6 +105,8 @@ impl ReadinessDegradedReason {
|
||||
ReadinessDegradedReason::IamNotReady => "iam_not_ready",
|
||||
ReadinessDegradedReason::LockQuorumUnavailable => "lock_quorum_unavailable",
|
||||
ReadinessDegradedReason::KmsNotReady => "kms_not_ready",
|
||||
ReadinessDegradedReason::ObjectReadStalled => "object_read_stalled",
|
||||
ReadinessDegradedReason::ObjectWriteStalled => "object_write_stalled",
|
||||
ReadinessDegradedReason::ClusterHealthTimeout => "cluster_health_timeout",
|
||||
ReadinessDegradedReason::PeerHealthUnavailable => "peer_health_unavailable",
|
||||
ReadinessDegradedReason::StorageAndIamUnavailable => "storage_and_iam_unavailable",
|
||||
@@ -714,6 +718,11 @@ fn record_readiness_report(report: &DependencyReadinessReport) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_readiness_overlay_reason(reason: ReadinessDegradedReason) {
|
||||
gauge!(METRIC_RUNTIME_READINESS_READY).set(0.0);
|
||||
counter!(METRIC_RUNTIME_READINESS_DEGRADED_TOTAL, "reason" => reason.as_str()).increment(1);
|
||||
}
|
||||
|
||||
fn dependency_readiness_report_from_readiness(readiness: DependencyReadiness) -> DependencyReadinessReport {
|
||||
DependencyReadinessReport {
|
||||
degraded_reasons: degraded_reasons(readiness),
|
||||
|
||||
@@ -59,11 +59,51 @@ use s3s::dto::VersioningConfiguration;
|
||||
#[cfg(test)]
|
||||
pub(crate) static VERSIONING_CONFIG_LOOKUPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
#[cfg(test)]
|
||||
type VersioningConfigTestHook = (String, std::sync::Arc<tokio::sync::Barrier>, std::sync::Arc<tokio::sync::Barrier>);
|
||||
|
||||
#[cfg(test)]
|
||||
static VERSIONING_CONFIG_TEST_HOOK: std::sync::OnceLock<std::sync::Mutex<Option<VersioningConfigTestHook>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_versioning_config_test_hook(
|
||||
bucket: String,
|
||||
entered: std::sync::Arc<tokio::sync::Barrier>,
|
||||
resume: std::sync::Arc<tokio::sync::Barrier>,
|
||||
) {
|
||||
*VERSIONING_CONFIG_TEST_HOOK
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("versioning config test hook lock should not be poisoned") = Some((bucket, entered, resume));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn wait_for_versioning_config_test_hook(bucket: &str) {
|
||||
let hook = {
|
||||
let mut slot = VERSIONING_CONFIG_TEST_HOOK
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("versioning config test hook lock should not be poisoned");
|
||||
if slot.as_ref().is_some_and(|(expected_bucket, _, _)| expected_bucket == bucket) {
|
||||
slot.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some((_bucket, entered, resume)) = hook {
|
||||
entered.wait().await;
|
||||
resume.wait().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the bucket's versioning configuration once so callers can derive
|
||||
/// enabled/suspended state without repeated metadata-sys lookups per request.
|
||||
pub(crate) async fn bucket_versioning_config(bucket: &str) -> VersioningConfiguration {
|
||||
#[cfg(test)]
|
||||
VERSIONING_CONFIG_LOOKUPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
#[cfg(test)]
|
||||
wait_for_versioning_config_test_hook(bucket).await;
|
||||
match BucketVersioningSys::get(bucket).await {
|
||||
Ok(cfg) => cfg,
|
||||
Err(err) => {
|
||||
|
||||
Reference in New Issue
Block a user