mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-07 20:46:11 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0486ca9877 | |||
| 54716aa61c | |||
| 1a5e2b6256 | |||
| 474fcf78fb | |||
| 752d4a81ab | |||
| 0686277ee4 | |||
| 7373a5902e | |||
| e95a0ed6d9 | |||
| f0e0f5307d | |||
| 6855640192 | |||
| f11697d6e2 | |||
| 736b6a366c | |||
| 7b40b9503b | |||
| e22b879996 | |||
| b0dac1ac24 | |||
| 90e3ed701e | |||
| fd92853ac4 | |||
| bc0f608431 | |||
| f20a575994 |
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: overtrue/repo-visuals-action@fd79cba437ecfac933d00a69add17eb95d3939c3 # v1.3.1
|
||||
- uses: overtrue/repo-visuals-action@ee2c632f6ce617e851fb46ea935ee8af762ebb93 # v1.4.0
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
output-branch: star-history
|
||||
|
||||
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385.
|
||||
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
|
||||
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
|
||||
|
||||
|
||||
+16
-1
@@ -66,6 +66,11 @@ Current guidance:
|
||||
|
||||
- `RUSTFS_BROWSER_REDIRECT_URL` sets the externally reachable browser origin used for OIDC callback, console success redirect, and logout fallback URLs. Configure it to the public scheme and authority without a path, for example `https://console.example.com`. In load-balancer deployments, keep OIDC authorize and callback requests on the same backend node because the in-flight OIDC `state` is local to the RustFS node.
|
||||
|
||||
## S3 API environment variables
|
||||
|
||||
- `RUSTFS_API_OBJECT_MAX_VERSIONS` caps the number of retained versions for a single object. It defaults to `9223372036854775807`, matching MinIO's practical-unlimited default. Set a positive integer to enforce a lower per-object metadata bound.
|
||||
- `MINIO_API_OBJECT_MAX_VERSIONS` is accepted as a compatibility alias when the canonical RustFS variable is not set.
|
||||
|
||||
## Distributed endpoint locality
|
||||
|
||||
- `RUSTFS_LOCAL_ENDPOINT_HOST` identifies this server's host in a distributed `RUSTFS_VOLUMES` topology without resolving every peer during startup. Set it to exactly one host, without a scheme, port, or path. It is accepted only for orchestrated URL topologies and must match at least one endpoint on the RustFS server port; invalid or unmatched values fail startup. Leave it unset to retain DNS-based locality discovery.
|
||||
@@ -153,14 +158,24 @@ concurrently. Small direct PUTs stay on the legacy path.
|
||||
- default is `0`.
|
||||
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
|
||||
- how long an `UploadPart` waits in the bounded queue for a permit before returning S3 `SlowDown`; `0` rejects immediately when the pool is full.
|
||||
- default is `30000`. Parts wait before body ingest, so SDK-default clients that send every part of an upload concurrently drain through the pool instead of failing.
|
||||
- default is `10000`. Parts wait before body ingest, so SDK-default clients that send every part of an upload concurrently drain through the pool instead of failing on a full pool.
|
||||
- RustFS does not read the request body while a part is queued, so the client's socket write stalls for the whole wait and whatever timeout the client or an intermediary has configured competes with this value. Keep it with margin below the shortest such timeout in use (botocore applies its 60 s `connect_timeout` to the body write; the AWS SDK for Java v2 has a 30 s socket write timeout; reverse proxies add their own body timeouts); a wait that outlives the client timeout surfaces as a dropped connection instead of `SlowDown`.
|
||||
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING`
|
||||
- maximum `UploadPart` requests waiting for a permit at once; parts beyond it return `SlowDown` without waiting.
|
||||
- default is `0`, which derives 16 times the permit limit (512 at stock settings).
|
||||
- each queued HTTP/1 part holds whatever unread body the client already pushed into the connection's kernel receive buffer (an HTTP/2 part holds up to its flow-control window in process memory), so this depth also bounds that memory. RustFS leaves the receive buffer to kernel autotuning (see `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` below), which keeps an unread connection at the kernel's initial size (128 KiB on current Linux).
|
||||
- `RUSTFS_PUT_FOREGROUND_ADMISSION_ENABLE`, `RUSTFS_PUT_FOREGROUND_ADMISSION_LIMIT`, `RUSTFS_PUT_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
|
||||
- experimental strict gate that applies to every foreground write regardless of size and replaces the pool above when enabled.
|
||||
- default is disabled; enabling it with limit `0` disables foreground write admission entirely.
|
||||
|
||||
## HTTP listener socket environment variables
|
||||
|
||||
- `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES`
|
||||
- fixed `SO_RCVBUF` for the API listener, inherited by every accepted socket; `0` leaves the receive buffer to kernel autotuning.
|
||||
- default is `0`. Earlier releases hard-coded 4 MiB, which Linux doubles to 8 MiB and which disables autotuning, so every connection whose body was not being read yet (a multipart part queued for a foreground write permit) could accumulate up to 8 MiB of unread body in kernel memory; at SDK-default multipart concurrency that was enough to push a node into TCP memory pressure.
|
||||
- with autotuning the per-connection receive ceiling is the kernel's (`net.ipv4.tcp_rmem` max, 6 MiB on stock Linux) instead of the former fixed 8 MiB, so a single very high-bandwidth-delay connection may see a somewhat lower ceiling; raise `net.ipv4.tcp_rmem` first, and set this variable only on kernels without receive-buffer autotuning (illumos/Solaris) or where the sysctl cannot be changed.
|
||||
- the send buffer stays fixed at 4 MiB because the stock Linux send autotuning ceiling (`net.ipv4.tcp_wmem` max, 4 MiB) is lower than a GB-level response stream needs.
|
||||
|
||||
## Remote tier timeout environment variables
|
||||
|
||||
- `RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS`
|
||||
|
||||
@@ -90,3 +90,15 @@ pub const ENV_API_MAX_CONNECTIONS: &str = "RUSTFS_API_MAX_CONNECTIONS";
|
||||
|
||||
/// Default for `RUSTFS_API_MAX_CONNECTIONS` (`0` = unlimited).
|
||||
pub const DEFAULT_API_MAX_CONNECTIONS: usize = 0;
|
||||
|
||||
/// Maximum retained versions per object.
|
||||
///
|
||||
/// The default follows MinIO and is effectively unlimited for practical
|
||||
/// deployments. Operators can lower it to bound per-object metadata growth.
|
||||
/// Environment variable: RUSTFS_API_OBJECT_MAX_VERSIONS
|
||||
/// MinIO-compatible alias: MINIO_API_OBJECT_MAX_VERSIONS
|
||||
/// Example: RUSTFS_API_OBJECT_MAX_VERSIONS=50000
|
||||
pub const ENV_API_OBJECT_MAX_VERSIONS: &str = "RUSTFS_API_OBJECT_MAX_VERSIONS";
|
||||
|
||||
/// Default for `RUSTFS_API_OBJECT_MAX_VERSIONS`.
|
||||
pub const DEFAULT_API_OBJECT_MAX_VERSIONS: u64 = 9_223_372_036_854_775_807;
|
||||
|
||||
@@ -376,21 +376,39 @@ pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 250;
|
||||
///
|
||||
/// SDK-default multipart clients send every part of an upload concurrently, so
|
||||
/// a single node routinely sees several times more parts in flight than the
|
||||
/// permit pool allows. Those parts have not ingested a body yet, so queueing
|
||||
/// them costs a connection rather than memory or internode streams; the pool
|
||||
/// still bounds the number of parts being written. The wait is long enough for
|
||||
/// an ordinary queue to drain on modest hardware, and a part that cannot get a
|
||||
/// permit within it fails with S3 `SlowDown`/503 for the client to retry.
|
||||
/// `0` rejects immediately when the pool is full.
|
||||
/// permit pool allows. A queued part waits before body ingest, so the pool
|
||||
/// still bounds the number of parts being written, but the wait is not free:
|
||||
/// RustFS does not read the request body while the part is queued (hyper only
|
||||
/// sends `100 Continue` once the body is first polled, and the AWS SDKs send
|
||||
/// the body after a 1-3 s `Expect: 100-continue` grace anyway), so the
|
||||
/// client's socket write stalls once the kernel buffers fill, and whatever
|
||||
/// timeout the client or an intermediary has configured decides the outcome.
|
||||
/// botocore applies its `connect_timeout` (60 s) to the body write, the AWS
|
||||
/// SDK for Java v2 has a 30 s socket write timeout, and MinIO bounds the same
|
||||
/// wait with a 10 s request deadline. The wait must leave margin under the
|
||||
/// shortest of those, not merely fall below an SDK default, so the part
|
||||
/// receives S3 `SlowDown`/503 for the client to retry instead of losing its
|
||||
/// connection (issue #7385). `0` rejects immediately when the pool is full.
|
||||
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str =
|
||||
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
|
||||
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 30_000;
|
||||
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 10_000;
|
||||
|
||||
// A queued part holds the client's body write open for the whole wait. The
|
||||
// shortest write timeout among mainstream S3 SDKs is the AWS SDK for Java v2's
|
||||
// 30 s socket write timeout; keep the compiled default at no more than a third
|
||||
// of it. This locks only the default; the environment variable may still raise
|
||||
// the wait past any client timeout.
|
||||
const _: () = assert!(DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS * 3 <= 30_000);
|
||||
|
||||
/// Maximum multipart UploadPart requests waiting for a foreground write permit per process.
|
||||
///
|
||||
/// Parts beyond this queue depth are rejected with S3 `SlowDown`/503 without
|
||||
/// waiting, so a genuinely saturated node still fails fast instead of holding
|
||||
/// an unbounded set of connections open for the whole wait timeout.
|
||||
/// an unbounded set of connections open for the whole wait timeout. Each
|
||||
/// queued HTTP/1 part also holds whatever unread body the client already
|
||||
/// pushed into that connection's kernel receive buffer, and a queued HTTP/2
|
||||
/// part holds up to its flow-control window in process memory, so the depth
|
||||
/// bounds socket and window memory as well as connections.
|
||||
/// `0` derives the depth from the permit limit.
|
||||
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING: &str = "RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING";
|
||||
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING: usize = 0;
|
||||
|
||||
@@ -159,6 +159,24 @@ pub const DEFAULT_HTTP1_HEADER_READ_TIMEOUT: u64 = 75;
|
||||
pub const ENV_HTTP1_MAX_BUF_SIZE: &str = "RUSTFS_HTTP1_MAX_BUF_SIZE";
|
||||
pub const DEFAULT_HTTP1_MAX_BUF_SIZE: usize = 64 * 1024; // 64 KB
|
||||
|
||||
/// Environment variable for a fixed kernel receive buffer (`SO_RCVBUF`, bytes)
|
||||
/// on the API listener. Default: 0, which leaves the buffer to kernel
|
||||
/// autotuning.
|
||||
///
|
||||
/// A fixed `SO_RCVBUF` is inherited by every accepted socket and disables
|
||||
/// receive-buffer autotuning, so a connection whose request body is not being
|
||||
/// read yet (a multipart part queued for a foreground write permit) lets up to
|
||||
/// the fixed size of unread body accumulate in kernel memory — Linux doubles
|
||||
/// the requested value, so the former hard-coded 4 MiB held up to 8 MiB per
|
||||
/// queued connection (issue #7385). Autotuning keeps an unread connection at
|
||||
/// the kernel's initial size and grows only connections that are being
|
||||
/// drained. Set this only on kernels without receive-buffer autotuning
|
||||
/// (illumos/Solaris) or on very high-bandwidth-delay links where the kernel's
|
||||
/// autotuning ceiling (`net.ipv4.tcp_rmem` on Linux) is too low and cannot be
|
||||
/// raised.
|
||||
pub const ENV_HTTP_SOCKET_RECV_BUFFER_BYTES: &str = "RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES";
|
||||
pub const DEFAULT_HTTP_SOCKET_RECV_BUFFER_BYTES: usize = 0;
|
||||
|
||||
/// Environment variable for the S3 request-body inter-chunk read timeout
|
||||
/// (seconds). Default: 300. Set to 0 to disable.
|
||||
///
|
||||
|
||||
@@ -416,8 +416,8 @@ pub mod disk {
|
||||
|
||||
pub mod error {
|
||||
pub use crate::error::{
|
||||
Error, Result, StorageError, classify_system_path_failure_reason, is_err_bucket_not_found, is_err_object_not_found,
|
||||
is_err_version_not_found,
|
||||
Error, PoolMetadataError, PoolMetadataFailure, Result, StorageError, classify_system_path_failure_reason,
|
||||
is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ pub type BucketConfigPublishHook = Box<dyn Fn(&str, &str, Option<(&[u8], OffsetD
|
||||
pub static BUCKET_CONFIG_PUBLISH_HOOK: std::sync::OnceLock<BucketConfigPublishHook> = std::sync::OnceLock::new();
|
||||
|
||||
const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
const LOG_SUBSYSTEM_BUCKET_METADATA: &str = "bucket_metadata";
|
||||
const EVENT_BUCKET_METADATA_LOAD_FAILED: &str = "bucket_metadata_load_failed";
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
struct ConfigWriteLockProbeState {
|
||||
@@ -1614,13 +1617,20 @@ impl BucketMetadataSys {
|
||||
|
||||
let results = join_all(futures).await;
|
||||
|
||||
for (idx, res) in results.into_iter().enumerate() {
|
||||
for (bucket, res) in buckets.iter().zip(results) {
|
||||
match res {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
error!("Unable to load bucket metadata, will be retried: {:?}", e);
|
||||
if let Some(bucket) = buckets.get(idx) {
|
||||
failed_buckets.insert(bucket.clone());
|
||||
if failed_buckets.insert(bucket.clone()) {
|
||||
error!(
|
||||
event = EVENT_BUCKET_METADATA_LOAD_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_BUCKET_METADATA,
|
||||
result = "retry_pending",
|
||||
bucket = %bucket,
|
||||
error_code = ?e.code(),
|
||||
"Unable to load bucket metadata; retry scheduled"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1647,12 +1657,19 @@ impl BucketMetadataSys {
|
||||
});
|
||||
}
|
||||
let results = join_all(futures).await;
|
||||
for (idx, result) in results.into_iter().enumerate() {
|
||||
if let Err(err) = result {
|
||||
error!("Unable to load bucket metadata, will be retried: {:?}", err);
|
||||
if let Some(bucket) = buckets.get(idx) {
|
||||
failed_buckets.insert(bucket.clone());
|
||||
}
|
||||
for (bucket, result) in buckets.iter().zip(results) {
|
||||
if let Err(err) = result
|
||||
&& failed_buckets.insert(bucket.clone())
|
||||
{
|
||||
error!(
|
||||
event = EVENT_BUCKET_METADATA_LOAD_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_BUCKET_METADATA,
|
||||
result = "retry_pending",
|
||||
bucket = %bucket,
|
||||
error_code = ?err.code(),
|
||||
"Unable to load bucket metadata; retry scheduled"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1594
-193
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@ mod capacity_dedup_tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: disks.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let total = get_total_usable_capacity(&disks, &info);
|
||||
@@ -73,6 +74,7 @@ mod capacity_dedup_tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: disks.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let total = get_total_usable_capacity(&disks, &info);
|
||||
@@ -150,6 +152,7 @@ mod capacity_dedup_tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: disks.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let total = get_total_usable_capacity(&disks, &info);
|
||||
|
||||
@@ -425,6 +425,7 @@ impl From<rustfs_filemeta::Error> for DiskError {
|
||||
rustfs_filemeta::Error::FileVersionNotFound => DiskError::FileVersionNotFound,
|
||||
rustfs_filemeta::Error::FileCorrupt => DiskError::FileCorrupt,
|
||||
rustfs_filemeta::Error::MethodNotAllowed => DiskError::MethodNotAllowed,
|
||||
rustfs_filemeta::Error::MaxVersionsExceeded => DiskError::MaxVersionsExceeded,
|
||||
e => DiskError::other(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,17 +23,59 @@ use s3s::S3ErrorCode;
|
||||
pub type Error = StorageError;
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PoolMetadataFailure {
|
||||
ReadUnavailable,
|
||||
RecoveryRequired,
|
||||
TransactionUnknown,
|
||||
FenceLost,
|
||||
}
|
||||
|
||||
impl PoolMetadataFailure {
|
||||
fn recovery_hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadUnavailable => "read unavailable; retry after the replicas are readable",
|
||||
Self::TransactionUnknown => "writes remain blocked pending fenced transaction recovery",
|
||||
Self::RecoveryRequired | Self::FenceLost => {
|
||||
"writes remain blocked after a recovery-required replica state; restart after all replicas are readable and consistent, with compatible formats"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadUnavailable => "read_unavailable",
|
||||
Self::RecoveryRequired => "recovery_required",
|
||||
Self::TransactionUnknown => "transaction_unknown",
|
||||
Self::FenceLost => "fence_lost",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Local control-plane context. Keep the existing storage error wire codes;
|
||||
/// the HTTP boundary recognizes this typed source, not an error-message prefix.
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
#[error("{operation}: pool metadata {hint} ({reason}, {phase}): {detail}", hint = kind.recovery_hint(), reason = kind.as_str(), detail = source.as_ref().map(ToString::to_string).unwrap_or_default())]
|
||||
pub struct PoolMetadataError {
|
||||
pub kind: PoolMetadataFailure,
|
||||
pub operation: String,
|
||||
pub phase: &'static str,
|
||||
pub since: time::OffsetDateTime,
|
||||
#[source]
|
||||
pub source: Option<std::sync::Arc<StorageError>>,
|
||||
}
|
||||
|
||||
/// Keeps high-cardinality diagnostic detail in the error source while making
|
||||
/// the rendered `io::Error` stable for quorum aggregation.
|
||||
#[derive(Debug)]
|
||||
struct StableIoContextError {
|
||||
message: &'static str,
|
||||
message: std::borrow::Cow<'static, str>,
|
||||
source: Box<dyn std::error::Error + Send + Sync>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StableIoContextError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(self.message)
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +90,7 @@ where
|
||||
E: Into<Box<dyn std::error::Error + Send + Sync>>,
|
||||
{
|
||||
std::io::Error::other(StableIoContextError {
|
||||
message,
|
||||
message: message.into(),
|
||||
source: source.into(),
|
||||
})
|
||||
}
|
||||
@@ -300,6 +342,22 @@ impl From<crate::erasure::coding::ErasureConstructionError> for StorageError {
|
||||
}
|
||||
|
||||
impl StorageError {
|
||||
pub fn pool_metadata_failure(&self) -> Option<&PoolMetadataError> {
|
||||
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(self);
|
||||
while let Some(error) = current {
|
||||
if let Some(context) = error.downcast_ref::<PoolMetadataError>() {
|
||||
return Some(context);
|
||||
}
|
||||
// io::Error::source skips its boxed context itself.
|
||||
current = if let Some(io) = error.downcast_ref::<std::io::Error>() {
|
||||
io.get_ref().map(|inner| inner as &(dyn std::error::Error + 'static))
|
||||
} else {
|
||||
error.source()
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn other<E>(error: E) -> Self
|
||||
where
|
||||
E: Into<Box<dyn std::error::Error + Send + Sync>>,
|
||||
@@ -530,6 +588,7 @@ impl From<rustfs_filemeta::Error> for StorageError {
|
||||
rustfs_filemeta::Error::FileVersionNotFound => StorageError::FileVersionNotFound,
|
||||
rustfs_filemeta::Error::FileCorrupt => StorageError::FileCorrupt,
|
||||
rustfs_filemeta::Error::Unexpected => StorageError::Unexpected,
|
||||
rustfs_filemeta::Error::MaxVersionsExceeded => StorageError::MaxVersionsExceeded,
|
||||
rustfs_filemeta::Error::Io(io_error) => io_error.into(),
|
||||
_ => StorageError::Io(std::io::Error::other(e)),
|
||||
}
|
||||
@@ -548,7 +607,19 @@ impl PartialEq for StorageError {
|
||||
impl Clone for StorageError {
|
||||
fn clone(&self) -> Self {
|
||||
match self {
|
||||
StorageError::Io(e) => StorageError::Io(std::io::Error::new(e.kind(), e.to_string())),
|
||||
StorageError::Io(e) => {
|
||||
if let Some(context) = self.pool_metadata_failure() {
|
||||
Self::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
StableIoContextError {
|
||||
message: e.to_string().into(),
|
||||
source: Box::new(context.clone()),
|
||||
},
|
||||
))
|
||||
} else {
|
||||
StorageError::Io(std::io::Error::new(e.kind(), e.to_string()))
|
||||
}
|
||||
}
|
||||
StorageError::FaultyDisk => StorageError::FaultyDisk,
|
||||
StorageError::DiskFull => StorageError::DiskFull,
|
||||
StorageError::VolumeNotFound => StorageError::VolumeNotFound,
|
||||
@@ -689,7 +760,8 @@ impl Clone for StorageError {
|
||||
}
|
||||
|
||||
impl StorageError {
|
||||
fn code(&self) -> StorageErrorCode {
|
||||
/// Stable classification without error payloads or storage paths.
|
||||
pub fn code(&self) -> StorageErrorCode {
|
||||
match self {
|
||||
StorageError::Io(_) => StorageErrorCode::Io,
|
||||
StorageError::FaultyDisk => StorageErrorCode::FaultyDisk,
|
||||
|
||||
@@ -55,6 +55,8 @@ mod set_disk;
|
||||
mod storage_api_contracts;
|
||||
mod store;
|
||||
|
||||
pub use store::PoolMetaWriteGateStatus;
|
||||
|
||||
// pub mod checksum;
|
||||
mod event;
|
||||
|
||||
|
||||
@@ -20,9 +20,10 @@ use chrono::Utc;
|
||||
use jiff::Timestamp;
|
||||
use rustfs_heal_contracts::heal_channel::DriveState;
|
||||
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
|
||||
use rustfs_io_metrics::s3_http_metrics::s3_http_metrics_snapshot;
|
||||
use rustfs_madmin::metrics::{
|
||||
DiskIOStats, DiskMetric, LastMinute as MadminLastMinute, NetDevLine, NetMetrics, RPCMetrics, RealtimeMetrics,
|
||||
ScannerCheckpointReport as MadminScannerCheckpointReport,
|
||||
DiskIOStats, DiskMetric, HttpMetrics, HttpRequestMetric, LastMinute as MadminLastMinute, NetDevLine, NetMetrics, RPCMetrics,
|
||||
RealtimeMetrics, ScannerCheckpointReport as MadminScannerCheckpointReport,
|
||||
ScannerLifecycleExpirySnapshot as MadminScannerLifecycleExpirySnapshot,
|
||||
ScannerLifecycleTransitionSnapshot as MadminScannerLifecycleTransitionSnapshot,
|
||||
ScannerMaintenanceControlSnapshot as MadminScannerMaintenanceControlSnapshot,
|
||||
@@ -61,9 +62,10 @@ impl MetricType {
|
||||
pub const MEM: MetricType = MetricType(1 << 6);
|
||||
pub const CPU: MetricType = MetricType(1 << 7);
|
||||
pub const RPC: MetricType = MetricType(1 << 8);
|
||||
pub const HTTP: MetricType = MetricType(1 << 9);
|
||||
|
||||
// MetricsAll must be last.
|
||||
pub const ALL: MetricType = MetricType((1 << 9) - 1);
|
||||
pub const ALL: MetricType = MetricType((1 << 10) - 1);
|
||||
|
||||
pub fn new(t: u32) -> Self {
|
||||
Self(t)
|
||||
@@ -410,6 +412,21 @@ pub async fn collect_local_metrics(types: MetricType, opts: &CollectMetricsOpts)
|
||||
by_host_name = local_node_name;
|
||||
}
|
||||
|
||||
if types.contains(&MetricType::HTTP) {
|
||||
real_time_metrics.aggregated.http = Some(HttpMetrics {
|
||||
collected_at: Timestamp::now(),
|
||||
requests: s3_http_metrics_snapshot()
|
||||
.into_iter()
|
||||
.map(|series| HttpRequestMetric {
|
||||
method: series.method.to_string(),
|
||||
operation: series.operation.to_string(),
|
||||
outcome: series.outcome.to_string(),
|
||||
total: series.total,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
|
||||
if types.contains(&MetricType::DISK) {
|
||||
debug!("start get disk metrics");
|
||||
let mut aggr = DiskMetric {
|
||||
@@ -585,11 +602,47 @@ mod test {
|
||||
assert!(t.contains(&MetricType::MEM));
|
||||
assert!(t.contains(&MetricType::CPU));
|
||||
assert!(t.contains(&MetricType::RPC));
|
||||
assert!(t.contains(&MetricType::HTTP));
|
||||
|
||||
let disk = MetricType::new(1 << 1);
|
||||
assert!(disk.contains(&MetricType::DISK));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collect_local_metrics_reports_the_same_http_outcome_counters() {
|
||||
let mut request = rustfs_io_metrics::s3_http_metrics::S3HttpRequestGuard::new("PUT");
|
||||
request.response(503);
|
||||
drop(request);
|
||||
let snapshot = s3_http_metrics_snapshot();
|
||||
let realtime = collect_local_metrics(MetricType::HTTP, &CollectMetricsOpts::default()).await;
|
||||
let http = realtime.aggregated.http.as_ref().expect("HTTP selection must report support");
|
||||
assert_eq!(http.requests.len(), snapshot.len());
|
||||
for (actual, expected) in http.requests.iter().zip(&snapshot) {
|
||||
assert_eq!(actual.method, expected.method);
|
||||
assert_eq!(actual.operation, expected.operation);
|
||||
assert_eq!(actual.outcome, expected.outcome);
|
||||
assert_eq!(actual.total, expected.total);
|
||||
}
|
||||
assert_eq!(realtime.by_host.len(), 1);
|
||||
assert_eq!(
|
||||
realtime
|
||||
.by_host
|
||||
.values()
|
||||
.next()
|
||||
.expect("local host")
|
||||
.http
|
||||
.as_ref()
|
||||
.expect("host HTTP")
|
||||
.requests,
|
||||
http.requests
|
||||
);
|
||||
let encoded = rmp_serde::to_vec_named(&realtime).expect("RPC metric map");
|
||||
let decoded: RealtimeMetrics = rmp_serde::from_slice(&encoded).expect("RPC metric roundtrip");
|
||||
assert_eq!(decoded.aggregated.http.expect("HTTP field survives RPC").requests, http.requests);
|
||||
let excluded = collect_local_metrics(MetricType::NET, &CollectMetricsOpts::default()).await;
|
||||
assert!(excluded.aggregated.http.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collect_local_metrics_reports_internode_net_and_rpc() {
|
||||
let metrics = global_internode_metrics();
|
||||
|
||||
@@ -31,7 +31,7 @@ use lazy_static::lazy_static;
|
||||
use rustfs_madmin::health::{Cpus, MemInfo, OsInfo, Partitions, ProcInfo, SysConfig, SysErrors, SysServices};
|
||||
use rustfs_madmin::metrics::RealtimeMetrics;
|
||||
use rustfs_madmin::net::NetInfo;
|
||||
use rustfs_madmin::{ItemState, ServerProperties, StorageInfo};
|
||||
use rustfs_madmin::{ItemState, ServerProperties, StorageInfo, StorageInfoObservation, StorageInfoProbeStatus};
|
||||
use rustfs_utils::XHost;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, hash_map::DefaultHasher};
|
||||
@@ -53,6 +53,7 @@ const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
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 EVENT_STORAGE_INFO_PROBE: &str = "storage_info_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);
|
||||
@@ -140,6 +141,8 @@ pub struct ScannerPublicationLeaseGrant {
|
||||
/// Cached result from the last successful admin call to a peer.
|
||||
struct PeerAdminCache {
|
||||
last_storage_info: Option<StorageInfo>,
|
||||
/// Wall time is for operators; the monotonic clock bounds cache reuse.
|
||||
last_storage_success: Option<(SystemTime, Instant)>,
|
||||
last_server_info: Option<ServerProperties>,
|
||||
storage_failures: u32,
|
||||
server_failures: u32,
|
||||
@@ -163,6 +166,7 @@ impl PeerAdminCache {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: None,
|
||||
storage_failures: 0,
|
||||
server_failures: 0,
|
||||
@@ -175,6 +179,9 @@ impl PeerAdminCache {
|
||||
/// failure: rather than reporting a stale `online`, the member falls through to
|
||||
/// the live unknown/degraded/offline classification (rustfs/backlog#1049 P2).
|
||||
const SERVER_INFO_CACHE_MAX_AGE: Duration = Duration::from_secs(60);
|
||||
// Diagnostic inventory may bridge a short probe interruption, but never more
|
||||
// than one minute. Failed probes are marked unknown even within this budget.
|
||||
const STORAGE_INFO_CACHE_MAX_AGE: Duration = Duration::from_secs(60);
|
||||
|
||||
lazy_static! {
|
||||
pub static ref GLOBAL_NOTIFICATION_SYS: OnceLock<Arc<NotificationSys>> = OnceLock::new();
|
||||
@@ -1906,6 +1913,7 @@ impl NotificationSys {
|
||||
for (idx, client) in self.peer_clients.iter().enumerate() {
|
||||
let endpoints = endpoints.clone();
|
||||
let cache = self.peer_admin_caches.get(idx);
|
||||
let topology_host = self.peer_topology_hosts.get(idx);
|
||||
futures.push(async move {
|
||||
if let Some(client) = client {
|
||||
let host = client.host.to_string();
|
||||
@@ -1916,32 +1924,46 @@ impl NotificationSys {
|
||||
normalize_and_cache_peer_storage_info(cache, &host, &mut info);
|
||||
Some(info)
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
warn!("peer {} storage_info failed: {}", host, err);
|
||||
handle_peer_failure(cache, &host, &endpoints)
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("peer {} storage_info timed out after {:?}", host, peer_timeout);
|
||||
handle_peer_failure(cache, &host, &endpoints)
|
||||
}
|
||||
Ok(Err(err)) => handle_peer_failure(cache, &host, &endpoints, &err),
|
||||
Err(_) => handle_peer_failure(cache, &host, &endpoints, &Error::Timeout),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
topology_host.and_then(|host| {
|
||||
handle_peer_failure(
|
||||
cache,
|
||||
host,
|
||||
&endpoints,
|
||||
&Error::RemoteClientUnavailable("storage inventory client is unavailable".to_string()),
|
||||
)
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut replies = join_all(futures).await;
|
||||
|
||||
replies.push(Some(StorageAdminApi::local_storage_info(api).await));
|
||||
let mut local = StorageAdminApi::local_storage_info(api).await;
|
||||
local.observations = vec![storage_info_observation(
|
||||
&runtime_sources::local_node_name().await,
|
||||
StorageInfoProbeStatus::Succeeded,
|
||||
false,
|
||||
Some((SystemTime::now(), Instant::now())),
|
||||
)];
|
||||
replies.push(Some(local));
|
||||
|
||||
let mut disks = Vec::new();
|
||||
let mut observations = Vec::new();
|
||||
for info in replies.into_iter().flatten() {
|
||||
disks.extend(info.disks);
|
||||
observations.extend(info.observations);
|
||||
}
|
||||
|
||||
let backend = StorageAdminApi::backend_info(api).await;
|
||||
rustfs_madmin::StorageInfo { disks, backend }
|
||||
rustfs_madmin::StorageInfo {
|
||||
disks,
|
||||
backend,
|
||||
observations,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn server_info(&self) -> Vec<ServerProperties> {
|
||||
@@ -3339,56 +3361,80 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a peer failure for storage_info: return cached data if available,
|
||||
/// or mark offline only after consecutive failures exceed the threshold.
|
||||
fn storage_info_observation(
|
||||
host: &str,
|
||||
status: StorageInfoProbeStatus,
|
||||
cached: bool,
|
||||
last_success: Option<(SystemTime, Instant)>,
|
||||
) -> StorageInfoObservation {
|
||||
StorageInfoObservation {
|
||||
endpoint: host.to_string(),
|
||||
status,
|
||||
cached,
|
||||
last_success_unix_millis: last_success
|
||||
.and_then(|(wall, _)| wall.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||
.and_then(|age| u64::try_from(age.as_millis()).ok()),
|
||||
snapshot_age_seconds: last_success.map(|(_, monotonic)| monotonic.elapsed().as_secs()),
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An admin RPC failure is missing evidence, not evidence of failed drives.
|
||||
/// Preserve bounded historical inventory without presenting its states as live.
|
||||
fn handle_peer_failure(
|
||||
cache: Option<&Mutex<PeerAdminCache>>,
|
||||
host: &str,
|
||||
endpoints: &EndpointServerPools,
|
||||
error: &Error,
|
||||
) -> Option<StorageInfo> {
|
||||
let cache = cache?;
|
||||
|
||||
let mut c = match cache.lock() {
|
||||
Ok(cache) => cache,
|
||||
Err(poisoned) => {
|
||||
warn!("peer {host} storage_info cache mutex poisoned");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
c.storage_failures += 1;
|
||||
|
||||
if let Some(ref cached) = c.last_storage_info
|
||||
&& c.storage_failures < CONSECUTIVE_FAILURE_THRESHOLD
|
||||
{
|
||||
debug!(
|
||||
event = "peer_probe_failure",
|
||||
peer = host,
|
||||
probe = "storage_info",
|
||||
consecutive_failures = c.storage_failures,
|
||||
threshold = CONSECUTIVE_FAILURE_THRESHOLD,
|
||||
"peer storage_info probe failed; returning cached state until the offline threshold is reached"
|
||||
);
|
||||
return Some(cached.clone());
|
||||
}
|
||||
|
||||
if c.storage_failures >= CONSECUTIVE_FAILURE_THRESHOLD {
|
||||
if c.storage_failures == CONSECUTIVE_FAILURE_THRESHOLD {
|
||||
let mut cache = cache.map(|cache| cache.lock().unwrap_or_else(|poisoned| poisoned.into_inner()));
|
||||
let last_success = cache.as_ref().and_then(|cache| cache.last_storage_success);
|
||||
let historical = cache
|
||||
.as_ref()
|
||||
.filter(|_| last_success.is_some_and(|(_, when)| when.elapsed() < STORAGE_INFO_CACHE_MAX_AGE))
|
||||
.and_then(|cache| cache.last_storage_info.clone());
|
||||
let cached = historical.is_some();
|
||||
let mut info = historical.unwrap_or_else(|| StorageInfo {
|
||||
disks: synthesized_disks(host, endpoints, ItemState::Unknown),
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(cache) = &mut cache {
|
||||
cache.storage_failures = cache.storage_failures.saturating_add(1);
|
||||
if cache.storage_failures == 1 {
|
||||
warn!(
|
||||
event = "peer_marked_offline",
|
||||
event = EVENT_STORAGE_INFO_PROBE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
|
||||
state = "failed",
|
||||
peer = host,
|
||||
probe = "storage_info",
|
||||
consecutive_failures = c.storage_failures,
|
||||
threshold = CONSECUTIVE_FAILURE_THRESHOLD,
|
||||
"reporting peer disks offline after consecutive storage_info failures"
|
||||
error_code = ?error.code(),
|
||||
cached,
|
||||
"Storage inventory probe failed; current drive health is unknown"
|
||||
);
|
||||
}
|
||||
return Some(StorageInfo {
|
||||
disks: synthesized_disks(host, endpoints, ItemState::Offline),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
for disk in &mut info.disks {
|
||||
disk.state = rustfs_madmin::ITEM_UNKNOWN.to_string();
|
||||
disk.runtime_state = Some(rustfs_madmin::ITEM_UNKNOWN.to_string());
|
||||
disk.offline_duration_seconds = None;
|
||||
disk.capacity_observation_source = Some(if cached { "snapshot" } else { "missing" }.to_string());
|
||||
disk.capacity_observation_age_seconds = if cached {
|
||||
disk.capacity_observation_age_seconds
|
||||
.zip(last_success)
|
||||
.map(|(age, (_, when))| age.saturating_add(when.elapsed().as_secs()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
disk.local = false;
|
||||
}
|
||||
info.observations = vec![storage_info_observation(
|
||||
host,
|
||||
StorageInfoProbeStatus::Failed,
|
||||
cached,
|
||||
last_success,
|
||||
)];
|
||||
info.observations[0].error_code = Some(format!("{:?}", error.code()));
|
||||
Some(info)
|
||||
}
|
||||
|
||||
fn normalize_and_cache_peer_storage_info(cache: Option<&Mutex<PeerAdminCache>>, host: &str, info: &mut StorageInfo) {
|
||||
@@ -3397,6 +3443,15 @@ fn normalize_and_cache_peer_storage_info(cache: Option<&Mutex<PeerAdminCache>>,
|
||||
for disk in &mut info.disks {
|
||||
disk.local = false;
|
||||
}
|
||||
let last_success = (SystemTime::now(), Instant::now());
|
||||
// The aggregator owns probe provenance, including when an older peer
|
||||
// returns no observation or a peer sends its own observation fields.
|
||||
info.observations = vec![storage_info_observation(
|
||||
host,
|
||||
StorageInfoProbeStatus::Succeeded,
|
||||
false,
|
||||
Some(last_success),
|
||||
)];
|
||||
|
||||
let Some(cache) = cache else {
|
||||
return;
|
||||
@@ -3409,16 +3464,20 @@ fn normalize_and_cache_peer_storage_info(cache: Option<&Mutex<PeerAdminCache>>,
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
if c.storage_failures >= CONSECUTIVE_FAILURE_THRESHOLD {
|
||||
if c.storage_failures > 0 {
|
||||
info!(
|
||||
event = "peer_recovered_online",
|
||||
event = EVENT_STORAGE_INFO_PROBE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
|
||||
state = "succeeded",
|
||||
peer = host,
|
||||
probe = "storage_info",
|
||||
consecutive_failures = c.storage_failures,
|
||||
"peer storage_info probe succeeded again; peer disks reported online"
|
||||
"Storage inventory probe recovered"
|
||||
);
|
||||
}
|
||||
c.last_storage_info = Some(info.clone());
|
||||
c.last_storage_success = Some(last_success);
|
||||
c.storage_failures = 0;
|
||||
}
|
||||
|
||||
@@ -4892,6 +4951,7 @@ mod tests {
|
||||
server_failures: 1,
|
||||
storage_failures: 0,
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
});
|
||||
let cache_b = Mutex::new(PeerAdminCache {
|
||||
last_server_info: Some(build_props("cached-b")),
|
||||
@@ -4899,6 +4959,7 @@ mod tests {
|
||||
server_failures: 1,
|
||||
storage_failures: 0,
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
});
|
||||
let caches = [cache_a, cache_b];
|
||||
let endpoints = EndpointServerPools::from(Vec::new());
|
||||
@@ -5297,13 +5358,78 @@ mod tests {
|
||||
|
||||
// --- Tests for handle_peer_failure / handle_server_info_failure caching ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn storage_info_preserves_failed_members_when_no_rpc_client_exists() {
|
||||
#[derive(Debug)]
|
||||
struct LocalInventory;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl StorageAdminApi for LocalInventory {
|
||||
type BackendInfo = rustfs_madmin::BackendInfo;
|
||||
type StorageInfo = StorageInfo;
|
||||
type Disk = ();
|
||||
type Error = Error;
|
||||
|
||||
async fn backend_info(&self) -> Self::BackendInfo {
|
||||
Self::BackendInfo::default()
|
||||
}
|
||||
|
||||
async fn storage_info(&self) -> StorageInfo {
|
||||
panic!("aggregation must query local inventory only")
|
||||
}
|
||||
|
||||
async fn local_storage_info(&self) -> StorageInfo {
|
||||
StorageInfo::default()
|
||||
}
|
||||
|
||||
async fn disk_set_inventory(
|
||||
&self,
|
||||
_: crate::storage_api_contracts::admin::DiskSetSelector,
|
||||
) -> Result<Vec<Option<Self::Disk>>> {
|
||||
panic!("admin probe must not access the data plane")
|
||||
}
|
||||
|
||||
fn set_drive_counts(&self) -> Vec<usize> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
let sys = NotificationSys {
|
||||
peer_clients: vec![None],
|
||||
all_peer_clients: vec![None, None],
|
||||
peer_topology_hosts: vec!["peer-unavailable".to_string()],
|
||||
peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())],
|
||||
tier_config_reload_workers: Default::default(),
|
||||
};
|
||||
let info = sys.storage_info(&LocalInventory).await;
|
||||
let peer = info
|
||||
.observations
|
||||
.iter()
|
||||
.find(|observation| observation.endpoint == "peer-unavailable")
|
||||
.expect("failed topology member remains visible");
|
||||
assert_eq!(peer.status, StorageInfoProbeStatus::Failed);
|
||||
assert!(!peer.cached);
|
||||
assert_eq!(peer.error_code.as_deref(), Some("RemoteClientUnavailable"));
|
||||
assert!(
|
||||
info.observations
|
||||
.iter()
|
||||
.any(|observation| observation.status == StorageInfoProbeStatus::Succeeded)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_peer_failure_first_failure_returns_none_when_no_cache() {
|
||||
fn handle_peer_failure_first_failure_reports_unknown_inventory_without_cache() {
|
||||
let cache = Mutex::new(PeerAdminCache::new());
|
||||
let endpoints = EndpointServerPools::default();
|
||||
|
||||
let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints);
|
||||
assert!(result.is_none());
|
||||
let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints, &Error::Timeout);
|
||||
let info = result.expect("failed peer must remain visible without cached disks");
|
||||
assert!(info.disks.is_empty());
|
||||
assert_eq!(info.observations[0].status, StorageInfoProbeStatus::Failed);
|
||||
assert!(!info.observations[0].cached);
|
||||
assert_eq!(info.observations[0].last_success_unix_millis, None);
|
||||
assert_eq!(info.observations[0].snapshot_age_seconds, None);
|
||||
assert_eq!(info.observations[0].error_code.as_deref(), Some("Timeout"));
|
||||
assert_eq!(cache.lock().unwrap().storage_failures, 1);
|
||||
}
|
||||
|
||||
@@ -5320,6 +5446,7 @@ mod tests {
|
||||
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: Some(cached_info),
|
||||
last_storage_success: Some((SystemTime::now(), Instant::now())),
|
||||
last_server_info: None,
|
||||
storage_failures: 0,
|
||||
server_failures: 0,
|
||||
@@ -5327,11 +5454,17 @@ mod tests {
|
||||
});
|
||||
let endpoints = EndpointServerPools::default();
|
||||
|
||||
// First failure: should return cached data
|
||||
let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints);
|
||||
// Historical inventory is available, but its health is not live.
|
||||
let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints, &Error::Timeout);
|
||||
let info = result.unwrap();
|
||||
assert_eq!(info.disks.len(), 1);
|
||||
assert_eq!(info.disks[0].state, "ok");
|
||||
assert_eq!(info.disks[0].state, "unknown");
|
||||
assert_eq!(info.disks[0].runtime_state.as_deref(), Some("unknown"));
|
||||
assert_eq!(info.disks[0].capacity_observation_source.as_deref(), Some("snapshot"));
|
||||
assert_eq!(info.disks[0].capacity_observation_age_seconds, None);
|
||||
assert!(info.observations[0].cached);
|
||||
assert_eq!(info.observations[0].status, StorageInfoProbeStatus::Failed);
|
||||
assert!(info.observations[0].last_success_unix_millis.is_some());
|
||||
assert_eq!(cache.lock().unwrap().storage_failures, 1);
|
||||
}
|
||||
|
||||
@@ -5377,13 +5510,13 @@ mod tests {
|
||||
);
|
||||
drop(cached);
|
||||
|
||||
let degraded = handle_peer_failure(Some(&cache), "peer-1", &EndpointServerPools::default())
|
||||
let degraded = handle_peer_failure(Some(&cache), "peer-1", &EndpointServerPools::default(), &Error::Timeout)
|
||||
.expect("first peer failure must return the cached snapshot");
|
||||
assert!(degraded.disks.iter().all(|disk| !disk.local));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_peer_failure_returns_offline_after_threshold_exceeded() {
|
||||
fn handle_peer_failure_cache_age_does_not_depend_on_poll_count() {
|
||||
let cached_info = StorageInfo {
|
||||
disks: vec![rustfs_madmin::Disk {
|
||||
endpoint: "disk-0".to_string(),
|
||||
@@ -5395,6 +5528,7 @@ mod tests {
|
||||
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: Some(cached_info),
|
||||
last_storage_success: Some((SystemTime::now(), Instant::now())),
|
||||
last_server_info: None,
|
||||
storage_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
|
||||
server_failures: 0,
|
||||
@@ -5402,10 +5536,31 @@ mod tests {
|
||||
});
|
||||
let endpoints = EndpointServerPools::default();
|
||||
|
||||
// This failure pushes us to the threshold => offline
|
||||
let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(cache.lock().unwrap().storage_failures, CONSECUTIVE_FAILURE_THRESHOLD);
|
||||
for _ in 0..10 {
|
||||
let info = handle_peer_failure(Some(&cache), "peer-1", &endpoints, &Error::Timeout).expect("failed probe");
|
||||
assert_eq!(info.disks.len(), 1);
|
||||
assert_eq!(info.disks[0].state, "unknown");
|
||||
assert!(info.observations[0].cached);
|
||||
}
|
||||
cache.lock().expect("age cache").last_storage_success =
|
||||
Some((SystemTime::now() - Duration::from_secs(61), Instant::now() - Duration::from_secs(61)));
|
||||
let info = handle_peer_failure(Some(&cache), "peer-1", &endpoints, &Error::Timeout).expect("expired probe");
|
||||
assert!(!info.observations[0].cached);
|
||||
assert!(info.observations[0].snapshot_age_seconds.expect("known last success") >= 61);
|
||||
assert!(info.disks.is_empty(), "expired inventory must not be reused");
|
||||
|
||||
let mut recovered = StorageInfo {
|
||||
disks: vec![rustfs_madmin::Disk {
|
||||
state: "ok".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
normalize_and_cache_peer_storage_info(Some(&cache), "peer-1", &mut recovered);
|
||||
assert_eq!(recovered.disks[0].state, "ok");
|
||||
assert_eq!(recovered.observations[0].status, StorageInfoProbeStatus::Succeeded);
|
||||
assert!(!recovered.observations[0].cached);
|
||||
assert_eq!(cache.lock().expect("recovered cache").storage_failures, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5418,6 +5573,7 @@ mod tests {
|
||||
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: Some(cached_props),
|
||||
storage_failures: 0,
|
||||
server_failures: 0,
|
||||
@@ -5448,6 +5604,7 @@ mod tests {
|
||||
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: Some(cached_props),
|
||||
storage_failures: 0,
|
||||
server_failures: 0,
|
||||
@@ -5575,6 +5732,7 @@ mod tests {
|
||||
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: Some(cached_props),
|
||||
storage_failures: 0,
|
||||
server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
|
||||
@@ -5594,6 +5752,7 @@ mod tests {
|
||||
// the real per-drive health), not offline (rustfs/backlog#1049 P0-B).
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: None,
|
||||
storage_failures: 0,
|
||||
server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
|
||||
@@ -5622,6 +5781,7 @@ mod tests {
|
||||
// this is a genuine offline, degraded must not mask it.
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: None,
|
||||
storage_failures: 0,
|
||||
server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
|
||||
@@ -5645,6 +5805,7 @@ mod tests {
|
||||
fn success_resets_failure_counters_independently() {
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: None,
|
||||
storage_failures: 2,
|
||||
server_failures: 2,
|
||||
@@ -5666,6 +5827,7 @@ mod tests {
|
||||
fn storage_failures_do_not_affect_server_failures() {
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: Some(StorageInfo::default()),
|
||||
last_storage_success: None,
|
||||
last_server_info: Some(ServerProperties {
|
||||
endpoint: "peer-1".to_string(),
|
||||
state: "online".to_string(),
|
||||
@@ -5677,7 +5839,7 @@ mod tests {
|
||||
});
|
||||
let endpoints = EndpointServerPools::default();
|
||||
|
||||
let storage_result = handle_peer_failure(Some(&cache), "peer-1", &endpoints);
|
||||
let storage_result = handle_peer_failure(Some(&cache), "peer-1", &endpoints, &Error::Timeout);
|
||||
assert!(storage_result.is_some());
|
||||
|
||||
let server_result = handle_server_info_failure(Some(&cache), "peer-1", &endpoints, None);
|
||||
@@ -5700,8 +5862,10 @@ mod tests {
|
||||
panic!("poison server cache mutex");
|
||||
});
|
||||
|
||||
let storage_result = handle_peer_failure(Some(&storage_cache), "peer-1", &endpoints);
|
||||
assert!(storage_result.is_none());
|
||||
let storage_result = handle_peer_failure(Some(&storage_cache), "peer-1", &endpoints, &Error::Timeout);
|
||||
let storage = storage_result.expect("poisoned cache must still report the failed peer");
|
||||
assert_eq!(storage.observations[0].status, StorageInfoProbeStatus::Failed);
|
||||
assert!(!storage.observations[0].cached);
|
||||
|
||||
let server_result = handle_server_info_failure(Some(&server_cache), "peer-1", &endpoints, None);
|
||||
assert_eq!(server_result.endpoint, "peer-1");
|
||||
@@ -5712,6 +5876,7 @@ mod tests {
|
||||
fn poisoned_admin_cache_recovers_on_success_and_resets_failures() {
|
||||
let storage_cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: None,
|
||||
storage_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
|
||||
server_failures: 0,
|
||||
@@ -5719,6 +5884,7 @@ mod tests {
|
||||
});
|
||||
let server_cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: None,
|
||||
storage_failures: 0,
|
||||
server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
|
||||
@@ -5757,9 +5923,11 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let storage_result = handle_peer_failure(Some(&storage_cache), "peer-1", &endpoints);
|
||||
let storage_result = handle_peer_failure(Some(&storage_cache), "peer-1", &endpoints, &Error::Timeout);
|
||||
assert!(storage_result.is_some());
|
||||
assert_eq!(storage_result.unwrap().disks[0].state, "ok");
|
||||
let storage = storage_result.expect("failed probe after recovery");
|
||||
assert_eq!(storage.disks[0].state, "unknown");
|
||||
assert!(storage.observations[0].cached);
|
||||
|
||||
let server_result = handle_server_info_failure(Some(&server_cache), "peer-1", &endpoints, None);
|
||||
assert_eq!(server_result.state, "online");
|
||||
|
||||
@@ -300,11 +300,13 @@ pub(super) fn ensure_rebalance_worker_active(meta: Option<&RebalanceMeta>, expec
|
||||
let Some(meta) = meta else {
|
||||
return Err(rebalance_metadata_not_initialized_error(stage));
|
||||
};
|
||||
if meta.stopped_at.is_some()
|
||||
|| meta
|
||||
.cancel
|
||||
.as_ref()
|
||||
.is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
|
||||
if meta.stopped_at.is_some() || meta.stop_requested {
|
||||
return Err(Error::OperationCanceled);
|
||||
}
|
||||
if meta
|
||||
.cancel
|
||||
.as_ref()
|
||||
.is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
|
||||
|| !is_rebalance_conflicting_with_decommission(meta)
|
||||
{
|
||||
return Err(Error::other(format!("inactive rebalance worker rejected during {stage}: {expected_id}")));
|
||||
@@ -629,6 +631,7 @@ impl ECStore {
|
||||
if let Some(meta) = rebalance_meta.as_mut()
|
||||
&& is_rebalance_conflicting_with_decommission(meta)
|
||||
{
|
||||
meta.stop_requested = true;
|
||||
meta.cancel
|
||||
.get_or_insert_with(tokio_util::sync::CancellationToken::new)
|
||||
.cancel();
|
||||
@@ -643,12 +646,13 @@ impl ECStore {
|
||||
let Some(meta) = rebalance_meta.as_mut() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !is_rebalance_conflicting_with_decommission(meta) {
|
||||
if meta.stopped_at.is_some() || (!is_rebalance_conflicting_with_decommission(meta) && !meta.stop_requested) {
|
||||
return Ok(None);
|
||||
}
|
||||
if meta.id.is_empty() {
|
||||
return Err(Error::other("active rebalance metadata has no activation id"));
|
||||
}
|
||||
meta.stop_requested = true;
|
||||
meta.cancel
|
||||
.get_or_insert_with(tokio_util::sync::CancellationToken::new)
|
||||
.cancel();
|
||||
@@ -673,7 +677,13 @@ impl ECStore {
|
||||
let movement_changed = rebalance_movement_snapshot_changed(self.rebalance_meta.read().await.as_ref(), &meta);
|
||||
{
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
|
||||
if let Some(current) = rebalance_meta.as_ref()
|
||||
&& current.id == meta.id
|
||||
{
|
||||
meta.cancel = current.cancel.clone();
|
||||
meta.activation_gate = Arc::clone(¤t.activation_gate);
|
||||
meta.stop_requested = current.stop_requested;
|
||||
}
|
||||
*rebalance_meta = Some(meta);
|
||||
|
||||
drop(rebalance_meta);
|
||||
@@ -1188,11 +1198,12 @@ impl ECStore {
|
||||
let meta = rebalance_meta
|
||||
.as_mut()
|
||||
.ok_or_else(|| rebalance_metadata_not_initialized_error("cancel rebalance admission"))?;
|
||||
if meta.stopped_at.is_some() || !is_rebalance_conflicting_with_decommission(meta) {
|
||||
if meta.stopped_at.is_some() || (!is_rebalance_conflicting_with_decommission(meta) && !meta.stop_requested) {
|
||||
return Err(Error::other(format!(
|
||||
"inactive rebalance rejected while cancelling admission: {expected_id}"
|
||||
)));
|
||||
}
|
||||
meta.stop_requested = true;
|
||||
meta.cancel
|
||||
.get_or_insert_with(tokio_util::sync::CancellationToken::new)
|
||||
.cancel();
|
||||
@@ -1213,6 +1224,7 @@ impl ECStore {
|
||||
ensure_rebalance_run_id(rebalance_meta.as_ref(), expected_id, "stop rebalance")?;
|
||||
}
|
||||
rebalance_meta.as_mut().map(|meta| {
|
||||
meta.stop_requested |= is_rebalance_conflicting_with_decommission(meta);
|
||||
let cancel = meta.cancel.get_or_insert_with(tokio_util::sync::CancellationToken::new);
|
||||
cancel.cancel();
|
||||
Arc::clone(&meta.activation_gate)
|
||||
@@ -1377,6 +1389,79 @@ mod tests {
|
||||
probe.wait_until_attempted().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebalance_stop_classification_checks_identity_and_explicit_intent() {
|
||||
let mut meta = RebalanceMeta {
|
||||
id: "current".to_string(),
|
||||
cancel: Some(tokio_util::sync::CancellationToken::new()),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
ensure_rebalance_worker_active(Some(&meta), "current", "test").expect("active worker");
|
||||
meta.cancel.as_ref().unwrap().cancel();
|
||||
assert!(
|
||||
!matches!(
|
||||
ensure_rebalance_worker_active(Some(&meta), "current", "test"),
|
||||
Err(Error::OperationCanceled)
|
||||
),
|
||||
"a sibling failure is not an operator stop"
|
||||
);
|
||||
meta.stop_requested = true;
|
||||
assert!(matches!(
|
||||
ensure_rebalance_worker_active(Some(&meta), "current", "test"),
|
||||
Err(Error::OperationCanceled)
|
||||
));
|
||||
assert!(
|
||||
!matches!(ensure_rebalance_worker_active(Some(&meta), "old", "test"), Err(Error::OperationCanceled)),
|
||||
"stale identity remains a failure even during stop"
|
||||
);
|
||||
assert!(!matches!(
|
||||
ensure_rebalance_worker_active(None, "current", "test"),
|
||||
Err(Error::OperationCanceled)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebalance_stop_intent_does_not_survive_replacement_run_reload() {
|
||||
let (_temp_dirs, store) = crate::services::rebalance::test_store_with_persisted_rebalance_meta(RebalanceMeta {
|
||||
id: "replacement".to_string(),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Completed,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let previous_gate = {
|
||||
let mut meta = store.rebalance_meta.write().await;
|
||||
let meta = meta.as_mut().unwrap();
|
||||
meta.id = "previous".to_string();
|
||||
meta.stop_requested = true;
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
cancel.cancel();
|
||||
meta.cancel = Some(cancel);
|
||||
Arc::clone(&meta.activation_gate)
|
||||
};
|
||||
store.load_rebalance_meta().await.expect("reload replacement run");
|
||||
let meta = store.rebalance_meta.read().await;
|
||||
let meta = meta.as_ref().unwrap();
|
||||
assert_eq!(meta.id, "replacement");
|
||||
assert!(!meta.stop_requested);
|
||||
assert!(meta.cancel.is_none());
|
||||
assert!(!Arc::ptr_eq(&previous_gate, &meta.activation_gate));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_rebalance_admission_is_id_checked_and_idempotent() {
|
||||
let rebalance_id = "rebalance-admission-current";
|
||||
@@ -1415,6 +1500,59 @@ mod tests {
|
||||
.await
|
||||
.expect("retrying admission cancellation should be idempotent");
|
||||
assert!(cancel.is_cancelled());
|
||||
let err = store
|
||||
.update_pool_stats_batch_for_rebalance(0, "bucket".to_string(), &[&FileInfo::default()], rebalance_id)
|
||||
.await
|
||||
.expect_err("stop racing with a final stats update must cancel that update");
|
||||
assert!(matches!(err, Error::OperationCanceled), "operator stop lost its cancellation type: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_rebalance_stop_preserves_intent_when_worker_stops_before_reload() {
|
||||
let id = "stop-worker-before-reload";
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
let (_temp_dirs, store) = crate::services::rebalance::test_store_with_persisted_rebalance_meta(RebalanceMeta {
|
||||
id: id.to_string(),
|
||||
cancel: Some(cancel.clone()),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Stopped,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let gate = {
|
||||
let mut meta = store.rebalance_meta.write().await;
|
||||
let meta = meta.as_mut().expect("local rebalance metadata");
|
||||
meta.pool_stats[0].info.status = RebalStatus::Started;
|
||||
Arc::clone(&meta.activation_gate)
|
||||
};
|
||||
assert_eq!(
|
||||
store.prepare_rebalance_stop().await.expect("prepare the same run stop"),
|
||||
Some(id.to_string())
|
||||
);
|
||||
{
|
||||
let meta = store.rebalance_meta.read().await;
|
||||
let meta = meta.as_ref().expect("reloaded stop target");
|
||||
assert!(Arc::ptr_eq(&gate, &meta.activation_gate), "reload must retain the drained run's gate");
|
||||
assert!(meta.cancel.as_ref().is_some_and(|token| token.is_cancelled()));
|
||||
}
|
||||
store
|
||||
.stop_rebalance_for_id(Some(id))
|
||||
.await
|
||||
.expect("finish the stop after the worker's terminal event");
|
||||
store
|
||||
.load_rebalance_meta()
|
||||
.await
|
||||
.expect("reload the acknowledged durable stop");
|
||||
let meta = store.rebalance_meta.read().await;
|
||||
let meta = meta.as_ref().expect("durable stopped metadata");
|
||||
assert!(meta.stopped_at.is_some(), "a successful stop must retain its durable timestamp");
|
||||
assert_eq!(meta.pool_stats[0].info.status, RebalStatus::Stopped);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2031,7 +2169,10 @@ mod tests {
|
||||
let err = acquire_persisted_rebalance_run_guard(set_disks, active.id.as_str(), "cross-node stale snapshot")
|
||||
.await
|
||||
.expect_err("persisted stop must fence a node that missed stop propagation");
|
||||
assert!(err.to_string().contains("inactive rebalance worker rejected"));
|
||||
assert!(
|
||||
matches!(err, Error::OperationCanceled),
|
||||
"a durable remote stop cancels the same run: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,11 +19,11 @@ use super::meta::{
|
||||
use super::migration::{RebalanceMigrationBackend, migrate_entry_version};
|
||||
use super::worker::{
|
||||
RebalanceEntryCleanupResult, RebalanceEntryTask, load_rebalance_bucket_configs, rebalance_max_attempts,
|
||||
resolve_rebalance_bucket_error, resolve_rebalance_entry_cleanup_delete_result, resolve_rebalance_file_info_versions_result,
|
||||
resolve_rebalance_migrate_result_error, resolve_rebalance_stats_update_result, resolve_rebalance_worker_result,
|
||||
run_rebalance_listing_with_retry, should_cleanup_rebalance_source_entry, should_count_rebalance_version_complete,
|
||||
should_defer_rebalance_entry_failure, should_skip_rebalance_delete_marker, wait_rebalance_entry_tasks,
|
||||
with_rebalance_entry_context,
|
||||
record_rebalance_error, resolve_rebalance_bucket_error, resolve_rebalance_entry_cleanup_delete_result,
|
||||
resolve_rebalance_file_info_versions_result, resolve_rebalance_migrate_result_error, resolve_rebalance_stats_update_result,
|
||||
resolve_rebalance_worker_result, run_rebalance_listing_with_retry, should_cleanup_rebalance_source_entry,
|
||||
should_count_rebalance_version_complete, should_defer_rebalance_entry_failure, should_skip_rebalance_delete_marker,
|
||||
wait_rebalance_entry_tasks, with_rebalance_entry_context,
|
||||
};
|
||||
use super::{
|
||||
EVENT_REBALANCE_BUCKET, EVENT_REBALANCE_ENTRY, EVENT_REBALANCE_STATE, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_REBALANCE,
|
||||
@@ -676,10 +676,8 @@ impl ECStore {
|
||||
}
|
||||
error!("rebalance_entry: data movement admission failed: {err}");
|
||||
let mut first_err = entry_error.lock().await;
|
||||
if first_err.is_none() {
|
||||
*first_err = Some(err);
|
||||
callback_rx.cancel();
|
||||
}
|
||||
record_rebalance_error(&mut first_err, err);
|
||||
callback_rx.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -721,10 +719,8 @@ impl ECStore {
|
||||
if let Err(err) = &result {
|
||||
error!("rebalance_entry: rebalance entry failed: {err}");
|
||||
let mut first_err = entry_error.lock().await;
|
||||
if first_err.is_none() {
|
||||
*first_err = Some(err.clone());
|
||||
callback_rx.cancel();
|
||||
}
|
||||
record_rebalance_error(&mut first_err, err.clone());
|
||||
callback_rx.cancel();
|
||||
}
|
||||
debug!(
|
||||
event = EVENT_REBALANCE_ENTRY,
|
||||
@@ -793,10 +789,7 @@ impl ECStore {
|
||||
deferred_error = Some(last_error);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) if worker_error.is_none() => {
|
||||
worker_error = Some(err);
|
||||
}
|
||||
Err(_) => {}
|
||||
Err(err) => record_rebalance_error(&mut worker_error, err),
|
||||
}
|
||||
}
|
||||
let entry_error = entry_error.lock().await.clone();
|
||||
|
||||
@@ -643,16 +643,12 @@ pub(super) fn should_skip_start_rebalance(cancel_attached: bool, in_progress: bo
|
||||
cancel_attached && in_progress
|
||||
}
|
||||
|
||||
pub(super) fn is_rebalance_stopped_terminal_event(terminal_event: &RebalanceTerminalEvent) -> bool {
|
||||
matches!(terminal_event, RebalanceTerminalEvent::Stopped { .. })
|
||||
}
|
||||
|
||||
pub(super) fn should_preserve_rebalance_stopped_state(
|
||||
meta_stopped: bool,
|
||||
status: RebalStatus,
|
||||
terminal_event: &RebalanceTerminalEvent,
|
||||
) -> bool {
|
||||
(meta_stopped || status == RebalStatus::Stopped) && !is_rebalance_stopped_terminal_event(terminal_event)
|
||||
(meta_stopped || status == RebalStatus::Stopped) && matches!(terminal_event, RebalanceTerminalEvent::Completed { .. })
|
||||
}
|
||||
|
||||
pub(super) fn resolve_rebalance_participants(pool_stats: &[RebalanceStats], pool_count: usize) -> Vec<bool> {
|
||||
@@ -920,7 +916,7 @@ pub(super) fn clear_rebalance_cancel_token(meta: Option<&mut RebalanceMeta>) ->
|
||||
|
||||
pub(super) fn stop_rebalance_state(meta: &mut RebalanceMeta, now: OffsetDateTime) {
|
||||
clear_rebalance_cancel_token(Some(meta));
|
||||
if meta.stopped_at.is_none() && is_rebalance_in_progress(meta) {
|
||||
if meta.stopped_at.is_none() && (meta.stop_requested || is_rebalance_in_progress(meta)) {
|
||||
apply_stopped_at(meta, now);
|
||||
} else if meta.stopped_at.is_some() {
|
||||
mark_started_rebalance_pools_stopping(meta);
|
||||
|
||||
@@ -19,14 +19,14 @@ use super::meta::{
|
||||
complete_rebalance_pools_at_goal, complete_rebalance_pools_with_empty_queue, defer_bucket_in_rebalance_queue,
|
||||
ensure_rebalance_not_decommissioning, ensure_valid_rebalance_pool_index, first_rebalance_bucket,
|
||||
has_deferred_rebalance_error, is_rebalance_actively_running, is_rebalance_conflicting_with_decommission,
|
||||
is_rebalance_in_progress, is_rebalance_meta_replaceable_for_new_id, is_rebalance_stopped_terminal_event,
|
||||
mark_rebalance_bucket_done, merge_rebalance_bucket_lists, merge_rebalance_meta, next_rebal_bucket_from_stat,
|
||||
percent_free_ratio, rebalance_goal_reached, rebalance_meta_load_no_data_error, rebalance_meta_load_unknown_format_error,
|
||||
rebalance_meta_load_unknown_version_error, rebalance_requires_worker_activation, record_rebalance_cleanup_warning_in_meta,
|
||||
remove_rebalanced_buckets_from_queue, resolve_next_rebalance_bucket, resolve_rebalance_participants,
|
||||
should_accept_rebalance_stats_update, should_ignore_rebalance_data_usage_cache, should_pool_participate,
|
||||
should_preserve_rebalance_stopped_state, should_skip_start_rebalance, stop_rebalance_meta_snapshot, stop_rebalance_state,
|
||||
take_bucket_from_rebalance_queue, validate_init_rebalance_state, validate_start_rebalance_state,
|
||||
is_rebalance_in_progress, is_rebalance_meta_replaceable_for_new_id, mark_rebalance_bucket_done, merge_rebalance_bucket_lists,
|
||||
merge_rebalance_meta, next_rebal_bucket_from_stat, percent_free_ratio, rebalance_goal_reached,
|
||||
rebalance_meta_load_no_data_error, rebalance_meta_load_unknown_format_error, rebalance_meta_load_unknown_version_error,
|
||||
rebalance_requires_worker_activation, record_rebalance_cleanup_warning_in_meta, remove_rebalanced_buckets_from_queue,
|
||||
resolve_next_rebalance_bucket, resolve_rebalance_participants, should_accept_rebalance_stats_update,
|
||||
should_ignore_rebalance_data_usage_cache, should_pool_participate, should_preserve_rebalance_stopped_state,
|
||||
should_skip_start_rebalance, stop_rebalance_meta_snapshot, stop_rebalance_state, take_bucket_from_rebalance_queue,
|
||||
validate_init_rebalance_state, validate_start_rebalance_state,
|
||||
};
|
||||
use super::migration::{
|
||||
MigrationBackend, MigrationVersionResult, migrate_entry_version, migrate_entry_version_with_retry_wait,
|
||||
@@ -1676,6 +1676,30 @@ fn test_resolve_rebalance_stats_update_result_passthrough() {
|
||||
assert!(resolve_rebalance_stats_update_result(Ok(()), 0, "bucket", "object").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rebalance_stop_preserves_cancellation_through_entry_context() {
|
||||
let err = resolve_rebalance_stats_update_result(Err(Error::OperationCanceled), 0, "bucket", "object")
|
||||
.expect_err("canceled stats update");
|
||||
let err = with_rebalance_entry_context("stats", "bucket", "object", err);
|
||||
assert!(matches!(err, Error::OperationCanceled));
|
||||
assert!(matches!(
|
||||
classify_rebalance_terminal_event(Some(Err(err)), OffsetDateTime::now_utc()),
|
||||
RebalanceTerminalEvent::Stopped { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rebalance_stop_does_not_hide_later_entry_failure() {
|
||||
let tasks = Arc::new(tokio::sync::Mutex::new(vec![
|
||||
tokio::spawn(async { Err(Error::OperationCanceled) }),
|
||||
tokio::spawn(async { Err(Error::ErasureWriteQuorum) }),
|
||||
]));
|
||||
let err = wait_rebalance_entry_tasks(0, tasks)
|
||||
.await
|
||||
.expect_err("entry I/O failure must survive sibling cancellation");
|
||||
assert!(matches!(err, Error::ErasureWriteQuorum));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_rebalance_stats_update_result_wraps_error_context() {
|
||||
let err = resolve_rebalance_stats_update_result(Err(Error::SlowDown), 2, "bucket-a", "obj.txt")
|
||||
@@ -2365,9 +2389,9 @@ fn test_resolve_rebalance_terminal_error_wraps_signal_failure_context() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_rebalance_bucket_error_prefers_entry_error() {
|
||||
fn test_resolve_rebalance_bucket_error_prefers_real_failure_over_entry_cancellation() {
|
||||
let err = resolve_rebalance_bucket_error(Some(Error::OperationCanceled), Some(Error::SlowDown)).unwrap_err();
|
||||
assert!(matches!(err, Error::OperationCanceled));
|
||||
assert!(matches!(err, Error::SlowDown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2512,19 +2536,6 @@ fn test_apply_rebalance_terminal_event_stopped_clears_error() {
|
||||
assert_eq!(last_error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_rebalance_stopped_terminal_event_only_matches_stopped_variant() {
|
||||
let stopped = RebalanceTerminalEvent::Stopped {
|
||||
msg: "stopped".to_string(),
|
||||
};
|
||||
let completed = RebalanceTerminalEvent::Completed {
|
||||
msg: "completed".to_string(),
|
||||
};
|
||||
|
||||
assert!(is_rebalance_stopped_terminal_event(&stopped));
|
||||
assert!(!is_rebalance_stopped_terminal_event(&completed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_preserve_rebalance_stopped_state_when_meta_marked_stopped() {
|
||||
let event = RebalanceTerminalEvent::Completed {
|
||||
@@ -2535,13 +2546,14 @@ fn test_should_preserve_rebalance_stopped_state_when_meta_marked_stopped() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_preserve_rebalance_stopped_state_when_pool_already_stopped() {
|
||||
fn test_rebalance_stop_does_not_hide_real_terminal_failure() {
|
||||
let event = RebalanceTerminalEvent::Failed {
|
||||
msg: "failed".to_string(),
|
||||
last_error: "boom".to_string(),
|
||||
};
|
||||
|
||||
assert!(should_preserve_rebalance_stopped_state(false, RebalStatus::Stopped, &event));
|
||||
assert!(!should_preserve_rebalance_stopped_state(false, RebalStatus::Stopped, &event));
|
||||
assert!(!should_preserve_rebalance_stopped_state(true, RebalStatus::Started, &event));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2716,6 +2728,32 @@ async fn test_start_rebalance_for_id_rejects_stopped_metadata() {
|
||||
assert!(err.to_string().contains("was stopped before start"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rebalance_stop_intent_blocks_activation_before_durable_timestamp() {
|
||||
let mut meta = RebalanceMeta {
|
||||
id: "stopping".to_string(),
|
||||
stop_requested: true,
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
buckets: vec!["pending".to_string()],
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let outcome = commit_local_rebalance_worker_activation(&mut meta, "stopping", CancellationToken::new())
|
||||
.expect("stop must prevent activation without a new error");
|
||||
assert_eq!(outcome, RebalanceLocalActivationOutcome::NotStartedTerminal);
|
||||
assert!(meta.cancel.is_none());
|
||||
assert!(meta.stopped_at.is_none());
|
||||
let bytes = rmp_serde::to_vec_named(&meta).expect("encode legacy-compatible metadata");
|
||||
let reloaded: RebalanceMeta = rmp_serde::from_slice(&bytes).expect("decode metadata");
|
||||
assert!(!reloaded.stop_requested, "operator intent is local, not a new persisted field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stopped_activation_state_prevents_worker_token_commit() {
|
||||
let mut meta = RebalanceMeta {
|
||||
|
||||
@@ -57,7 +57,7 @@ pub(super) fn commit_local_rebalance_worker_activation(
|
||||
meta.id
|
||||
)));
|
||||
}
|
||||
if meta.stopped_at.is_some() || !is_rebalance_in_progress(meta) {
|
||||
if meta.stopped_at.is_some() || meta.stop_requested || !is_rebalance_in_progress(meta) {
|
||||
return Ok(RebalanceLocalActivationOutcome::NotStartedTerminal);
|
||||
}
|
||||
meta.cancel = Some(cancel);
|
||||
|
||||
@@ -143,6 +143,10 @@ pub struct DiskStat {
|
||||
pub struct RebalanceMeta {
|
||||
#[serde(skip)]
|
||||
pub cancel: Option<CancellationToken>, // To be invoked on rebalance-stop
|
||||
/// Local operator intent, scoped to this run ID; a worker failure also cancels
|
||||
/// `cancel`, so the token alone cannot identify an administrative stop.
|
||||
#[serde(skip)]
|
||||
pub stop_requested: bool,
|
||||
#[serde(skip)]
|
||||
pub activation_gate: std::sync::Arc<tokio::sync::RwLock<()>>,
|
||||
#[serde(skip)]
|
||||
|
||||
@@ -38,6 +38,17 @@ pub(super) fn resolve_rebalance_worker_result<T>(
|
||||
|
||||
pub(super) type RebalanceEntryTask = tokio::task::JoinHandle<Result<RebalanceEntryOutcome>>;
|
||||
|
||||
/// Preserve the first real failure even when another task observes cancellation
|
||||
/// first. Cancellation is an outcome only when no entry or worker failed.
|
||||
pub(super) fn record_rebalance_error(first_error: &mut Option<Error>, err: Error) {
|
||||
if first_error
|
||||
.as_ref()
|
||||
.is_none_or(|first| is_err_operation_canceled(first) && !is_err_operation_canceled(&err))
|
||||
{
|
||||
*first_error = Some(err);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) enum RebalanceEntryCleanupResult {
|
||||
Completed { warning: Option<String> },
|
||||
@@ -65,16 +76,12 @@ pub(super) async fn wait_rebalance_entry_tasks(
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
error!("rebalance entry task failed for set {}: {}", set_idx, err);
|
||||
if first_error.is_none() {
|
||||
first_error = Some(err);
|
||||
}
|
||||
record_rebalance_error(&mut first_error, err);
|
||||
}
|
||||
Err(err) => {
|
||||
let err = Error::other(format!("rebalance entry task join error for set {set_idx}: {err}"));
|
||||
error!("{}", err);
|
||||
if first_error.is_none() {
|
||||
first_error = Some(err);
|
||||
}
|
||||
record_rebalance_error(&mut first_error, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +142,9 @@ pub(super) fn resolve_rebalance_stats_update_result(
|
||||
object_name: &str,
|
||||
) -> Result<()> {
|
||||
result.map_err(|err| {
|
||||
if is_err_operation_canceled(&err) {
|
||||
return err;
|
||||
}
|
||||
Error::other(format!(
|
||||
"rebalance stats update failed for pool {pool_idx} bucket {bucket} object {object_name}: {err}"
|
||||
))
|
||||
@@ -214,16 +224,11 @@ pub(super) fn resolve_rebalance_terminal_error(primary_err: Error, signal_result
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_rebalance_bucket_error(entry_error: Option<Error>, worker_error: Option<Error>) -> Result<()> {
|
||||
if let Some(err) = entry_error {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
pub(super) fn resolve_rebalance_bucket_error(mut entry_error: Option<Error>, worker_error: Option<Error>) -> Result<()> {
|
||||
if let Some(err) = worker_error {
|
||||
return Err(err);
|
||||
record_rebalance_error(&mut entry_error, err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
entry_error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
pub(super) fn resolve_rebalance_bucket_result(
|
||||
@@ -362,6 +367,9 @@ pub(super) fn ensure_rebalance_listing_disks_available(has_disks: bool, bucket:
|
||||
}
|
||||
|
||||
pub(super) fn with_rebalance_entry_context(stage: &str, bucket: &str, object_name: &str, err: Error) -> Error {
|
||||
if is_err_operation_canceled(&err) {
|
||||
return err;
|
||||
}
|
||||
Error::other(format!("rebalance entry {stage} failed for {bucket}/{object_name}: {err}"))
|
||||
}
|
||||
|
||||
|
||||
@@ -6650,6 +6650,7 @@ async fn get_storage_info(disks: &[Option<DiskStore>], eps: &[Endpoint]) -> rust
|
||||
total_sets,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
pub async fn stat_all_dirs(disks: &[Option<DiskStore>], bucket: &str, prefix: &str) -> Vec<Option<DiskError>> {
|
||||
|
||||
@@ -2353,14 +2353,19 @@ mod tests {
|
||||
.await
|
||||
.expect("quorum boundary heal should return a mapped result");
|
||||
*store.pools[0].disk_set[0].disks.write().await = original_quorum_disks;
|
||||
let quorum_err_text = quorum_err.as_ref().map(ToString::to_string);
|
||||
assert!(
|
||||
quorum_err_text.as_deref().is_some_and(|err| {
|
||||
err.contains("target capacity admission failed")
|
||||
&& err.contains("pool metadata update cannot overwrite an unreadable replica")
|
||||
}),
|
||||
"heal must fail closed when capacity admission cannot verify pool metadata, got {quorum_err:?}"
|
||||
let quorum_err = quorum_err
|
||||
.as_ref()
|
||||
.expect("heal must fail closed when capacity admission cannot verify pool metadata");
|
||||
let quorum_failure = quorum_err
|
||||
.pool_metadata_failure()
|
||||
.expect("capacity admission failure should preserve typed pool metadata context");
|
||||
assert_eq!(
|
||||
quorum_failure.kind,
|
||||
crate::error::PoolMetadataFailure::ReadUnavailable,
|
||||
"read-only capacity admission failure must remain retryable"
|
||||
);
|
||||
assert_eq!(quorum_failure.operation, "target capacity admission failed");
|
||||
assert_eq!(quorum_failure.phase, "pool_read");
|
||||
assert!(
|
||||
store.pool_meta_writes_ready().await,
|
||||
"read-only capacity admission failure must not latch the pool metadata writer"
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
|
||||
use super::*;
|
||||
use crate::core::pools::{
|
||||
PoolMetaBootstrapAuthority, PoolMetaReplicaState, PoolMetaWriteState, load_pool_meta_identity_observing,
|
||||
local_decommission_queue_prefix, persist_pool_meta_identity_for_startup, pool_meta_has_active_decommission,
|
||||
PoolMetaBootstrapAuthority, PoolMetaReplicaState, PoolMetaWriteState, local_decommission_queue_prefix,
|
||||
persist_pool_meta_identity_for_startup, pool_meta_has_active_decommission,
|
||||
};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
@@ -153,14 +153,11 @@ async fn load_pool_meta_for_startup<S>(
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
load_pool_meta_identity_observing(pools.clone(), write_state)
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("store init failed during load_pool_meta_identity: {err}")))?;
|
||||
let mut meta = PoolMeta::default();
|
||||
let replica_state = meta
|
||||
.load_no_lock_from_replicas_observing(pools, write_state)
|
||||
.load_for_startup_observing(pools, write_state)
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("store init failed during load_pool_meta: {err}")))?;
|
||||
.map_err(|err| Error::other_with_context("store init failed during load_pool_meta", err))?;
|
||||
write_state.observe_replicas(replica_state);
|
||||
write_state
|
||||
.ensure_missing_metadata_can_initialize()
|
||||
@@ -769,6 +766,33 @@ impl ECStore {
|
||||
});
|
||||
}
|
||||
|
||||
let recovery_store = self.clone();
|
||||
let recovery_rx = rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut delay = std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = recovery_rx.cancelled() => return,
|
||||
_ = tokio::time::sleep(delay) => {}
|
||||
}
|
||||
let result = tokio::select! {
|
||||
_ = recovery_rx.cancelled() => return,
|
||||
result = tokio::time::timeout(std::time::Duration::from_secs(30), recovery_store.recover_pool_meta_transaction()) => result,
|
||||
};
|
||||
delay = match result {
|
||||
Ok(Ok(_)) => std::time::Duration::from_secs(5),
|
||||
failure => {
|
||||
let error = match failure {
|
||||
Ok(Err(error)) => error,
|
||||
_ => Error::Timeout,
|
||||
};
|
||||
recovery_store.record_pool_meta_recovery_failure(error);
|
||||
(delay * 2).min(std::time::Duration::from_secs(60))
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
runtime_sources::init_bucket_monitor_for_current_endpoints();
|
||||
crate::bucket::bucket_target_sys::BucketTargetSys::get().start_heartbeat();
|
||||
|
||||
@@ -2493,6 +2517,106 @@ mod tests {
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn pool_metadata_preflight_recovery_preserves_single_and_multi_pool_public_mutations() {
|
||||
for layout in [vec![4], vec![4, 4]] {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let (_ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "pool-meta-retry", &layout)).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let bucket = format!("pool-meta-retry-{}", Uuid::new_v4());
|
||||
store.make_bucket(&bucket, &MakeBucketOptions::default()).await.unwrap();
|
||||
let mut saved_disks = Vec::new();
|
||||
for set in &store.pools[0].disk_set {
|
||||
let mut disks = set.disks.write().await;
|
||||
let count = disks.len();
|
||||
saved_disks.push((set.clone(), std::mem::replace(&mut *disks, vec![None; count])));
|
||||
}
|
||||
let indices = (0..layout.len()).collect::<Vec<_>>();
|
||||
let err = store.save_current_pool_meta_for_test(&indices).await.unwrap_err();
|
||||
assert_eq!(
|
||||
err.pool_metadata_failure().unwrap().kind,
|
||||
crate::error::PoolMetadataFailure::ReadUnavailable
|
||||
);
|
||||
for (set, disks) in saved_disks {
|
||||
*set.disks.write().await = disks;
|
||||
}
|
||||
store.save_current_pool_meta_for_test(&indices).await.unwrap();
|
||||
assert!(store.pool_meta_writes_ready().await);
|
||||
|
||||
let payload = b"pool metadata recovery payload".to_vec();
|
||||
store
|
||||
.put_object(&bucket, "put", &mut PutObjReader::from_vec(payload.clone()), &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut reader = store
|
||||
.get_object_reader(&bucket, "put", None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut actual = Vec::new();
|
||||
reader.stream.read_to_end(&mut actual).await.unwrap();
|
||||
assert_eq!(actual, payload);
|
||||
drop(reader);
|
||||
store.delete_object(&bucket, "put", ObjectOptions::default()).await.unwrap();
|
||||
assert!(crate::error::is_err_object_not_found(
|
||||
&store
|
||||
.get_object_info(&bucket, "put", &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap_err()
|
||||
));
|
||||
|
||||
let upload = store
|
||||
.new_multipart_upload(&bucket, "multipart", &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let part = store
|
||||
.put_object_part(
|
||||
&bucket,
|
||||
"multipart",
|
||||
&upload.upload_id,
|
||||
1,
|
||||
&mut PutObjReader::from_vec(payload.clone()),
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.clone()
|
||||
.complete_multipart_upload(
|
||||
&bucket,
|
||||
"multipart",
|
||||
&upload.upload_id,
|
||||
vec![crate::storage_api_contracts::multipart::CompletePart {
|
||||
part_num: part.part_num,
|
||||
etag: part.etag,
|
||||
..Default::default()
|
||||
}],
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut reader = store
|
||||
.get_object_reader(&bucket, "multipart", None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
actual.clear();
|
||||
reader.stream.read_to_end(&mut actual).await.unwrap();
|
||||
assert_eq!(actual, payload);
|
||||
drop(reader);
|
||||
let upload = store
|
||||
.new_multipart_upload(&bucket, "abort", &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.abort_multipart_upload(&bucket, "abort", &upload.upload_id, &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.pool_meta_writes_ready().await);
|
||||
shutdown.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
|
||||
@@ -527,6 +527,31 @@ impl Default for ScannerDataMovementPauseStatus {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct PoolMetaWriteGateStatus {
|
||||
pub writes_ready: bool,
|
||||
pub write_blocked: bool,
|
||||
pub transaction_aborted: bool,
|
||||
pub pool_meta_absent: bool,
|
||||
pub identity_initialized: Option<bool>,
|
||||
pub identity_needs_repair: bool,
|
||||
pub cluster_epoch: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for PoolMetaWriteGateStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
writes_ready: true,
|
||||
write_blocked: false,
|
||||
transaction_aborted: false,
|
||||
pool_meta_absent: false,
|
||||
identity_initialized: None,
|
||||
identity_needs_repair: false,
|
||||
cluster_epoch: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn offset_unix_seconds(value: OffsetDateTime) -> u64 {
|
||||
u64::try_from(value.unix_timestamp()).unwrap_or(0)
|
||||
}
|
||||
|
||||
@@ -4178,6 +4178,24 @@ impl ECStore {
|
||||
|
||||
#[instrument(level = "trace", skip(self))]
|
||||
pub(super) async fn handle_get_object_info(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<ObjectInfo> {
|
||||
self.get_object_info_snapshot(bucket, object, opts, false).await
|
||||
}
|
||||
|
||||
/// Return metadata for DELETE preflight, including an explicitly addressed
|
||||
/// delete marker. Read APIs must keep using `get_object_info`; authorization
|
||||
/// and Object Lock enforcement still belong to the caller and locked delete.
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub async fn get_object_info_for_delete(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<ObjectInfo> {
|
||||
self.get_object_info_snapshot(bucket, object, opts, true).await
|
||||
}
|
||||
|
||||
async fn get_object_info_snapshot(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
allow_delete_marker: bool,
|
||||
) -> Result<ObjectInfo> {
|
||||
check_object_args(bucket, object)?;
|
||||
|
||||
let object = encode_dir_object(object);
|
||||
@@ -4188,6 +4206,8 @@ impl ECStore {
|
||||
|
||||
let info = if self.single_pool() {
|
||||
self.pools[0].get_object_info(bucket, object.as_str(), &opts).await?
|
||||
} else if allow_delete_marker {
|
||||
self.get_latest_object_info_with_idx(bucket, object.as_str(), &opts).await?.0
|
||||
} else {
|
||||
self.get_latest_accessible_object_info_with_idx(bucket, object.as_str(), &opts)
|
||||
.await?
|
||||
|
||||
@@ -1146,7 +1146,11 @@ impl ECStore {
|
||||
}
|
||||
|
||||
let backend = StorageAdminApi::backend_info(self).await;
|
||||
rustfs_madmin::StorageInfo { backend, disks }
|
||||
rustfs_madmin::StorageInfo {
|
||||
backend,
|
||||
disks,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
|
||||
@@ -37,6 +37,9 @@ pub enum Error {
|
||||
#[error("Method not allowed")]
|
||||
MethodNotAllowed,
|
||||
|
||||
#[error("You've exceeded the limit on the number of versions you can create on this object")]
|
||||
MaxVersionsExceeded,
|
||||
|
||||
#[error("Unexpected error")]
|
||||
Unexpected,
|
||||
|
||||
@@ -86,6 +89,7 @@ impl PartialEq for Error {
|
||||
(Error::FileCorrupt, Error::FileCorrupt) => true,
|
||||
(Error::DoneForNow, Error::DoneForNow) => true,
|
||||
(Error::MethodNotAllowed, Error::MethodNotAllowed) => true,
|
||||
(Error::MaxVersionsExceeded, Error::MaxVersionsExceeded) => true,
|
||||
(Error::FileNotFound, Error::FileNotFound) => true,
|
||||
(Error::FileVersionNotFound, Error::FileVersionNotFound) => true,
|
||||
(Error::VolumeNotFound, Error::VolumeNotFound) => true,
|
||||
@@ -111,6 +115,7 @@ impl Clone for Error {
|
||||
Error::FileCorrupt => Error::FileCorrupt,
|
||||
Error::DoneForNow => Error::DoneForNow,
|
||||
Error::MethodNotAllowed => Error::MethodNotAllowed,
|
||||
Error::MaxVersionsExceeded => Error::MaxVersionsExceeded,
|
||||
Error::VolumeNotFound => Error::VolumeNotFound,
|
||||
Error::Io(e) => Error::Io(std::io::Error::new(e.kind(), e.to_string())),
|
||||
Error::RmpSerdeDecode(s) => Error::RmpSerdeDecode(s.clone()),
|
||||
|
||||
+134
-14
@@ -34,11 +34,14 @@ use rustfs_utils::http::{
|
||||
};
|
||||
use s3s::header::X_AMZ_RESTORE;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(test)]
|
||||
use std::cell::Cell;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::hash::Hasher;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
|
||||
use std::{collections::HashMap, io::Cursor};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
@@ -67,8 +70,46 @@ const _XL_FLAG_INLINE_DATA: u8 = 1 << 2;
|
||||
const META_DATA_READ_DEFAULT: usize = 4 << 10;
|
||||
const MSGP_UINT32_SIZE: usize = 5;
|
||||
|
||||
/// Max object versions per object, default is 10000
|
||||
const DEFAULT_OBJECT_MAX_VERSIONS: usize = 10000;
|
||||
/// Default max object versions per object, aligned with MinIO's default.
|
||||
pub const DEFAULT_OBJECT_MAX_VERSIONS: usize = if usize::BITS >= 64 {
|
||||
9_223_372_036_854_775_807
|
||||
} else {
|
||||
usize::MAX
|
||||
};
|
||||
|
||||
static OBJECT_MAX_VERSIONS: AtomicUsize = AtomicUsize::new(DEFAULT_OBJECT_MAX_VERSIONS);
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static OBJECT_MAX_VERSIONS_OVERRIDE: Cell<Option<usize>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn object_max_versions() -> usize {
|
||||
#[cfg(test)]
|
||||
if let Some(limit) = OBJECT_MAX_VERSIONS_OVERRIDE.with(Cell::get) {
|
||||
return limit;
|
||||
}
|
||||
|
||||
OBJECT_MAX_VERSIONS.load(AtomicOrdering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn set_object_max_versions(limit: usize) -> Result<()> {
|
||||
if limit == 0 {
|
||||
return Err(Error::other("object max versions must be greater than 0"));
|
||||
}
|
||||
OBJECT_MAX_VERSIONS.store(limit, AtomicOrdering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_object_max_versions_override_for_test(limit: Option<usize>) -> Option<usize> {
|
||||
OBJECT_MAX_VERSIONS_OVERRIDE.with(|override_limit| {
|
||||
let previous = override_limit.get();
|
||||
override_limit.set(limit);
|
||||
previous
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the inline data map key for a version_id. "null" for null version.
|
||||
pub(crate) fn data_key_for_version(version_id: Option<Uuid>) -> String {
|
||||
@@ -460,18 +501,6 @@ impl FileMeta {
|
||||
return Err(Error::other("file meta version invalid"));
|
||||
}
|
||||
|
||||
// check max versions limit
|
||||
if self.versions.len() + 1 > DEFAULT_OBJECT_MAX_VERSIONS {
|
||||
return Err(Error::other(
|
||||
"You've exceeded the limit on the number of versions you can create on this object",
|
||||
));
|
||||
}
|
||||
|
||||
if self.versions.is_empty() {
|
||||
self.versions.push(FileMetaShallowVersion::try_from(version)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let vid = version.get_version_id();
|
||||
let vid_is_null = vid.is_none() || vid == Some(Uuid::nil());
|
||||
let existing_idx = if vid_is_null {
|
||||
@@ -490,6 +519,15 @@ impl FileMeta {
|
||||
return self.set_idx(fidx, version);
|
||||
}
|
||||
|
||||
if self.versions.len() >= object_max_versions() {
|
||||
return Err(Error::MaxVersionsExceeded);
|
||||
}
|
||||
|
||||
if self.versions.is_empty() {
|
||||
self.versions.push(FileMetaShallowVersion::try_from(version)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let new_shallow = FileMetaShallowVersion::try_from(version)?;
|
||||
let insert_pos = self
|
||||
.versions
|
||||
@@ -1330,6 +1368,88 @@ mod test {
|
||||
}
|
||||
}
|
||||
|
||||
struct ObjectMaxVersionsRestore {
|
||||
previous: Option<usize>,
|
||||
}
|
||||
|
||||
impl Drop for ObjectMaxVersionsRestore {
|
||||
fn drop(&mut self) {
|
||||
set_object_max_versions_override_for_test(self.previous);
|
||||
}
|
||||
}
|
||||
|
||||
fn with_object_max_versions_for_test<R>(limit: usize, test: impl FnOnce() -> R) -> R {
|
||||
let previous = set_object_max_versions_override_for_test(Some(limit));
|
||||
let _restore = ObjectMaxVersionsRestore { previous };
|
||||
test()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_version_filemata_rejects_new_version_above_configured_limit() {
|
||||
with_object_max_versions_for_test(2, || {
|
||||
let mut fm = FileMeta::new();
|
||||
fm.add_version_filemata(valid_object_version(Uuid::from_u128(1), vec![10, 20]))
|
||||
.expect("add first version within limit");
|
||||
fm.add_version_filemata(valid_object_version(Uuid::from_u128(2), vec![10, 20]))
|
||||
.expect("add second version at limit");
|
||||
|
||||
let err = fm
|
||||
.add_version_filemata(valid_object_version(Uuid::from_u128(3), vec![10, 20]))
|
||||
.expect_err("new version above limit must fail");
|
||||
|
||||
assert_eq!(err, Error::MaxVersionsExceeded);
|
||||
assert_eq!(fm.versions.len(), 2, "failed insert must not mutate version list");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_version_filemata_allows_same_version_replacement_at_limit() {
|
||||
with_object_max_versions_for_test(2, || {
|
||||
let mut fm = FileMeta::new();
|
||||
let target = Uuid::from_u128(10);
|
||||
fm.add_version_filemata(valid_object_version(target, vec![10, 20]))
|
||||
.expect("add target version");
|
||||
fm.add_version_filemata(valid_object_version(Uuid::from_u128(20), vec![10, 20]))
|
||||
.expect("add peer version at limit");
|
||||
|
||||
fm.add_version_filemata(valid_object_version(target, vec![30, 40]))
|
||||
.expect("same version replacement at limit must succeed");
|
||||
|
||||
assert_eq!(fm.versions.len(), 2);
|
||||
let replaced = fm
|
||||
.versions
|
||||
.iter()
|
||||
.find(|version| version.header.version_id == Some(target))
|
||||
.expect("target version must remain present")
|
||||
.parse_version_meta()
|
||||
.expect("parse replaced version");
|
||||
assert_eq!(replaced.object.expect("object version").part_sizes, vec![30, 40]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_version_allows_null_version_replacement_at_limit() {
|
||||
with_object_max_versions_for_test(1, || {
|
||||
let mut fm = FileMeta::new();
|
||||
let mut first = FileInfo::new("object", 2, 2);
|
||||
first.mod_time = Some(OffsetDateTime::now_utc());
|
||||
first.version_id = None;
|
||||
fm.add_version(first).expect("add initial null version");
|
||||
|
||||
let mut replacement = FileInfo::new("object", 2, 2);
|
||||
replacement.mod_time = Some(OffsetDateTime::now_utc());
|
||||
replacement.version_id = None;
|
||||
replacement.size = 42;
|
||||
fm.add_version(replacement)
|
||||
.expect("null version replacement at limit must succeed");
|
||||
|
||||
assert_eq!(fm.versions.len(), 1);
|
||||
assert_eq!(fm.versions[0].header.version_id, Some(Uuid::nil()));
|
||||
let replaced = fm.versions[0].parse_version_meta().expect("parse null replacement");
|
||||
assert_eq!(replaced.object.expect("object version").size, 42);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_version_filemata_uses_canonical_equal_time_order() {
|
||||
let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid test timestamp");
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::heal::{
|
||||
task::{HealOptions, HealPriority, HealRequest, HealTask, HealTaskStatus, HealType, demote_to_debug_when},
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
use metrics::{counter, gauge};
|
||||
use metrics::{counter, gauge, histogram};
|
||||
use rustfs_concurrency::WorkloadAdmissionSnapshotProvider;
|
||||
use rustfs_concurrency::workload::{ForegroundPressure, foreground_pressure};
|
||||
#[cfg(test)]
|
||||
@@ -34,7 +34,7 @@ use std::sync::LazyLock;
|
||||
use std::{
|
||||
collections::{BinaryHeap, HashMap, HashSet},
|
||||
sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard},
|
||||
time::{Duration, SystemTime},
|
||||
time::{Duration, Instant, SystemTime},
|
||||
};
|
||||
use tokio::{
|
||||
sync::{Mutex, Notify, RwLock},
|
||||
@@ -181,6 +181,13 @@ fn lock_displaced_terminals(
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_admission_telemetry(registry: &StdMutex<HealAdmissionTelemetry>) -> StdMutexGuard<'_, HealAdmissionTelemetry> {
|
||||
match registry.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_displaced_terminal(
|
||||
registry: &StdMutex<HashMap<String, Arc<CompletedHealStatus>>>,
|
||||
request: &HealRequest,
|
||||
@@ -384,6 +391,61 @@ impl HealSourceCounts {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct HealAdmissionTelemetry {
|
||||
pub accepted: u64,
|
||||
pub merged: u64,
|
||||
pub full: u64,
|
||||
pub dropped: u64,
|
||||
pub duplicate: u64,
|
||||
pub overlap_rejected: u64,
|
||||
pub displaced: u64,
|
||||
pub force_start: u64,
|
||||
pub max_start_duration_micros: u64,
|
||||
pub max_lock_phase_micros: u64,
|
||||
}
|
||||
|
||||
impl HealAdmissionTelemetry {
|
||||
fn record(&mut self, observation: HealAdmissionObservation) {
|
||||
match observation.result {
|
||||
HealAdmissionResult::Accepted => self.accepted = self.accepted.saturating_add(1),
|
||||
HealAdmissionResult::Merged => self.merged = self.merged.saturating_add(1),
|
||||
HealAdmissionResult::Full => self.full = self.full.saturating_add(1),
|
||||
HealAdmissionResult::Dropped(_) => self.dropped = self.dropped.saturating_add(1),
|
||||
}
|
||||
if observation.context == "duplicate" {
|
||||
self.duplicate = self.duplicate.saturating_add(1);
|
||||
}
|
||||
if observation.context == "overlap_rejected" {
|
||||
self.overlap_rejected = self.overlap_rejected.saturating_add(1);
|
||||
}
|
||||
if observation.displaced {
|
||||
self.displaced = self.displaced.saturating_add(1);
|
||||
}
|
||||
if observation.force_start {
|
||||
self.force_start = self.force_start.saturating_add(1);
|
||||
}
|
||||
self.max_start_duration_micros = self
|
||||
.max_start_duration_micros
|
||||
.max(duration_micros_saturated(observation.start_duration));
|
||||
self.max_lock_phase_micros = self
|
||||
.max_lock_phase_micros
|
||||
.max(duration_micros_saturated(observation.lock_phase));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct HealAdmissionObservation {
|
||||
source: HealRequestSource,
|
||||
result: HealAdmissionResult,
|
||||
context: &'static str,
|
||||
force_start: bool,
|
||||
displaced: bool,
|
||||
start_duration: Duration,
|
||||
lock_phase: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct HealOperationsSnapshot {
|
||||
@@ -396,12 +458,18 @@ pub struct HealOperationsSnapshot {
|
||||
pub queued_by_source: HealSourceCounts,
|
||||
pub active_by_source: HealSourceCounts,
|
||||
pub retrying_by_source: HealSourceCounts,
|
||||
#[serde(default)]
|
||||
pub admission: HealAdmissionTelemetry,
|
||||
}
|
||||
|
||||
fn usize_to_u64_saturated(value: usize) -> u64 {
|
||||
u64::try_from(value).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
fn duration_micros_saturated(duration: Duration) -> u64 {
|
||||
u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
fn heal_type_matches_path(heal_type: &HealType, heal_path: &str) -> bool {
|
||||
let heal_path = heal_path.trim_matches('/');
|
||||
if heal_path.is_empty() || heal_path == LEGACY_ROOT_HEAL_PATH {
|
||||
@@ -764,6 +832,9 @@ pub struct HealManager {
|
||||
notify: Arc<Notify>,
|
||||
/// Optional runtime workload snapshot provider used to protect foreground data-plane work.
|
||||
workload_provider: Option<WorkloadSnapshotProviderRef>,
|
||||
/// Bounded, low-cardinality admission telemetry exposed through the
|
||||
/// existing operations snapshot for cluster E2E assertions.
|
||||
admission_telemetry: Arc<StdMutex<HealAdmissionTelemetry>>,
|
||||
}
|
||||
|
||||
/// Where a task-id lookup resolved. The variants carry the resolved state
|
||||
@@ -919,6 +990,33 @@ impl HealManager {
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
fn record_admission_observation(&self, observation: HealAdmissionObservation) {
|
||||
let result = observation.result.result_label().to_string();
|
||||
let reason = observation.result.reason_label().to_string();
|
||||
let source = observation.source.as_str().to_string();
|
||||
let context = observation.context.to_string();
|
||||
let force_start = observation.force_start.to_string();
|
||||
histogram!(
|
||||
"rustfs_heal_admission_start_duration_seconds",
|
||||
"source" => source.clone(),
|
||||
"result" => result.clone(),
|
||||
"reason" => reason.clone(),
|
||||
"context" => context.clone(),
|
||||
"force_start" => force_start.clone()
|
||||
)
|
||||
.record(observation.start_duration.as_secs_f64());
|
||||
histogram!(
|
||||
"rustfs_heal_admission_lock_phase_seconds",
|
||||
"source" => source,
|
||||
"result" => result,
|
||||
"reason" => reason,
|
||||
"context" => context,
|
||||
"force_start" => force_start
|
||||
)
|
||||
.record(observation.lock_phase.as_secs_f64());
|
||||
lock_admission_telemetry(&self.admission_telemetry).record(observation);
|
||||
}
|
||||
|
||||
fn remove_mrf_repair_notice_targets_for_task(&self, task_id: &str) {
|
||||
let targets = lock_mrf_repair_notice_targets(&self.mrf_repair_notice_targets).remove(task_id);
|
||||
if let Some(targets) = targets {
|
||||
@@ -1265,6 +1363,7 @@ impl HealManager {
|
||||
statistics: Arc::new(RwLock::new(HealStatistics::new())),
|
||||
notify: Arc::new(Notify::new()),
|
||||
workload_provider,
|
||||
admission_telemetry: Arc::new(StdMutex::new(HealAdmissionTelemetry::default())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1455,6 +1554,9 @@ impl HealManager {
|
||||
preserve_alias: bool,
|
||||
mrf_notice_target: Option<MrfRepairNoticeTarget>,
|
||||
) -> Result<HealAdmissionReceipt> {
|
||||
let admission_start = Instant::now();
|
||||
let source = request.source;
|
||||
let force_start = request.force_start;
|
||||
// HS-06 forceStart semantics (admin only): MinIO stops the old task
|
||||
// first and then starts the new one. Cancel any active admin task
|
||||
// overlapping this request's path before entering admission, so the
|
||||
@@ -1505,6 +1607,7 @@ impl HealManager {
|
||||
// Match the scheduler's active -> queue order and keep retry ownership
|
||||
// in the same atomic view. Otherwise queue -> active and
|
||||
// active -> retrying transitions can slip between duplicate checks.
|
||||
let lock_phase_start = Instant::now();
|
||||
let active_heals = self.active_heals.lock().await;
|
||||
#[cfg(test)]
|
||||
pause_duplicate_admission_after_active_lock(&request.id).await;
|
||||
@@ -1539,7 +1642,17 @@ impl HealManager {
|
||||
drop(retrying_heals);
|
||||
drop(queue);
|
||||
drop(active_heals);
|
||||
let lock_phase = lock_phase_start.elapsed();
|
||||
Self::record_admission_metric(request.source, admission, "duplicate");
|
||||
self.record_admission_observation(HealAdmissionObservation {
|
||||
source,
|
||||
result: admission,
|
||||
context: "duplicate",
|
||||
force_start,
|
||||
displaced: false,
|
||||
start_duration: admission_start.elapsed(),
|
||||
lock_phase,
|
||||
});
|
||||
|
||||
match admission {
|
||||
HealAdmissionResult::Merged => {
|
||||
@@ -1618,7 +1731,17 @@ impl HealManager {
|
||||
drop(retrying_heals);
|
||||
drop(queue);
|
||||
drop(active_heals);
|
||||
let lock_phase = lock_phase_start.elapsed();
|
||||
Self::record_admission_metric(request.source, HealAdmissionResult::Dropped(reason), "overlap_rejected");
|
||||
self.record_admission_observation(HealAdmissionObservation {
|
||||
source,
|
||||
result: HealAdmissionResult::Dropped(reason),
|
||||
context: "overlap_rejected",
|
||||
force_start,
|
||||
displaced: false,
|
||||
start_duration: admission_start.elapsed(),
|
||||
lock_phase,
|
||||
});
|
||||
warn!(
|
||||
target: "rustfs::heal::manager",
|
||||
event = EVENT_HEAL_QUEUE_ADMISSION,
|
||||
@@ -1663,6 +1786,8 @@ impl HealManager {
|
||||
drop(retrying_heals);
|
||||
drop(queue);
|
||||
drop(active_heals);
|
||||
let lock_phase = lock_phase_start.elapsed();
|
||||
let displaced = displaced_terminal.is_some();
|
||||
|
||||
if let (Some(displaced_task_id), Some(displaced_terminal)) = (displaced_task_id, displaced_terminal) {
|
||||
// The queue has already removed the displaced request, so the
|
||||
@@ -1676,6 +1801,16 @@ impl HealManager {
|
||||
self.notify.notify_one();
|
||||
}
|
||||
|
||||
self.record_admission_observation(HealAdmissionObservation {
|
||||
source,
|
||||
result: admission,
|
||||
context: "submit",
|
||||
force_start,
|
||||
displaced,
|
||||
start_duration: admission_start.elapsed(),
|
||||
lock_phase,
|
||||
});
|
||||
|
||||
Ok(HealAdmissionReceipt {
|
||||
result: admission,
|
||||
task_id,
|
||||
@@ -2111,17 +2246,25 @@ impl HealManager {
|
||||
}
|
||||
publish_active_heal_count(&active_heals);
|
||||
publish_heal_queue_length(&queue);
|
||||
let queue_length = usize_to_u64_saturated(queue.len());
|
||||
let active_tasks = usize_to_u64_saturated(active_heals.len());
|
||||
let retrying_tasks = usize_to_u64_saturated(retrying_heals.len());
|
||||
drop(retrying_heals);
|
||||
drop(queue);
|
||||
drop(active_heals);
|
||||
let admission = *lock_admission_telemetry(&self.admission_telemetry);
|
||||
|
||||
HealOperationsSnapshot {
|
||||
queue_length: usize_to_u64_saturated(queue.len()),
|
||||
active_tasks: usize_to_u64_saturated(active_heals.len()),
|
||||
retrying_tasks: usize_to_u64_saturated(retrying_heals.len()),
|
||||
queue_length,
|
||||
active_tasks,
|
||||
retrying_tasks,
|
||||
queued_by_priority,
|
||||
active_by_priority,
|
||||
retrying_by_priority,
|
||||
queued_by_source,
|
||||
active_by_source,
|
||||
retrying_by_source,
|
||||
admission,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2792,6 +2792,88 @@ async fn admin_force_start_cancels_overlapping_active_task_first() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admission_snapshot_tracks_start_duplicate_force_start_and_displacement() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
let manager = Arc::new(HealManager::new(
|
||||
storage,
|
||||
Some(HealConfig {
|
||||
queue_size: 1,
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
|
||||
let mut paused = admin_prefix_request("bucket-a", "logs/");
|
||||
paused.priority = HealPriority::Low;
|
||||
let hook = Arc::new(DuplicateAdmissionTestHook {
|
||||
request_id: paused.id.clone(),
|
||||
active_lock_reached: Notify::new(),
|
||||
active_lock_release: Notify::new(),
|
||||
});
|
||||
*DUPLICATE_ADMISSION_TEST_HOOK.lock().await = Some(hook.clone());
|
||||
|
||||
let submit_manager = Arc::clone(&manager);
|
||||
let mut paused_submission = tokio::spawn(async move { submit_manager.submit_heal_request(paused).await });
|
||||
tokio::time::timeout(Duration::from_secs(1), hook.active_lock_reached.notified())
|
||||
.await
|
||||
.expect("admission should reach the test-only lock phase hook");
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(10), &mut paused_submission)
|
||||
.await
|
||||
.is_err(),
|
||||
"admission must wait while the lock-phase hook is held"
|
||||
);
|
||||
hook.active_lock_release.notify_one();
|
||||
assert_eq!(
|
||||
paused_submission
|
||||
.await
|
||||
.expect("paused admission task should join")
|
||||
.expect("paused admission should succeed"),
|
||||
HealAdmissionResult::Accepted
|
||||
);
|
||||
*DUPLICATE_ADMISSION_TEST_HOOK.lock().await = None;
|
||||
|
||||
let duplicate = admin_prefix_request("bucket-a", "logs/");
|
||||
let duplicate_receipt = manager
|
||||
.submit_heal_request_with_receipt(duplicate)
|
||||
.await
|
||||
.expect("duplicate admission should return a canonical receipt");
|
||||
assert_eq!(duplicate_receipt.result, HealAdmissionResult::Merged);
|
||||
|
||||
let mut high = admin_prefix_request("bucket-b", "logs/");
|
||||
high.priority = HealPriority::High;
|
||||
assert_eq!(
|
||||
manager
|
||||
.submit_heal_request(high)
|
||||
.await
|
||||
.expect("higher priority admin request should displace queued low-priority work"),
|
||||
HealAdmissionResult::Accepted
|
||||
);
|
||||
|
||||
let mut forced = admin_prefix_request("bucket-c", "logs/");
|
||||
forced.force_start = true;
|
||||
assert_eq!(
|
||||
manager
|
||||
.submit_heal_request(forced)
|
||||
.await
|
||||
.expect("forceStart should keep explicit admission semantics"),
|
||||
HealAdmissionResult::Accepted
|
||||
);
|
||||
|
||||
let admission = manager.operations_snapshot().await.admission;
|
||||
assert_eq!(admission.accepted, 3);
|
||||
assert_eq!(admission.merged, 1);
|
||||
assert_eq!(admission.full, 0);
|
||||
assert_eq!(admission.dropped, 0);
|
||||
assert_eq!(admission.duplicate, 1);
|
||||
assert_eq!(admission.displaced, 1);
|
||||
assert_eq!(admission.force_start, 1);
|
||||
assert!(
|
||||
admission.max_lock_phase_micros > 0,
|
||||
"snapshot should expose a measurable queue/admission lock phase for p95-style external aggregation"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_operations_snapshot_counts_active_by_source_and_priority() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
|
||||
@@ -34,7 +34,7 @@ use storage_api::owner::{
|
||||
};
|
||||
|
||||
pub use erasure_healer::ErasureSetHealer;
|
||||
pub use manager::{HealManager, HealOperationsSnapshot, HealPriorityCounts, HealSourceCounts};
|
||||
pub use manager::{HealAdmissionTelemetry, HealManager, HealOperationsSnapshot, HealPriorityCounts, HealSourceCounts};
|
||||
pub use resume::{CheckpointManager, ResumeCheckpoint, ResumeManager, ResumeState, ResumeUtils};
|
||||
pub use task::{HealOptions, HealPriority, HealRequest, HealTask, HealType};
|
||||
|
||||
|
||||
@@ -516,6 +516,7 @@ async fn submit_mrf_heal_request(manager: &HealManager, intent: &MrfIntent) -> c
|
||||
|
||||
struct MrfRuntime {
|
||||
queue: MrfQueue,
|
||||
retained_replay_intents: Vec<MrfIntent>,
|
||||
config: MrfConsumerConfig,
|
||||
new_since_flush: usize,
|
||||
/// True while the in-memory pending set has changed since the last
|
||||
@@ -524,9 +525,8 @@ struct MrfRuntime {
|
||||
/// waiting out an admission backoff must not re-fsync every local disk
|
||||
/// twice a second.
|
||||
dirty: bool,
|
||||
/// True while a journal snapshot exists on disk that no longer reflects
|
||||
/// an all-consumed pending set; the next idle tick removes it (MinIO
|
||||
/// deletes its `list.bin` after replay for the same reason).
|
||||
/// True while a journal snapshot exists on disk that may still be needed
|
||||
/// for replay or cleanup.
|
||||
journal_on_disk: bool,
|
||||
/// Earliest instant a full-admission retry may proceed.
|
||||
backoff_until: Option<tokio::time::Instant>,
|
||||
@@ -536,7 +536,7 @@ impl MrfRuntime {
|
||||
fn snapshot(&self) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut authoritative = Vec::new();
|
||||
let mut legacy = Vec::new();
|
||||
for intent in self.queue.intents() {
|
||||
for intent in self.retained_replay_intents.iter().chain(self.queue.intents()) {
|
||||
let scoped_identity =
|
||||
!matches!(intent.kind, rustfs_common::mrf_channel::MrfKind::MetadataCorruption) && intent.scope.is_some();
|
||||
if !encode_intent(intent, &mut authoritative) {
|
||||
@@ -674,10 +674,11 @@ pub async fn replay_journal_once(manager: &Arc<HealManager>) -> usize {
|
||||
struct ReplayOutcome {
|
||||
replayed: usize,
|
||||
journal_on_disk: bool,
|
||||
retained_replay_intents: Vec<MrfIntent>,
|
||||
}
|
||||
|
||||
fn replay_must_retain_journal(rearm_incomplete: bool, pending_depth: usize) -> bool {
|
||||
rearm_incomplete || pending_depth > 0
|
||||
fn replay_must_retain_journal(rearm_incomplete: bool, pending_depth: usize, retained_replay_depth: usize) -> bool {
|
||||
rearm_incomplete || pending_depth > 0 || retained_replay_depth > 0
|
||||
}
|
||||
|
||||
/// Shared replay core: read + decode + re-arm, then drain what fits. The
|
||||
@@ -699,6 +700,7 @@ async fn replay_into(
|
||||
return ReplayOutcome {
|
||||
replayed: 0,
|
||||
journal_on_disk: false,
|
||||
retained_replay_intents: Vec::new(),
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -738,23 +740,42 @@ async fn replay_into(
|
||||
|
||||
// Drain the replayed intents immediately; whatever the manager refuses
|
||||
// stays armed in `queue` for the consumer's retry loop.
|
||||
let mut retained_replay_intents = Vec::new();
|
||||
if backoff_until.is_none() {
|
||||
while let Some(mut intent) = queue.pop_front() {
|
||||
match submit_mrf_heal_request(manager, &intent).await {
|
||||
Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => {}
|
||||
Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => {
|
||||
retained_replay_intents.push(intent);
|
||||
}
|
||||
Ok(HealAdmissionResult::Full) | Ok(HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull)) => {
|
||||
intent.attempts = intent.attempts.saturating_add(1);
|
||||
if intent.attempts < MRF_MAX_ATTEMPTS {
|
||||
queue.push_back(intent);
|
||||
*backoff_until = Some(tokio::time::Instant::now());
|
||||
} else {
|
||||
rearm_incomplete = true;
|
||||
counter!("rustfs_heal_mrf_dropped_total", "reason" => "attempts_exhausted").increment(1);
|
||||
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(HealAdmissionResult::Dropped(_)) => {}
|
||||
Err(_) => {
|
||||
intent.attempts = intent.attempts.saturating_add(1);
|
||||
if intent.attempts < MRF_MAX_ATTEMPTS {
|
||||
queue.push_back(intent);
|
||||
*backoff_until = Some(tokio::time::Instant::now());
|
||||
} else {
|
||||
rearm_incomplete = true;
|
||||
counter!("rustfs_heal_mrf_dropped_total", "reason" => "attempts_exhausted").increment(1);
|
||||
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(HealAdmissionResult::Dropped(_)) | Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let journal_on_disk = if replay_must_retain_journal(rearm_incomplete, queue.depth()) {
|
||||
let journal_on_disk = if replay_must_retain_journal(rearm_incomplete, queue.depth(), retained_replay_intents.len()) {
|
||||
true
|
||||
} else {
|
||||
!delete_journals().await
|
||||
@@ -762,6 +783,7 @@ async fn replay_into(
|
||||
ReplayOutcome {
|
||||
replayed,
|
||||
journal_on_disk,
|
||||
retained_replay_intents,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,6 +793,7 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
let config = MrfConsumerConfig::default();
|
||||
let mut runtime = MrfRuntime {
|
||||
queue: MrfQueue::new(config.queue_capacity, config.journal_max_bytes),
|
||||
retained_replay_intents: Vec::new(),
|
||||
config: config.clone(),
|
||||
new_since_flush: 0,
|
||||
dirty: false,
|
||||
@@ -782,6 +805,7 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
// on disk whenever any replayed intent still needs a successor snapshot.
|
||||
let replay = replay_into(&manager, &mut runtime.queue, &mut runtime.backoff_until).await;
|
||||
runtime.journal_on_disk = replay.journal_on_disk;
|
||||
runtime.retained_replay_intents = replay.retained_replay_intents;
|
||||
// Anything still pending (e.g. the manager was full and backoff armed)
|
||||
// must be re-persisted by the next flush before replay can delete the
|
||||
// startup anchor.
|
||||
@@ -799,7 +823,7 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
// provably current AND idle (a dirty or pending state
|
||||
// gets one last persist attempt, matching the shutdown
|
||||
// retry the unconditional flush used to provide).
|
||||
if runtime.dirty || runtime.queue.depth() > 0 {
|
||||
if runtime.dirty || runtime.queue.depth() > 0 || !runtime.retained_replay_intents.is_empty() {
|
||||
runtime.flush().await;
|
||||
}
|
||||
tracing::info!(
|
||||
@@ -825,7 +849,12 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
}
|
||||
}
|
||||
_ = flush_tick.tick() => {
|
||||
match tick_action(runtime.dirty, runtime.queue.depth(), runtime.journal_on_disk) {
|
||||
match tick_action(
|
||||
runtime.dirty,
|
||||
runtime.queue.depth(),
|
||||
runtime.retained_replay_intents.len(),
|
||||
runtime.journal_on_disk,
|
||||
) {
|
||||
TickAction::Flush => {
|
||||
runtime.flush().await;
|
||||
runtime.dispatch(manager.as_ref()).await;
|
||||
@@ -838,8 +867,8 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
runtime.dispatch(manager.as_ref()).await;
|
||||
}
|
||||
TickAction::DeleteJournal => {
|
||||
// All intents consumed: remove the journal so a restart
|
||||
// replays nothing (mirrors MinIO's post-replay unlink).
|
||||
// Only remove a stale journal after every replayed
|
||||
// intent has a durable successor proof.
|
||||
if delete_journals().await {
|
||||
runtime.journal_on_disk = false;
|
||||
gauge!("rustfs_heal_mrf_journal_bytes").set(0.0);
|
||||
@@ -868,11 +897,13 @@ enum TickAction {
|
||||
Idle,
|
||||
}
|
||||
|
||||
fn tick_action(dirty: bool, depth: usize, journal_on_disk: bool) -> TickAction {
|
||||
fn tick_action(dirty: bool, depth: usize, retained_replay_depth: usize, journal_on_disk: bool) -> TickAction {
|
||||
if dirty {
|
||||
TickAction::Flush
|
||||
} else if depth > 0 {
|
||||
TickAction::Retry
|
||||
} else if retained_replay_depth > 0 {
|
||||
TickAction::Idle
|
||||
} else if journal_on_disk {
|
||||
TickAction::DeleteJournal
|
||||
} else {
|
||||
@@ -905,34 +936,73 @@ mod tests {
|
||||
|
||||
// Dirty dominates: a changed pending set flushes even when idle
|
||||
// otherwise.
|
||||
assert!(matches!(tick_action(true, 0, false), Flush));
|
||||
assert!(matches!(tick_action(true, 3, true), Flush));
|
||||
assert!(matches!(tick_action(true, 0, 0, false), Flush));
|
||||
assert!(matches!(tick_action(true, 3, 0, true), Flush));
|
||||
|
||||
// Clean backlog: no rewrite, but keep draining so an expired
|
||||
// admission backoff retries on time.
|
||||
assert!(matches!(tick_action(false, 1, false), Retry));
|
||||
assert!(matches!(tick_action(false, 2, true), Retry));
|
||||
assert!(matches!(tick_action(false, 1, 0, false), Retry));
|
||||
assert!(matches!(tick_action(false, 2, 0, true), Retry));
|
||||
|
||||
// Replayed records accepted by the manager are still restart anchors
|
||||
// until a durable successor proof can tombstone them.
|
||||
assert!(matches!(tick_action(false, 0, 1, true), Idle));
|
||||
|
||||
// Quiescent with a stale journal file on disk: remove it.
|
||||
assert!(matches!(tick_action(false, 0, true), DeleteJournal));
|
||||
assert!(matches!(tick_action(false, 0, 0, true), DeleteJournal));
|
||||
|
||||
// Fully quiescent: nothing to do.
|
||||
assert!(matches!(tick_action(false, 0, false), Idle));
|
||||
assert!(matches!(tick_action(false, 0, 0, false), Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_cleanup_retains_journal_for_unarmed_or_refused_records() {
|
||||
assert!(
|
||||
replay_must_retain_journal(true, 0),
|
||||
replay_must_retain_journal(true, 0, 0),
|
||||
"a rejected replay record still needs its disk anchor"
|
||||
);
|
||||
assert!(
|
||||
replay_must_retain_journal(false, 1),
|
||||
replay_must_retain_journal(false, 1, 0),
|
||||
"a Full admission retry must keep the startup journal until the next snapshot"
|
||||
);
|
||||
assert!(
|
||||
!replay_must_retain_journal(false, 0),
|
||||
"only a fully consumed replay snapshot may be deleted"
|
||||
replay_must_retain_journal(false, 0, 1),
|
||||
"an accepted replay record still needs a durable successor before cleanup"
|
||||
);
|
||||
assert!(
|
||||
!replay_must_retain_journal(false, 0, 0),
|
||||
"only a fully consumed replay snapshot with no retained anchors may be deleted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_replay_anchor_remains_in_successor_snapshot() {
|
||||
let retained = intent("accepted-replay", "object", 0);
|
||||
let mut runtime = MrfRuntime {
|
||||
queue: MrfQueue::new(8, 8192),
|
||||
retained_replay_intents: vec![retained.clone()],
|
||||
config: MrfConsumerConfig::default(),
|
||||
new_since_flush: 0,
|
||||
dirty: false,
|
||||
journal_on_disk: true,
|
||||
backoff_until: None,
|
||||
};
|
||||
assert_eq!(
|
||||
runtime.queue.try_push_typed(intent("new-pending", "object", 0)),
|
||||
MrfQueuePushResult::Enqueued
|
||||
);
|
||||
|
||||
let (authoritative, legacy) = runtime.snapshot();
|
||||
let (decoded, truncated) = decode_journal(&authoritative);
|
||||
let (legacy_decoded, legacy_truncated) = decode_journal(&legacy);
|
||||
|
||||
assert_eq!(truncated, 0);
|
||||
assert_eq!(legacy_truncated, 0);
|
||||
assert_eq!(decoded.len(), 2);
|
||||
assert_eq!(legacy_decoded.len(), 2);
|
||||
assert!(
|
||||
decoded.iter().any(|intent| intent.bucket == retained.bucket),
|
||||
"accepted replay anchor must remain crash-replayable"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ pub mod heal;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use heal::{
|
||||
HealManager, HealOperationsSnapshot, HealOptions, HealPriority, HealPriorityCounts, HealRequest, HealSourceCounts, HealType,
|
||||
HealAdmissionTelemetry, HealManager, HealOperationsSnapshot, HealOptions, HealPriority, HealPriorityCounts, HealRequest,
|
||||
HealSourceCounts, HealType,
|
||||
channel::HealChannelProcessor,
|
||||
progress::{HealProgress, aggregate_heal_progress},
|
||||
resume::{ReplacementRecoveryRecord, ReplacementRecoveryState, ResumeUtils},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use rustfs_io_metrics::{MetricsCollector, PerformanceMetrics, record_get_object_request_started};
|
||||
use rustfs_io_metrics::{record_s3_op, s3_http_metrics::S3HttpRequestGuard};
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
use std::hint::black_box;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -8,6 +10,17 @@ fn bench_record_get_object_request_started(c: &mut Criterion) {
|
||||
c.bench_function("record_get_object_request_started", |b| b.iter(record_get_object_request_started));
|
||||
}
|
||||
|
||||
fn bench_s3_http_outcomes(c: &mut Criterion) {
|
||||
c.bench_function("s3_http_handler_counter", |b| b.iter(|| record_s3_op(black_box(S3Operation::PutObject))));
|
||||
c.bench_function("s3_http_handler_counter_with_outcome", |b| {
|
||||
b.iter(|| {
|
||||
let mut request = S3HttpRequestGuard::new(black_box("PUT"));
|
||||
request.in_scope(|| record_s3_op(black_box(S3Operation::PutObject)));
|
||||
request.response(black_box(200));
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_update_concurrent_requests(c: &mut Criterion) {
|
||||
let metrics = PerformanceMetrics::new();
|
||||
|
||||
@@ -37,6 +50,7 @@ fn bench_metrics_collector_record_io_operation(c: &mut Criterion) {
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_record_get_object_request_started,
|
||||
bench_s3_http_outcomes,
|
||||
bench_update_concurrent_requests,
|
||||
bench_metrics_collector_record_io_operation
|
||||
);
|
||||
|
||||
@@ -226,6 +226,7 @@ pub mod lock_metrics;
|
||||
pub mod performance;
|
||||
pub mod process_lock_metrics;
|
||||
pub mod s3_api_metrics;
|
||||
pub mod s3_http_metrics;
|
||||
pub mod sampler;
|
||||
pub mod system_path_metrics;
|
||||
pub mod timeout_metrics;
|
||||
|
||||
@@ -43,6 +43,7 @@ fn s3_op_counters() -> &'static [AtomicU64] {
|
||||
/// This mirrors MinIO, which never labels its default operation counters with
|
||||
/// bucket. The `op` dimension is bounded (<= 122 variants).
|
||||
pub fn record_s3_op(op: S3Operation) {
|
||||
crate::s3_http_metrics::observe_s3_http_operation(op);
|
||||
if let Some(counter) = s3_op_counters().get(op.metric_index()) {
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// 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.
|
||||
|
||||
//! External S3 HTTP outcomes, including requests rejected before S3 dispatch.
|
||||
//! Admin snapshots and metric exporters share these counters. The older
|
||||
//! operation counter counts handler entries and is not an HTTP denominator.
|
||||
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
use std::cell::Cell;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{LazyLock, OnceLock};
|
||||
|
||||
const METRIC: &str = "rustfs_s3_http_requests_total";
|
||||
const METHODS: [&str; 10] = [
|
||||
"GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS", "PATCH", "CONNECT", "TRACE", "OTHER",
|
||||
];
|
||||
const OUTCOMES: [&str; 8] = ["1xx", "2xx", "3xx", "4xx", "5xx", "unknown", "service_error", "cancelled"];
|
||||
const UNKNOWN_OPERATION: usize = S3Operation::ALL.len();
|
||||
static COUNTERS: LazyLock<HttpOutcomeCounters> = LazyLock::new(HttpOutcomeCounters::new);
|
||||
|
||||
tokio::task_local! {
|
||||
static CURRENT_OPERATION: Cell<usize>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct S3HttpMetricSnapshot {
|
||||
pub method: &'static str,
|
||||
pub operation: &'static str,
|
||||
pub outcome: &'static str,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
struct OutcomeCounter {
|
||||
total: AtomicU64,
|
||||
exported: OnceLock<metrics::Counter>,
|
||||
}
|
||||
|
||||
struct HttpOutcomeCounters(Box<[OutcomeCounter]>);
|
||||
|
||||
impl HttpOutcomeCounters {
|
||||
fn new() -> Self {
|
||||
Self(
|
||||
std::iter::repeat_with(|| OutcomeCounter {
|
||||
total: AtomicU64::new(0),
|
||||
exported: OnceLock::new(),
|
||||
})
|
||||
.take(METHODS.len() * (UNKNOWN_OPERATION + 1) * OUTCOMES.len())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn record(&self, method: usize, operation: usize, outcome: usize) {
|
||||
let counter = &self.0[(method * (UNKNOWN_OPERATION + 1) + operation) * OUTCOMES.len() + outcome];
|
||||
counter.total.fetch_add(1, Ordering::Relaxed);
|
||||
counter
|
||||
.exported
|
||||
.get_or_init(|| {
|
||||
counter!(METRIC, "method" => METHODS[method], "op" => operation_label(operation), "outcome" => OUTCOMES[outcome])
|
||||
})
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Vec<S3HttpMetricSnapshot> {
|
||||
// Individual series are monotonic; a concurrent snapshot is not a
|
||||
// transaction across series. Rates must compare consecutive samples.
|
||||
self.0
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, counter)| {
|
||||
let total = counter.total.load(Ordering::Relaxed);
|
||||
(total != 0).then(|| S3HttpMetricSnapshot {
|
||||
method: METHODS[index / OUTCOMES.len() / (UNKNOWN_OPERATION + 1)],
|
||||
operation: operation_label(index / OUTCOMES.len() % (UNKNOWN_OPERATION + 1)),
|
||||
outcome: OUTCOMES[index % OUTCOMES.len()],
|
||||
total,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn operation_label(index: usize) -> &'static str {
|
||||
S3Operation::ALL.get(index).map_or("unknown", |op| op.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn observe_s3_http_operation(op: S3Operation) {
|
||||
let _ = CURRENT_OPERATION.try_with(|current| {
|
||||
// Internal operations must not overwrite the external request's first
|
||||
// dispatched operation. No task-local scope means non-HTTP work.
|
||||
if current.get() == UNKNOWN_OPERATION {
|
||||
current.set(op.metric_index());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// An external request is counted exactly once: at response headers, at a
|
||||
/// service error, or when its future is dropped before producing a response.
|
||||
/// Body-stream failures after headers use the existing streaming metrics.
|
||||
pub struct S3HttpRequestGuard {
|
||||
method: usize,
|
||||
operation: usize,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl S3HttpRequestGuard {
|
||||
pub fn is_active() -> bool {
|
||||
CURRENT_OPERATION.try_with(|_| ()).is_ok()
|
||||
}
|
||||
|
||||
pub fn new(method: &str) -> Self {
|
||||
Self {
|
||||
method: METHODS.iter().position(|known| *known == method).unwrap_or(METHODS.len() - 1),
|
||||
operation: UNKNOWN_OPERATION,
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attribute existing operation instrumentation without changing S3
|
||||
/// handlers or propagating metric labels through storage/RPC contracts.
|
||||
pub fn in_scope<T>(&mut self, f: impl FnOnce() -> T) -> T {
|
||||
CURRENT_OPERATION.sync_scope(Cell::new(self.operation), || {
|
||||
let result = f();
|
||||
self.operation = CURRENT_OPERATION.with(Cell::get);
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
pub fn response(&mut self, status: u16) {
|
||||
let outcome = match status {
|
||||
100..=599 => usize::from(status / 100 - 1),
|
||||
_ => 5,
|
||||
};
|
||||
self.finish(outcome);
|
||||
}
|
||||
|
||||
pub fn service_error(&mut self) {
|
||||
self.finish(6);
|
||||
}
|
||||
|
||||
fn finish(&mut self, outcome: usize) {
|
||||
if !self.finished {
|
||||
COUNTERS.record(self.method, self.operation, outcome);
|
||||
self.finished = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for S3HttpRequestGuard {
|
||||
fn drop(&mut self) {
|
||||
self.finish(7);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn s3_http_metrics_snapshot() -> Vec<S3HttpMetricSnapshot> {
|
||||
COUNTERS.snapshot()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use metrics::with_local_recorder;
|
||||
use metrics_util::debugging::DebuggingRecorder;
|
||||
|
||||
#[test]
|
||||
fn outcome_counters_distinguish_partial_and_complete_write_failure() {
|
||||
let counters = HttpOutcomeCounters::new();
|
||||
let recorder = DebuggingRecorder::new();
|
||||
with_local_recorder(&recorder, || {
|
||||
for _ in 0..99 {
|
||||
counters.record(1, S3Operation::PutObject.metric_index(), 1);
|
||||
}
|
||||
counters.record(1, S3Operation::PutObject.metric_index(), 4);
|
||||
for _ in 0..100 {
|
||||
counters.record(1, UNKNOWN_OPERATION, 4);
|
||||
}
|
||||
});
|
||||
let snapshot = counters.snapshot();
|
||||
assert_eq!(snapshot.iter().map(|series| series.total).sum::<u64>(), 200);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.iter()
|
||||
.find(|s| s.operation == S3Operation::PutObject.as_str() && s.outcome == "5xx")
|
||||
.expect("write failure")
|
||||
.total,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.iter()
|
||||
.find(|s| s.operation == "unknown")
|
||||
.expect("pre-dispatch failures")
|
||||
.total,
|
||||
100
|
||||
);
|
||||
let exported = recorder.snapshotter().snapshot().into_vec();
|
||||
assert_eq!(exported.len(), 3);
|
||||
for (key, _, _, _) in exported {
|
||||
let labels: Vec<_> = key.key().labels().map(|label| label.key()).collect();
|
||||
assert_eq!(labels, ["method", "op", "outcome"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_guard_preserves_operation_across_polls_and_finishes_once() {
|
||||
let totals = || {
|
||||
s3_http_metrics_snapshot()
|
||||
.into_iter()
|
||||
.filter(|series| series.method == "CONNECT")
|
||||
.map(|series| ((series.operation, series.outcome), series.total))
|
||||
.collect::<std::collections::BTreeMap<_, _>>()
|
||||
};
|
||||
let before = totals();
|
||||
let mut request = S3HttpRequestGuard::new("CONNECT");
|
||||
request.in_scope(|| observe_s3_http_operation(S3Operation::PutObject));
|
||||
request.in_scope(|| {
|
||||
assert!(S3HttpRequestGuard::is_active());
|
||||
observe_s3_http_operation(S3Operation::GetObject);
|
||||
});
|
||||
assert!(!S3HttpRequestGuard::is_active());
|
||||
request.response(204);
|
||||
request.response(503);
|
||||
request.service_error();
|
||||
drop(request);
|
||||
let after = totals();
|
||||
let key = (S3Operation::PutObject.as_str(), "2xx");
|
||||
assert_eq!(after[&key] - before.get(&key).copied().unwrap_or_default(), 1);
|
||||
assert_eq!(after.values().sum::<u64>() - before.values().sum::<u64>(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_operation_is_scoped_and_first_dispatch_wins() {
|
||||
let mut request = S3HttpRequestGuard::new("PUT");
|
||||
request.in_scope(|| {
|
||||
observe_s3_http_operation(S3Operation::PutObject);
|
||||
observe_s3_http_operation(S3Operation::GetObject);
|
||||
});
|
||||
assert_eq!(request.operation, S3Operation::PutObject.metric_index());
|
||||
observe_s3_http_operation(S3Operation::GetObject);
|
||||
let other = S3HttpRequestGuard::new("attacker-controlled-method");
|
||||
assert_eq!(other.method, METHODS.len() - 1);
|
||||
assert_eq!(other.operation, UNKNOWN_OPERATION);
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,30 @@ pub enum BackendByte {
|
||||
pub struct StorageInfo {
|
||||
pub disks: Vec<Disk>,
|
||||
pub backend: BackendInfo,
|
||||
/// Missing observations from older nodes are unknown, never proof of health.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub observations: Vec<StorageInfoObservation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StorageInfoProbeStatus {
|
||||
Succeeded,
|
||||
Failed,
|
||||
#[default]
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct StorageInfoObservation {
|
||||
pub endpoint: String,
|
||||
pub status: StorageInfoProbeStatus,
|
||||
pub cached: bool,
|
||||
pub last_success_unix_millis: Option<u64>,
|
||||
pub snapshot_age_seconds: Option<u64>,
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
@@ -879,6 +903,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
backend: BackendInfo::default(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(storage_info.disks.len(), 2);
|
||||
@@ -886,6 +911,40 @@ mod tests {
|
||||
assert_eq!(storage_info.disks[1].state, "offline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_info_observation_is_additive_and_unknown_for_old_peers() {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct LegacyStorageInfo {
|
||||
disks: Vec<Disk>,
|
||||
backend: BackendInfo,
|
||||
}
|
||||
let old = LegacyStorageInfo {
|
||||
disks: Vec::new(),
|
||||
backend: BackendInfo::default(),
|
||||
};
|
||||
let encoded = rmp_serde::to_vec_named(&old).expect("legacy map");
|
||||
let decoded: StorageInfo = rmp_serde::from_slice(&encoded).expect("old peer response");
|
||||
assert!(decoded.observations.is_empty());
|
||||
let mut new = decoded;
|
||||
new.observations.push(StorageInfoObservation {
|
||||
endpoint: "node2:9000".into(),
|
||||
status: StorageInfoProbeStatus::Failed,
|
||||
cached: true,
|
||||
last_success_unix_millis: Some(1_700_000_000_000),
|
||||
snapshot_age_seconds: Some(5),
|
||||
error_code: Some("Timeout".into()),
|
||||
});
|
||||
let encoded = rmp_serde::to_vec_named(&new).expect("new map");
|
||||
let legacy: LegacyStorageInfo = rmp_serde::from_slice(&encoded).expect("old reader ignores new fields");
|
||||
assert!(legacy.disks.is_empty());
|
||||
let roundtrip: StorageInfo = rmp_serde::from_slice(&encoded).expect("new reader preserves observation");
|
||||
assert_eq!(roundtrip.observations, new.observations);
|
||||
let unknown: StorageInfoObservation =
|
||||
serde_json::from_str(r#"{"endpoint":"node2","status":"future_state"}"#).expect("future state remains unknown");
|
||||
assert_eq!(unknown.status, StorageInfoProbeStatus::Unknown);
|
||||
assert_eq!(unknown.snapshot_age_seconds, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backend_disks_new() {
|
||||
let backend_disks = BackendDisks::new();
|
||||
@@ -1391,6 +1450,7 @@ mod tests {
|
||||
let storage_info = StorageInfo {
|
||||
disks: vec![],
|
||||
backend: BackendInfo::default(),
|
||||
..Default::default()
|
||||
};
|
||||
let backend_info = BackendInfo::default();
|
||||
let mem_stats = MemStats::default();
|
||||
|
||||
@@ -997,10 +997,56 @@ pub struct Metrics {
|
||||
pub cpu: Option<CPUMetrics>,
|
||||
#[serde(rename = "rpc", skip_serializing_if = "Option::is_none")]
|
||||
pub rpc: Option<RPCMetrics>,
|
||||
/// Absent means this node did not report HTTP outcomes, not zero traffic.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub http: Option<HttpMetrics>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct HttpMetrics {
|
||||
#[serde(rename = "collected")]
|
||||
pub collected_at: Timestamp,
|
||||
pub requests: Vec<HttpRequestMetric>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct HttpRequestMetric {
|
||||
pub method: String,
|
||||
pub operation: String,
|
||||
pub outcome: String,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
impl HttpMetrics {
|
||||
fn merge(&mut self, other: &Self) {
|
||||
self.collected_at = self.collected_at.max(other.collected_at);
|
||||
let mut totals = std::collections::BTreeMap::new();
|
||||
for series in self.requests.drain(..).chain(other.requests.iter().cloned()) {
|
||||
let total = totals
|
||||
.entry((series.method, series.operation, series.outcome))
|
||||
.or_insert(0_u64);
|
||||
*total = total.saturating_add(series.total);
|
||||
}
|
||||
self.requests = totals
|
||||
.into_iter()
|
||||
.map(|((method, operation, outcome), total)| HttpRequestMetric {
|
||||
method,
|
||||
operation,
|
||||
outcome,
|
||||
total,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
pub fn merge(&mut self, other: &Self) {
|
||||
if let Some(http) = &other.http {
|
||||
match &mut self.http {
|
||||
Some(existing) => existing.merge(http),
|
||||
None => self.http = Some(http.clone()),
|
||||
}
|
||||
}
|
||||
if let Some(scanner) = other.scanner.as_ref() {
|
||||
match self.scanner {
|
||||
Some(ref mut s_scanner) => s_scanner.merge(scanner),
|
||||
@@ -1473,6 +1519,70 @@ mod tests {
|
||||
Timestamp::constant(1_700_000_000, 123_456_000)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_metrics_merge_preserves_outcomes_and_missing_node_support() {
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct LegacyMetrics {
|
||||
rpc: Option<RPCMetrics>,
|
||||
}
|
||||
let old_map = rmp_serde::to_vec_named(&LegacyMetrics::default()).expect("legacy map");
|
||||
assert!(rmp_serde::from_slice::<Metrics>(&old_map).expect("new reader").http.is_none());
|
||||
let missing: Metrics = serde_json::from_str("{}").expect("old node metrics");
|
||||
assert!(missing.http.is_none());
|
||||
let mut combined = RealtimeMetrics::default();
|
||||
for (host, successes, failures) in [("node1", 99, 1), ("node2", 0, 100)] {
|
||||
let metrics = Metrics {
|
||||
http: Some(HttpMetrics {
|
||||
collected_at: fixed_timestamp(),
|
||||
requests: [("2xx", successes), ("5xx", failures)]
|
||||
.into_iter()
|
||||
.map(|(outcome, total)| HttpRequestMetric {
|
||||
method: "PUT".into(),
|
||||
operation: "s3:PutObject".into(),
|
||||
outcome: outcome.into(),
|
||||
total,
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = rmp_serde::to_vec_named(&metrics).expect("peer metric map");
|
||||
let old_reader: LegacyMetrics = rmp_serde::from_slice(&encoded).expect("old reader ignores HTTP field");
|
||||
assert!(old_reader.rpc.is_none());
|
||||
let decoded: Metrics = rmp_serde::from_slice(&encoded).expect("peer metric roundtrip");
|
||||
combined.merge(RealtimeMetrics {
|
||||
aggregated: decoded,
|
||||
by_host: HashMap::from([(host.into(), metrics)]),
|
||||
hosts: vec![host.into()],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let aggregate = combined.aggregated.http.as_ref().expect("HTTP support");
|
||||
assert_eq!(
|
||||
aggregate
|
||||
.requests
|
||||
.iter()
|
||||
.find(|s| s.outcome == "5xx")
|
||||
.expect("failures")
|
||||
.total,
|
||||
101
|
||||
);
|
||||
assert_eq!(aggregate.requests.iter().map(|s| s.total).sum::<u64>(), 200);
|
||||
assert_eq!(combined.by_host["node2"].http.as_ref().expect("node2").requests[1].total, 100);
|
||||
combined.aggregated.merge(&missing);
|
||||
assert_eq!(
|
||||
combined
|
||||
.aggregated
|
||||
.http
|
||||
.as_ref()
|
||||
.expect("supported peers remain")
|
||||
.requests
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_metrics_timestamps_serialize_as_rfc3339_utc() {
|
||||
let timestamp = fixed_timestamp();
|
||||
|
||||
@@ -69,6 +69,8 @@ pub mod scanner_folder;
|
||||
#[cfg(test)]
|
||||
mod scanner_heal_admission_baseline;
|
||||
pub mod scanner_io;
|
||||
#[doc(hidden)]
|
||||
pub mod segment_invalidation;
|
||||
pub mod sleeper;
|
||||
pub(crate) mod storage_api;
|
||||
mod workload_admission;
|
||||
|
||||
+131
-183
@@ -1,253 +1,200 @@
|
||||
//! Fixture-only range diagnostics. No result is supplied to a scan selector.
|
||||
|
||||
use super::*;
|
||||
use crate::DATA_USAGE_CACHE_KEY_FORMAT;
|
||||
use crate::segment_invalidation::{
|
||||
MAX_SEGMENT_INVALIDATION_BYTES, MAX_SEGMENT_INVALIDATION_ENTRIES, SegmentInvalidationDomain, SegmentInvalidationEnvelope,
|
||||
SegmentInvalidationError, SegmentInvalidationProducer, SegmentInvalidationProof, admit_segment_invalidation,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const MAX_SEGMENTS: usize = 4;
|
||||
const MAX_SEGMENT_BYTES: usize = 128;
|
||||
const MAX_WALK_SAMPLES: usize = 32;
|
||||
const MAX_WALK_BYTES: usize = 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ProposalError {
|
||||
EntryLimit,
|
||||
ByteLimit,
|
||||
InvalidKey,
|
||||
fn segment_producers() -> BTreeSet<SegmentInvalidationProducer> {
|
||||
SegmentInvalidationProducer::REQUIRED.into_iter().collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ProducerKind {
|
||||
Put,
|
||||
Delete,
|
||||
DeleteMarker,
|
||||
Multipart,
|
||||
Replication,
|
||||
Tier,
|
||||
DirectoryObject,
|
||||
}
|
||||
|
||||
impl ProducerKind {
|
||||
const REQUIRED: [Self; 7] = [
|
||||
Self::Put,
|
||||
Self::Delete,
|
||||
Self::DeleteMarker,
|
||||
Self::Multipart,
|
||||
Self::Replication,
|
||||
Self::Tier,
|
||||
Self::DirectoryObject,
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SegmentInvalidationDomain {
|
||||
LocalSingleSet,
|
||||
DistributedEc,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SegmentObservationEnvelope<'a> {
|
||||
source: DataUsageCacheSource,
|
||||
bucket_incarnation: uuid::Uuid,
|
||||
key_format: u16,
|
||||
baseline_scan_plan_digest: DataUsageScanPlanDigest,
|
||||
process_epoch: &'a str,
|
||||
generation_start: u64,
|
||||
generation_end: u64,
|
||||
restart_gap: bool,
|
||||
overflow: bool,
|
||||
producers: BTreeSet<&'a str>,
|
||||
keys: &'a [&'a str],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SegmentObservationProof<'a> {
|
||||
source: DataUsageCacheSource,
|
||||
bucket_incarnation: uuid::Uuid,
|
||||
key_format: u16,
|
||||
baseline_scan_plan_digest: DataUsageScanPlanDigest,
|
||||
process_epoch: &'a str,
|
||||
durable_producer_identity: bool,
|
||||
invalidation_domain: SegmentInvalidationDomain,
|
||||
distributed_ec_invalidation: bool,
|
||||
cold_zero_walk_oracle: bool,
|
||||
}
|
||||
|
||||
fn producer_name(kind: ProducerKind) -> &'static str {
|
||||
match kind {
|
||||
ProducerKind::Put => "put",
|
||||
ProducerKind::Delete => "delete",
|
||||
ProducerKind::DeleteMarker => "delete_marker",
|
||||
ProducerKind::Multipart => "multipart",
|
||||
ProducerKind::Replication => "replication",
|
||||
ProducerKind::Tier => "tier",
|
||||
ProducerKind::DirectoryObject => "directory_object",
|
||||
fn segment_envelope() -> SegmentInvalidationEnvelope {
|
||||
SegmentInvalidationEnvelope {
|
||||
source: DataUsageCacheSource::new(2, 3),
|
||||
bucket_incarnation: uuid::Uuid::from_u128(0x12345678123456781234567812345678),
|
||||
key_format: crate::DATA_USAGE_CACHE_KEY_FORMAT,
|
||||
baseline_scan_plan_digest: DataUsageScanPlanDigest([9; 32]),
|
||||
process_epoch: "epoch-a".to_string(),
|
||||
generation_start: 11,
|
||||
generation_end: 13,
|
||||
restart_gap: false,
|
||||
overflow: false,
|
||||
producers: segment_producers(),
|
||||
}
|
||||
}
|
||||
|
||||
fn trusted_fixture_proposal(
|
||||
envelope: &SegmentObservationEnvelope<'_>,
|
||||
proof: &SegmentObservationProof<'_>,
|
||||
) -> Result<BTreeSet<String>, ProposalError> {
|
||||
if envelope.source != proof.source
|
||||
|| envelope.bucket_incarnation.is_nil()
|
||||
|| envelope.bucket_incarnation != proof.bucket_incarnation
|
||||
|| envelope.key_format != proof.key_format
|
||||
|| envelope.baseline_scan_plan_digest != proof.baseline_scan_plan_digest
|
||||
|| envelope.process_epoch != proof.process_epoch
|
||||
|| !proof.durable_producer_identity
|
||||
|| !proof.cold_zero_walk_oracle
|
||||
|| (proof.invalidation_domain == SegmentInvalidationDomain::DistributedEc && !proof.distributed_ec_invalidation)
|
||||
|| envelope.generation_start == 0
|
||||
|| envelope.generation_end < envelope.generation_start
|
||||
|| envelope.restart_gap
|
||||
|| envelope.overflow
|
||||
|| !ProducerKind::REQUIRED
|
||||
.iter()
|
||||
.all(|producer| envelope.producers.contains(producer_name(*producer)))
|
||||
{
|
||||
return Err(ProposalError::InvalidKey);
|
||||
fn segment_proof() -> SegmentInvalidationProof {
|
||||
let envelope = segment_envelope();
|
||||
SegmentInvalidationProof {
|
||||
source: envelope.source,
|
||||
bucket_incarnation: envelope.bucket_incarnation,
|
||||
key_format: envelope.key_format,
|
||||
baseline_scan_plan_digest: envelope.baseline_scan_plan_digest,
|
||||
process_epoch: envelope.process_epoch,
|
||||
generation_start: envelope.generation_start,
|
||||
generation_end: envelope.generation_end,
|
||||
durable_producer_identity: true,
|
||||
invalidation_domain: SegmentInvalidationDomain::LocalSingleSet,
|
||||
distributed_ec_invalidation: false,
|
||||
cold_zero_walk_oracle: true,
|
||||
}
|
||||
|
||||
fixture_proposal(envelope.keys)
|
||||
}
|
||||
|
||||
// Keys come from successful fixture writes, not a production mutation stream.
|
||||
fn fixture_proposal(keys: &[&str]) -> Result<BTreeSet<String>, ProposalError> {
|
||||
let mut segments = BTreeSet::new();
|
||||
let mut bytes = 0;
|
||||
for key in keys {
|
||||
if key.is_empty() || key.contains(['\\', '\0']) || key.split('/').any(|part| matches!(part, "" | "." | "..")) {
|
||||
return Err(ProposalError::InvalidKey);
|
||||
}
|
||||
let segment = key.split('/').next().expect("validated nonempty key");
|
||||
if segments.contains(segment) {
|
||||
continue;
|
||||
}
|
||||
if segments.len() == MAX_SEGMENTS {
|
||||
return Err(ProposalError::EntryLimit);
|
||||
}
|
||||
if segment.len() > MAX_SEGMENT_BYTES - bytes {
|
||||
return Err(ProposalError::ByteLimit);
|
||||
}
|
||||
bytes += segment.len();
|
||||
segments.insert(segment.to_string());
|
||||
}
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_observation_fixture_proposal_bounds() {
|
||||
assert_eq!(fixture_proposal(&["hot/one", "hot/two"]), Ok(BTreeSet::from(["hot".to_string()])));
|
||||
assert_eq!(fixture_proposal(&["a", "b", "c", "d"]).expect("entry boundary").len(), MAX_SEGMENTS);
|
||||
assert_eq!(fixture_proposal(&["a", "b", "c", "d", "e"]), Err(ProposalError::EntryLimit));
|
||||
let exact = "x".repeat(MAX_SEGMENT_BYTES);
|
||||
assert!(fixture_proposal(&[&exact]).is_ok());
|
||||
assert_eq!(fixture_proposal(&[&exact, "y"]), Err(ProposalError::ByteLimit));
|
||||
let oversized = "x".repeat(MAX_SEGMENT_BYTES + 1);
|
||||
assert_eq!(fixture_proposal(&[&oversized]), Err(ProposalError::ByteLimit));
|
||||
let envelope = segment_envelope();
|
||||
let proof = segment_proof();
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, ["hot/one", "hot/two"]),
|
||||
Ok(BTreeSet::from(["hot".to_string()]))
|
||||
);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, ["a", "b", "c", "d"])
|
||||
.expect("entry boundary")
|
||||
.len(),
|
||||
MAX_SEGMENT_INVALIDATION_ENTRIES
|
||||
);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, ["a", "b", "c", "d", "e"]),
|
||||
Err(SegmentInvalidationError::EntryLimit)
|
||||
);
|
||||
let exact = "x".repeat(MAX_SEGMENT_INVALIDATION_BYTES);
|
||||
assert!(admit_segment_invalidation(&envelope, &proof, [&exact]).is_ok());
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, [&exact, "y"]),
|
||||
Err(SegmentInvalidationError::ByteLimit)
|
||||
);
|
||||
let oversized = "x".repeat(MAX_SEGMENT_INVALIDATION_BYTES + 1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, [&oversized]),
|
||||
Err(SegmentInvalidationError::ByteLimit)
|
||||
);
|
||||
for key in ["", "/hot", "hot/../cold", "hot//one", "hot\\one", "hot/\0"] {
|
||||
assert_eq!(fixture_proposal(&[key]), Err(ProposalError::InvalidKey));
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, [key]),
|
||||
Err(SegmentInvalidationError::InvalidKey)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_observation_trusted_proposal_requires_identity_and_complete_producer_coverage() {
|
||||
let source = DataUsageCacheSource::new(2, 3);
|
||||
let incarnation = uuid::Uuid::from_u128(0x12345678123456781234567812345678);
|
||||
let baseline = DataUsageScanPlanDigest([9; 32]);
|
||||
let producers = ProducerKind::REQUIRED
|
||||
.iter()
|
||||
.map(|producer| producer_name(*producer))
|
||||
.collect::<BTreeSet<_>>();
|
||||
let envelope = SegmentObservationEnvelope {
|
||||
source,
|
||||
bucket_incarnation: incarnation,
|
||||
key_format: DATA_USAGE_CACHE_KEY_FORMAT,
|
||||
baseline_scan_plan_digest: baseline,
|
||||
process_epoch: "epoch-a",
|
||||
generation_start: 11,
|
||||
generation_end: 13,
|
||||
restart_gap: false,
|
||||
overflow: false,
|
||||
producers,
|
||||
keys: &["hot/one", "hot/two", "archive/delete-marker"],
|
||||
};
|
||||
let proof = SegmentObservationProof {
|
||||
source,
|
||||
bucket_incarnation: incarnation,
|
||||
key_format: DATA_USAGE_CACHE_KEY_FORMAT,
|
||||
baseline_scan_plan_digest: baseline,
|
||||
process_epoch: "epoch-a",
|
||||
durable_producer_identity: true,
|
||||
invalidation_domain: SegmentInvalidationDomain::LocalSingleSet,
|
||||
distributed_ec_invalidation: false,
|
||||
cold_zero_walk_oracle: true,
|
||||
};
|
||||
let envelope = segment_envelope();
|
||||
let proof = segment_proof();
|
||||
|
||||
assert_eq!(
|
||||
trusted_fixture_proposal(&envelope, &proof),
|
||||
admit_segment_invalidation(&envelope, &proof, ["hot/one", "hot/two", "archive/delete-marker"]),
|
||||
Ok(BTreeSet::from(["archive".to_string(), "hot".to_string()]))
|
||||
);
|
||||
|
||||
let mut wrong_source = envelope.clone();
|
||||
wrong_source.source = DataUsageCacheSource::new(2, 4);
|
||||
assert_eq!(trusted_fixture_proposal(&wrong_source, &proof), Err(ProposalError::InvalidKey));
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_source, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut missing_incarnation = envelope.clone();
|
||||
missing_incarnation.bucket_incarnation = uuid::Uuid::nil();
|
||||
assert_eq!(trusted_fixture_proposal(&missing_incarnation, &proof), Err(ProposalError::InvalidKey));
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&missing_incarnation, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_key_format = envelope.clone();
|
||||
wrong_key_format.key_format = DATA_USAGE_CACHE_KEY_FORMAT.saturating_add(1);
|
||||
assert_eq!(trusted_fixture_proposal(&wrong_key_format, &proof), Err(ProposalError::InvalidKey));
|
||||
wrong_key_format.key_format = crate::DATA_USAGE_CACHE_KEY_FORMAT.saturating_add(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_key_format, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_baseline = envelope.clone();
|
||||
wrong_baseline.baseline_scan_plan_digest = DataUsageScanPlanDigest([8; 32]);
|
||||
assert_eq!(trusted_fixture_proposal(&wrong_baseline, &proof), Err(ProposalError::InvalidKey));
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_baseline, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_epoch = envelope.clone();
|
||||
wrong_epoch.process_epoch = "epoch-b";
|
||||
assert_eq!(trusted_fixture_proposal(&wrong_epoch, &proof), Err(ProposalError::InvalidKey));
|
||||
wrong_epoch.process_epoch = "epoch-b".to_string();
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_epoch, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_generation_start = proof.clone();
|
||||
wrong_generation_start.generation_start = wrong_generation_start.generation_start.saturating_sub(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &wrong_generation_start, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_generation_end = proof.clone();
|
||||
wrong_generation_end.generation_end = wrong_generation_end.generation_end.saturating_add(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &wrong_generation_end, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut no_durable_identity = proof.clone();
|
||||
no_durable_identity.durable_producer_identity = false;
|
||||
assert_eq!(trusted_fixture_proposal(&envelope, &no_durable_identity), Err(ProposalError::InvalidKey));
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &no_durable_identity, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut restart_gap = envelope.clone();
|
||||
restart_gap.restart_gap = true;
|
||||
assert_eq!(trusted_fixture_proposal(&restart_gap, &proof), Err(ProposalError::InvalidKey));
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&restart_gap, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut overflow = envelope.clone();
|
||||
overflow.overflow = true;
|
||||
assert_eq!(trusted_fixture_proposal(&overflow, &proof), Err(ProposalError::InvalidKey));
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&overflow, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut generation_gap = envelope.clone();
|
||||
generation_gap.generation_end = generation_gap.generation_start - 1;
|
||||
assert_eq!(trusted_fixture_proposal(&generation_gap, &proof), Err(ProposalError::InvalidKey));
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&generation_gap, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut missing_producer = envelope.clone();
|
||||
missing_producer.producers.remove(producer_name(ProducerKind::Replication));
|
||||
assert_eq!(trusted_fixture_proposal(&missing_producer, &proof), Err(ProposalError::InvalidKey));
|
||||
missing_producer.producers.remove(&SegmentInvalidationProducer::Replication);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&missing_producer, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut missing_zero_walk_oracle = proof.clone();
|
||||
missing_zero_walk_oracle.cold_zero_walk_oracle = false;
|
||||
assert_eq!(
|
||||
trusted_fixture_proposal(&envelope, &missing_zero_walk_oracle),
|
||||
Err(ProposalError::InvalidKey)
|
||||
admit_segment_invalidation(&envelope, &missing_zero_walk_oracle, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut distributed_without_invalidation = proof.clone();
|
||||
let mut distributed_without_invalidation = proof;
|
||||
distributed_without_invalidation.invalidation_domain = SegmentInvalidationDomain::DistributedEc;
|
||||
assert_eq!(
|
||||
trusted_fixture_proposal(&envelope, &distributed_without_invalidation),
|
||||
Err(ProposalError::InvalidKey)
|
||||
admit_segment_invalidation(&envelope, &distributed_without_invalidation, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut distributed_with_invalidation = distributed_without_invalidation;
|
||||
distributed_with_invalidation.distributed_ec_invalidation = true;
|
||||
assert_eq!(
|
||||
trusted_fixture_proposal(&envelope, &distributed_with_invalidation),
|
||||
admit_segment_invalidation(&envelope, &distributed_with_invalidation, ["hot/one", "hot/two", "archive/delete-marker"]),
|
||||
Ok(BTreeSet::from(["archive".to_string(), "hot".to_string()]))
|
||||
);
|
||||
}
|
||||
@@ -314,7 +261,8 @@ async fn walk_and_save(observe: bool) -> (Vec<String>, serde_json::Value) {
|
||||
assert!(path.len() <= MAX_WALK_BYTES - bytes, "fixture walk exceeded its byte budget");
|
||||
paths.push(path.to_string());
|
||||
if observe {
|
||||
let proposed = fixture_proposal(&[changed_key]).expect("bounded successful fixture mutation");
|
||||
let proposed = admit_segment_invalidation(&segment_envelope(), &segment_proof(), [changed_key])
|
||||
.expect("bounded successful fixture mutation");
|
||||
if let Some(segment) = path.strip_prefix("bucket/").and_then(|path| path.split('/').next())
|
||||
&& proposed.contains(segment)
|
||||
{
|
||||
|
||||
@@ -166,16 +166,22 @@ async fn round(request: &Request) -> serde_json::Value {
|
||||
let reloaded = DataUsageCache::unmarshal(&read_bounded(&cache_path).await).expect("reload returned cache codec");
|
||||
let retained = reloaded.checked_flatten("bucket").expect("reloaded bucket root");
|
||||
let scanned = returned.checked_flatten("bucket").expect("returned bucket root");
|
||||
let raw_page_index_committed_entries = reloaded
|
||||
.validated_raw_enumeration_page_index()
|
||||
let raw_page_index = reloaded.validated_raw_enumeration_page_index();
|
||||
let raw_page_index_committed_entries = raw_page_index
|
||||
.and_then(|index| index.committed_entries().ok())
|
||||
.map(|entries| entries.len())
|
||||
.unwrap_or(0);
|
||||
let raw_page_index_indexed_entries = reloaded
|
||||
.validated_raw_enumeration_page_index()
|
||||
let raw_page_index_indexed_entries = raw_page_index
|
||||
.and_then(|index| index.indexed_entries().ok())
|
||||
.map(|entries| entries.len())
|
||||
.unwrap_or(0);
|
||||
let (raw_page_index_parent, raw_page_index_complete) = raw_page_index
|
||||
.map(|index| match index.status() {
|
||||
crate::raw_page_index::RawEnumerationPageOwnerStatus::Building { parent, .. } => (Some(parent), false),
|
||||
crate::raw_page_index::RawEnumerationPageOwnerStatus::Ready { parent, complete, .. } => (Some(parent), complete),
|
||||
crate::raw_page_index::RawEnumerationPageOwnerStatus::Unsupported => (None, false),
|
||||
})
|
||||
.unwrap_or((None, false));
|
||||
assert_eq!(
|
||||
(retained.objects, retained.versions, retained.size),
|
||||
(scanned.objects, scanned.versions, scanned.size)
|
||||
@@ -188,6 +194,8 @@ async fn round(request: &Request) -> serde_json::Value {
|
||||
"objects_expected": request.objects, "raw_entry_budget": request.raw_entry_budget,
|
||||
"raw_entries": observation.entries, "raw_name_bytes": observation.name_bytes,
|
||||
"raw_first_entry": observation.first_entry, "raw_last_entry": observation.last_entry,
|
||||
"raw_page_index_parent": raw_page_index_parent,
|
||||
"raw_page_index_complete": raw_page_index_complete,
|
||||
"raw_page_index_committed_entries": raw_page_index_committed_entries,
|
||||
"raw_page_index_indexed_entries": raw_page_index_indexed_entries,
|
||||
"objects_processed": budget.progress().0,
|
||||
|
||||
@@ -898,6 +898,54 @@ mod tests {
|
||||
assert!(cohort.overflowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn service_cohort_overflow_new_lower_names_do_not_starve_cursor_tail() {
|
||||
let source = DataUsageCacheSource::new(0, 0);
|
||||
let mut cohort = ScannerServiceCohort {
|
||||
max_members: 2,
|
||||
max_name_bytes: 64,
|
||||
..Default::default()
|
||||
};
|
||||
let first = cohort_inventory(&["mm00", "zz10", "zz20", "zz30"]);
|
||||
cohort.refresh(&first);
|
||||
cohort.record_admitted(source, "mm00");
|
||||
cohort.record_admitted(source, "zz10");
|
||||
|
||||
let with_new_lower_names = cohort_inventory(&["aa-new", "ab-new", "mm00", "zz10", "zz20", "zz30"]);
|
||||
cohort.refresh(&with_new_lower_names);
|
||||
assert_eq!(
|
||||
cohort.members[&source].keys().map(AsRef::as_ref).collect::<HashSet<&str>>(),
|
||||
HashSet::from(["zz20", "zz30"]),
|
||||
"cursor-tail members must enter the tracked window before newly injected lower names"
|
||||
);
|
||||
let mut ordered = with_new_lower_names[&source].clone();
|
||||
cohort.order_buckets(source, &mut ordered);
|
||||
assert_eq!(
|
||||
ordered.iter().take(2).map(|bucket| bucket.name.as_str()).collect::<Vec<_>>(),
|
||||
vec!["zz20", "zz30"],
|
||||
"dispatch order must keep the old overflow tail ahead of newer lower names"
|
||||
);
|
||||
|
||||
cohort.record_admitted(source, "zz20");
|
||||
let with_more_lower_names = cohort_inventory(&["a0-new", "aa-new", "ab-new", "mm00", "zz10", "zz20", "zz30"]);
|
||||
cohort.refresh(&with_more_lower_names);
|
||||
let mut ordered = with_more_lower_names[&source].clone();
|
||||
cohort.order_buckets(source, &mut ordered);
|
||||
assert_eq!(
|
||||
ordered.first().map(|bucket| bucket.name.as_str()),
|
||||
Some("zz30"),
|
||||
"a still-waiting cursor-tail member must retain priority across repeated lower-name arrivals"
|
||||
);
|
||||
|
||||
cohort.record_admitted(source, "zz30");
|
||||
cohort.refresh(&with_more_lower_names);
|
||||
assert!(
|
||||
cohort.members[&source].keys().any(|bucket| bucket.as_ref().starts_with('a')),
|
||||
"new lower names become eligible after the cursor tail has been serviced"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn service_cohort_bounds_names_and_does_not_reset_duplicate_dirty_age() {
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
// Copyright 2026 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 crate::{DATA_USAGE_CACHE_KEY_FORMAT, DataUsageCacheSource, DataUsageScanPlanDigest};
|
||||
use std::collections::BTreeSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const MAX_SEGMENT_INVALIDATION_ENTRIES: usize = 4;
|
||||
pub const MAX_SEGMENT_INVALIDATION_BYTES: usize = 128;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SegmentInvalidationError {
|
||||
EntryLimit,
|
||||
ByteLimit,
|
||||
InvalidProof,
|
||||
InvalidKey,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum SegmentInvalidationProducer {
|
||||
Put,
|
||||
Delete,
|
||||
DeleteMarker,
|
||||
Multipart,
|
||||
Replication,
|
||||
Tier,
|
||||
DirectoryObject,
|
||||
}
|
||||
|
||||
impl SegmentInvalidationProducer {
|
||||
pub const REQUIRED: [Self; 7] = [
|
||||
Self::Put,
|
||||
Self::Delete,
|
||||
Self::DeleteMarker,
|
||||
Self::Multipart,
|
||||
Self::Replication,
|
||||
Self::Tier,
|
||||
Self::DirectoryObject,
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SegmentInvalidationDomain {
|
||||
LocalSingleSet,
|
||||
DistributedEc,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SegmentInvalidationEnvelope {
|
||||
pub source: DataUsageCacheSource,
|
||||
pub bucket_incarnation: Uuid,
|
||||
pub key_format: u16,
|
||||
pub baseline_scan_plan_digest: DataUsageScanPlanDigest,
|
||||
pub process_epoch: String,
|
||||
pub generation_start: u64,
|
||||
pub generation_end: u64,
|
||||
pub restart_gap: bool,
|
||||
pub overflow: bool,
|
||||
pub producers: BTreeSet<SegmentInvalidationProducer>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SegmentInvalidationProof {
|
||||
pub source: DataUsageCacheSource,
|
||||
pub bucket_incarnation: Uuid,
|
||||
pub key_format: u16,
|
||||
pub baseline_scan_plan_digest: DataUsageScanPlanDigest,
|
||||
pub process_epoch: String,
|
||||
pub generation_start: u64,
|
||||
pub generation_end: u64,
|
||||
pub durable_producer_identity: bool,
|
||||
pub invalidation_domain: SegmentInvalidationDomain,
|
||||
pub distributed_ec_invalidation: bool,
|
||||
pub cold_zero_walk_oracle: bool,
|
||||
}
|
||||
|
||||
pub fn admit_segment_invalidation<I, K>(
|
||||
envelope: &SegmentInvalidationEnvelope,
|
||||
proof: &SegmentInvalidationProof,
|
||||
keys: I,
|
||||
) -> Result<BTreeSet<String>, SegmentInvalidationError>
|
||||
where
|
||||
I: IntoIterator<Item = K>,
|
||||
K: AsRef<str>,
|
||||
{
|
||||
validate_segment_invalidation_proof(envelope, proof)?;
|
||||
segment_invalidation_top_level_entries(keys)
|
||||
}
|
||||
|
||||
fn validate_segment_invalidation_proof(
|
||||
envelope: &SegmentInvalidationEnvelope,
|
||||
proof: &SegmentInvalidationProof,
|
||||
) -> Result<(), SegmentInvalidationError> {
|
||||
if envelope.source != proof.source
|
||||
|| envelope.bucket_incarnation.is_nil()
|
||||
|| envelope.bucket_incarnation != proof.bucket_incarnation
|
||||
|| envelope.key_format != DATA_USAGE_CACHE_KEY_FORMAT
|
||||
|| envelope.key_format != proof.key_format
|
||||
|| envelope.baseline_scan_plan_digest != proof.baseline_scan_plan_digest
|
||||
|| envelope.process_epoch.is_empty()
|
||||
|| envelope.process_epoch != proof.process_epoch
|
||||
|| envelope.generation_start != proof.generation_start
|
||||
|| envelope.generation_end != proof.generation_end
|
||||
|| !proof.durable_producer_identity
|
||||
|| !proof.cold_zero_walk_oracle
|
||||
|| (proof.invalidation_domain == SegmentInvalidationDomain::DistributedEc && !proof.distributed_ec_invalidation)
|
||||
|| envelope.generation_start == 0
|
||||
|| envelope.generation_end < envelope.generation_start
|
||||
|| proof.generation_start == 0
|
||||
|| proof.generation_end < proof.generation_start
|
||||
|| envelope.restart_gap
|
||||
|| envelope.overflow
|
||||
|| !SegmentInvalidationProducer::REQUIRED
|
||||
.iter()
|
||||
.all(|producer| envelope.producers.contains(producer))
|
||||
{
|
||||
return Err(SegmentInvalidationError::InvalidProof);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn segment_invalidation_top_level_entries<I, K>(keys: I) -> Result<BTreeSet<String>, SegmentInvalidationError>
|
||||
where
|
||||
I: IntoIterator<Item = K>,
|
||||
K: AsRef<str>,
|
||||
{
|
||||
let mut segments = BTreeSet::new();
|
||||
let mut bytes = 0usize;
|
||||
for key in keys {
|
||||
let segment = top_level_segment(key.as_ref())?;
|
||||
if segments.contains(segment) {
|
||||
continue;
|
||||
}
|
||||
if segments.len() == MAX_SEGMENT_INVALIDATION_ENTRIES {
|
||||
return Err(SegmentInvalidationError::EntryLimit);
|
||||
}
|
||||
if segment.len() > MAX_SEGMENT_INVALIDATION_BYTES - bytes {
|
||||
return Err(SegmentInvalidationError::ByteLimit);
|
||||
}
|
||||
bytes += segment.len();
|
||||
segments.insert(segment.to_string());
|
||||
}
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
fn top_level_segment(key: &str) -> Result<&str, SegmentInvalidationError> {
|
||||
if key.is_empty()
|
||||
|| key.starts_with('/')
|
||||
|| key.contains(['\\', '\0'])
|
||||
|| key.split('/').any(|part| matches!(part, "" | "." | ".."))
|
||||
{
|
||||
return Err(SegmentInvalidationError::InvalidKey);
|
||||
}
|
||||
Ok(key.split('/').next().expect("validated nonempty key"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn producers() -> BTreeSet<SegmentInvalidationProducer> {
|
||||
SegmentInvalidationProducer::REQUIRED.into_iter().collect()
|
||||
}
|
||||
|
||||
fn envelope() -> SegmentInvalidationEnvelope {
|
||||
SegmentInvalidationEnvelope {
|
||||
source: DataUsageCacheSource::new(2, 3),
|
||||
bucket_incarnation: Uuid::from_u128(0x12345678123456781234567812345678),
|
||||
key_format: DATA_USAGE_CACHE_KEY_FORMAT,
|
||||
baseline_scan_plan_digest: DataUsageScanPlanDigest([9; 32]),
|
||||
process_epoch: "epoch-a".to_string(),
|
||||
generation_start: 11,
|
||||
generation_end: 13,
|
||||
restart_gap: false,
|
||||
overflow: false,
|
||||
producers: producers(),
|
||||
}
|
||||
}
|
||||
|
||||
fn proof() -> SegmentInvalidationProof {
|
||||
let envelope = envelope();
|
||||
SegmentInvalidationProof {
|
||||
source: envelope.source,
|
||||
bucket_incarnation: envelope.bucket_incarnation,
|
||||
key_format: envelope.key_format,
|
||||
baseline_scan_plan_digest: envelope.baseline_scan_plan_digest,
|
||||
process_epoch: envelope.process_epoch,
|
||||
generation_start: envelope.generation_start,
|
||||
generation_end: envelope.generation_end,
|
||||
durable_producer_identity: true,
|
||||
invalidation_domain: SegmentInvalidationDomain::LocalSingleSet,
|
||||
distributed_ec_invalidation: false,
|
||||
cold_zero_walk_oracle: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_invalidation_admits_only_complete_identity_proof() {
|
||||
let envelope = envelope();
|
||||
let proof = proof();
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, ["hot/one", "hot/two", "archive/delete-marker"]),
|
||||
Ok(BTreeSet::from(["archive".to_string(), "hot".to_string()]))
|
||||
);
|
||||
|
||||
let mut wrong_source = envelope.clone();
|
||||
wrong_source.source = DataUsageCacheSource::new(2, 4);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_source, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut missing_incarnation = envelope.clone();
|
||||
missing_incarnation.bucket_incarnation = Uuid::nil();
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&missing_incarnation, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_key_format = envelope.clone();
|
||||
wrong_key_format.key_format = DATA_USAGE_CACHE_KEY_FORMAT.saturating_add(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_key_format, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_baseline = envelope.clone();
|
||||
wrong_baseline.baseline_scan_plan_digest = DataUsageScanPlanDigest([8; 32]);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_baseline, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_epoch = envelope.clone();
|
||||
wrong_epoch.process_epoch = "epoch-b".to_string();
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_epoch, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_generation_start = proof.clone();
|
||||
wrong_generation_start.generation_start = wrong_generation_start.generation_start.saturating_sub(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &wrong_generation_start, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_generation_end = proof.clone();
|
||||
wrong_generation_end.generation_end = wrong_generation_end.generation_end.saturating_add(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &wrong_generation_end, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut no_durable_identity = proof.clone();
|
||||
no_durable_identity.durable_producer_identity = false;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &no_durable_identity, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut restart_gap = envelope.clone();
|
||||
restart_gap.restart_gap = true;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&restart_gap, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut overflow = envelope.clone();
|
||||
overflow.overflow = true;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&overflow, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut generation_gap = envelope.clone();
|
||||
generation_gap.generation_end = generation_gap.generation_start - 1;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&generation_gap, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut missing_producer = envelope.clone();
|
||||
missing_producer.producers.remove(&SegmentInvalidationProducer::Replication);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&missing_producer, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut missing_zero_walk_oracle = proof.clone();
|
||||
missing_zero_walk_oracle.cold_zero_walk_oracle = false;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &missing_zero_walk_oracle, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut distributed_without_invalidation = proof;
|
||||
distributed_without_invalidation.invalidation_domain = SegmentInvalidationDomain::DistributedEc;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &distributed_without_invalidation, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut distributed_with_invalidation = distributed_without_invalidation;
|
||||
distributed_with_invalidation.distributed_ec_invalidation = true;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &distributed_with_invalidation, ["hot/one"]),
|
||||
Ok(BTreeSet::from(["hot".to_string()]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_invalidation_entries_are_bounded_and_key_checked() {
|
||||
let envelope = envelope();
|
||||
let proof = proof();
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, ["hot/one", "hot/two"]),
|
||||
Ok(BTreeSet::from(["hot".to_string()]))
|
||||
);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, ["a", "b", "c", "d"])
|
||||
.expect("entry boundary")
|
||||
.len(),
|
||||
MAX_SEGMENT_INVALIDATION_ENTRIES
|
||||
);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, ["a", "b", "c", "d", "e"]),
|
||||
Err(SegmentInvalidationError::EntryLimit)
|
||||
);
|
||||
let exact = "x".repeat(MAX_SEGMENT_INVALIDATION_BYTES);
|
||||
assert!(admit_segment_invalidation(&envelope, &proof, [&exact]).is_ok());
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, [&exact, "y"]),
|
||||
Err(SegmentInvalidationError::ByteLimit)
|
||||
);
|
||||
let oversized = "x".repeat(MAX_SEGMENT_INVALIDATION_BYTES + 1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, [&oversized]),
|
||||
Err(SegmentInvalidationError::ByteLimit)
|
||||
);
|
||||
for key in ["", "/hot", "hot/../cold", "hot//one", "hot\\one", "hot/\0"] {
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, [key]),
|
||||
Err(SegmentInvalidationError::InvalidKey)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,7 @@ const EXTERNAL_COMPATIBLE_SUFFIXES: &[&str] = &[
|
||||
"ACCESS_KEY",
|
||||
"ACCESS_KEY_FILE",
|
||||
"ADDRESS",
|
||||
"API_OBJECT_MAX_VERSIONS",
|
||||
"API_XFF_HEADER",
|
||||
"AUDIT_WEBHOOK_AUTH_TOKEN",
|
||||
"AUDIT_WEBHOOK_CLIENT_CERT",
|
||||
@@ -900,4 +901,15 @@ mod tests {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_env_compat_includes_api_object_max_versions() {
|
||||
let report =
|
||||
build_external_env_compat_report_from_entries([("MINIO_API_OBJECT_MAX_VERSIONS".to_string(), "50000".to_string())]);
|
||||
|
||||
assert_eq!(
|
||||
report.mapped_pairs,
|
||||
vec![("MINIO_API_OBJECT_MAX_VERSIONS".to_string(), "RUSTFS_API_OBJECT_MAX_VERSIONS".to_string())]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,47 @@
|
||||
// limitations under the License.
|
||||
|
||||
use std::fmt;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Bounds a repetitive diagnostic without changing its underlying counters.
|
||||
/// Each emitted event includes the number suppressed since the previous one.
|
||||
pub struct LogThrottle {
|
||||
interval_ms: u64,
|
||||
last_ms: AtomicU64,
|
||||
suppressed: AtomicU64,
|
||||
}
|
||||
|
||||
impl LogThrottle {
|
||||
pub const fn new(interval_ms: u64) -> Self {
|
||||
Self {
|
||||
interval_ms,
|
||||
last_ms: AtomicU64::new(u64::MAX),
|
||||
suppressed: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn claim(&self) -> Option<u64> {
|
||||
static ANCHOR: OnceLock<std::time::Instant> = OnceLock::new();
|
||||
let now = ANCHOR.get_or_init(std::time::Instant::now).elapsed().as_millis();
|
||||
self.claim_at(u64::try_from(now).unwrap_or(u64::MAX - 1))
|
||||
}
|
||||
|
||||
fn claim_at(&self, now: u64) -> Option<u64> {
|
||||
let last = self.last_ms.load(Ordering::Relaxed);
|
||||
if (last == u64::MAX || now.saturating_sub(last) >= self.interval_ms)
|
||||
&& self
|
||||
.last_ms
|
||||
.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
Some(self.suppressed.swap(0, Ordering::Relaxed))
|
||||
} else {
|
||||
self.suppressed.fetch_add(1, Ordering::Relaxed);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct MaskedAccessKey<'a>(pub &'a str);
|
||||
@@ -51,7 +92,32 @@ impl fmt::Debug for MaskedAccessKey<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MaskedAccessKey;
|
||||
use super::{LogThrottle, MaskedAccessKey};
|
||||
|
||||
#[test]
|
||||
fn log_throttle_emits_once_per_interval_and_reports_suppression() {
|
||||
let throttle = LogThrottle::new(5_000);
|
||||
assert_eq!(throttle.claim_at(0), Some(0));
|
||||
assert_eq!(throttle.claim_at(1), None);
|
||||
assert_eq!(throttle.claim_at(4_999), None);
|
||||
assert_eq!(throttle.claim_at(5_000), Some(2));
|
||||
assert_eq!(throttle.claim_at(5_001), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_throttle_allows_only_one_concurrent_claim() {
|
||||
let throttle = LogThrottle::new(5_000);
|
||||
let reported = std::thread::scope(|scope| {
|
||||
let threads: Vec<_> = (0..16).map(|_| scope.spawn(|| throttle.claim_at(0))).collect();
|
||||
let emitted: Vec<_> = threads
|
||||
.into_iter()
|
||||
.filter_map(|thread| thread.join().expect("claim worker"))
|
||||
.collect();
|
||||
assert_eq!(emitted.len(), 1);
|
||||
emitted[0]
|
||||
});
|
||||
assert_eq!(reported + throttle.claim_at(5_000).expect("next window"), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_short_values() {
|
||||
|
||||
@@ -61,3 +61,5 @@ Required headings and strings in these files are asserted by `scripts/check_arch
|
||||
| [minio-file-format-compat.md](minio-file-format-compat.md) | deciding whether a MinIO drive set, bucket-metadata blob, or SSE object can be read or imported by a given RustFS build, or before touching a listed version anchor |
|
||||
|
||||
Operations runbooks live in [../operations/](../README.md#operations) and testing references in [../testing/README.md](../testing/README.md).
|
||||
|
||||
For per-node HTTP failure ratios and cached storage probe provenance, see [S3 write failure diagnostics](../operations/s3-write-failure-diagnostics.md).
|
||||
|
||||
@@ -40,6 +40,9 @@ an unknown or unsupported peer-health snapshot degrades readiness with
|
||||
- Liveness reports process availability and must not depend on storage, IAM,
|
||||
lock quorum, or peer health.
|
||||
- Node readiness reports local dependency readiness.
|
||||
- A blocked pool metadata writer degrades node and cluster-write readiness with
|
||||
`pool_metadata_blocked`. Metadata save-gate inspection is bounded to 100 ms;
|
||||
contention reports `pool_metadata_check_timeout` without installing a block.
|
||||
- Cluster write readiness requires write quorum and the runtime dependency
|
||||
readiness used by `FullReady`.
|
||||
- Cluster read readiness may use the read-quorum path and cluster-health
|
||||
|
||||
@@ -32,6 +32,27 @@ A V3 update first conditionally writes a pending generation containing the last
|
||||
|
||||
Do not hand-edit a pending record or select a replica only because it is in pool zero. Preserve all copies when escalating recovery.
|
||||
|
||||
## Runtime write recovery
|
||||
|
||||
A runtime metadata or identity read failure before any write is dispatched rejects that operation but does not permanently block subsequent retries. Errors retain their typed cause; pool metadata unavailability reaches S3 as `503 ServiceUnavailable`, without exposing internal error details. Cancellation during preflight or a rejected first conditional write is also retryable. After any identity, prepare, or commit write may have started, cancellation, an uncertain write result, or abandoned runtime publication blocks further metadata-dependent mutations.
|
||||
|
||||
The node checks for interrupted pool metadata transactions every five seconds. Failed recovery attempts back off to at most sixty seconds; each attempt has a thirty-second budget and stops on shutdown. Healthy nodes do not read metadata for this worker. Recovery:
|
||||
|
||||
1. Cancels old decommission workers and waits for their supervisors to drain them. It does not cancel a separate rebalance operation; an attached rebalance worker prevents recovery until quiescent.
|
||||
2. Holds the local start/movement gates and distributed `pool.bin` write fence, validates the initialized deployment identity and unchanged pool topology, and selects the authoritative durable transaction.
|
||||
3. Repairs pending, missing, or lagging copies using conditional writes, then rereads and verifies convergence. A prepare-only first V3 migration commits the predecessor as V3, preserving the observed format floor.
|
||||
4. Invalidates old movement snapshots, installs the verified durable state, rechecks the fence, and only then clears the block. Speculative in-memory progress is never used as the recovery source. The existing decommission supervisor resumes eligible work afterward.
|
||||
|
||||
An unreadable replica, lost fence, or conditional-write conflict leaves the original block in place. Recovery never initializes an all-missing metadata set. Corruption, incompatible layouts, conflicting identities/epochs/transactions, and topology changes require operator reconciliation; restore readability and consistency using the procedures below. Blocks originating in startup validation or storage-format heal are not cleared by the pool transaction worker: restart only after repairing the underlying condition. There is no force-clear switch. If an attached rebalance worker cannot quiesce, collect its status and restart the affected node after verifying the durable metadata; do not manually detach its worker token.
|
||||
|
||||
### Diagnostics
|
||||
|
||||
- The first block emits `decommission_state` with `state=pool_metadata_blocked`, `reason`, `phase`, and `blocked_since`. A change in recovery failure classification emits `state=pool_metadata_recovery_pending`; successful recovery emits `state=pool_metadata_recovered` with the original timestamp.
|
||||
- `rustfs_pool_metadata_blocks_total{reason}` and `rustfs_pool_metadata_recoveries_total` count block and recovery transitions. The original cause and phase remain attached to local typed errors; storage/RPC error numbers and on-disk formats are unchanged.
|
||||
- Node and cluster-write readiness include `pool_metadata_blocked`. Waiting for the metadata save mutex is bounded to 100 ms and reports `pool_metadata_check_timeout`, not a persistent block. Cluster probes retain their existing cache and overall timeout behavior. Liveness and cluster-read quorum checks are unchanged.
|
||||
|
||||
If a block persists, inspect the first block and subsequent recovery phase, restore disk/peer readability, and verify every metadata and identity copy before restarting. Do not delete metadata to make readiness green.
|
||||
|
||||
## Disk replacement and metadata erasure
|
||||
|
||||
1. Keep a quorum of nodes online and verify the cluster is ready.
|
||||
@@ -39,4 +60,4 @@ Do not hand-edit a pending record or select a replica only because it is in pool
|
||||
3. Restore storage formats and the `pool.bin.identity` marker from the same deployment before rejoining it.
|
||||
4. Start the node and wait for it to load the verified committed generation and repair its replicas before touching another node.
|
||||
|
||||
An initialized identity with every `pool.bin` missing is recovery required, as are existing storage formats with neither identity nor `pool.bin`. Format creation alone is not fresh-cluster proof: only the elected first topology node may create a durable `initialized=false` bootstrap identity with a fresh-bootstrap nonce, and only after every configured disk explicitly responds that it is unformatted. An unreachable peer, a non-elected distributed node, or an existing format is not sufficient proof. All-missing `pool.bin` replicas are accepted only by the same startup that proved the fresh topology and persisted that pending identity; when every `pool.bin` is missing, a later startup must recover even if the pending identity survived. This prevents a wiped or lagging node from rebuilding empty state and overwriting the cluster. Runtime reload, rebalance activation, and rebalance worker admission all fail closed and latch the same recovery gate until the node is restarted with readable metadata.
|
||||
An initialized identity with every `pool.bin` missing is recovery required, as are existing storage formats with neither identity nor `pool.bin`. Format creation alone is not fresh-cluster proof: only the elected first topology node may create a durable `initialized=false` bootstrap identity with a fresh-bootstrap nonce, and only after every configured disk explicitly responds that it is unformatted. An unreachable peer, a non-elected distributed node, or an existing format is not sufficient proof. All-missing `pool.bin` replicas are accepted only by the same startup that proved the fresh topology and persisted that pending identity; when every `pool.bin` is missing, a later startup must recover even if the pending identity survived. This prevents a wiped or lagging node from rebuilding empty state and overwriting the cluster. Runtime reload, rebalance activation, and rebalance worker admission fail closed on this missing-authority condition; a clean probe alone cannot clear it.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# S3 write failure diagnostics
|
||||
|
||||
**Use this when:** distinguishing occasional write failures from a node-wide outage, or interpreting cached storage inventory during an internode failure.
|
||||
|
||||
## Measure the failure ratio
|
||||
|
||||
`rustfs_s3_http_requests_total` counts external S3 HTTP outcomes independently of the configured log level. Its bounded labels are `method`, `op`, and `outcome`; the exporter target identifies the node. Bucket names, object keys, request IDs, and error text are not metric labels.
|
||||
|
||||
The counter increments exactly once when response headers are produced, the service returns an error without a response (`service_error`), or its pending future is dropped (`cancelled`). Outcomes `1xx` through `5xx` classify HTTP responses; `unknown` is reserved for a response outside those classes. A successful response header is not proof that a streamed response body reached the client. Body-stream errors remain separate streaming diagnostics.
|
||||
|
||||
The `op` label uses the existing S3 operation names, such as `s3:PutObject`. A request rejected before operation dispatch has `op="unknown"`, while retaining its HTTP method. Include these requests when measuring a node outage. Do not infer `PutObject` from `PUT` alone: bucket and multipart operations also use that method. Admin, console, health, RPC, STS, and enabled non-S3 protocol routes are excluded.
|
||||
|
||||
For example, with the usual Prometheus `instance` target label, compare the per-node PUT-method HTTP 5xx ratio:
|
||||
|
||||
```promql
|
||||
sum by (instance) (rate(rustfs_s3_http_requests_total{method="PUT",outcome="5xx"}[5m]))
|
||||
/
|
||||
sum by (instance) (rate(rustfs_s3_http_requests_total{method="PUT",outcome=~"[1-5]xx"}[5m]))
|
||||
```
|
||||
|
||||
Inspect `service_error` and `cancelled` separately; neither implies a received HTTP status. A zero denominator or absent series means no observed traffic, not proof of health. Use `rate` or reset-aware deltas because counters restart with the process. The older `rustfs_s3_operations_total` counter measures handler entries and excludes pre-dispatch rejections; it is not this HTTP denominator.
|
||||
|
||||
The authenticated admin metrics endpoint exposes the same counters through the optional `http` field in `aggregated` and `by_host`. Request `/rustfs/admin/v3/metrics?types=512&by-host=true&n=1` on each node for HTTP-only data. The default type selection also includes HTTP outcomes. This endpoint remains an NDJSON stream and does not become a cluster-wide peer fanout. `http.requests` contains `method`, `operation`, `outcome`, and `total`; `http.collected` timestamps collection. Compare consecutive samples from the same host. Concurrent snapshots are not atomic across series.
|
||||
|
||||
A missing `http` field means an older or non-reporting node, not zero failures. Old map-encoded RPC readers ignore the additive field; new readers accept older snapshots. In mixed-version deployments, check reporting coverage before aggregating a fleet-wide ratio.
|
||||
|
||||
## Interpret failed storage probes
|
||||
|
||||
Storage inventory includes an `observations` entry for each probed node. The aggregator owns this provenance even when a peer runs an older version.
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `endpoint` | Node whose local inventory was queried. |
|
||||
| `status` | `succeeded`, `failed`, or `unknown`; this is the probe result, not physical drive health. |
|
||||
| `cached` | Historical inventory was reused for this response. |
|
||||
| `last_success_unix_millis` | Wall-clock time of the last successful observation, when known. |
|
||||
| `snapshot_age_seconds` | Monotonic elapsed age since that observation, when known. |
|
||||
| `error_code` | Bounded storage error classification for a failed probe, without raw error text. |
|
||||
|
||||
After a failed probe, inventory younger than 60 seconds may retain drive identity and capacity, but returned drive `state` and `runtime_state` become `unknown` immediately. Capacity is marked as a `snapshot`; its age advances when the original observation age was known. Expired or absent inventory is synthesized from topology, with capacity observation source `missing`. Repeated polling does not extend this age budget. A successful probe replaces the historical snapshot and clears the failure streak.
|
||||
|
||||
An admin RPC timeout or authentication error is not proof of failed physical disks. It also cannot supply fresh evidence of healthy disks. Consequently, cluster health reports can become unready on the first failed probe when remaining known-online drives cannot demonstrate the existing quorum. The quorum thresholds, S3 admission gate, drive-health tracker, and metadata recovery algorithm are unchanged. Consult independent drive and transport diagnostics before replacing a disk. Legacy snapshots without observations have unknown provenance.
|
||||
|
||||
The probe round timeout is configured independently; see [Admin peer probe timeout](admin-peer-probe-timeout.md).
|
||||
|
||||
## Correlate bounded diagnostics
|
||||
|
||||
Normal operation does not require success logs at WARN. Request counters remain available with WARN logging, while existing runtime readiness diagnostics distinguish `pool_meta_write_blocked` from insufficient storage quorum. Do not clear a metadata write fence merely to make readiness green.
|
||||
|
||||
PUT storage failures retain their typed source chain internally and emit bounded S3/storage error codes, I/O kinds, and RPC status codes alongside the existing request ID, bucket, and key. Raw nested error strings and RPC metadata are not logged by this diagnostic. A repeated PUT diagnostic is limited to one event per five seconds; HTTP server-error logs are limited per status code over the same interval for accounted S3 traffic. `suppressed_errors` reports suppressed events at the next emitted event; use the HTTP counter, not log-line counts, to measure failures. HTTP server-error URI diagnostics omit query strings, including presigned credentials.
|
||||
|
||||
Storage inventory emits a WARN event on the first failed probe and an INFO event on recovery, using `event="storage_info_probe"`. A recovery event confirms the RPC succeeded, not that every reported disk is healthy. Bucket metadata load/retry errors include the bucket and a bounded error code, so one failing bucket can be identified without dumping its metadata.
|
||||
|
||||
No new environment variable, admin authorization action, or recovery command is required.
|
||||
@@ -19,11 +19,11 @@ python3 scripts/diagnose_scanner_enumeration_restart.py \
|
||||
--objects 128 --raw-entry-budget 8 --rounds 8
|
||||
```
|
||||
|
||||
The output directory must not exist. Each round starts a new OS test-worker process, opens the same synthetic disk, decodes the preceding cache, invokes the real scanner, encodes the returned cache, and decodes it again. When cancellation returns no useful partial cache, it preserves the previous cache. Reports identify the actual child PID, round, raw entries and name bytes observed, processed objects, retained object/version/byte counts, and completeness. No observed-name set, `readdir` offset, or assumed stable ordering is used as durable progress. Namespace creation happens only during fixture setup, before scan accounting.
|
||||
The output directory must not exist. Each round starts a new OS test-worker process, opens the same synthetic disk, decodes the preceding cache, invokes the real scanner, encodes the returned cache, and decodes it again. When cancellation returns no useful partial cache, it preserves the previous cache. Reports identify the actual child PID, round, raw entries and name bytes observed, raw page-index parent/entries, processed/classified objects, retained object/version/byte counts, and completeness. The driver rejects retained coverage that advances beyond classified object work, root raw page indexes that outrun the fixture namespace, committed page coverage that exceeds indexed coverage, same-parent committed coverage regressions before completion, and any process-restart regression in retained coverage. No observed-name set, `readdir` offset, or assumed stable ordering is used as durable progress. Namespace creation happens only during fixture setup, before scan accounting.
|
||||
|
||||
The `cfg(test)` hook observes actual entries delivered by `read_dir` and cancels the existing cycle token at the fixed entry limit. This is a deterministic injected **raw-entry work budget**, not a wall-clock performance measurement or a claim that kernel prefetch, probes, allocations, name bytes, or cache I/O are independently budgeted. The watchdog timeout only bounds worker lifetime. The hook does not replace enumeration, classification, or recursion, and does not exist in production builds. In particular, `xl.meta` object-boundary classification is unchanged.
|
||||
|
||||
Exit 0 requires exact complete object/version/byte coverage within the same fixed budget on every executed round. Exit 1 means the strict convergence oracle remains unmet, including the current flat-directory enumeration starvation case. Exit 2 means invalid input, worker failure, or invalid evidence; it is not a successful reproduction. There is no final unbudgeted sweep. Small fixtures can pass; that does not establish the general R-E gate from [the scanner review comment](https://github.com/rustfs/backlog/issues/2240#issuecomment-5549222480). Raw entries observed are not a retained enumeration watermark. This is scanner-worker process restart plus codec evidence, **not** whole-daemon restart, EC quorum persistence, crash/fsync durability, remote RPC, or a throughput benchmark. The caller owns the bounded evidence directory and may remove it after inspection.
|
||||
Exit 0 requires exact complete object/version/byte coverage within the same fixed budget on every executed round and positive evidence for all three stages: raw enumeration/indexing, object classification/processing, and durable cache retention after a fresh worker process reloads the previous report. Exit 1 means the strict convergence oracle remains unmet, including the current flat-directory enumeration starvation case. Exit 2 means invalid input, worker failure, or invalid evidence; it is not a successful reproduction. There is no final unbudgeted sweep. Small fixtures can pass; that does not establish the general R-E gate from [the scanner review comment](https://github.com/rustfs/backlog/issues/2240#issuecomment-5549222480). Raw entries observed are not a retained enumeration watermark. This is scanner-worker process restart plus codec evidence, **not** whole-daemon restart, EC quorum persistence, crash/fsync durability, remote RPC, or a throughput benchmark. The caller owns the bounded evidence directory and may remove it after inspection.
|
||||
|
||||
### Missing Storage Capability
|
||||
|
||||
@@ -64,7 +64,7 @@ The nested `segment_observation` fixture compares diagnostic on/off runs of the
|
||||
|
||||
Entry/byte overflow and malformed keys reject the fixture proposal. Missing producers, process restarts, event gaps, and compacted child coverage remain **unverified production capabilities**, not simulated success cases in this fixture. Mainline bucket dirty generations and hashed metadata-cache invalidation stripes are not an exact, replayable object-key stream. The open [prefix reuse proposal #7208](https://github.com/rustfs/rustfs/pull/7208) is a separate candidate implementation; these tests neither import its hint map nor activate its skip path.
|
||||
|
||||
The ECStore `segment_observation_equal_size_mutations_retire_metadata_generation` test uses the existing exact-key, test-only invalidation probe and actual owner operations. A same-length PUT must change the returned body and ETag while retiring the old generation; metadata-only PUT must change returned metadata and retire the old generation while size and ETag remain equal. Setup uses the existing full-fanout cache-priming helper; the observed mutations use normal owner locking. This is focused producer evidence, not an end-to-end connection between the owner probe and scanner range selection. The existing semantic mutation matrix covers additional owner entry points separately.
|
||||
The ECStore `segment_observation_equal_size_mutations_retire_metadata_generation` test uses the existing exact-key, test-only invalidation probe and actual owner operations. A same-length PUT must change the returned body and ETag while retiring the old generation; metadata-only PUT must change returned metadata and retire the old generation while size and ETag remain equal. Setup uses the existing full-fanout cache-priming helper; the observed mutations use normal owner locking. This is focused producer evidence, not an end-to-end connection between the owner probe and scanner range selection. The segment invalidation proof is bound to the same generation window as the observed envelope, so an old distributed or cold-walk proof cannot authorize a later mutation range. The existing semantic mutation matrix covers additional owner entry points separately.
|
||||
|
||||
```sh
|
||||
cargo test -p rustfs-scanner --lib segment_observation -- --list
|
||||
|
||||
@@ -25,7 +25,8 @@ use crate::admin::{
|
||||
system,
|
||||
};
|
||||
use crate::cluster_snapshot::{
|
||||
ClusterReadOnlySnapshot, ClusterRuntimeReadinessState, ClusterRuntimeStatusSnapshot, cluster_has_actionable_pressure,
|
||||
ClusterPoolMetaWriteGateSnapshot, ClusterReadOnlySnapshot, ClusterRuntimeReadinessState, ClusterRuntimeStatusSnapshot,
|
||||
cluster_has_actionable_pressure,
|
||||
};
|
||||
use crate::server::{ADMIN_PREFIX, ReadinessDegradedReason};
|
||||
use http::{HeaderMap, HeaderValue, StatusCode};
|
||||
@@ -154,6 +155,7 @@ pub(crate) struct ClusterSnapshotView {
|
||||
pub observability: ObservabilitySnapshot,
|
||||
pub workload_admission: Vec<WorkloadAdmissionView>,
|
||||
pub runtime_status: ClusterRuntimeStatusView,
|
||||
pub pool_meta_write_gate: ClusterPoolMetaWriteGateView,
|
||||
pub actionable_pressure: bool,
|
||||
}
|
||||
|
||||
@@ -182,11 +184,38 @@ impl ClusterSnapshotView {
|
||||
observability: snapshot.observability,
|
||||
workload_admission: workload_admission_views(snapshot.workload_admission),
|
||||
runtime_status: ClusterRuntimeStatusView::from(snapshot.runtime_status),
|
||||
pool_meta_write_gate: ClusterPoolMetaWriteGateView::from(snapshot.pool_meta_write_gate),
|
||||
actionable_pressure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ClusterPoolMetaWriteGateView {
|
||||
pub writes_ready: bool,
|
||||
pub write_blocked: bool,
|
||||
pub transaction_aborted: bool,
|
||||
pub pool_meta_absent: bool,
|
||||
pub identity_initialized: Option<bool>,
|
||||
pub identity_needs_repair: bool,
|
||||
pub cluster_epoch: Option<u64>,
|
||||
}
|
||||
|
||||
impl From<ClusterPoolMetaWriteGateSnapshot> for ClusterPoolMetaWriteGateView {
|
||||
fn from(snapshot: ClusterPoolMetaWriteGateSnapshot) -> Self {
|
||||
Self {
|
||||
writes_ready: snapshot.writes_ready,
|
||||
write_blocked: snapshot.write_blocked,
|
||||
transaction_aborted: snapshot.transaction_aborted,
|
||||
pool_meta_absent: snapshot.pool_meta_absent,
|
||||
identity_initialized: snapshot.identity_initialized,
|
||||
identity_needs_repair: snapshot.identity_needs_repair,
|
||||
cluster_epoch: snapshot.cluster_epoch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct ClusterComponentStatusView {
|
||||
pub storage: ClusterComponentStatus,
|
||||
@@ -977,8 +1006,8 @@ mod tests {
|
||||
ClusterPoolStateSnapshot, ClusterRpcBoundarySnapshot, ClusterRpcChannelSnapshot, ClusterRpcPlane, ClusterRpcTransport,
|
||||
};
|
||||
use crate::cluster_snapshot::{
|
||||
ClusterListingDiagnosticsSnapshot, ClusterReadOnlySnapshot, ClusterRuntimeReadinessState, ClusterRuntimeStatusSnapshot,
|
||||
ClusterUsageFreshnessSnapshot,
|
||||
ClusterListingDiagnosticsSnapshot, ClusterPoolMetaWriteGateSnapshot, ClusterReadOnlySnapshot,
|
||||
ClusterRuntimeReadinessState, ClusterRuntimeStatusSnapshot, ClusterUsageFreshnessSnapshot,
|
||||
};
|
||||
use crate::shared_types::{DependencyReadiness, ReadinessDegradedReason};
|
||||
use rustfs_concurrency::{AdmissionState, WorkloadAdmissionRegistrySnapshot, WorkloadAdmissionSnapshot, WorkloadClass};
|
||||
@@ -1124,6 +1153,15 @@ mod tests {
|
||||
listing_diagnostics: ClusterListingDiagnosticsSnapshot {
|
||||
internode_stall_timeouts_total: 2,
|
||||
},
|
||||
pool_meta_write_gate: ClusterPoolMetaWriteGateSnapshot {
|
||||
writes_ready: false,
|
||||
write_blocked: true,
|
||||
transaction_aborted: true,
|
||||
identity_initialized: Some(true),
|
||||
identity_needs_repair: true,
|
||||
cluster_epoch: Some(7),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(ClusterSnapshotView::from_snapshot(snapshot, Some(":::9000".to_string())))
|
||||
@@ -1136,6 +1174,13 @@ mod tests {
|
||||
assert_eq!(value["components"]["listing"]["source"], "workload_admission+internode_metrics");
|
||||
assert_eq!(value["components"]["listing"]["condition"], "unknown");
|
||||
assert_eq!(value["components"]["listing"]["internode_stall_timeouts_total"], 2);
|
||||
assert_eq!(value["pool_meta_write_gate"]["writesReady"], false);
|
||||
assert_eq!(value["pool_meta_write_gate"]["writeBlocked"], true);
|
||||
assert_eq!(value["pool_meta_write_gate"]["transactionAborted"], true);
|
||||
assert_eq!(value["pool_meta_write_gate"]["identityInitialized"], true);
|
||||
assert_eq!(value["pool_meta_write_gate"]["identityNeedsRepair"], true);
|
||||
assert_eq!(value["pool_meta_write_gate"]["clusterEpoch"], 7);
|
||||
assert_eq!(value["actionable_pressure"], true);
|
||||
assert_eq!(value["components"]["usage"]["source"], "scanner_metrics");
|
||||
assert_eq!(value["components"]["usage"]["condition"], "stale");
|
||||
assert_eq!(value["membership"]["nodes"][0]["server_info_endpoint"], ":::9000");
|
||||
@@ -1193,6 +1238,7 @@ mod tests {
|
||||
},
|
||||
usage_freshness: ClusterUsageFreshnessSnapshot::default(),
|
||||
listing_diagnostics: ClusterListingDiagnosticsSnapshot::default(),
|
||||
pool_meta_write_gate: ClusterPoolMetaWriteGateSnapshot::default(),
|
||||
};
|
||||
|
||||
let summary = ClusterSnapshotSummary::from(&snapshot);
|
||||
@@ -1259,6 +1305,7 @@ mod tests {
|
||||
listing_diagnostics: ClusterListingDiagnosticsSnapshot {
|
||||
internode_stall_timeouts_total: 0,
|
||||
},
|
||||
pool_meta_write_gate: ClusterPoolMetaWriteGateSnapshot::default(),
|
||||
};
|
||||
|
||||
let view = ClusterSnapshotView::from(snapshot);
|
||||
@@ -1306,6 +1353,11 @@ mod tests {
|
||||
},
|
||||
usage_freshness: ClusterUsageFreshnessSnapshot::default(),
|
||||
listing_diagnostics: ClusterListingDiagnosticsSnapshot::default(),
|
||||
pool_meta_write_gate: ClusterPoolMetaWriteGateSnapshot {
|
||||
writes_ready: false,
|
||||
write_blocked: true,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let view = ClusterSnapshotView::from(snapshot);
|
||||
@@ -1341,6 +1393,7 @@ mod tests {
|
||||
listing_diagnostics: ClusterListingDiagnosticsSnapshot {
|
||||
internode_stall_timeouts_total: 2,
|
||||
},
|
||||
pool_meta_write_gate: ClusterPoolMetaWriteGateSnapshot::default(),
|
||||
};
|
||||
|
||||
let component = super::summarize_listing_metacache(&snapshot);
|
||||
@@ -1384,6 +1437,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
listing_diagnostics: ClusterListingDiagnosticsSnapshot::default(),
|
||||
pool_meta_write_gate: ClusterPoolMetaWriteGateSnapshot::default(),
|
||||
};
|
||||
|
||||
let component = super::summarize_usage_freshness(&snapshot);
|
||||
@@ -1421,6 +1475,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
listing_diagnostics: ClusterListingDiagnosticsSnapshot::default(),
|
||||
pool_meta_write_gate: ClusterPoolMetaWriteGateSnapshot::default(),
|
||||
};
|
||||
|
||||
let component = super::summarize_usage_freshness(&snapshot);
|
||||
@@ -1470,6 +1525,7 @@ mod tests {
|
||||
},
|
||||
usage_freshness: ClusterUsageFreshnessSnapshot::default(),
|
||||
listing_diagnostics: ClusterListingDiagnosticsSnapshot::default(),
|
||||
pool_meta_write_gate: ClusterPoolMetaWriteGateSnapshot::default(),
|
||||
};
|
||||
|
||||
let summary = ClusterSnapshotSummary::from(&snapshot);
|
||||
|
||||
@@ -339,6 +339,19 @@ fn add_source_counts(total: &mut rustfs_heal::HealSourceCounts, next: rustfs_hea
|
||||
total.mrf = total.mrf.saturating_add(next.mrf);
|
||||
}
|
||||
|
||||
fn add_admission_telemetry(total: &mut rustfs_heal::HealAdmissionTelemetry, next: rustfs_heal::HealAdmissionTelemetry) {
|
||||
total.accepted = total.accepted.saturating_add(next.accepted);
|
||||
total.merged = total.merged.saturating_add(next.merged);
|
||||
total.full = total.full.saturating_add(next.full);
|
||||
total.dropped = total.dropped.saturating_add(next.dropped);
|
||||
total.duplicate = total.duplicate.saturating_add(next.duplicate);
|
||||
total.overlap_rejected = total.overlap_rejected.saturating_add(next.overlap_rejected);
|
||||
total.displaced = total.displaced.saturating_add(next.displaced);
|
||||
total.force_start = total.force_start.saturating_add(next.force_start);
|
||||
total.max_start_duration_micros = total.max_start_duration_micros.max(next.max_start_duration_micros);
|
||||
total.max_lock_phase_micros = total.max_lock_phase_micros.max(next.max_lock_phase_micros);
|
||||
}
|
||||
|
||||
fn add_operations(total: &mut rustfs_heal::HealOperationsSnapshot, next: rustfs_heal::HealOperationsSnapshot) {
|
||||
total.queue_length = total.queue_length.saturating_add(next.queue_length);
|
||||
total.active_tasks = total.active_tasks.saturating_add(next.active_tasks);
|
||||
@@ -349,6 +362,7 @@ fn add_operations(total: &mut rustfs_heal::HealOperationsSnapshot, next: rustfs_
|
||||
add_source_counts(&mut total.queued_by_source, next.queued_by_source);
|
||||
add_source_counts(&mut total.active_by_source, next.active_by_source);
|
||||
add_source_counts(&mut total.retrying_by_source, next.retrying_by_source);
|
||||
add_admission_telemetry(&mut total.admission, next.admission);
|
||||
}
|
||||
|
||||
fn aggregate_cluster_heal_status(snapshots: Vec<NodeHealStatusSnapshot>) -> ClusterHealStatusSnapshot {
|
||||
@@ -2307,6 +2321,10 @@ mod tests {
|
||||
assert!(json["healOperations"]["queuedBySource"]["admin"].is_u64());
|
||||
assert!(json["healOperations"]["queuedByPriority"]["low"].is_u64());
|
||||
assert!(json["healOperations"]["queuedByPriority"]["high"].is_u64());
|
||||
assert!(json["healOperations"]["admission"]["accepted"].is_u64());
|
||||
assert!(json["healOperations"]["admission"]["duplicate"].is_u64());
|
||||
assert!(json["healOperations"]["admission"]["forceStart"].is_u64());
|
||||
assert!(json["healOperations"]["admission"]["maxLockPhaseMicros"].is_u64());
|
||||
assert_eq!(json["state"], "active");
|
||||
assert_eq!(json["clusterStatusComplete"], true);
|
||||
assert!(json["progress"].is_null());
|
||||
@@ -2486,6 +2504,18 @@ mod tests {
|
||||
queued_by_source: sources(value),
|
||||
active_by_source: sources(value),
|
||||
retrying_by_source: sources(value),
|
||||
admission: rustfs_heal::HealAdmissionTelemetry {
|
||||
accepted: value,
|
||||
merged: value,
|
||||
full: value,
|
||||
dropped: value,
|
||||
duplicate: value,
|
||||
overlap_rejected: value,
|
||||
displaced: value,
|
||||
force_start: value,
|
||||
max_start_duration_micros: value,
|
||||
max_lock_phase_micros: value,
|
||||
},
|
||||
};
|
||||
let progress = |value| NodeHealProgress {
|
||||
objects_scanned: value,
|
||||
|
||||
+468
-14
@@ -327,6 +327,23 @@ fn delete_response_version_id(version_id: Option<Uuid>, synthetic_version_id: bo
|
||||
}
|
||||
}
|
||||
|
||||
fn project_delete_objects_pre_stat_error(
|
||||
object: ObjectToDelete,
|
||||
synthetic_version_id: bool,
|
||||
error: ApiError,
|
||||
) -> S3Result<s3s::dto::Error> {
|
||||
// Bucket loss invalidates the shared request, not just one object.
|
||||
if error.code == S3ErrorCode::NoSuchBucket {
|
||||
return Err(error.into());
|
||||
}
|
||||
Ok(s3s::dto::Error {
|
||||
code: Some(error.code.as_str().to_string()),
|
||||
key: Some(object.object_name),
|
||||
message: Some(error.message),
|
||||
version_id: delete_response_version_id(object.version_id, synthetic_version_id),
|
||||
})
|
||||
}
|
||||
|
||||
/// Version identity for a `DeleteObjects` `<Deleted>` entry (and its
|
||||
/// notification). A delete marker removed by version id carries no storage
|
||||
/// `version_id` on the committed result, so fall back to the identity the
|
||||
@@ -577,11 +594,12 @@ impl DefaultObjectUsecase {
|
||||
});
|
||||
}
|
||||
|
||||
struct AdmittedDelete {
|
||||
struct PreStatDelete {
|
||||
idx: usize,
|
||||
object: ObjectToDelete,
|
||||
versioned: bool,
|
||||
version_suspended: bool,
|
||||
error: Option<ApiError>,
|
||||
}
|
||||
|
||||
// Phase 2 (bounded concurrency, backlog#929 / HP-8): collect the
|
||||
@@ -590,7 +608,7 @@ impl DefaultObjectUsecase {
|
||||
// Lock admission is enforced later in set_disk under the write lock.
|
||||
let store_ref = &store;
|
||||
let bucket_ref = bucket.as_str();
|
||||
let admitted_deletes: Vec<AdmittedDelete> =
|
||||
let pre_stat_deletes: Vec<PreStatDelete> =
|
||||
futures::stream::iter(prepared_deletes.into_iter().map(|prepared| async move {
|
||||
let PreparedDelete {
|
||||
idx,
|
||||
@@ -599,35 +617,49 @@ impl DefaultObjectUsecase {
|
||||
skip_stat,
|
||||
} = prepared;
|
||||
let synthetic_version_id = object.version_id.is_none() && is_dir_object(&object.object_name);
|
||||
if !skip_stat {
|
||||
match store_ref.get_object_info(bucket_ref, &object.object_name, &opts).await {
|
||||
Ok(_) => {}
|
||||
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {}
|
||||
Err(err) => return Err(ApiError::from(err)),
|
||||
let error = if skip_stat {
|
||||
None
|
||||
} else {
|
||||
match store_ref
|
||||
.get_object_info_for_delete(bucket_ref, &object.object_name, &opts)
|
||||
.await
|
||||
{
|
||||
Ok(_) => None,
|
||||
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => None,
|
||||
Err(err) => Some(ApiError::from(err)),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if synthetic_version_id {
|
||||
object.version_id = Some(Uuid::nil());
|
||||
}
|
||||
|
||||
Ok::<_, ApiError>(AdmittedDelete {
|
||||
PreStatDelete {
|
||||
idx,
|
||||
object,
|
||||
versioned: opts.versioned,
|
||||
version_suspended: opts.version_suspended,
|
||||
})
|
||||
error,
|
||||
}
|
||||
}))
|
||||
.buffered(DELETE_OBJECTS_PRE_STAT_CONCURRENCY)
|
||||
.try_collect()
|
||||
.await?;
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
// Phase 3 (serial): apply outcomes in the original request order so
|
||||
// per-key success/failure reporting is unchanged.
|
||||
let mut object_to_delete = Vec::new();
|
||||
let mut object_to_delete_idx = Vec::new();
|
||||
let mut object_versioning = Vec::new();
|
||||
for admitted in admitted_deletes {
|
||||
for admitted in pre_stat_deletes {
|
||||
if let Some(error) = admitted.error {
|
||||
delete_results[admitted.idx].error = Some(project_delete_objects_pre_stat_error(
|
||||
admitted.object,
|
||||
delete_results[admitted.idx].synthetic_version_id,
|
||||
error,
|
||||
)?);
|
||||
continue;
|
||||
}
|
||||
object_to_delete_idx.push(admitted.idx);
|
||||
object_versioning.push((admitted.versioned, admitted.version_suspended));
|
||||
object_to_delete.push(admitted.object);
|
||||
@@ -904,7 +936,7 @@ impl DefaultObjectUsecase {
|
||||
let mut force_delete_intent = None;
|
||||
|
||||
let get_opts = opts.clone();
|
||||
let existing_object_info = match store.get_object_info(&bucket, &key, &get_opts).await {
|
||||
let existing_object_info = match store.get_object_info_for_delete(&bucket, &key, &get_opts).await {
|
||||
Ok(obj_info) => Some(obj_info),
|
||||
Err(err) => {
|
||||
// If object not found, allow deletion to proceed (will return 204 No Content)
|
||||
@@ -1159,6 +1191,428 @@ mod tests {
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn execute_delete_marker_versions_in_single_and_multi_pool() {
|
||||
crate::app::gating_test_env::run_large_stack_test("delete-marker-api", || async {
|
||||
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
|
||||
let single_pool = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
if current_app_context().is_none() {
|
||||
crate::app::runtime_sources::install_test_app_context(Arc::clone(&single_pool)).await;
|
||||
}
|
||||
let ambient = current_app_context().expect("delete API test context");
|
||||
let (_temp_dir, _disk_paths, multi_pool) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await;
|
||||
for (pool_count, store) in [(2, multi_pool), (1, single_pool)] {
|
||||
let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms()));
|
||||
let usecase = DefaultObjectUsecase::with_context(Some(context));
|
||||
for (suspended, batch) in [(false, false), (false, true), (true, false), (true, true)] {
|
||||
let bucket = format!("delete-marker-api-{pool_count}-{}", Uuid::new_v4());
|
||||
store
|
||||
.make_bucket(
|
||||
&bucket,
|
||||
&MakeBucketOptions {
|
||||
versioning_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create versioned API fixture");
|
||||
let key = if batch { "batch" } else { "single" };
|
||||
let payload = b"historical payload must survive marker removal";
|
||||
let mut reader = PutObjReader::from_vec(payload.to_vec());
|
||||
let original = store
|
||||
.put_object(
|
||||
&bucket,
|
||||
key,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write historical version");
|
||||
if suspended {
|
||||
store
|
||||
.update_bucket_metadata_config(
|
||||
&bucket,
|
||||
crate::app::storage_api::test::bucket::metadata::BUCKET_VERSIONING_CONFIG,
|
||||
b"<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>".to_vec(),
|
||||
)
|
||||
.await
|
||||
.expect("suspend versioning after writing the historical UUID version");
|
||||
}
|
||||
let marker = store
|
||||
.delete_object(
|
||||
&bucket,
|
||||
key,
|
||||
ObjectOptions {
|
||||
versioned: !suspended,
|
||||
version_suspended: suspended,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create marker");
|
||||
assert!(marker.delete_marker);
|
||||
let marker_id = delete_response_version_id(marker.version_id, false).expect("marker has a version identity");
|
||||
assert_eq!(marker_id == "null", suspended);
|
||||
let get = GetObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(key.to_string())
|
||||
.version_id(Some(marker_id.clone()))
|
||||
.build()
|
||||
.unwrap();
|
||||
let get_err = Box::pin(usecase.execute_get_object(build_request(get, Method::GET)))
|
||||
.await
|
||||
.expect_err("GET of a marker remains forbidden");
|
||||
assert_eq!(get_err.code(), &S3ErrorCode::MethodNotAllowed);
|
||||
let head = HeadObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(key.to_string())
|
||||
.version_id(Some(marker_id.clone()))
|
||||
.build()
|
||||
.unwrap();
|
||||
let head_err = Box::pin(usecase.execute_head_object(build_request(head, Method::HEAD)))
|
||||
.await
|
||||
.expect_err("HEAD of a marker remains forbidden");
|
||||
assert_eq!(head_err.code(), &S3ErrorCode::MethodNotAllowed);
|
||||
|
||||
if batch {
|
||||
let mut req = build_request(
|
||||
DeleteObjectsInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.delete(Delete {
|
||||
objects: vec![ObjectIdentifier {
|
||||
key: key.to_string(),
|
||||
version_id: Some(marker_id.clone()),
|
||||
..Default::default()
|
||||
}],
|
||||
quiet: None,
|
||||
})
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::POST,
|
||||
);
|
||||
req.extensions.insert(crate::storage::access::ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
let response = Box::pin(usecase.execute_delete_objects(req))
|
||||
.await
|
||||
.expect("batch marker delete must reach the authoritative mutation");
|
||||
assert!(response.output.errors.as_ref().is_none_or(Vec::is_empty), "{:?}", response.output.errors);
|
||||
let deleted = response.output.deleted.expect("batch deleted entries");
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert_eq!(deleted[0].version_id.as_deref(), Some(marker_id.as_str()));
|
||||
assert_eq!(deleted[0].delete_marker, Some(true));
|
||||
} else {
|
||||
let mut req = build_request(
|
||||
DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(key.to_string())
|
||||
.version_id(Some(marker_id.clone()))
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::DELETE,
|
||||
);
|
||||
req.extensions.insert(crate::storage::access::ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
let response = Box::pin(usecase.execute_delete_object(req))
|
||||
.await
|
||||
.expect("single marker delete must reach the authoritative mutation");
|
||||
assert_eq!(response.output.version_id.as_deref(), Some(marker_id.as_str()));
|
||||
assert_eq!(response.output.delete_marker, Some(true));
|
||||
}
|
||||
let remaining = store
|
||||
.get_object_info(
|
||||
&bucket,
|
||||
key,
|
||||
&ObjectOptions {
|
||||
versioned: !suspended,
|
||||
version_suspended: suspended,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("historical version is current after removing the marker");
|
||||
assert_eq!(remaining.version_id, original.version_id);
|
||||
assert!(!remaining.delete_marker);
|
||||
assert_eq!(remaining.size, payload.len() as i64);
|
||||
let removed = GetObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(key.to_string())
|
||||
.version_id(Some(marker_id))
|
||||
.build()
|
||||
.unwrap();
|
||||
let missing = Box::pin(usecase.execute_get_object(build_request(removed, Method::GET)))
|
||||
.await
|
||||
.expect_err("the marker version must actually be gone");
|
||||
assert_eq!(
|
||||
missing.code(),
|
||||
&if pool_count == 1 {
|
||||
S3ErrorCode::NoSuchKey
|
||||
} else {
|
||||
S3ErrorCode::NoSuchVersion
|
||||
},
|
||||
"pool_count={pool_count} suspended={suspended} batch={batch}: removed marker lookup returned {missing:?}"
|
||||
);
|
||||
let get = GetObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(key.to_string())
|
||||
.build()
|
||||
.unwrap();
|
||||
let response = Box::pin(usecase.execute_get_object(build_request(get, Method::GET)))
|
||||
.await
|
||||
.expect("GET restores the historical payload");
|
||||
let mut body = response.output.body.expect("GET body");
|
||||
let mut actual = Vec::new();
|
||||
while let Some(chunk) = body.next().await {
|
||||
actual.extend_from_slice(&chunk.expect("historical data remains readable"));
|
||||
}
|
||||
assert_eq!(actual, payload);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn execute_delete_marker_batch_isolates_pre_stat_errors_and_retention() {
|
||||
crate::app::gating_test_env::run_large_stack_test("delete-marker-batch-errors", || async {
|
||||
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
let shared = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
if current_app_context().is_none() {
|
||||
crate::app::runtime_sources::install_test_app_context(shared).await;
|
||||
}
|
||||
let ambient = current_app_context().expect("delete API test context");
|
||||
let (_temp_dir, disk_paths, store) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await;
|
||||
let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms()));
|
||||
let usecase = DefaultObjectUsecase::with_context(Some(context));
|
||||
let bucket = format!("delete-marker-batch-errors-{}", Uuid::new_v4());
|
||||
store
|
||||
.make_bucket(
|
||||
&bucket,
|
||||
&MakeBucketOptions {
|
||||
lock_enabled: true,
|
||||
versioning_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create locked versioned bucket");
|
||||
let mut objects = Vec::new();
|
||||
for key in ["damaged", "healthy", "retained"] {
|
||||
let mut user_defined = HashMap::new();
|
||||
if key == "retained" {
|
||||
user_defined.insert("x-amz-object-lock-mode".to_string(), "COMPLIANCE".to_string());
|
||||
user_defined.insert(
|
||||
"x-amz-object-lock-retain-until-date".to_string(),
|
||||
(time::OffsetDateTime::now_utc() + time::Duration::days(30))
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
let mut reader = PutObjReader::from_vec(b"must not silently disappear".to_vec());
|
||||
let original = store
|
||||
.put_object(
|
||||
&bucket,
|
||||
key,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
user_defined,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write batch fixture");
|
||||
objects.push(ObjectIdentifier {
|
||||
key: key.to_string(),
|
||||
version_id: original.version_id.map(|id| id.to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let marker = store
|
||||
.delete_object(
|
||||
&bucket,
|
||||
"marker",
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create explicit marker fixture");
|
||||
objects.push(ObjectIdentifier {
|
||||
key: "marker".to_string(),
|
||||
version_id: marker.version_id.map(|id| id.to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
objects.push(ObjectIdentifier {
|
||||
key: "invalid-version".to_string(),
|
||||
version_id: Some("not-a-uuid".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
let retained_id = objects[2].version_id.clone();
|
||||
let all_pre_stat_failures = vec![objects[0].clone(), objects[4].clone()];
|
||||
let mut corrupted = 0;
|
||||
for path in disk_paths.iter().flatten() {
|
||||
let metadata = path.join(&bucket).join("damaged").join("xl.meta");
|
||||
if metadata.is_file() {
|
||||
tokio::fs::write(metadata, b"invalid xl metadata")
|
||||
.await
|
||||
.expect("corrupt only this test object's metadata");
|
||||
corrupted += 1;
|
||||
}
|
||||
}
|
||||
assert!(corrupted >= 3, "the corrupt-metadata failure must cover read quorum");
|
||||
let mut req = build_request(
|
||||
DeleteObjectsInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.delete(Delete { objects, quiet: None })
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::POST,
|
||||
);
|
||||
req.extensions.insert(crate::storage::access::ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
let response = Box::pin(usecase.execute_delete_objects(req))
|
||||
.await
|
||||
.expect("a pre-stat failure must not abort independent batch entries");
|
||||
let deleted = response.output.deleted.expect("per-key successes");
|
||||
assert_eq!(
|
||||
deleted.iter().map(|item| item.key.as_deref().unwrap()).collect::<Vec<_>>(),
|
||||
vec!["healthy", "marker"]
|
||||
);
|
||||
let errors = response.output.errors.expect("per-key errors");
|
||||
assert_eq!(
|
||||
errors.iter().map(|item| item.key.as_deref().unwrap()).collect::<Vec<_>>(),
|
||||
vec!["damaged", "retained", "invalid-version"]
|
||||
);
|
||||
assert_eq!(errors[0].code.as_deref(), Some("InternalError"));
|
||||
assert_eq!(errors[0].message.as_deref(), Some("File is corrupted"));
|
||||
assert_eq!(errors[1].code.as_deref(), Some("AccessDenied"));
|
||||
assert_eq!(errors[2].code.as_deref(), Some("NoSuchVersion"));
|
||||
for quiet in [false, true] {
|
||||
let mut req = build_request(
|
||||
DeleteObjectsInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.delete(Delete {
|
||||
objects: all_pre_stat_failures.clone(),
|
||||
quiet: Some(quiet),
|
||||
})
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::POST,
|
||||
);
|
||||
req.extensions.insert(crate::storage::access::ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
let response = Box::pin(usecase.execute_delete_objects(req))
|
||||
.await
|
||||
.expect("all pre-stat failures still produce per-key results");
|
||||
assert!(response.output.deleted.as_ref().is_none_or(Vec::is_empty));
|
||||
let errors = response.output.errors.expect("quiet mode must not suppress errors");
|
||||
assert_eq!(
|
||||
errors.iter().map(|item| item.key.as_deref().unwrap()).collect::<Vec<_>>(),
|
||||
vec!["damaged", "invalid-version"]
|
||||
);
|
||||
assert_eq!(errors[0].code.as_deref(), Some("InternalError"));
|
||||
assert_eq!(errors[0].version_id, all_pre_stat_failures[0].version_id);
|
||||
assert_eq!(errors[1].code.as_deref(), Some("NoSuchVersion"));
|
||||
}
|
||||
let mut req = build_request(
|
||||
DeleteObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key("retained".to_string())
|
||||
.version_id(retained_id.clone())
|
||||
.build()
|
||||
.unwrap(),
|
||||
Method::DELETE,
|
||||
);
|
||||
req.extensions.insert(crate::storage::access::ReqInfo {
|
||||
cred: Some(rustfs_credentials::Credentials::default()),
|
||||
is_owner: true,
|
||||
..Default::default()
|
||||
});
|
||||
let error = Box::pin(usecase.execute_delete_object(req))
|
||||
.await
|
||||
.expect_err("single delete must also retain Object Lock protection");
|
||||
assert_eq!(error.code(), &S3ErrorCode::AccessDenied);
|
||||
store
|
||||
.get_object_info(
|
||||
&bucket,
|
||||
"retained",
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: retained_id,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("both rejected deletes preserve the retained version");
|
||||
for key in ["healthy", "marker"] {
|
||||
let error = store
|
||||
.get_object_info(
|
||||
&bucket,
|
||||
key,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("successful entries must actually be removed");
|
||||
assert!(is_err_object_not_found(&error) || is_err_version_not_found(&error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_objects_pre_stat_error_preserves_request_scope_and_version_identity() {
|
||||
let error = project_delete_objects_pre_stat_error(
|
||||
ObjectToDelete::default(),
|
||||
false,
|
||||
ApiError::from(StorageError::BucketNotFound("missing".to_string())),
|
||||
)
|
||||
.expect_err("shared bucket loss remains a request failure");
|
||||
assert_eq!(error.code(), &S3ErrorCode::NoSuchBucket);
|
||||
|
||||
let version = Uuid::new_v4();
|
||||
for (version_id, synthetic, expected) in [
|
||||
(Some(version), false, Some(version.to_string())),
|
||||
(Some(Uuid::nil()), false, Some("null".to_string())),
|
||||
(Some(Uuid::nil()), true, None),
|
||||
(None, false, None),
|
||||
] {
|
||||
let error = project_delete_objects_pre_stat_error(
|
||||
ObjectToDelete {
|
||||
object_name: "damaged".to_string(),
|
||||
version_id,
|
||||
..Default::default()
|
||||
},
|
||||
synthetic,
|
||||
ApiError::from(StorageError::FileCorrupt),
|
||||
)
|
||||
.expect("metadata corruption belongs to the addressed entry");
|
||||
assert_eq!(error.code.as_deref(), Some("InternalError"));
|
||||
assert_eq!(error.message.as_deref(), Some("File is corrupted"));
|
||||
assert_eq!(error.key.as_deref(), Some("damaged"));
|
||||
assert_eq!(error.version_id, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_response_version_id_preserves_null_and_synthetic_semantics() {
|
||||
let version_id = Uuid::new_v4();
|
||||
|
||||
@@ -2285,7 +2285,7 @@ impl DefaultObjectUsecase {
|
||||
// threshold and per-request WARNs flood the log.
|
||||
rustfs_io_metrics::record_io_queue_congestion();
|
||||
|
||||
if let Some(suppressed_warns) = IO_QUEUE_CONGESTION_WARN_THROTTLE.claim(IoQueueCongestionWarnThrottle::now_ms()) {
|
||||
if let Some(suppressed_warns) = IO_QUEUE_CONGESTION_WARN_THROTTLE.claim() {
|
||||
warn!(
|
||||
bucket = %bucket,
|
||||
key = %key,
|
||||
|
||||
@@ -121,7 +121,7 @@ use crate::error::ApiError;
|
||||
use crate::shared_types::convert_ecstore_object_info;
|
||||
use crate::table_catalog;
|
||||
use bytes::{BufMut as _, Bytes, BytesMut};
|
||||
use futures::{Stream, StreamExt, TryStreamExt};
|
||||
use futures::{Stream, StreamExt};
|
||||
use http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use md5::{Digest as Md5Digest, Md5};
|
||||
use metrics::{counter, histogram};
|
||||
@@ -239,9 +239,9 @@ use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
#[cfg(test)]
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use time::{
|
||||
|
||||
@@ -18,6 +18,7 @@ use super::*;
|
||||
|
||||
use crate::auth::{RUSTFS_MAX_CONTENT_LENGTH_QUERY, VerifiedPresignedRequest, parse_presigned_put_max_content_length};
|
||||
use crate::error::UploadLimitExceeded;
|
||||
static PUT_FAILURE_LOGS: rustfs_utils::LogThrottle = rustfs_utils::LogThrottle::new(5_000);
|
||||
|
||||
const DEFAULT_PUT_LARGE_CONCURRENCY_TUNING_MIN_SIZE_BYTES: i64 = 32 * 1024 * 1024;
|
||||
|
||||
@@ -1976,21 +1977,29 @@ impl DefaultObjectUsecase {
|
||||
Err(err) => {
|
||||
store_put_watchdog.cancel();
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from("app_store_put", store_put_stage_start);
|
||||
warn!(
|
||||
target: "rustfs::app::object_usecase",
|
||||
event = EVENT_PUT_OBJECT_STORE_RETURNED,
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_OBJECT,
|
||||
request_id = %request_id,
|
||||
bucket = %bucket,
|
||||
key = %key,
|
||||
put_path = %put_path,
|
||||
object_size = actual_size,
|
||||
duration_ms = start_time.elapsed().as_millis() as u64,
|
||||
result = "error",
|
||||
error = %err,
|
||||
"PutObject store write returned"
|
||||
);
|
||||
if let Some(suppressed_errors) = PUT_FAILURE_LOGS.claim() {
|
||||
let diagnostic = err.diagnostic();
|
||||
warn!(
|
||||
target: "rustfs::app::object_usecase",
|
||||
event = EVENT_PUT_OBJECT_STORE_RETURNED,
|
||||
component = LOG_COMPONENT_APP,
|
||||
subsystem = LOG_SUBSYSTEM_OBJECT,
|
||||
request_id = %request_id,
|
||||
bucket = %bucket,
|
||||
key = %key,
|
||||
put_path = %put_path,
|
||||
object_size = actual_size,
|
||||
duration_ms = start_time.elapsed().as_millis() as u64,
|
||||
result = "error",
|
||||
error_code = %err.code.as_str(),
|
||||
storage_error_code = ?diagnostic.storage_code,
|
||||
io_error_kind = ?diagnostic.io_kind,
|
||||
rpc_error_code = ?diagnostic.rpc_code,
|
||||
source_chain_truncated = diagnostic.truncated,
|
||||
suppressed_errors,
|
||||
"PutObject store write returned"
|
||||
);
|
||||
}
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -808,56 +808,7 @@ pub(super) async fn resolve_put_object_expiration(bucket: &str, obj_info: &Objec
|
||||
build_put_object_expiration_header(&event)
|
||||
}
|
||||
|
||||
/// Cadence for the "I/O queue congestion detected" WARN. Under sustained
|
||||
/// overload (client concurrency at or above the disk-read permit pool) every
|
||||
/// GET observes >=80% utilization, so an unthrottled WARN floods the log
|
||||
/// from the already saturated hot path; congestion metrics stay per-request.
|
||||
const IO_QUEUE_CONGESTION_WARN_INTERVAL_MS: u64 = 5_000;
|
||||
|
||||
/// At-most-one-WARN-per-interval limiter for the I/O queue congestion log.
|
||||
/// Callers supply monotonic milliseconds so tests can drive the clock.
|
||||
pub(super) struct IoQueueCongestionWarnThrottle {
|
||||
/// Timestamp of the last emitted WARN; `u64::MAX` until the first one.
|
||||
last_warn_ms: AtomicU64,
|
||||
/// Congested requests left unlogged since the last emitted WARN.
|
||||
suppressed: AtomicU64,
|
||||
}
|
||||
|
||||
impl IoQueueCongestionWarnThrottle {
|
||||
const fn new() -> Self {
|
||||
Self {
|
||||
last_warn_ms: AtomicU64::new(u64::MAX),
|
||||
suppressed: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim the right to emit one WARN. Returns the number of events
|
||||
/// suppressed since the previous emission, or `None` while the interval
|
||||
/// window is still closed (the event is counted, not logged).
|
||||
pub(super) fn claim(&self, now_ms: u64) -> Option<u64> {
|
||||
let last = self.last_warn_ms.load(Ordering::Relaxed);
|
||||
let window_open = last == u64::MAX || now_ms.saturating_sub(last) >= IO_QUEUE_CONGESTION_WARN_INTERVAL_MS;
|
||||
if window_open
|
||||
&& self
|
||||
.last_warn_ms
|
||||
.compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
Some(self.suppressed.swap(0, Ordering::Relaxed))
|
||||
} else {
|
||||
self.suppressed.fetch_add(1, Ordering::Relaxed);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Monotonic milliseconds since the first call, for production callers.
|
||||
pub(super) fn now_ms() -> u64 {
|
||||
static ANCHOR: OnceLock<std::time::Instant> = OnceLock::new();
|
||||
ANCHOR.get_or_init(std::time::Instant::now).elapsed().as_millis() as u64
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) static IO_QUEUE_CONGESTION_WARN_THROTTLE: IoQueueCongestionWarnThrottle = IoQueueCongestionWarnThrottle::new();
|
||||
pub(super) static IO_QUEUE_CONGESTION_WARN_THROTTLE: rustfs_utils::LogThrottle = rustfs_utils::LogThrottle::new(5_000);
|
||||
|
||||
pub(super) async fn track_object_read_setup<F>(health: Option<&ObjectTrafficHealth>, future: F) -> F::Output
|
||||
where
|
||||
@@ -1063,19 +1014,6 @@ mod tests {
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn io_queue_congestion_warn_throttle_emits_once_per_interval() {
|
||||
let throttle = IoQueueCongestionWarnThrottle::new();
|
||||
// The first congested request logs immediately.
|
||||
assert_eq!(throttle.claim(0), Some(0));
|
||||
// Requests inside the window are counted, not logged.
|
||||
assert_eq!(throttle.claim(1), None);
|
||||
assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS - 1), None);
|
||||
// The next emission reports how many stayed silent.
|
||||
assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS), Some(2));
|
||||
assert_eq!(throttle.claim(IO_QUEUE_CONGESTION_WARN_INTERVAL_MS + 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_expires_header_accepts_http_date() {
|
||||
let expires = parse_expires_header(Some("Wed, 21 Oct 2015 07:28:00 GMT"))
|
||||
|
||||
@@ -40,6 +40,7 @@ pub struct ClusterReadOnlySnapshot {
|
||||
pub runtime_status: ClusterRuntimeStatusSnapshot,
|
||||
pub usage_freshness: ClusterUsageFreshnessSnapshot,
|
||||
pub listing_diagnostics: ClusterListingDiagnosticsSnapshot,
|
||||
pub pool_meta_write_gate: ClusterPoolMetaWriteGateSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -104,6 +105,31 @@ pub struct ClusterListingDiagnosticsSnapshot {
|
||||
pub internode_stall_timeouts_total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClusterPoolMetaWriteGateSnapshot {
|
||||
pub writes_ready: bool,
|
||||
pub write_blocked: bool,
|
||||
pub transaction_aborted: bool,
|
||||
pub pool_meta_absent: bool,
|
||||
pub identity_initialized: Option<bool>,
|
||||
pub identity_needs_repair: bool,
|
||||
pub cluster_epoch: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for ClusterPoolMetaWriteGateSnapshot {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
writes_ready: true,
|
||||
write_blocked: false,
|
||||
transaction_aborted: false,
|
||||
pool_meta_absent: false,
|
||||
identity_initialized: None,
|
||||
identity_needs_repair: false,
|
||||
cluster_epoch: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<InternodeMetricsSnapshot> for ClusterListingDiagnosticsSnapshot {
|
||||
fn from(snapshot: InternodeMetricsSnapshot) -> Self {
|
||||
Self {
|
||||
@@ -164,6 +190,7 @@ pub fn cluster_read_only_snapshot_from_control_plane(
|
||||
runtime_status,
|
||||
usage_freshness: ClusterUsageFreshnessSnapshot::default(),
|
||||
listing_diagnostics: ClusterListingDiagnosticsSnapshot::default(),
|
||||
pool_meta_write_gate: ClusterPoolMetaWriteGateSnapshot::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +199,7 @@ pub async fn collect_cluster_read_only_snapshot(endpoint_pools: &EndpointServerP
|
||||
let mut snapshot = cluster_read_only_snapshot_from_endpoint_pools(endpoint_pools, runtime_status);
|
||||
snapshot.usage_freshness = current_usage_freshness_snapshot().await;
|
||||
snapshot.listing_diagnostics = current_listing_diagnostics_snapshot();
|
||||
snapshot.pool_meta_write_gate = current_pool_meta_write_gate_snapshot().await;
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
@@ -188,8 +216,27 @@ fn current_listing_diagnostics_snapshot() -> ClusterListingDiagnosticsSnapshot {
|
||||
ClusterListingDiagnosticsSnapshot::from(metrics.snapshot())
|
||||
}
|
||||
|
||||
async fn current_pool_meta_write_gate_snapshot() -> ClusterPoolMetaWriteGateSnapshot {
|
||||
match crate::runtime_sources::current_object_store_handle() {
|
||||
Some(store) => {
|
||||
let status = store.pool_meta_write_gate_status().await;
|
||||
ClusterPoolMetaWriteGateSnapshot {
|
||||
writes_ready: status.writes_ready,
|
||||
write_blocked: status.write_blocked,
|
||||
transaction_aborted: status.transaction_aborted,
|
||||
pool_meta_absent: status.pool_meta_absent,
|
||||
identity_initialized: status.identity_initialized,
|
||||
identity_needs_repair: status.identity_needs_repair,
|
||||
cluster_epoch: status.cluster_epoch,
|
||||
}
|
||||
}
|
||||
None => ClusterPoolMetaWriteGateSnapshot::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cluster_has_actionable_pressure(snapshot: &ClusterReadOnlySnapshot) -> bool {
|
||||
snapshot.runtime_status.state == ClusterRuntimeReadinessState::Degraded
|
||||
|| !snapshot.pool_meta_write_gate.writes_ready
|
||||
|| snapshot
|
||||
.workload_admission
|
||||
.entries()
|
||||
@@ -312,6 +359,7 @@ mod tests {
|
||||
},
|
||||
usage_freshness: ClusterUsageFreshnessSnapshot::default(),
|
||||
listing_diagnostics: ClusterListingDiagnosticsSnapshot::default(),
|
||||
pool_meta_write_gate: ClusterPoolMetaWriteGateSnapshot::default(),
|
||||
};
|
||||
assert!(!cluster_has_actionable_pressure(&no_pressure));
|
||||
|
||||
@@ -335,9 +383,19 @@ mod tests {
|
||||
WorkloadClass::Repair,
|
||||
AdmissionState::Unknown,
|
||||
)]),
|
||||
..no_pressure
|
||||
..no_pressure.clone()
|
||||
};
|
||||
assert!(cluster_has_actionable_pressure(&admission_pressure));
|
||||
|
||||
let pool_meta_pressure = ClusterReadOnlySnapshot {
|
||||
pool_meta_write_gate: ClusterPoolMetaWriteGateSnapshot {
|
||||
writes_ready: false,
|
||||
write_blocked: true,
|
||||
..Default::default()
|
||||
},
|
||||
..no_pressure
|
||||
};
|
||||
assert!(cluster_has_actionable_pressure(&pool_meta_pressure));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+155
-6
@@ -12,11 +12,15 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::storage_api::error::contract::range::HTTPRangeError;
|
||||
use crate::storage_api::error::contract::{StorageErrorCode, range::HTTPRangeError};
|
||||
use crate::storage_api::error::{QuotaError, StorageError};
|
||||
use http::StatusCode;
|
||||
use rustfs_kms::KmsUnavailableError;
|
||||
use s3s::{S3Error, S3ErrorCode};
|
||||
|
||||
const MAX_VERSIONS_EXCEEDED_CODE: &str = "MaxVersionsExceeded";
|
||||
const MAX_VERSIONS_EXCEEDED_MESSAGE: &str = "You've exceeded the limit on the number of versions you can create on this object";
|
||||
|
||||
/// Marks a request body that exceeded a presigned upload size capability.
|
||||
///
|
||||
/// This marker must survive the body-reader and storage layers so the client
|
||||
@@ -73,9 +77,47 @@ impl std::fmt::Display for ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ApiError {}
|
||||
impl std::error::Error for ApiError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
self.source.as_deref().map(|source| source as _)
|
||||
}
|
||||
}
|
||||
|
||||
/// Only bounded classifications are safe to include in routine diagnostics.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct ApiErrorDiagnostic {
|
||||
pub storage_code: Option<StorageErrorCode>,
|
||||
pub io_kind: Option<std::io::ErrorKind>,
|
||||
pub rpc_code: Option<tonic::Code>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub(crate) fn diagnostic(&self) -> ApiErrorDiagnostic {
|
||||
let mut diagnostic = ApiErrorDiagnostic::default();
|
||||
let mut current = std::error::Error::source(self);
|
||||
for _ in 0..16 {
|
||||
let Some(error) = current else {
|
||||
return diagnostic;
|
||||
};
|
||||
if let Some(storage) = error.downcast_ref::<StorageError>() {
|
||||
diagnostic.storage_code = Some(storage.code());
|
||||
}
|
||||
if let Some(status) = error.downcast_ref::<tonic::Status>() {
|
||||
diagnostic.rpc_code = Some(status.code());
|
||||
}
|
||||
current = if let Some(io) = error.downcast_ref::<std::io::Error>() {
|
||||
diagnostic.io_kind = Some(io.kind());
|
||||
// io::Error::source can skip the wrapped error itself.
|
||||
io.get_ref().map(|source| source as &(dyn std::error::Error + 'static))
|
||||
} else {
|
||||
error.source()
|
||||
};
|
||||
}
|
||||
diagnostic.truncated = current.is_some();
|
||||
diagnostic
|
||||
}
|
||||
|
||||
/// Access-denied error with the exact message emitted by the authorization
|
||||
/// paths in `storage::access`; callers there match on the code only.
|
||||
pub fn access_denied() -> Self {
|
||||
@@ -246,6 +288,9 @@ impl ApiError {
|
||||
S3ErrorCode::EvaluatorBindingDoesNotExist => "A column name or a path provided does not exist in the SQL expression".to_string(),
|
||||
S3ErrorCode::InvalidColumnIndex => "The column index is invalid. Please check the service documentation and try again.".to_string(),
|
||||
S3ErrorCode::UnsupportedFunction => "Encountered an unsupported SQL function.".to_string(),
|
||||
S3ErrorCode::Custom(code) if &**code == MAX_VERSIONS_EXCEEDED_CODE => {
|
||||
MAX_VERSIONS_EXCEEDED_MESSAGE.to_string()
|
||||
}
|
||||
_ => code.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
@@ -324,6 +369,9 @@ fn error_chain_s3s_body_stream_error(err: &(dyn std::error::Error + 'static)) ->
|
||||
impl From<ApiError> for S3Error {
|
||||
fn from(err: ApiError) -> Self {
|
||||
let mut s3e = S3Error::with_message(err.code, err.message);
|
||||
if matches!(s3e.code(), S3ErrorCode::Custom(code) if &**code == MAX_VERSIONS_EXCEEDED_CODE) {
|
||||
s3e.set_status_code(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
if let Some(source) = err.source {
|
||||
s3e.set_source(source);
|
||||
}
|
||||
@@ -333,6 +381,13 @@ impl From<ApiError> for S3Error {
|
||||
|
||||
impl From<StorageError> for ApiError {
|
||||
fn from(err: StorageError) -> Self {
|
||||
if err.pool_metadata_failure().is_some() {
|
||||
return ApiError {
|
||||
code: S3ErrorCode::ServiceUnavailable,
|
||||
message: ApiError::error_code_to_message(&S3ErrorCode::ServiceUnavailable),
|
||||
source: Some(Box::new(err)),
|
||||
};
|
||||
}
|
||||
if let StorageError::Io(ref io_err) = err
|
||||
&& let Some(inner) = io_err.get_ref()
|
||||
{
|
||||
@@ -410,6 +465,7 @@ impl From<StorageError> for ApiError {
|
||||
| StorageError::InsufficientWriteQuorum(_, _) => S3ErrorCode::ServiceUnavailable,
|
||||
StorageError::NamespaceLockQuorumUnavailable { .. } => S3ErrorCode::ServiceUnavailable,
|
||||
StorageError::QuotaExceeded { .. } => S3ErrorCode::InvalidRequest,
|
||||
StorageError::MaxVersionsExceeded => S3ErrorCode::Custom(MAX_VERSIONS_EXCEEDED_CODE.into()),
|
||||
StorageError::Lock(_) => S3ErrorCode::ServiceUnavailable,
|
||||
StorageError::DecommissionNotStarted => S3ErrorCode::InvalidRequest,
|
||||
StorageError::DecommissionAlreadyRunning => S3ErrorCode::InvalidRequest,
|
||||
@@ -440,7 +496,9 @@ impl From<StorageError> for ApiError {
|
||||
|
||||
let message = if matches!(&err, StorageError::QuotaExceeded { .. }) {
|
||||
err.to_string()
|
||||
} else if code == S3ErrorCode::InternalError && matches!(&err, StorageError::Io(_)) {
|
||||
} else if matches!(&err, StorageError::MaxVersionsExceeded)
|
||||
|| (code == S3ErrorCode::InternalError && matches!(&err, StorageError::Io(_)))
|
||||
{
|
||||
ApiError::error_code_to_message(&code)
|
||||
} else if code == S3ErrorCode::InternalError {
|
||||
err.to_string()
|
||||
@@ -562,6 +620,51 @@ mod tests {
|
||||
use s3s::{S3Error, S3ErrorCode};
|
||||
use std::io::{Error as IoError, ErrorKind};
|
||||
|
||||
#[test]
|
||||
fn api_error_diagnostic_preserves_typed_cause_without_sensitive_payload() {
|
||||
let error = ApiError::from(StorageError::Io(IoError::new(ErrorKind::TimedOut, "secret=do-not-log")));
|
||||
let diagnostic = error.diagnostic();
|
||||
assert_eq!(diagnostic.storage_code, Some(StorageErrorCode::Io));
|
||||
assert_eq!(diagnostic.io_kind, Some(ErrorKind::TimedOut));
|
||||
assert!(!diagnostic.truncated);
|
||||
assert!(!format!("{diagnostic:?}").contains("do-not-log"));
|
||||
assert!(std::error::Error::source(&error).is_some());
|
||||
|
||||
let error = ApiError::from(StorageError::Io(IoError::other(StorageError::ErasureWriteQuorum)));
|
||||
assert_eq!(error.diagnostic().storage_code, Some(StorageErrorCode::ErasureWriteQuorum));
|
||||
|
||||
let mut status = tonic::Status::unavailable("secret RPC message");
|
||||
status
|
||||
.metadata_mut()
|
||||
.insert("authorization", "secret-token".parse().expect("metadata value"));
|
||||
let error = ApiError::from(StorageError::from(status));
|
||||
assert_eq!(error.diagnostic().rpc_code, Some(tonic::Code::Unavailable));
|
||||
assert!(!format!("{:?}", error.diagnostic()).contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_error_diagnostic_bounds_cyclic_error_chains() {
|
||||
#[derive(Debug)]
|
||||
struct Cycle;
|
||||
impl std::fmt::Display for Cycle {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("secret cycle")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for Cycle {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
let error = ApiError {
|
||||
code: S3ErrorCode::InternalError,
|
||||
message: "safe".into(),
|
||||
source: Some(Box::new(Cycle)),
|
||||
};
|
||||
assert!(error.diagnostic().truncated);
|
||||
assert!(!format!("{:?}", error.diagnostic()).contains("secret"));
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum MockUploadStreamError {
|
||||
Underlying(IoError),
|
||||
@@ -848,6 +951,35 @@ mod tests {
|
||||
assert_eq!(api_error.message, "The service is unavailable. Please retry.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_metadata_failures_map_to_503_and_preserve_typed_private_context() {
|
||||
use crate::storage_api::error::{PoolMetadataError, PoolMetadataFailure};
|
||||
for kind in [
|
||||
PoolMetadataFailure::ReadUnavailable,
|
||||
PoolMetadataFailure::RecoveryRequired,
|
||||
PoolMetadataFailure::TransactionUnknown,
|
||||
PoolMetadataFailure::FenceLost,
|
||||
] {
|
||||
let error = StorageError::other(PoolMetadataError {
|
||||
kind,
|
||||
operation: "pool metadata test".to_owned(),
|
||||
phase: "prepare_cas",
|
||||
since: time::OffsetDateTime::now_utc(),
|
||||
source: Some(std::sync::Arc::new(StorageError::other("private disk failure"))),
|
||||
});
|
||||
let error = StorageError::Io(std::io::Error::new(std::io::ErrorKind::TimedOut, error));
|
||||
let cloned = error.clone();
|
||||
assert_eq!(cloned, error, "cloning must preserve the outer I/O kind and message");
|
||||
assert_eq!(cloned.pool_metadata_failure().unwrap().kind, kind);
|
||||
let api = ApiError::from(cloned);
|
||||
assert_eq!(api.code, S3ErrorCode::ServiceUnavailable);
|
||||
assert_eq!(api.message, "The service is unavailable. Please retry.");
|
||||
assert!(!api.message.contains("private"));
|
||||
let source = api.source.as_ref().unwrap().downcast_ref::<StorageError>().unwrap();
|
||||
assert_eq!(source.pool_metadata_failure().unwrap().kind, kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_authoritative_quota_usage_maps_to_retryable_error() {
|
||||
let api_error = ApiError::from(QuotaError::UsageUnavailable {
|
||||
@@ -1070,6 +1202,19 @@ mod tests {
|
||||
assert_eq!(api_error.message, "Bucket quota exceeded. Current usage: 5 bytes, limit: 10 bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_versions_exceeded_maps_to_minio_compatible_s3_error() {
|
||||
let api_error: ApiError = StorageError::MaxVersionsExceeded.into();
|
||||
|
||||
assert_eq!(api_error.code, S3ErrorCode::Custom(MAX_VERSIONS_EXCEEDED_CODE.into()));
|
||||
assert_eq!(api_error.message, MAX_VERSIONS_EXCEEDED_MESSAGE);
|
||||
|
||||
let s3_error: S3Error = api_error.into();
|
||||
assert_eq!(s3_error.code(), &S3ErrorCode::Custom(MAX_VERSIONS_EXCEEDED_CODE.into()));
|
||||
assert_eq!(s3_error.message(), Some(MAX_VERSIONS_EXCEEDED_MESSAGE));
|
||||
assert_eq!(s3_error.status_code(), Some(StatusCode::BAD_REQUEST));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_error_to_s3_error_without_source() {
|
||||
let api_error = ApiError {
|
||||
@@ -1158,8 +1303,12 @@ mod tests {
|
||||
// Test that it implements std::error::Error
|
||||
let error: &dyn std::error::Error = &api_error;
|
||||
assert_eq!(error.to_string(), "Test error");
|
||||
// ApiError doesn't implement Error::source() properly, so this would be None
|
||||
// This is expected because ApiError is not a typical Error implementation
|
||||
assert!(error.source().is_none());
|
||||
let source = error
|
||||
.source()
|
||||
.expect("typed source must remain reachable through the error trait");
|
||||
let source = source.downcast_ref::<IoError>().expect("original I/O error");
|
||||
assert_eq!(source.kind(), ErrorKind::Other);
|
||||
assert_eq!(source.to_string(), "source error");
|
||||
assert!(std::error::Error::source(&ApiError::access_denied()).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1014,6 +1014,10 @@ pub async fn start_http_server(
|
||||
// Common setup for both IPv4 and successful dual-stack IPv6
|
||||
let backlog = get_listen_backlog();
|
||||
let keepalive = get_default_tcp_keepalive();
|
||||
let recv_buffer_bytes = rustfs_utils::get_env_usize(
|
||||
rustfs_config::ENV_HTTP_SOCKET_RECV_BUFFER_BYTES,
|
||||
rustfs_config::DEFAULT_HTTP_SOCKET_RECV_BUFFER_BYTES,
|
||||
);
|
||||
|
||||
// Helper to configure socket with optimized parameters
|
||||
let configure_socket = |socket: &socket2::Socket| -> Result<()> {
|
||||
@@ -1068,10 +1072,25 @@ pub async fn start_http_server(
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Increase receive/send buffer to support BDP at GB-level throughput.
|
||||
// 4. Socket buffers. The receive buffer is left to kernel autotuning
|
||||
// unless RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES is set: a fixed SO_RCVBUF
|
||||
// is inherited by every accepted socket and disables autotuning, so a
|
||||
// request whose body is not being read yet (a multipart part queued
|
||||
// for a foreground write permit) lets up to the fixed size of unread
|
||||
// body accumulate in kernel memory — the former hard-coded 4 MiB held
|
||||
// up to 8 MiB per queued connection on Linux, which doubles the
|
||||
// requested size. Autotuning keeps an unread connection at the
|
||||
// kernel's initial size and grows only connections that are actually
|
||||
// being drained (issue #7385). The send buffer stays fixed at 4 MiB
|
||||
// because the stock Linux send autotuning ceiling (`tcp_wmem` max,
|
||||
// 4 MiB) is below what a GB-level response stream needs, whereas the
|
||||
// receive ceiling (`tcp_rmem` max, 6 MiB) already exceeds the old
|
||||
// fixed request.
|
||||
// Some constrained local environments reject these socket options with
|
||||
// EPERM/ENOPROTOOPT-style failures; log and continue in that case.
|
||||
if let Err(e) = socket.set_recv_buffer_size(4 * rustfs_config::MI_B) {
|
||||
if recv_buffer_bytes > 0
|
||||
&& let Err(e) = socket.set_recv_buffer_size(recv_buffer_bytes)
|
||||
{
|
||||
debug!(
|
||||
event = "socket_option_unavailable",
|
||||
component = LOG_COMPONENT_SERVER,
|
||||
@@ -1566,9 +1585,11 @@ pub async fn start_http_server(
|
||||
let socket_ref = SockRef::from(&socket);
|
||||
|
||||
// ── POST-ACCEPT SOCKET SYSCALLS ──
|
||||
// The listening socket already sets TCP_NODELAY, TCP_KEEPALIVE,
|
||||
// SO_RCVBUF, and SO_SNDBUF. On Linux/BSD, these are inherited by
|
||||
// accepted sockets, so we skip redundant re-application here.
|
||||
// The listening socket already sets TCP_NODELAY, TCP_KEEPALIVE, and
|
||||
// SO_SNDBUF (SO_RCVBUF stays kernel-autotuned unless
|
||||
// RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES is set, see the listener
|
||||
// setup). On Linux/BSD, these are inherited by accepted sockets, so
|
||||
// we skip redundant re-application here.
|
||||
//
|
||||
// Only TCP_QUICKACK (Linux) is kept — it is inherently per-connection
|
||||
// and NOT inherited from the listening socket.
|
||||
|
||||
+240
-4
@@ -38,6 +38,7 @@ use hyper::body::Incoming;
|
||||
use pin_project_lite::pin_project;
|
||||
use quick_xml::events::Event;
|
||||
use rustfs_common::GlobalReadiness;
|
||||
use rustfs_io_metrics::s3_http_metrics::S3HttpRequestGuard;
|
||||
use rustfs_obs::HTTP_SERVER_LOG_TARGET;
|
||||
#[cfg(feature = "swift")]
|
||||
use rustfs_protocols::swift::SwiftRouter;
|
||||
@@ -66,6 +67,7 @@ const LOG_SUBSYSTEM_HTTP: &str = "http";
|
||||
const REDACTED_QUERY_VALUE: &str = "redacted";
|
||||
const OBJECT_ZIP_DOWNLOADS_PATH: &str = "/v3/object-zip-downloads/";
|
||||
const HTTP_REQUEST_INFLIGHT_WARN_THRESHOLD: Duration = Duration::from_secs(5);
|
||||
static HTTP_SERVER_ERROR_LOGS: [rustfs_utils::LogThrottle; 100] = [const { rustfs_utils::LogThrottle::new(5_000) }; 100];
|
||||
const STS_RESPONSE_METADATA_TAG: &str = "ResponseMetadata";
|
||||
const STS_REQUEST_ID_TAG: &str = "RequestId";
|
||||
const STS_SUCCESS_RESPONSE_TAGS: [&str; 2] = ["AssumeRoleResponse", "AssumeRoleWithWebIdentityResponse"];
|
||||
@@ -269,10 +271,18 @@ where
|
||||
};
|
||||
req.extensions_mut().insert(request_context);
|
||||
|
||||
// This outer boundary includes readiness, rate-limit and auth
|
||||
// rejections. Metric attribution never depends on an enabled span.
|
||||
let mut metrics = is_s3.then(|| S3HttpRequestGuard::new(req.method().as_str()));
|
||||
let inner = match metrics.as_mut() {
|
||||
Some(metrics) => metrics.in_scope(|| self.inner.call(req)),
|
||||
None => self.inner.call(req),
|
||||
};
|
||||
ExternalRequestContextFuture {
|
||||
inner: self.inner.call(req),
|
||||
inner,
|
||||
request_id,
|
||||
is_s3,
|
||||
metrics,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,6 +293,7 @@ pin_project! {
|
||||
inner: F,
|
||||
request_id: Option<HeaderValue>,
|
||||
is_s3: bool,
|
||||
metrics: Option<S3HttpRequestGuard>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,12 +305,24 @@ where
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
let this = self.project();
|
||||
let mut response = match this.inner.poll(cx) {
|
||||
let result = match this.metrics.as_mut() {
|
||||
Some(metrics) => metrics.in_scope(|| this.inner.poll(cx)),
|
||||
None => this.inner.poll(cx),
|
||||
};
|
||||
let mut response = match result {
|
||||
Poll::Ready(Ok(response)) => response,
|
||||
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
||||
Poll::Ready(Err(error)) => {
|
||||
if let Some(metrics) = this.metrics.as_mut() {
|
||||
metrics.service_error();
|
||||
}
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
Poll::Pending => return Poll::Pending,
|
||||
};
|
||||
|
||||
if let Some(metrics) = this.metrics.as_mut() {
|
||||
metrics.response(response.status().as_u16());
|
||||
}
|
||||
if let Some(request_id) = this.request_id.take() {
|
||||
if *this.is_s3 {
|
||||
response.headers_mut().insert(REQUEST_ID_HEADER, request_id.clone());
|
||||
@@ -340,6 +363,7 @@ struct RequestLogContext {
|
||||
uri: Uri,
|
||||
request_started_at: Option<RequestContext>,
|
||||
fallback_start: Instant,
|
||||
has_s3_accounting: bool,
|
||||
}
|
||||
|
||||
impl RequestLogContext {
|
||||
@@ -359,6 +383,7 @@ impl RequestLogContext {
|
||||
uri: req.uri().clone(),
|
||||
request_started_at: request_context,
|
||||
fallback_start: Instant::now(),
|
||||
has_s3_accounting: S3HttpRequestGuard::is_active(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +452,14 @@ impl RequestLogContext {
|
||||
if !tracing::enabled!(target: HTTP_SERVER_LOG_TARGET, Level::ERROR) {
|
||||
return;
|
||||
}
|
||||
let suppressed_errors = if self.has_s3_accounting {
|
||||
let Some(suppressed) = HTTP_SERVER_ERROR_LOGS[usize::from(status_code - 500)].claim() else {
|
||||
return;
|
||||
};
|
||||
suppressed
|
||||
} else {
|
||||
0
|
||||
};
|
||||
error!(
|
||||
target: HTTP_SERVER_LOG_TARGET,
|
||||
event = HTTP_REQUEST_COMPLETED_EVENT,
|
||||
@@ -437,8 +470,9 @@ impl RequestLogContext {
|
||||
span_id = %span_id,
|
||||
peer_addr = %self.peer_addr(),
|
||||
method = %self.method.as_str(),
|
||||
uri = %self.redacted_uri(),
|
||||
uri = self.uri.path(),
|
||||
status_code,
|
||||
suppressed_errors,
|
||||
duration_ms,
|
||||
result,
|
||||
"HTTP request completed"
|
||||
@@ -2232,6 +2266,144 @@ mod tests {
|
||||
PublicHealthEndpointLayer::new(crate::runtime_sources::ServerContextSlot::new(), readiness)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_s3_http_outcomes_cover_response_error_cancel_and_exclusions_at_warn() {
|
||||
use rustfs_io_metrics::{record_s3_op, s3_http_metrics::s3_http_metrics_snapshot};
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
let _logs = tracing::subscriber::set_default(
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::WARN)
|
||||
.with_writer(std::io::sink)
|
||||
.finish(),
|
||||
);
|
||||
let totals = || {
|
||||
s3_http_metrics_snapshot()
|
||||
.into_iter()
|
||||
.filter(|series| series.operation == S3Operation::RestoreObject.as_str())
|
||||
.fold(std::collections::BTreeMap::<String, u64>::new(), |mut result, series| {
|
||||
*result.entry(series.outcome.to_string()).or_default() += series.total;
|
||||
result
|
||||
})
|
||||
};
|
||||
let before = totals();
|
||||
let inner = tower::service_fn(|req: Request<()>| async move {
|
||||
record_s3_op(S3Operation::RestoreObject);
|
||||
match req.uri().path() {
|
||||
"/bucket/cancel" => std::future::pending::<Result<Response<()>, io::Error>>().await,
|
||||
"/bucket/service-error" => Err(io::Error::other("test service failure")),
|
||||
path => Ok(Response::builder()
|
||||
.status(match path {
|
||||
"/bucket/denied" => StatusCode::FORBIDDEN,
|
||||
"/bucket/unavailable" => StatusCode::SERVICE_UNAVAILABLE,
|
||||
_ => StatusCode::OK,
|
||||
})
|
||||
.body(())
|
||||
.expect("response")),
|
||||
}
|
||||
});
|
||||
let mut service = ExternalRequestContextLayer::default().layer(inner);
|
||||
for path in ["/bucket/ok", "/bucket/denied", "/bucket/unavailable"] {
|
||||
let response = service
|
||||
.call(Request::builder().method(Method::PATCH).uri(path).body(()).expect("request"))
|
||||
.await
|
||||
.expect("response");
|
||||
assert!(response.headers().contains_key(AMZ_REQUEST_ID));
|
||||
}
|
||||
let error = service
|
||||
.call(
|
||||
Request::builder()
|
||||
.method(Method::PATCH)
|
||||
.uri("/bucket/service-error")
|
||||
.body(())
|
||||
.expect("request"),
|
||||
)
|
||||
.await;
|
||||
assert!(error.is_err());
|
||||
let mut cancelled = Box::pin(
|
||||
service.call(
|
||||
Request::builder()
|
||||
.method(Method::PATCH)
|
||||
.uri("/bucket/cancel")
|
||||
.body(())
|
||||
.expect("request"),
|
||||
),
|
||||
);
|
||||
assert!(futures::poll!(cancelled.as_mut()).is_pending());
|
||||
drop(cancelled);
|
||||
for path in [
|
||||
"/rustfs/admin/v3/metrics",
|
||||
"/minio/admin/v3/storageinfo",
|
||||
"/rustfs/console/",
|
||||
"/rustfs/rpc/test",
|
||||
"/health/ready",
|
||||
"/_iceberg/v1/config",
|
||||
] {
|
||||
service
|
||||
.call(Request::builder().uri(path).body(()).expect("excluded request"))
|
||||
.await
|
||||
.expect("excluded response");
|
||||
}
|
||||
let after = totals();
|
||||
for outcome in ["2xx", "4xx", "5xx", "service_error", "cancelled"] {
|
||||
assert_eq!(
|
||||
after.get(outcome).copied().unwrap_or_default() - before.get(outcome).copied().unwrap_or_default(),
|
||||
1,
|
||||
"{outcome}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_s3_http_outcomes_include_real_readiness_rejections() {
|
||||
use rustfs_io_metrics::s3_http_metrics::s3_http_metrics_snapshot;
|
||||
let rejected = || {
|
||||
s3_http_metrics_snapshot()
|
||||
.into_iter()
|
||||
.filter(|series| series.method == "TRACE" && series.operation == "unknown" && series.outcome == "5xx")
|
||||
.map(|series| series.total)
|
||||
.sum::<u64>()
|
||||
};
|
||||
let before = rejected();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("isolated HTTP listener");
|
||||
let addr = listener.local_addr().expect("listener address");
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("HTTP client");
|
||||
let service = tower::ServiceBuilder::new()
|
||||
.layer(ExternalRequestContextLayer::default())
|
||||
.layer(crate::server::ReadinessGateLayer::new(Arc::new(GlobalReadiness::new())))
|
||||
.service(StatusService::new(StatusCode::OK));
|
||||
hyper::server::conn::http1::Builder::new()
|
||||
.serve_connection(
|
||||
hyper_util::rt::TokioIo::new(stream),
|
||||
hyper_util::service::TowerToHyperService::new(service),
|
||||
)
|
||||
.await
|
||||
.expect("HTTP connection");
|
||||
});
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.http1_only()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("local HTTP client");
|
||||
let response = client
|
||||
.request(Method::TRACE, format!("http://{addr}/bucket/object"))
|
||||
.header(http::header::CONNECTION, "close")
|
||||
.send()
|
||||
.await
|
||||
.expect("readiness response");
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert!(response.headers().contains_key(AMZ_REQUEST_ID));
|
||||
let _body = response.bytes().await.expect("readiness body");
|
||||
tokio::time::timeout(Duration::from_secs(5), server)
|
||||
.await
|
||||
.expect("bounded HTTP server shutdown")
|
||||
.expect("server task");
|
||||
assert_eq!(rejected() - before, 1, "rejection is counted before the inner trace layer");
|
||||
}
|
||||
|
||||
async fn public_health_layer_with_tracker(object_traffic_health: Arc<ObjectTrafficHealth>) -> PublicHealthEndpointLayer {
|
||||
let readiness = Arc::new(GlobalReadiness::new());
|
||||
readiness.mark_stage(rustfs_common::SystemStage::FullReady);
|
||||
@@ -5135,6 +5307,70 @@ mod tests {
|
||||
assert_eq!(redact_sensitive_uri_query(&uri), "/rustfs/admin/v3/users?token=not-a-download-token");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_logging_bounds_s3_failure_bursts_without_losing_counts_or_leaking_queries() {
|
||||
use rustfs_io_metrics::s3_http_metrics::s3_http_metrics_snapshot;
|
||||
let count = || {
|
||||
s3_http_metrics_snapshot()
|
||||
.iter()
|
||||
.filter(|series| series.method == "CONNECT" && series.operation == "unknown" && series.outcome == "5xx")
|
||||
.map(|series| series.total)
|
||||
.sum::<u64>()
|
||||
};
|
||||
let before = count();
|
||||
let writer = SharedWriter::default();
|
||||
let captured = writer.buffer.clone();
|
||||
let _subscriber = tracing::subscriber::set_default(
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::WARN)
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.with_writer(writer)
|
||||
.finish(),
|
||||
);
|
||||
// A distinct status isolates this test's process-wide log window.
|
||||
let mut service = tower::ServiceBuilder::new()
|
||||
.layer(ExternalRequestContextLayer::default())
|
||||
.layer(RequestLoggingLayer)
|
||||
.service(StatusService::new(StatusCode::from_u16(599).expect("server error")));
|
||||
for _ in 0..10 {
|
||||
service
|
||||
.call(
|
||||
Request::builder()
|
||||
.method(Method::CONNECT)
|
||||
.uri("/bucket/object?X-Amz-Signature=private-signature&X-Amz-Security-Token=private-session")
|
||||
.body(())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
}
|
||||
assert_eq!(count() - before, 10);
|
||||
let output = String::from_utf8(captured.lock().expect("logs").clone()).expect("UTF-8 logs");
|
||||
assert_eq!(output.matches("http_request_completed").count(), 1, "{output}");
|
||||
assert!(output.contains("/bucket/object"));
|
||||
assert!(!output.contains("private-"));
|
||||
assert!(!output.contains("X-Amz-"));
|
||||
for _ in 0..2 {
|
||||
service
|
||||
.call(
|
||||
Request::builder()
|
||||
.uri("/rustfs/admin/v3/info")
|
||||
.body(())
|
||||
.expect("admin request"),
|
||||
)
|
||||
.await
|
||||
.expect("admin response");
|
||||
}
|
||||
let output = String::from_utf8(captured.lock().expect("logs").clone()).expect("UTF-8 logs");
|
||||
assert_eq!(
|
||||
output.matches("http_request_completed").count(),
|
||||
3,
|
||||
"admin logging is not throttled: {output}"
|
||||
);
|
||||
assert_eq!(count() - before, 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_logging_layer_emits_single_completion_event_with_standard_fields() {
|
||||
let writer = SharedWriter::default();
|
||||
|
||||
@@ -1381,6 +1381,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(pool_erasure_layout(&info, 0, 4), Some((2, 2)));
|
||||
@@ -1401,6 +1402,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(pool_write_quorum(&info, 0, 8), Some(6));
|
||||
@@ -1419,6 +1421,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: online_readiness_disks(0, 8),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(pool_erasure_layout(&info, 0, 8), Some((6, 2)));
|
||||
@@ -1437,6 +1440,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: online_readiness_disks(0, 8),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(pool_erasure_layout(&info, 0, 8), None);
|
||||
@@ -1453,6 +1457,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(pool_erasure_layout(&info, 0, 4), Some((3, 1)));
|
||||
@@ -1468,6 +1473,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: online_readiness_disks(0, 3),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(pool_erasure_layout(&info, 0, 3), Some((2, 1)));
|
||||
@@ -1487,10 +1493,12 @@ mod tests {
|
||||
let three_online = StorageInfo {
|
||||
backend: backend.clone(),
|
||||
disks: online_readiness_disks(0, 3),
|
||||
..Default::default()
|
||||
};
|
||||
let two_online = StorageInfo {
|
||||
backend,
|
||||
disks: online_readiness_disks(0, 2),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(storage_read_ready_from_runtime_state(&three_online));
|
||||
@@ -1510,6 +1518,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: online_readiness_disks(0, 3),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!storage_read_ready_from_runtime_state(&info));
|
||||
@@ -1525,6 +1534,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: online_readiness_disks(0, 3),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!storage_read_ready_from_runtime_state(&info));
|
||||
@@ -1540,6 +1550,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: online_readiness_disks(0, 3),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!storage_read_ready_from_runtime_state(&info));
|
||||
@@ -1566,6 +1577,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!storage_ready_from_runtime_state(&info));
|
||||
@@ -1588,6 +1600,7 @@ mod tests {
|
||||
runtime_state: Some("offline".to_string()),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!storage_ready_from_runtime_state(&info));
|
||||
@@ -1609,6 +1622,7 @@ mod tests {
|
||||
runtime_state: Some("online".to_string()),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(storage_ready_from_runtime_state(&info));
|
||||
@@ -1637,12 +1651,47 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(storage_read_ready_from_runtime_state(&info));
|
||||
assert!(!storage_ready_from_runtime_state(&info));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_inventory_does_not_supply_quorum_evidence() {
|
||||
let mut info = StorageInfo {
|
||||
backend: BackendInfo {
|
||||
standard_sc_data: vec![2],
|
||||
total_sets: vec![1],
|
||||
drives_per_set: vec![4],
|
||||
..Default::default()
|
||||
},
|
||||
disks: (0..4)
|
||||
.map(|disk_index| Disk {
|
||||
endpoint: format!("node-{disk_index}"),
|
||||
pool_index: 0,
|
||||
set_index: 0,
|
||||
disk_index,
|
||||
state: "ok".to_string(),
|
||||
runtime_state: Some("online".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(storage_ready_from_runtime_state(&info));
|
||||
for disk in &mut info.disks[2..] {
|
||||
disk.state = rustfs_madmin::ITEM_UNKNOWN.to_string();
|
||||
disk.runtime_state = Some(rustfs_madmin::ITEM_UNKNOWN.to_string());
|
||||
}
|
||||
assert!(storage_read_ready_from_runtime_state(&info));
|
||||
assert!(!storage_ready_from_runtime_state(&info));
|
||||
assert_eq!(pool_read_quorum(&info, 0, 4), Some(2));
|
||||
assert_eq!(pool_write_quorum(&info, 0, 4), Some(3));
|
||||
assert!(info.disks.iter().all(|disk| disk.state != rustfs_madmin::ITEM_OFFLINE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_ready_from_runtime_state_deduplicates_duplicate_disk_rows() {
|
||||
let duplicate_disk = Disk {
|
||||
@@ -1663,6 +1712,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: vec![duplicate_disk.clone(), duplicate_disk],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!storage_ready_from_runtime_state(&info), "duplicate rows must not satisfy write quorum");
|
||||
@@ -1719,6 +1769,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(
|
||||
|
||||
@@ -17,12 +17,108 @@ use crate::{
|
||||
startup_runtime_hooks::{init_profiling_runtime, install_default_crypto_provider, log_startup_runtime_diagnostics},
|
||||
startup_tls_material::init_outbound_tls_material,
|
||||
};
|
||||
use std::io::Result;
|
||||
use rustfs_config::ENV_API_OBJECT_MAX_VERSIONS;
|
||||
use rustfs_utils::EnvParseOutcome;
|
||||
use std::io::{Error, Result};
|
||||
|
||||
pub(crate) async fn init_startup_runtime_foundation(config: &Config) -> Result<()> {
|
||||
log_startup_runtime_diagnostics();
|
||||
init_profiling_runtime().await;
|
||||
rustfs_trusted_proxies::init();
|
||||
install_default_crypto_provider();
|
||||
init_object_max_versions_config()?;
|
||||
init_outbound_tls_material(config).await
|
||||
}
|
||||
|
||||
fn init_object_max_versions_config() -> Result<()> {
|
||||
let limit = match rustfs_utils::get_env_parse_outcome::<u64>(ENV_API_OBJECT_MAX_VERSIONS) {
|
||||
EnvParseOutcome::Absent => rustfs_filemeta::DEFAULT_OBJECT_MAX_VERSIONS,
|
||||
EnvParseOutcome::Invalid => {
|
||||
return Err(Error::other(format!(
|
||||
"{ENV_API_OBJECT_MAX_VERSIONS} must be a positive integer no greater than {}",
|
||||
usize::MAX
|
||||
)));
|
||||
}
|
||||
EnvParseOutcome::Parsed(value) => object_max_versions_limit_from_u64(value)?,
|
||||
};
|
||||
|
||||
rustfs_filemeta::set_object_max_versions(limit).map_err(Error::other)
|
||||
}
|
||||
|
||||
fn object_max_versions_limit_from_u64(value: u64) -> Result<usize> {
|
||||
if value == 0 {
|
||||
return Err(Error::other(format!(
|
||||
"{ENV_API_OBJECT_MAX_VERSIONS} must be a positive integer no greater than {}",
|
||||
usize::MAX
|
||||
)));
|
||||
}
|
||||
|
||||
usize::try_from(value).map_err(|_| {
|
||||
Error::other(format!(
|
||||
"{ENV_API_OBJECT_MAX_VERSIONS} must be a positive integer no greater than {}",
|
||||
usize::MAX
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct ObjectMaxVersionsRestore {
|
||||
previous: usize,
|
||||
}
|
||||
|
||||
impl Drop for ObjectMaxVersionsRestore {
|
||||
fn drop(&mut self) {
|
||||
rustfs_filemeta::set_object_max_versions(self.previous).expect("restore object max versions limit after test");
|
||||
}
|
||||
}
|
||||
|
||||
fn with_object_max_versions_env<R>(rustfs_value: Option<&str>, minio_value: Option<&str>, test: impl FnOnce() -> R) -> R {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
let _serial = LOCK.lock().expect("serialize object max versions env tests");
|
||||
let previous = rustfs_filemeta::object_max_versions();
|
||||
let _restore = ObjectMaxVersionsRestore { previous };
|
||||
|
||||
temp_env::with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_API_OBJECT_MAX_VERSIONS, rustfs_value),
|
||||
("MINIO_API_OBJECT_MAX_VERSIONS", minio_value),
|
||||
],
|
||||
test,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_max_versions_env_sets_filemeta_limit() {
|
||||
with_object_max_versions_env(Some("3"), None, || {
|
||||
init_object_max_versions_config().expect("valid object max versions env must initialize");
|
||||
assert_eq!(rustfs_filemeta::object_max_versions(), 3);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minio_object_max_versions_env_alias_sets_filemeta_limit() {
|
||||
with_object_max_versions_env(None, Some("4"), || {
|
||||
init_object_max_versions_config().expect("valid MinIO alias must initialize");
|
||||
assert_eq!(rustfs_filemeta::object_max_versions(), 4);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_max_versions_env_rejects_zero() {
|
||||
with_object_max_versions_env(Some("0"), None, || {
|
||||
let err = init_object_max_versions_config().expect_err("zero object max versions must fail startup config");
|
||||
assert!(err.to_string().contains(rustfs_config::ENV_API_OBJECT_MAX_VERSIONS));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_max_versions_env_rejects_malformed_value() {
|
||||
with_object_max_versions_env(Some("not-a-number"), None, || {
|
||||
let err = init_object_max_versions_config().expect_err("malformed object max versions must fail startup config");
|
||||
assert!(err.to_string().contains(rustfs_config::ENV_API_OBJECT_MAX_VERSIONS));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +360,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +535,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: vec![first, second],
|
||||
..Default::default()
|
||||
};
|
||||
let captured = CapturedLog::default();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
@@ -593,6 +595,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: vec![pool_zero, pool_one],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(inventory_capacity(&incomplete_widths).expect("numeric fallback"), (300, 120));
|
||||
}
|
||||
@@ -614,6 +617,7 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: vec![pool_zero_set_zero, pool_zero_set_one, pool_one],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(inventory_capacity(&info).expect("configured topology"), (400, 160));
|
||||
|
||||
@@ -36,9 +36,12 @@ use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
||||
use tracing::debug;
|
||||
|
||||
const DERIVED_LARGE_PUT_ADMISSION_LIMIT_MAX: usize = 32;
|
||||
// A queued multipart part holds a connection but no body, so the queue can be
|
||||
// several times deeper than the permit pool. Sixteen uploads sending sixteen
|
||||
// parts each through one node fits inside the derived depth of 32 * 16.
|
||||
// A queued multipart part holds a connection but no user-space body buffer on
|
||||
// HTTP/1 (only whatever unread body the client already pushed into the kernel
|
||||
// receive buffer); on HTTP/2 it holds up to the per-stream flow-control window
|
||||
// in process memory. Either way the queue can be several times deeper than the
|
||||
// permit pool. Sixteen uploads sending sixteen parts each through one node
|
||||
// fits inside the derived depth of 32 * 16.
|
||||
const DERIVED_MULTIPART_ADMISSION_MAX_PENDING_FACTOR: usize = 16;
|
||||
// Framed S2 alone can retain one encoded and one decoded block of roughly
|
||||
// 4 MiB each, while other codecs have their own larger windows. Four keeps
|
||||
@@ -332,6 +335,16 @@ impl ForegroundWriteAdmissionPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn multipart_wait_timeout_for_test(&self) -> Option<Duration> {
|
||||
match self {
|
||||
Self::Large {
|
||||
multipart_wait_timeout, ..
|
||||
} => Some(*multipart_wait_timeout),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn strict_for_test(enabled: bool, limit: usize, wait_timeout: Duration) -> Self {
|
||||
if enabled {
|
||||
@@ -1228,8 +1241,9 @@ mod integration_tests {
|
||||
use super::super::io_schedule::{IoLoadLevel, IoPriority};
|
||||
use super::super::request_guard::GetObjectGuard;
|
||||
use super::{
|
||||
ConcurrencyManager, ForegroundWriteAdmission, SNOWBALL_ARCHIVE_DECODER_LIMIT, SNOWBALL_MEMBER_COMMIT_LIMIT,
|
||||
SNOWBALL_STAGING_BYTES_LIMIT, derive_large_put_admission_limit, derive_multipart_admission_max_pending,
|
||||
ConcurrencyManager, ForegroundWriteAdmission, ForegroundWriteAdmissionPolicy, SNOWBALL_ARCHIVE_DECODER_LIMIT,
|
||||
SNOWBALL_MEMBER_COMMIT_LIMIT, SNOWBALL_STAGING_BYTES_LIMIT, derive_large_put_admission_limit,
|
||||
derive_multipart_admission_max_pending,
|
||||
};
|
||||
use crate::storage::storage_api::concurrency_consumer::PutObjectGuard;
|
||||
use rustfs_concurrency::{AdmissionState, WorkloadAdmissionSnapshotProvider, WorkloadClass};
|
||||
@@ -1742,6 +1756,51 @@ mod integration_tests {
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial]
|
||||
async fn test_concurrency_manager_multipart_part_zero_wait_rejects_without_queueing() {
|
||||
let manager = ConcurrencyManager::with_multipart_admission_queue_for_test(1, Duration::ZERO, 4);
|
||||
let held = manager
|
||||
.admit_multipart_part(1024)
|
||||
.await
|
||||
.expect("first multipart part admission should acquire");
|
||||
|
||||
let rejected = manager
|
||||
.admit_multipart_part(1024)
|
||||
.await
|
||||
.expect("zero multipart wait should reject, not close");
|
||||
assert!(matches!(rejected, ForegroundWriteAdmission::Rejected));
|
||||
assert_eq!(manager.put_object_admission_snapshot().queued, Some(0));
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_concurrency_manager_multipart_wait_default_stays_below_sdk_write_timeouts() {
|
||||
let unset = [
|
||||
(rustfs_config::ENV_PUT_FOREGROUND_ADMISSION_ENABLE, None::<&str>),
|
||||
(rustfs_config::ENV_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE, None),
|
||||
(rustfs_config::ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS, None),
|
||||
];
|
||||
temp_env::with_vars(unset, || {
|
||||
let policy = ForegroundWriteAdmissionPolicy::from_env(64);
|
||||
// A queued part stalls the client's socket write for the whole wait, so the
|
||||
// default must answer with `SlowDown` before mainstream SDK write timeouts.
|
||||
assert_eq!(policy.multipart_wait_timeout_for_test(), Some(Duration::from_secs(10)));
|
||||
});
|
||||
|
||||
let overridden = [
|
||||
(rustfs_config::ENV_PUT_FOREGROUND_ADMISSION_ENABLE, None::<&str>),
|
||||
(rustfs_config::ENV_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE, None),
|
||||
(rustfs_config::ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS, Some("60000")),
|
||||
];
|
||||
temp_env::with_vars(overridden, || {
|
||||
let policy = ForegroundWriteAdmissionPolicy::from_env(64);
|
||||
// The bound applies to the default only; operators may still raise the wait.
|
||||
assert_eq!(policy.multipart_wait_timeout_for_test(), Some(Duration::from_secs(60)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_concurrency_manager_derives_multipart_admission_max_pending_from_limit() {
|
||||
assert_eq!(derive_multipart_admission_max_pending(7, 32), 7);
|
||||
|
||||
@@ -119,6 +119,7 @@ mod tests {
|
||||
drives_per_set: vec![4],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let buf = encode_msgpack_map(&value).unwrap();
|
||||
|
||||
@@ -726,6 +726,7 @@ mod tests {
|
||||
assert_eq!(decoded.info().bitrot_start_cycle, 9);
|
||||
assert_eq!(decoded.operations.queue_length, 2);
|
||||
assert_eq!(decoded.operations.queued_by_source.mrf, 0);
|
||||
assert_eq!(decoded.operations.admission, rustfs_heal::HealAdmissionTelemetry::default());
|
||||
let progress = decoded.progress.expect("legacy progress should decode");
|
||||
assert_eq!(progress.objects_scanned, 7);
|
||||
assert!(!progress.baseline_known);
|
||||
|
||||
@@ -268,6 +268,7 @@ mod tests {
|
||||
drives_per_set: vec![4, 4],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let encoded = encode_msgpack_map(&info).expect("storage info should serialize");
|
||||
|
||||
@@ -479,6 +479,8 @@ pub(crate) mod ecstore_error {
|
||||
pub(crate) use rustfs_ecstore::api::error::{
|
||||
Error, Result, StorageError, is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::error::{PoolMetadataError, PoolMetadataFailure};
|
||||
}
|
||||
|
||||
pub(crate) mod ecstore_event {
|
||||
|
||||
@@ -76,11 +76,15 @@ pub(crate) mod config_test {
|
||||
|
||||
pub(crate) mod error {
|
||||
pub(crate) mod contract {
|
||||
pub(crate) use super::super::storage_contracts::error::StorageErrorCode;
|
||||
|
||||
pub(crate) mod range {
|
||||
pub(crate) use super::super::super::storage_contracts::HTTPRangeError;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::storage::storage_api::ecstore_error::{PoolMetadataError, PoolMetadataFailure};
|
||||
pub(crate) use crate::storage::storage_api::{QuotaError, StorageError};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,10 +47,24 @@ def validate_report(report, *, round_number, pid, objects, budget):
|
||||
continue
|
||||
if type(value) is not str or not 0 < len(value.encode("utf-8")) <= 512:
|
||||
raise ValueError(f"invalid raw entry marker: {key}")
|
||||
if "raw_page_index_parent" not in report:
|
||||
raise ValueError("missing raw page index parent")
|
||||
raw_page_index_parent = report.get("raw_page_index_parent")
|
||||
if raw_page_index_parent is not None and (type(raw_page_index_parent) is not str
|
||||
or not 0 < len(raw_page_index_parent.encode("utf-8")) <= 512):
|
||||
raise ValueError("invalid raw page index parent")
|
||||
if type(report.get("raw_page_index_complete")) is not bool:
|
||||
raise ValueError("missing raw page index completeness")
|
||||
if type(report.get("snapshot_complete")) is not bool:
|
||||
raise ValueError("missing explicit completeness")
|
||||
if report.get("outcome") not in ("complete", "partial", "cancelled_without_cache"):
|
||||
raise ValueError("unexpected scanner outcome")
|
||||
if report["raw_page_index_committed_entries"] > report["raw_page_index_indexed_entries"]:
|
||||
raise ValueError("raw page index committed entries exceed indexed entries")
|
||||
if report["raw_page_index_parent"] == "bucket" and report["raw_page_index_indexed_entries"] > objects:
|
||||
raise ValueError("raw page index exceeds fixture object count")
|
||||
if report["objects_retained"] > report["objects_before"] + report["objects_processed"]:
|
||||
raise ValueError("retained coverage advanced beyond classified object work")
|
||||
|
||||
|
||||
def converged(report, objects):
|
||||
@@ -66,6 +80,38 @@ def replays_raw_window(previous, current):
|
||||
and current["objects_retained"] == previous["objects_retained"])
|
||||
|
||||
|
||||
def validate_recoverable_quantum(reports, *, objects, budget, require_converged):
|
||||
if not reports:
|
||||
raise ValueError("no scanner restart reports were produced")
|
||||
previous = None
|
||||
made_enumeration_progress = False
|
||||
made_classification_progress = False
|
||||
made_durable_progress = False
|
||||
for index, report in enumerate(reports):
|
||||
validate_report(report, round_number=index, pid=report["pid"], objects=objects, budget=budget)
|
||||
if previous is not None:
|
||||
if report["objects_before"] != previous["objects_retained"]:
|
||||
raise ValueError("durable retained coverage did not survive process restart")
|
||||
if report["objects_retained"] < previous["objects_retained"]:
|
||||
raise ValueError("durable retained coverage regressed across restart")
|
||||
if (report["raw_page_index_parent"] == previous["raw_page_index_parent"]
|
||||
and report["raw_page_index_committed_entries"] < previous["raw_page_index_committed_entries"]
|
||||
and not previous["raw_page_index_complete"]):
|
||||
raise ValueError("committed raw enumeration page coverage regressed before completion")
|
||||
made_enumeration_progress |= report["raw_entries"] > 0 or report["raw_page_index_indexed_entries"] > 0
|
||||
made_classification_progress |= report["objects_processed"] > 0
|
||||
made_durable_progress |= report["objects_retained"] > report["objects_before"]
|
||||
previous = report
|
||||
if not made_enumeration_progress:
|
||||
raise ValueError("restart proof did not exercise raw enumeration")
|
||||
if not made_classification_progress:
|
||||
raise ValueError("restart proof did not exercise object classification")
|
||||
if not made_durable_progress:
|
||||
raise ValueError("restart proof did not persist processed object coverage")
|
||||
if require_converged and not converged(reports[-1], objects):
|
||||
raise ValueError("fixed-budget restart convergence was not established")
|
||||
|
||||
|
||||
def run(args):
|
||||
binary = args.test_binary.resolve(strict=True)
|
||||
listed = subprocess.run([str(binary), WORKER, "--exact", "--list"],
|
||||
@@ -103,15 +149,15 @@ def run(args):
|
||||
report = json.loads(raw)
|
||||
validate_report(report, round_number=round_number, pid=worker.pid,
|
||||
objects=args.objects, budget=args.raw_entry_budget)
|
||||
if reports and report["objects_before"] != reports[-1]["objects_retained"]:
|
||||
raise ValueError("cache coverage did not survive the process boundary")
|
||||
if reports and replays_raw_window(reports[-1], report):
|
||||
replayed_raw_window = True
|
||||
reports.append(report)
|
||||
print(json.dumps(report, sort_keys=True), flush=True)
|
||||
if converged(report, args.objects):
|
||||
print("PASS: bounded scanner-worker restart convergence for this fixture only")
|
||||
validate_recoverable_quantum(reports, objects=args.objects, budget=args.raw_entry_budget, require_converged=True)
|
||||
print("PASS: bounded scanner-worker restart convergence with enumeration/classification/processing evidence")
|
||||
return 0
|
||||
validate_recoverable_quantum(reports, objects=args.objects, budget=args.raw_entry_budget, require_converged=False)
|
||||
reason = "replayed raw enumeration window" if replayed_raw_window else "no bounded restart convergence"
|
||||
print(f"FAIL: fixed-budget restart convergence not established ({reason}); R-E gate remains unmet",
|
||||
file=sys.stderr)
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
10|crates/ecstore/src/cluster/rpc/remote_disk.rs
|
||||
6|crates/ecstore/src/config/com.rs
|
||||
14|crates/ecstore/src/config/storageclass.rs
|
||||
182|crates/ecstore/src/core/pools.rs
|
||||
180|crates/ecstore/src/core/pools.rs
|
||||
7|crates/ecstore/src/data_movement/mod.rs
|
||||
2|crates/ecstore/src/data_usage/local_snapshot.rs
|
||||
12|crates/ecstore/src/data_usage/mod.rs
|
||||
@@ -66,7 +66,7 @@
|
||||
3|crates/ecstore/src/set_disk/read.rs
|
||||
5|crates/ecstore/src/store/bucket.rs
|
||||
1|crates/ecstore/src/store/heal_walk.rs
|
||||
12|crates/ecstore/src/store/init.rs
|
||||
10|crates/ecstore/src/store/init.rs
|
||||
3|crates/ecstore/src/store/multipart.rs
|
||||
6|crates/ecstore/src/store/object.rs
|
||||
5|crates/ecstore/src/store/rebalance/support.rs
|
||||
|
||||
@@ -159,6 +159,13 @@ fi
|
||||
|
||||
case_field "$CASE_ID" name >/dev/null
|
||||
TEST_FILTER="$(test_filter_for "$CASE_ID")"
|
||||
case "$CASE_ID" in
|
||||
background-target-crash|background-target-restart)
|
||||
export RUSTFS_HEAL_CHAOS_OBJECT_COUNT="${RUSTFS_HEAL_CHAOS_OBJECT_COUNT:-64}"
|
||||
export RUSTFS_HEAL_CHAOS_OBJECT_SIZE_BYTES="${RUSTFS_HEAL_CHAOS_OBJECT_SIZE_BYTES:-16777216}"
|
||||
export RUSTFS_HEAL_CHAOS_PARTIAL_TIMEOUT_SECS="${RUSTFS_HEAL_CHAOS_PARTIAL_TIMEOUT_SECS:-120}"
|
||||
;;
|
||||
esac
|
||||
if [[ -z "$RUN_DIR" ]]; then
|
||||
RUN_DIR="$ROOT/target/scanner-heal-evidence/${CASE_ID}-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
elif [[ "$RUN_DIR" != /* ]]; then
|
||||
@@ -197,6 +204,11 @@ fi
|
||||
printf '%s' "$BUILD_FEATURES" >"$ROOT/target/debug/rustfs.features"
|
||||
|
||||
LISTING_TMP="$TMP_DIR/listing.json"
|
||||
NO_PROXY="${NO_PROXY:-127.0.0.1,localhost}" \
|
||||
HTTP_PROXY= \
|
||||
HTTPS_PROXY= \
|
||||
RUSTFS_SCANNER_HEAL_RUN_DIR="$RUN_DIR" \
|
||||
cargo nextest run --profile "$PROFILE" -p e2e_test -E "$TEST_FILTER" --no-run --no-tests=fail
|
||||
cargo nextest list --profile "$PROFILE" -p e2e_test -E "$TEST_FILTER" --message-format json >"$LISTING_TMP"
|
||||
TEST_BINARY="$(test_binary_from_listing "$LISTING_TMP" "$CASE_ID")"
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import unittest
|
||||
|
||||
from diagnose_scanner_enumeration_restart import converged, replays_raw_window, validate_report
|
||||
from diagnose_scanner_enumeration_restart import (
|
||||
converged,
|
||||
replays_raw_window,
|
||||
validate_recoverable_quantum,
|
||||
validate_report,
|
||||
)
|
||||
|
||||
|
||||
class ReportTests(unittest.TestCase):
|
||||
@@ -10,6 +15,7 @@ class ReportTests(unittest.TestCase):
|
||||
return dict(schema=1, round=0, pid=123, objects_expected=4, raw_entry_budget=16,
|
||||
raw_entries=8, raw_name_bytes=64, objects_before=0, objects_retained=4,
|
||||
versions_retained=4, bytes_retained=4, objects_processed=4,
|
||||
raw_page_index_parent="bucket", raw_page_index_complete=True,
|
||||
raw_page_index_committed_entries=4,
|
||||
raw_page_index_indexed_entries=4,
|
||||
raw_first_entry="bucket/object-0000",
|
||||
@@ -46,6 +52,26 @@ class ReportTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
self.validate(report)
|
||||
|
||||
def test_raw_page_index_ordering_and_bounds_are_checked(self):
|
||||
report = self.report()
|
||||
report["raw_page_index_committed_entries"] = 5
|
||||
report["raw_page_index_indexed_entries"] = 4
|
||||
with self.assertRaisesRegex(ValueError, "committed entries exceed indexed entries"):
|
||||
self.validate(report)
|
||||
|
||||
report = self.report()
|
||||
report["raw_page_index_indexed_entries"] = 5
|
||||
with self.assertRaisesRegex(ValueError, "exceeds fixture object count"):
|
||||
self.validate(report)
|
||||
|
||||
def test_retained_coverage_cannot_advance_without_classified_work(self):
|
||||
report = self.report()
|
||||
report["objects_before"] = 1
|
||||
report["objects_processed"] = 1
|
||||
report["objects_retained"] = 3
|
||||
with self.assertRaisesRegex(ValueError, "advanced beyond classified object work"):
|
||||
self.validate(report)
|
||||
|
||||
report = self.report()
|
||||
report["objects_processed"] = 17
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -73,6 +99,11 @@ class ReportTests(unittest.TestCase):
|
||||
report["raw_first_entry"] = None
|
||||
report["raw_last_entry"] = None
|
||||
report["objects_processed"] = 1
|
||||
report["objects_retained"] = 1
|
||||
report["versions_retained"] = 1
|
||||
report["bytes_retained"] = 1
|
||||
report["snapshot_complete"] = False
|
||||
report["outcome"] = "partial"
|
||||
self.validate(report)
|
||||
|
||||
def test_missing_wrong_type_and_negative_counter_rejected(self):
|
||||
@@ -84,7 +115,7 @@ class ReportTests(unittest.TestCase):
|
||||
self.validate(report)
|
||||
|
||||
def test_missing_completeness_or_unknown_outcome_rejected(self):
|
||||
for key in ("snapshot_complete", "outcome"):
|
||||
for key in ("raw_page_index_parent", "raw_page_index_complete", "snapshot_complete", "outcome"):
|
||||
report = self.report()
|
||||
del report[key]
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -109,6 +140,59 @@ class ReportTests(unittest.TestCase):
|
||||
advanced = dict(current, objects_retained=1)
|
||||
self.assertFalse(replays_raw_window(previous, advanced))
|
||||
|
||||
def test_recoverable_quantum_requires_three_stage_progress_and_convergence(self):
|
||||
first = self.report()
|
||||
first.update(round=0, pid=123, raw_entries=2, raw_page_index_committed_entries=2,
|
||||
raw_page_index_indexed_entries=2, objects_processed=2, objects_before=0,
|
||||
objects_retained=2, versions_retained=2, bytes_retained=2,
|
||||
snapshot_complete=False, outcome="partial")
|
||||
second = self.report()
|
||||
second.update(round=1, pid=124, raw_entries=2, raw_page_index_committed_entries=4,
|
||||
raw_page_index_indexed_entries=4, objects_processed=2, objects_before=2,
|
||||
objects_retained=4, snapshot_complete=True, outcome="complete")
|
||||
validate_recoverable_quantum([first, second], objects=4, budget=16, require_converged=True)
|
||||
|
||||
def test_recoverable_quantum_rejects_restart_regression(self):
|
||||
first = self.report()
|
||||
first.update(snapshot_complete=False, outcome="partial", objects_retained=2,
|
||||
versions_retained=2, bytes_retained=2)
|
||||
second = self.report()
|
||||
second.update(round=1, pid=124, objects_before=1, objects_retained=1,
|
||||
versions_retained=1, bytes_retained=1, snapshot_complete=False,
|
||||
outcome="partial")
|
||||
with self.assertRaisesRegex(ValueError, "did not survive process restart"):
|
||||
validate_recoverable_quantum([first, second], objects=4, budget=16, require_converged=False)
|
||||
|
||||
def test_recoverable_quantum_allows_new_raw_page_parent_after_processing(self):
|
||||
first = self.report()
|
||||
first.update(snapshot_complete=False, outcome="partial", objects_before=0,
|
||||
objects_processed=0, objects_retained=0, versions_retained=0,
|
||||
bytes_retained=0, raw_page_index_parent="bucket",
|
||||
raw_page_index_complete=True, raw_page_index_committed_entries=4,
|
||||
raw_page_index_indexed_entries=4)
|
||||
second = self.report()
|
||||
second.update(round=1, pid=124, raw_entries=0, raw_first_entry=None,
|
||||
raw_last_entry=None, raw_name_bytes=0, objects_before=0,
|
||||
objects_processed=2, objects_retained=2,
|
||||
versions_retained=2, bytes_retained=2,
|
||||
snapshot_complete=False, outcome="partial",
|
||||
raw_page_index_parent="bucket/object-0000",
|
||||
raw_page_index_complete=False,
|
||||
raw_page_index_committed_entries=1,
|
||||
raw_page_index_indexed_entries=1)
|
||||
validate_recoverable_quantum([first, second], objects=4, budget=16, require_converged=False)
|
||||
|
||||
def test_recoverable_quantum_rejects_missing_processing_stage(self):
|
||||
report = self.report()
|
||||
report["objects_processed"] = 0
|
||||
report["objects_retained"] = 0
|
||||
report["versions_retained"] = 0
|
||||
report["bytes_retained"] = 0
|
||||
report["snapshot_complete"] = False
|
||||
report["outcome"] = "partial"
|
||||
with self.assertRaisesRegex(ValueError, "object classification"):
|
||||
validate_recoverable_quantum([report], objects=4, budget=16, require_converged=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user