mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-18 10:43:15 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abca7ddfd7 | |||
| 68547ed7ea | |||
| f06a9c9cba | |||
| bd296eff9e | |||
| a5800033bd | |||
| a4ea36b298 | |||
| 4f68f117ba | |||
| 0f30a75fdb |
@@ -66,8 +66,8 @@ s3s-footprint-check: ## Check the s3s dependency footprint ratchet stays frozen
|
||||
./scripts/check_s3s_footprint.sh
|
||||
|
||||
.PHONY: fips-wording-check
|
||||
fips-wording-check: ## Check outward docs do not make unsupported FIPS claims
|
||||
@echo "📣 Checking FIPS wording guard..."
|
||||
fips-wording-check: ## Check docs and crates/kms do not over-claim crypto capabilities
|
||||
@echo "📣 Checking cryptographic capability wording guard..."
|
||||
./scripts/check_fips_wording.sh
|
||||
|
||||
.PHONY: log-analyzer-rules-check
|
||||
|
||||
@@ -117,6 +117,9 @@ jobs:
|
||||
- name: Check s3s footprint ratchet
|
||||
run: ./scripts/check_s3s_footprint.sh
|
||||
|
||||
- name: Check cryptographic capability wording
|
||||
run: ./scripts/check_fips_wording.sh
|
||||
|
||||
- name: Check no planning docs committed
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
|
||||
@@ -152,6 +152,9 @@ jobs:
|
||||
- name: Check s3s footprint ratchet
|
||||
run: ./scripts/check_s3s_footprint.sh
|
||||
|
||||
- name: Check cryptographic capability wording
|
||||
run: ./scripts/check_fips_wording.sh
|
||||
|
||||
- name: Check no planning docs committed
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
|
||||
+4
-4
@@ -31,7 +31,7 @@ HTTP request
|
||||
→ storage/ecfs (erasure coding, encryption, checksums)
|
||||
→ ecstore (disk pool selection, data distribution)
|
||||
→ rio (reader pipeline: encrypt → compress → hash → write)
|
||||
→ io-core (zero-copy I/O, buffer pool, direct I/O)
|
||||
→ io-core (buffer pool, storage profiling, admission control)
|
||||
→ local disk / remote disk via RPC
|
||||
```
|
||||
|
||||
@@ -55,7 +55,7 @@ rustfs/ # Workspace root (virtual manifest)
|
||||
├── crates/ # library crates (authoritative list: Cargo.toml [workspace].members)
|
||||
│ ├── ecstore/ # Erasure-coded storage engine
|
||||
│ ├── rio/ # Reader I/O pipeline (encrypt, compress, hash)
|
||||
│ ├── io-core/ # Zero-copy I/O, scheduling, buffer pool
|
||||
│ ├── io-core/ # Buffer pool, storage profiling, admission control
|
||||
│ ├── io-metrics/ # I/O metrics collection
|
||||
│ ├── common/ # Shared runtime state, globals, data usage types
|
||||
│ ├── config/ # Configuration types and parsing
|
||||
@@ -302,7 +302,7 @@ The binary (`main.rs`) boots in this order:
|
||||
│ │ │
|
||||
┌─────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||
│ ecstore │ │ rio │ │ io-core │
|
||||
│ (core) │ │ (readers) │ │ (zero-copy) │
|
||||
│ (core) │ │ (readers) │ │ (buffers) │
|
||||
└─────┬──────┘ └─────────────┘ └─────────────┘
|
||||
│
|
||||
┌─────┬──┼──┬─────┬──────┐
|
||||
@@ -314,7 +314,7 @@ The binary (`main.rs`) boots in this order:
|
||||
|
||||
- **"Where does S3 PutObject go?"**
|
||||
`server/` routes → `app/object_usecase` validates → `storage/ecfs` encodes →
|
||||
`ecstore` distributes → `rio` encrypts/compresses → `io-core` writes
|
||||
`ecstore` distributes → `rio` encrypts/compresses → `io-core` supplies buffers
|
||||
|
||||
- **"Where are bucket policies enforced?"**
|
||||
`app/bucket_usecase` calls into `crates/policy/`
|
||||
|
||||
Generated
+3
-1
@@ -9465,6 +9465,7 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tonic",
|
||||
"tonic-prost",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
@@ -9538,6 +9539,8 @@ version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"crc-fast",
|
||||
"futures",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
@@ -9610,7 +9613,6 @@ version = "1.0.0-rc.2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"hotpath",
|
||||
"memmap2",
|
||||
"rustfs-io-metrics",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
|
||||
@@ -224,6 +224,13 @@ pub struct HealOpts {
|
||||
pub enum HealAdmissionDropReason {
|
||||
QueueFull,
|
||||
PolicyDropped,
|
||||
/// HS-06: an admin heal start overlaps (same bucket with mutually
|
||||
/// containing prefixes, or the same erasure set) an already running or
|
||||
/// queued task. Only produced when RUSTFS_HEAL_OVERLAP_POLICY=minio_error.
|
||||
AlreadyRunning,
|
||||
/// HS-06: same as [`Self::AlreadyRunning`] but for paths that merely
|
||||
/// contain (or are contained by) the active task's path.
|
||||
OverlappingPaths,
|
||||
}
|
||||
|
||||
impl HealAdmissionDropReason {
|
||||
@@ -231,6 +238,8 @@ impl HealAdmissionDropReason {
|
||||
match self {
|
||||
Self::QueueFull => "queue_full",
|
||||
Self::PolicyDropped => "policy_dropped",
|
||||
Self::AlreadyRunning => "already_running",
|
||||
Self::OverlappingPaths => "overlapping_paths",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,6 +326,9 @@ pub enum HealChannelCommand {
|
||||
Query {
|
||||
heal_path: String,
|
||||
client_token: String,
|
||||
/// Incremental result cursor (HS-06): only items with a sequence
|
||||
/// greater than this are returned; `None` keeps the full snapshot.
|
||||
since_seq: Option<u64>,
|
||||
response_tx: oneshot::Sender<Result<HealChannelResponse, String>>,
|
||||
},
|
||||
/// Cancel heal task
|
||||
@@ -522,10 +534,21 @@ async fn receive_heal_channel_response(
|
||||
|
||||
/// Send heal query request
|
||||
pub async fn query_heal_status(heal_path: String, client_token: String) -> Result<HealChannelResponse, String> {
|
||||
query_heal_status_since(heal_path, client_token, None).await
|
||||
}
|
||||
|
||||
/// Incremental heal query (HS-06): pass the client's last seen sequence
|
||||
/// number to receive only newer result items.
|
||||
pub async fn query_heal_status_since(
|
||||
heal_path: String,
|
||||
client_token: String,
|
||||
since_seq: Option<u64>,
|
||||
) -> Result<HealChannelResponse, String> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
send_heal_command(HealChannelCommand::Query {
|
||||
heal_path,
|
||||
client_token,
|
||||
since_seq,
|
||||
response_tx,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -205,3 +205,12 @@ pub const DEFAULT_HEAL_MRF_JOURNAL_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Default MRF replay batch size.
|
||||
pub const DEFAULT_HEAL_MRF_REPLAY_BATCH: usize = 256;
|
||||
|
||||
/// Environment variable selecting how admin heal starts behave when the
|
||||
/// requested path overlaps an already running or queued heal: `merge`
|
||||
/// (default, keep today's dedup/merge semantics) or `minio_error` (return a
|
||||
/// typed already-running / overlapping-paths rejection like madmin).
|
||||
pub const ENV_HEAL_OVERLAP_POLICY: &str = "RUSTFS_HEAL_OVERLAP_POLICY";
|
||||
|
||||
/// Default overlap policy: merge duplicate/overlapping requests.
|
||||
pub const DEFAULT_HEAL_OVERLAP_POLICY: &str = "merge";
|
||||
|
||||
@@ -30,7 +30,6 @@ use md5::{Digest as Md5Digest, Md5};
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::sign_v4;
|
||||
use s3s::Body;
|
||||
use serial_test::serial;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::io::Cursor;
|
||||
@@ -356,7 +355,6 @@ async fn run_post_object_policy_case(
|
||||
/// smuggles one extra field the policy never declared, and the upload must be
|
||||
/// rejected with 403 AccessDenied naming the offending field.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_fields_missing_from_policy_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -484,7 +482,6 @@ async fn test_anonymous_post_object_rejects_fields_missing_from_policy_condition
|
||||
/// sends a different one, and the upload must be rejected with 400
|
||||
/// InvalidPolicyDocument naming the field.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_exact_condition_policy_mismatches()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -689,7 +686,6 @@ async fn test_anonymous_post_object_rejects_exact_condition_policy_mismatches()
|
||||
/// one of them with a different value, and the upload must be rejected with
|
||||
/// 400 InvalidPolicyDocument naming the mismatched field.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_object_lock_policy_mismatches() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
@@ -757,7 +753,6 @@ async fn test_anonymous_post_object_rejects_object_lock_policy_mismatches() -> R
|
||||
/// exact values, the form sends a different parameter value, and the upload
|
||||
/// must be rejected with 400 InvalidPolicyDocument naming the parameter.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_sse_kms_policy_mismatches() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -839,7 +834,6 @@ async fn test_anonymous_post_object_rejects_sse_kms_policy_mismatches() -> Resul
|
||||
/// NotImplemented (SSE-KMS POST uploads are not implemented), not with a
|
||||
/// policy error.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_sse_kms_params_outside_policy_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -894,7 +888,6 @@ async fn test_anonymous_post_object_rejects_sse_kms_params_outside_policy_condit
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_multipart_control_apis_require_auth() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -968,7 +961,6 @@ async fn test_anonymous_multipart_control_apis_require_auth() -> Result<(), Box<
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_requires_auth() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -1002,7 +994,6 @@ async fn test_anonymous_post_object_requires_auth() -> Result<(), Box<dyn std::e
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_honors_success_action_status() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -1066,7 +1057,6 @@ async fn test_anonymous_post_object_honors_success_action_status() -> Result<(),
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_honors_success_action_redirect() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -1139,7 +1129,6 @@ async fn test_anonymous_post_object_honors_success_action_redirect() -> Result<(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_defaults_to_no_content() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -1185,7 +1174,6 @@ async fn test_anonymous_post_object_defaults_to_no_content() -> Result<(), Box<d
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_sse_kms() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -1232,7 +1220,6 @@ async fn test_anonymous_post_object_rejects_sse_kms() -> Result<(), Box<dyn std:
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_sse_s3() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -1290,7 +1277,6 @@ async fn test_anonymous_post_object_accepts_sse_s3() -> Result<(), Box<dyn std::
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_uses_bucket_default_sse_s3() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -1363,7 +1349,6 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_s3() -> Result<(), B
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -1437,7 +1422,6 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(),
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_sse_s3_policy_mismatch() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -1488,7 +1472,6 @@ async fn test_anonymous_post_object_rejects_sse_s3_policy_mismatch() -> Result<(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_sse_s3_missing_from_policy_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -1552,7 +1535,6 @@ async fn test_anonymous_post_object_accepts_sse_s3_missing_from_policy_condition
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_storage_class_exact_policy_match()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -1606,7 +1588,6 @@ async fn test_anonymous_post_object_accepts_storage_class_exact_policy_match()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_storage_class_missing_from_policy_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -1657,7 +1638,6 @@ async fn test_anonymous_post_object_rejects_storage_class_missing_from_policy_co
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_invalid_storage_class_value() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
@@ -1709,7 +1689,6 @@ async fn test_anonymous_post_object_rejects_invalid_storage_class_value() -> Res
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_checksum_algorithm_missing_from_policy_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -1765,7 +1744,6 @@ async fn test_anonymous_post_object_rejects_checksum_algorithm_missing_from_poli
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_checksum_algorithm_policy_mismatch()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -1822,7 +1800,6 @@ async fn test_anonymous_post_object_rejects_checksum_algorithm_policy_mismatch()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_checksum_auxiliary_fields_missing_from_policy_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -1886,7 +1863,6 @@ async fn test_anonymous_post_object_rejects_checksum_auxiliary_fields_missing_fr
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_allows_sse_c_fields_outside_policy_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -1963,7 +1939,6 @@ async fn test_anonymous_post_object_allows_sse_c_fields_outside_policy_condition
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_sse_c_exact_policy_mismatch() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
@@ -2022,7 +1997,6 @@ async fn test_anonymous_post_object_rejects_sse_c_exact_policy_mismatch() -> Res
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_duplicate_key_form_values() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -2072,7 +2046,6 @@ async fn test_anonymous_post_object_rejects_duplicate_key_form_values() -> Resul
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_invalid_success_action_status() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
@@ -2120,7 +2093,6 @@ async fn test_anonymous_post_object_rejects_invalid_success_action_status() -> R
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_invalid_success_action_redirect()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -2168,7 +2140,6 @@ async fn test_anonymous_post_object_rejects_invalid_success_action_redirect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_form_fields_missing_from_policy_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -2223,7 +2194,6 @@ async fn test_anonymous_post_object_rejects_form_fields_missing_from_policy_cond
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_form_fields_covered_by_policy_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -2280,7 +2250,6 @@ async fn test_anonymous_post_object_accepts_form_fields_covered_by_policy_condit
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_starts_with_policy_mismatch() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
@@ -2335,7 +2304,6 @@ async fn test_anonymous_post_object_rejects_starts_with_policy_mismatch() -> Res
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_content_length_range_violation()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -2388,7 +2356,6 @@ async fn test_anonymous_post_object_rejects_content_length_range_violation()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_success_action_status_exact_policy_match()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -2445,7 +2412,6 @@ async fn test_anonymous_post_object_accepts_success_action_status_exact_policy_m
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_success_action_redirect_policy_mismatch()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -2502,7 +2468,6 @@ async fn test_anonymous_post_object_rejects_success_action_redirect_policy_misma
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_success_action_redirect_exact_policy_match()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -2568,7 +2533,6 @@ async fn test_anonymous_post_object_accepts_success_action_redirect_exact_policy
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_success_action_redirect_missing_from_policy_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -2621,7 +2585,6 @@ async fn test_anonymous_post_object_rejects_success_action_redirect_missing_from
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_metadata_field_covered_by_starts_with()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -2676,7 +2639,6 @@ async fn test_anonymous_post_object_accepts_metadata_field_covered_by_starts_wit
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_content_type_field_exact_policy_match()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -2734,7 +2696,6 @@ async fn test_anonymous_post_object_accepts_content_type_field_exact_policy_matc
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_content_type_field_covered_by_starts_with()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -2792,7 +2753,6 @@ async fn test_anonymous_post_object_accepts_content_type_field_covered_by_starts
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_content_disposition_field_exact_policy_match()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -2850,7 +2810,6 @@ async fn test_anonymous_post_object_accepts_content_disposition_field_exact_poli
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_cache_control_field_exact_policy_match()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -2908,7 +2867,6 @@ async fn test_anonymous_post_object_accepts_cache_control_field_exact_policy_mat
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_content_language_field_exact_policy_match()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -2966,7 +2924,6 @@ async fn test_anonymous_post_object_accepts_content_language_field_exact_policy_
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_content_encoding_field_exact_policy_match()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -3024,7 +2981,6 @@ async fn test_anonymous_post_object_accepts_content_encoding_field_exact_policy_
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_website_redirect_location_exact_policy_match()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -3082,7 +3038,6 @@ async fn test_anonymous_post_object_accepts_website_redirect_location_exact_poli
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_expires_field_exact_policy_match()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -3140,7 +3095,6 @@ async fn test_anonymous_post_object_accepts_expires_field_exact_policy_match()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_object_lock_retention_without_permission()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -3196,7 +3150,6 @@ async fn test_anonymous_post_object_rejects_object_lock_retention_without_permis
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_object_lock_retention_missing_from_policy_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -3256,7 +3209,6 @@ async fn test_anonymous_post_object_rejects_object_lock_retention_missing_from_p
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_object_lock_legal_hold_without_permission()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -3309,7 +3261,6 @@ async fn test_anonymous_post_object_rejects_object_lock_legal_hold_without_permi
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_object_lock_legal_hold_policy_mismatch()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -3368,7 +3319,6 @@ async fn test_anonymous_post_object_rejects_object_lock_legal_hold_policy_mismat
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_object_lock_legal_hold_missing_from_policy_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -3426,7 +3376,6 @@ async fn test_anonymous_post_object_rejects_object_lock_legal_hold_missing_from_
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_tagging_field_exact_policy_match()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -3492,7 +3441,6 @@ async fn test_anonymous_post_object_accepts_tagging_field_exact_policy_match()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_metadata_field_exact_policy_match()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -3551,7 +3499,6 @@ async fn test_anonymous_post_object_accepts_metadata_field_exact_policy_match()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_allows_x_ignore_fields_outside_policy_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -3604,7 +3551,6 @@ async fn test_anonymous_post_object_allows_x_ignore_fields_outside_policy_condit
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_sigv4_date_policy_mismatch() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -3657,7 +3603,6 @@ async fn test_anonymous_post_object_rejects_sigv4_date_policy_mismatch() -> Resu
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_mismatched_bucket_form_field() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
@@ -3712,7 +3657,6 @@ async fn test_anonymous_post_object_rejects_mismatched_bucket_form_field() -> Re
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_multiple_bucket_values() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -3764,7 +3708,6 @@ async fn test_anonymous_post_object_rejects_multiple_bucket_values() -> Result<(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_rejects_extra_content_disposition_field()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -3820,7 +3763,6 @@ async fn test_anonymous_post_object_rejects_extra_content_disposition_field()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_expands_tar_entries_with_prefix_headers()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -3891,7 +3833,6 @@ async fn test_signed_put_object_extract_expands_tar_entries_with_prefix_headers(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_preserves_request_metadata_on_extracted_objects()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -3956,7 +3897,6 @@ async fn test_signed_put_object_extract_preserves_request_metadata_on_extracted_
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_preserves_sse_s3_and_redirect() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -4004,7 +3944,6 @@ async fn test_signed_put_object_extract_preserves_sse_s3_and_redirect() -> Resul
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_preserves_storage_class() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -4047,7 +3986,6 @@ async fn test_signed_put_object_extract_preserves_storage_class() -> Result<(),
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_rejects_invalid_storage_class() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -4083,7 +4021,6 @@ async fn test_signed_put_object_extract_rejects_invalid_storage_class() -> Resul
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_rejects_write_offset_bytes_header() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -4137,7 +4074,6 @@ async fn test_signed_put_object_rejects_write_offset_bytes_header() -> Result<()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_raw_signed_put_object_write_offset_bytes_returns_minio_compatible_error_body()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -4176,7 +4112,6 @@ async fn test_raw_signed_put_object_write_offset_bytes_returns_minio_compatible_
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_put_object_write_offset_bytes_returns_minio_compatible_error_body()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -4235,7 +4170,6 @@ async fn test_anonymous_put_object_write_offset_bytes_returns_minio_compatible_e
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_uses_bucket_default_sse_s3() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -4300,7 +4234,6 @@ async fn test_signed_put_object_extract_uses_bucket_default_sse_s3() -> Result<(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_rejects_bucket_default_sse_kms() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -4356,7 +4289,6 @@ async fn test_signed_put_object_extract_rejects_bucket_default_sse_kms() -> Resu
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_preserves_sse_c() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -4421,7 +4353,6 @@ async fn test_signed_put_object_extract_preserves_sse_c() -> Result<(), Box<dyn
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_preserves_object_lock_legal_hold() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
@@ -4476,7 +4407,6 @@ async fn test_signed_put_object_extract_preserves_object_lock_legal_hold() -> Re
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_preserves_object_lock_retention() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
@@ -4536,7 +4466,6 @@ async fn test_signed_put_object_extract_preserves_object_lock_retention() -> Res
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_pax_retention_overrides_request_retention()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -4600,7 +4529,6 @@ async fn test_signed_put_object_extract_pax_retention_overrides_request_retentio
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_returns_archive_etag() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -4634,7 +4562,6 @@ async fn test_signed_put_object_extract_returns_archive_etag() -> Result<(), Box
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_preserves_entry_mtime() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -4670,7 +4597,6 @@ async fn test_signed_put_object_extract_preserves_entry_mtime() -> Result<(), Bo
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_preserves_pax_metadata_and_version_id()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -4724,7 +4650,6 @@ async fn test_signed_put_object_extract_preserves_pax_metadata_and_version_id()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retention_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -5034,7 +4959,6 @@ async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retent
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_accepts_compat_header() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -5076,7 +5000,6 @@ async fn test_signed_put_object_extract_accepts_compat_header() -> Result<(), Bo
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_preserves_directory_markers_by_default()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -5137,7 +5060,6 @@ async fn test_signed_put_object_extract_preserves_directory_markers_by_default()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_expands_tar_gz_archive() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -5189,7 +5111,6 @@ async fn test_signed_put_object_extract_expands_tar_gz_archive() -> Result<(), B
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_expands_tgz_archive() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -5241,7 +5162,6 @@ async fn test_signed_put_object_extract_expands_tgz_archive() -> Result<(), Box<
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_expands_tbz2_archive() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -5293,7 +5213,6 @@ async fn test_signed_put_object_extract_expands_tbz2_archive() -> Result<(), Box
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_expands_txz_archive() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -5345,7 +5264,6 @@ async fn test_signed_put_object_extract_expands_txz_archive() -> Result<(), Box<
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_skips_invalid_entry_when_ignore_errors_enabled()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -5419,7 +5337,6 @@ async fn test_signed_put_object_extract_skips_invalid_entry_when_ignore_errors_e
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_normalizes_prefix_header_value() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -5462,7 +5379,6 @@ async fn test_signed_put_object_extract_normalizes_prefix_header_value() -> Resu
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_expands_tzst_archive() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -5514,7 +5430,6 @@ async fn test_signed_put_object_extract_expands_tzst_archive() -> Result<(), Box
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_rejects_missing_archive_extension() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
@@ -5548,7 +5463,6 @@ async fn test_signed_put_object_extract_rejects_missing_archive_extension() -> R
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_rejects_invalid_tar_gz_payload() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ use aws_sdk_s3::types::{
|
||||
ObjectLockMode, ObjectLockRetentionMode,
|
||||
};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serial_test::serial;
|
||||
use tracing::info;
|
||||
|
||||
/// Initialize test logging
|
||||
@@ -107,7 +106,6 @@ fn parse_s3_datetime(value: &aws_sdk_s3::primitives::DateTime) -> DateTime<Utc>
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_object_blocked_by_compliance_retention() {
|
||||
init_logging();
|
||||
info!("🧪 Test: DeleteObject blocked by COMPLIANCE retention");
|
||||
@@ -145,7 +143,6 @@ async fn test_delete_object_blocked_by_compliance_retention() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_object_blocked_by_governance_without_bypass() {
|
||||
init_logging();
|
||||
info!("🧪 Test: DeleteObject blocked by GOVERNANCE retention without bypass");
|
||||
@@ -175,7 +172,6 @@ async fn test_delete_object_blocked_by_governance_without_bypass() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_object_allowed_by_governance_with_bypass() {
|
||||
init_logging();
|
||||
info!("🧪 Test: DeleteObject allowed by GOVERNANCE retention with bypass");
|
||||
@@ -215,7 +211,6 @@ async fn test_delete_object_allowed_by_governance_with_bypass() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_object_creates_delete_marker_for_retained_current_version() {
|
||||
init_logging();
|
||||
info!("🧪 Test: DeleteObject creates delete marker for retained current version");
|
||||
@@ -266,7 +261,6 @@ async fn test_delete_object_creates_delete_marker_for_retained_current_version()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_object_blocked_by_legal_hold() {
|
||||
init_logging();
|
||||
info!("🧪 Test: DeleteObject blocked by Legal Hold");
|
||||
@@ -299,7 +293,6 @@ async fn test_delete_object_blocked_by_legal_hold() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_object_allowed_with_legal_hold_off() {
|
||||
init_logging();
|
||||
info!("🧪 Test: DeleteObject allowed with Legal Hold OFF");
|
||||
@@ -335,7 +328,6 @@ async fn test_delete_object_allowed_with_legal_hold_off() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_object_after_legal_hold_removed() {
|
||||
init_logging();
|
||||
info!("🧪 Test: DeleteObject succeeds after Legal Hold is removed");
|
||||
@@ -369,7 +361,6 @@ async fn test_delete_object_after_legal_hold_removed() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_get_object_legal_hold_returns_updated_status() {
|
||||
init_logging();
|
||||
info!("🧪 Test: GetObjectLegalHold returns updated status");
|
||||
@@ -425,7 +416,6 @@ async fn test_get_object_legal_hold_returns_updated_status() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_get_object_retention_returns_configured_values() {
|
||||
init_logging();
|
||||
info!("🧪 Test: GetObjectRetention returns configured values");
|
||||
@@ -476,7 +466,6 @@ async fn test_get_object_retention_returns_configured_values() {
|
||||
// creating a new current version. The lock protects the existing version
|
||||
// from deletion; it never blocks new versions.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_put_object_overwrite_creates_new_version_under_legal_hold() {
|
||||
init_logging();
|
||||
info!("🧪 Test: PutObject overwrite of a legal-hold version creates a new version");
|
||||
@@ -561,7 +550,6 @@ async fn test_put_object_overwrite_creates_new_version_under_legal_hold() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_object_applies_requested_legal_hold() {
|
||||
init_logging();
|
||||
info!("🧪 Test: CopyObject applies requested Legal Hold");
|
||||
@@ -613,7 +601,6 @@ async fn test_copy_object_applies_requested_legal_hold() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_object_does_not_inherit_source_legal_hold() {
|
||||
init_logging();
|
||||
info!("🧪 Test: CopyObject does not inherit source Legal Hold");
|
||||
@@ -707,7 +694,6 @@ async fn test_copy_object_does_not_inherit_source_legal_hold() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_object_overwrite_creates_new_version_under_legal_hold() {
|
||||
init_logging();
|
||||
info!("🧪 Test: CopyObject overwrite of a legal-hold destination creates a new version");
|
||||
@@ -787,7 +773,6 @@ async fn test_copy_object_overwrite_creates_new_version_under_legal_hold() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_create_multipart_upload_applies_requested_legal_hold() {
|
||||
init_logging();
|
||||
info!("🧪 Test: CreateMultipartUpload applies requested Legal Hold");
|
||||
@@ -853,7 +838,6 @@ async fn test_create_multipart_upload_applies_requested_legal_hold() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_create_multipart_upload_creates_new_version_under_compliance_retention() {
|
||||
init_logging();
|
||||
info!("🧪 Test: CreateMultipartUpload over a COMPLIANCE-retained key creates a new version");
|
||||
@@ -933,7 +917,6 @@ async fn test_create_multipart_upload_creates_new_version_under_compliance_reten
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_completed_multipart_object_blocked_by_legal_hold() {
|
||||
init_logging();
|
||||
info!("🧪 Test: Delete completed multipart object blocked by Legal Hold");
|
||||
@@ -993,7 +976,6 @@ async fn test_delete_completed_multipart_object_blocked_by_legal_hold() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_completed_multipart_object_blocked_by_retention() {
|
||||
init_logging();
|
||||
info!("🧪 Test: Delete completed multipart object blocked by retention");
|
||||
@@ -1055,7 +1037,6 @@ async fn test_delete_completed_multipart_object_blocked_by_retention() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_complete_multipart_upload_creates_new_version_under_legal_hold() {
|
||||
init_logging();
|
||||
info!("🧪 Test: CompleteMultipartUpload creates a new version when the current version is under Legal Hold");
|
||||
@@ -1135,7 +1116,6 @@ async fn test_complete_multipart_upload_creates_new_version_under_legal_hold() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_complete_multipart_upload_creates_new_version_under_compliance_retention() {
|
||||
init_logging();
|
||||
info!("🧪 Test: CompleteMultipartUpload creates a new version when the current version is under COMPLIANCE retention");
|
||||
@@ -1209,7 +1189,6 @@ async fn test_complete_multipart_upload_creates_new_version_under_compliance_ret
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_write_paths_require_put_object_legal_hold_permission() {
|
||||
init_logging();
|
||||
info!("🧪 Test: write paths require PutObjectLegalHold permission");
|
||||
@@ -1273,7 +1252,6 @@ async fn test_write_paths_require_put_object_legal_hold_permission() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_write_paths_require_put_object_retention_permission() {
|
||||
init_logging();
|
||||
info!("🧪 Test: write paths require PutObjectRetention permission");
|
||||
@@ -1345,7 +1323,6 @@ async fn test_write_paths_require_put_object_retention_permission() {
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_objects_mixed_locked_unlocked() {
|
||||
init_logging();
|
||||
info!("🧪 Test: DeleteObjects with mixed locked and unlocked objects");
|
||||
@@ -1427,7 +1404,6 @@ async fn test_delete_objects_mixed_locked_unlocked() {
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_put_retention_compliance_cannot_shorten() {
|
||||
init_logging();
|
||||
info!("🧪 Test: PutObjectRetention cannot shorten COMPLIANCE retention");
|
||||
@@ -1468,7 +1444,6 @@ async fn test_put_retention_compliance_cannot_shorten() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_put_retention_compliance_can_extend() {
|
||||
init_logging();
|
||||
info!("🧪 Test: PutObjectRetention can extend COMPLIANCE retention");
|
||||
@@ -1509,7 +1484,6 @@ async fn test_put_retention_compliance_can_extend() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_put_retention_governance_extend_without_bypass() {
|
||||
init_logging();
|
||||
info!("🧪 Test: PutObjectRetention on GOVERNANCE can extend without bypass");
|
||||
@@ -1553,7 +1527,6 @@ async fn test_put_retention_governance_extend_without_bypass() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_put_retention_governance_shorten_requires_bypass() {
|
||||
init_logging();
|
||||
info!("🧪 Test: PutObjectRetention on GOVERNANCE requires bypass to shorten");
|
||||
@@ -1615,7 +1588,6 @@ async fn test_put_retention_governance_shorten_requires_bypass() {
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_default_retention_applied_to_new_objects() {
|
||||
init_logging();
|
||||
info!("🧪 Test: Default retention is applied to new objects");
|
||||
@@ -1685,7 +1657,6 @@ async fn test_default_retention_applied_to_new_objects() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_object_creates_delete_marker_for_default_retained_current_version() {
|
||||
init_logging();
|
||||
info!("🧪 Test: DeleteObject creates delete marker for default-retained current version");
|
||||
@@ -1770,7 +1741,6 @@ async fn test_delete_object_creates_delete_marker_for_default_retained_current_v
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_put_copy_and_multipart_reject_incomplete_retention_headers() {
|
||||
init_logging();
|
||||
info!("🧪 Test: write paths reject incomplete Object Lock retention headers");
|
||||
@@ -1869,7 +1839,6 @@ async fn test_put_copy_and_multipart_reject_incomplete_retention_headers() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_object_retention_uses_destination_policy() {
|
||||
init_logging();
|
||||
info!("🧪 Test: CopyObject retention follows destination policy");
|
||||
@@ -2051,7 +2020,6 @@ async fn test_copy_object_retention_uses_destination_policy() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_multipart_default_retention_fixed_at_create() {
|
||||
init_logging();
|
||||
info!("🧪 Test: multipart default retention is fixed at CreateMultipartUpload");
|
||||
@@ -2122,7 +2090,6 @@ async fn test_multipart_default_retention_fixed_at_create() {
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_unretained_object_lock_object_delete_and_bucket_cleanup() {
|
||||
init_logging();
|
||||
info!("🧪 Test: Unretained Object Lock object delete and bucket cleanup (Issue #5339)");
|
||||
@@ -2243,7 +2210,6 @@ async fn test_unretained_object_lock_object_delete_and_bucket_cleanup() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_versioning_auto_enabled_with_object_lock() {
|
||||
init_logging();
|
||||
info!("🧪 Test: Versioning is auto-enabled when Object Lock is configured");
|
||||
@@ -2302,7 +2268,6 @@ async fn test_versioning_auto_enabled_with_object_lock() {
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_error_message_distinguishes_legal_hold_from_retention() {
|
||||
init_logging();
|
||||
info!("🧪 Test: Error messages distinguish Legal Hold from Retention");
|
||||
|
||||
@@ -60,7 +60,6 @@ use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::sign_v4;
|
||||
use s3s::Body;
|
||||
use s3s::header::X_AMZ_REPLICATION_STATUS;
|
||||
use serial_test::serial;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::Infallible;
|
||||
@@ -2506,7 +2505,6 @@ async fn build_replication_pair(
|
||||
/// metadata was inherited wholesale from the source, so the scanner heal pass
|
||||
/// skipped it too — no PENDING/FAILED marker meant nothing to re-drive).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_object_replicates_to_target() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -2555,7 +2553,6 @@ async fn test_copy_object_replicates_to_target() -> TestResult {
|
||||
/// independent object; every member must replicate to the remote target like a
|
||||
/// regular PUT (MinIO PutObjectExtract parity).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_snowball_extract_replicates_members_to_target() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -2601,7 +2598,6 @@ async fn test_snowball_extract_replicates_members_to_target() -> TestResult {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_replication_check_succeeds_with_remote_target() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -2638,7 +2634,6 @@ async fn test_replication_check_succeeds_with_remote_target() -> Result<(), Box<
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_replication_check_rejects_target_without_object_lock() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -2692,7 +2687,6 @@ async fn test_replication_check_rejects_target_without_object_lock() -> Result<(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_set_remote_target_rejects_unversioned_source_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -2731,7 +2725,6 @@ async fn test_set_remote_target_rejects_unversioned_source_bucket() -> Result<()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_replication_check_rejects_unversioned_source_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -2755,7 +2748,6 @@ async fn test_replication_check_rejects_unversioned_source_bucket() -> Result<()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_replication_check_rejects_missing_replication_config() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -2779,7 +2771,6 @@ async fn test_replication_check_rejects_missing_replication_config() -> Result<(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_replication_check_rejects_invalid_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -2798,7 +2789,6 @@ async fn test_replication_check_rejects_invalid_bucket() -> Result<(), Box<dyn E
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_set_remote_target_rejects_same_bucket_on_same_deployment() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -2842,7 +2832,6 @@ async fn test_set_remote_target_rejects_same_bucket_on_same_deployment() -> Resu
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_set_remote_target_rejects_unversioned_target_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -2876,7 +2865,6 @@ async fn test_set_remote_target_rejects_unversioned_target_bucket() -> Result<()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_set_remote_target_update_requires_arn() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -2928,7 +2916,6 @@ async fn test_set_remote_target_update_requires_arn() -> Result<(), Box<dyn Erro
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_set_remote_target_update_rejects_missing_target() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -3017,7 +3004,6 @@ async fn fetch_single_target(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_set_remote_target_partial_update_preserves_credentials() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -3103,7 +3089,6 @@ async fn test_set_remote_target_partial_update_preserves_credentials() -> Result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_set_remote_target_rejects_invalid_target_url() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -3145,7 +3130,6 @@ async fn test_set_remote_target_rejects_invalid_target_url() -> Result<(), Box<d
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify()
|
||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -3230,7 +3214,6 @@ async fn test_set_remote_target_rejects_self_signed_https_target_without_skip_tl
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_set_remote_target_allows_self_signed_https_target_with_skip_tls_verify() -> Result<(), Box<dyn Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
@@ -3342,7 +3325,6 @@ async fn test_set_remote_target_allows_self_signed_https_target_with_skip_tls_ve
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem() -> Result<(), Box<dyn Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
@@ -3427,7 +3409,6 @@ async fn test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_set_remote_target_allows_private_ca_https_target_with_ca_cert_pem() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -3518,7 +3499,6 @@ async fn test_set_remote_target_allows_private_ca_https_target_with_ca_cert_pem(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_list_remote_targets_rejects_empty_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -3538,7 +3518,6 @@ async fn test_list_remote_targets_rejects_empty_bucket() -> Result<(), Box<dyn E
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_list_remote_targets_rejects_invalid_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -3557,7 +3536,6 @@ async fn test_list_remote_targets_rejects_invalid_bucket() -> Result<(), Box<dyn
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_remove_remote_target_rejects_missing_target() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -3598,7 +3576,6 @@ async fn test_remove_remote_target_rejects_missing_target() -> Result<(), Box<dy
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_remove_remote_target_rejects_missing_arn() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -3623,7 +3600,6 @@ async fn test_remove_remote_target_rejects_missing_arn() -> Result<(), Box<dyn E
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_remove_remote_target_rejects_invalid_bucket() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -3647,7 +3623,6 @@ async fn test_remove_remote_target_rejects_invalid_bucket() -> Result<(), Box<dy
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_remove_remote_target_rejects_target_used_by_replication() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -3687,7 +3662,6 @@ async fn test_remove_remote_target_rejects_target_used_by_replication() -> Resul
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_bucket_replication_removes_remote_target() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -3737,7 +3711,6 @@ async fn test_delete_bucket_replication_removes_remote_target() -> Result<(), Bo
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_replicates_put_object_issue_2539() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -3779,7 +3752,6 @@ async fn test_bucket_replication_replicates_put_object_issue_2539() -> Result<()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_converges_delete_marker_and_version_purge() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -3878,7 +3850,6 @@ async fn test_bucket_replication_converges_delete_marker_and_version_purge() ->
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_disabled_delete_marker_does_not_propagate() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -3965,7 +3936,6 @@ async fn test_bucket_replication_disabled_delete_marker_does_not_propagate() ->
|
||||
/// interoperability profile for a runner that provisions MinIO credentials
|
||||
/// and a reachable endpoint.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -4293,7 +4263,6 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_single_bucket_multipart_replication_fans_out_to_multiple_targets() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -4462,7 +4431,6 @@ async fn test_repl17_failure_observation_helpers() -> TestResult {
|
||||
/// the replica is decryptable only with the original customer key. The
|
||||
/// backlog#1291 property still holds: never a silent plaintext replica.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_sse_c_contract() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -4540,7 +4508,6 @@ async fn test_bucket_replication_sse_c_contract() -> TestResult {
|
||||
/// part — part boundaries and the encrypted-multipart marker survive so the
|
||||
/// replica decrypts each part with its part-derived nonce.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_sse_c_multipart_passthrough() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -4657,7 +4624,6 @@ async fn test_bucket_replication_sse_c_multipart_passthrough() -> TestResult {
|
||||
/// (independent KMS, so success proves target-owned envelopes), preserved
|
||||
/// source ETag, and a version that stays stable across scanner cycles.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_sse_s3_contract() -> TestResult {
|
||||
init_logging();
|
||||
assert_managed_sse_replicates_and_reencrypts("sse-s3", false).await
|
||||
@@ -4667,7 +4633,6 @@ async fn test_bucket_replication_sse_s3_contract() -> TestResult {
|
||||
/// fail closed — replication FAILED, and no plaintext (or any) replica ever
|
||||
/// materializes on the target.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_sse_s3_fails_closed_without_target_kms() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -4711,7 +4676,6 @@ async fn test_bucket_replication_sse_s3_fails_closed_without_target_kms() -> Tes
|
||||
/// the ETag comparison sees the preserved source ETag on the replica and does
|
||||
/// not rewrite it, so the replica's version stays stable through the resync.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_sse_s3_resync_converges() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -4768,7 +4732,6 @@ async fn test_bucket_replication_sse_s3_resync_converges() -> TestResult {
|
||||
/// re-encrypts under its own default key. The independent-KMS pair proves the
|
||||
/// replica's envelope is target-owned.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_sse_kms_contract() -> TestResult {
|
||||
init_logging();
|
||||
assert_managed_sse_replicates_and_reencrypts("sse-kms", true).await
|
||||
@@ -4779,7 +4742,6 @@ async fn test_bucket_replication_sse_kms_contract() -> TestResult {
|
||||
/// carries the full header set (SSE intent, content-type, user metadata) and
|
||||
/// the completed replica preserves the source's multipart ETag.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_sse_s3_multipart_reencrypts() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -4873,7 +4835,6 @@ async fn test_bucket_replication_sse_s3_multipart_reencrypts() -> TestResult {
|
||||
/// still-running source's data scanner (short cycle via [`FAST_SCANNER_ENV`])
|
||||
/// re-drives the failed objects once the target is reachable again.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_recovers_after_target_outage() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -4953,7 +4914,6 @@ async fn test_bucket_replication_recovers_after_target_outage() -> TestResult {
|
||||
/// must settle back to zero even though the historical failed counter remains
|
||||
/// non-zero.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_backlog_metrics_observe_outage_and_recovery() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -5087,7 +5047,6 @@ async fn test_bucket_replication_backlog_metrics_observe_outage_and_recovery() -
|
||||
/// must converge every persisted failure, including the replayed delete marker
|
||||
/// (whose replication decision is re-derived from the live config).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_replays_failed_entries_after_source_restart() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -5179,7 +5138,6 @@ async fn test_bucket_replication_replays_failed_entries_after_source_restart() -
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_replayed_delete_marker_preserves_source_mtime_without_source_restart() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -5249,7 +5207,6 @@ async fn test_bucket_replication_replayed_delete_marker_preserves_source_mtime_w
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_sequential_bucket_replication_succeeds_for_multiple_buckets() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -5293,7 +5250,6 @@ async fn test_sequential_bucket_replication_succeeds_for_multiple_buckets() -> R
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_replication_recovers_after_runtime_target_cache_is_cleared() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -5337,7 +5293,6 @@ async fn test_replication_recovers_after_runtime_target_cache_is_cleared() -> Re
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_allows_self_signed_https_with_skip_tls_verify_real_dual_node() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -5416,7 +5371,6 @@ async fn test_site_replication_allows_self_signed_https_with_skip_tls_verify_rea
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_allows_private_ca_https_with_ca_cert_pem_real_dual_node() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -5495,7 +5449,6 @@ async fn test_site_replication_allows_private_ca_https_with_ca_cert_pem_real_dua
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_resync_lifecycle_survives_real_server_restart() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let resync_process_env = [
|
||||
@@ -5715,7 +5668,6 @@ async fn test_site_replication_resync_lifecycle_survives_real_server_restart() -
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_edit_and_status_peer_state_real_three_node() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -5964,7 +5916,6 @@ async fn test_site_replication_edit_and_status_peer_state_real_three_node() -> R
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_remove_all_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -6084,7 +6035,6 @@ async fn test_site_replication_remove_all_real_dual_node() -> Result<(), Box<dyn
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_state_edit_fresh_and_stale_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -6193,7 +6143,6 @@ async fn test_site_replication_state_edit_fresh_and_stale_real_dual_node() -> Re
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_replicates_object_with_bucket_versioning_real_dual_node() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -6284,7 +6233,6 @@ async fn test_site_replication_replicates_object_with_bucket_versioning_real_dua
|
||||
/// receiver was dropped with only a debug line, while `replicate status` still reported
|
||||
/// "1/1 Buckets in sync" because both configs were byte-identical.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_config_broadcast_keeps_reverse_direction_real_dual_node() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -6423,7 +6371,6 @@ async fn wait_for_site_replication_rule(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_active_active_converges_without_loops_real_dual_node() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -6741,7 +6688,6 @@ async fn test_site_replication_active_active_converges_without_loops_real_dual_n
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_replicates_policy_backed_user_access_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -6829,7 +6775,6 @@ async fn test_site_replication_replicates_policy_backed_user_access_real_dual_no
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_replicates_group_policy_backed_access_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
@@ -6920,7 +6865,6 @@ async fn test_site_replication_replicates_group_policy_backed_access_real_dual_n
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_service_account_policy_from_accountinfo_round_trips_real_single_node() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -6972,7 +6916,6 @@ async fn test_service_account_policy_from_accountinfo_round_trips_real_single_no
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_replicates_multiple_service_accounts_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -7073,7 +7016,6 @@ async fn test_site_replication_replicates_multiple_service_accounts_real_dual_no
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_replicates_service_accounts_created_from_sts_session_real_dual_node() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -7214,7 +7156,6 @@ async fn wait_for_target_request_version_id(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_resync_restart_revisits_objects_before_out_of_order_checkpoint() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -7333,7 +7274,6 @@ async fn test_bucket_resync_restart_revisits_objects_before_out_of_order_checkpo
|
||||
/// CreateMultipartUpload (the version is decided at initiate time) must both
|
||||
/// carry the source version as `?versionId=`.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_replication_put_and_create_multipart_carry_source_version_id_query() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -7448,7 +7388,6 @@ async fn test_replication_put_and_create_multipart_carry_source_version_id_query
|
||||
/// flow to the onward bucket, proving B's outbound replication and scanner
|
||||
/// are live.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_scanner_never_cascades_inbound_replicas() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -7529,7 +7468,6 @@ async fn test_scanner_never_cascades_inbound_replicas() -> TestResult {
|
||||
/// version ids and still mint its own there — the check must not report OK
|
||||
/// while multipart deletes and heals would silently miss.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_replication_check_flags_multipart_only_version_minting_target() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -7602,7 +7540,6 @@ async fn test_replication_check_flags_multipart_only_version_minting_target() ->
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_replication_check_aborts_failed_multipart_probes() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -7765,7 +7702,6 @@ async fn test_replication_check_aborts_failed_multipart_probes() -> TestResult {
|
||||
/// BucketRemoteTargetVersionMismatch — while still cleaning up the probe
|
||||
/// object via the version id the target actually assigned.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_replication_check_flags_version_minting_target() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -8006,7 +7942,6 @@ async fn wait_for_target_marker_purged(
|
||||
/// the target forever. Contract under test: a failed purge attempt is retried
|
||||
/// within the watch window and converges once the fault clears.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delayed_delete_marker_purge_retries_after_transient_target_failure() -> TestResult {
|
||||
init_logging();
|
||||
let source_bucket = "delayed-purge-retry-src";
|
||||
@@ -8066,7 +8001,6 @@ async fn test_delayed_delete_marker_purge_retries_after_transient_target_failure
|
||||
/// with an idempotent 204, which used to look like success and strand the
|
||||
/// real marker on the target forever.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delayed_delete_marker_purge_uses_target_assigned_version() -> TestResult {
|
||||
init_logging();
|
||||
let source_bucket = "delayed-purge-mint-src";
|
||||
@@ -8099,7 +8033,6 @@ async fn test_delayed_delete_marker_purge_uses_target_assigned_version() -> Test
|
||||
/// replayed purge succeeds, the entry must be acknowledged instead of being
|
||||
/// retained as Missed forever.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delayed_delete_marker_purge_exhaustion_persists_to_mrf_and_replays_on_restart() -> TestResult {
|
||||
init_logging();
|
||||
let source_bucket = "delayed-purge-mrf-src";
|
||||
@@ -8236,7 +8169,6 @@ async fn build_scanner_compensation_pair(
|
||||
/// nil-version objects entirely (`scanner_folder.rs` heal_replication), so it
|
||||
/// must NEVER be compensated.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_scanner_compensates_existing_objects_across_write_paths() -> TestResult {
|
||||
init_logging();
|
||||
let source_bucket = "scanner-comp-src";
|
||||
@@ -8352,7 +8284,6 @@ async fn test_scanner_compensates_existing_objects_across_write_paths() -> TestR
|
||||
/// written after the rule replicate normally (the setting only gates the
|
||||
/// existing-object resync path).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_scanner_never_compensates_when_existing_object_replication_disabled() -> TestResult {
|
||||
init_logging();
|
||||
let source_bucket = "scanner-disabled-src";
|
||||
|
||||
@@ -273,6 +273,7 @@ proptest = "1"
|
||||
rcgen.workspace = true
|
||||
insta = { workspace = true, features = ["yaml", "json"] }
|
||||
rustfs-crypto = { workspace = true }
|
||||
tonic-prost = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
shadow-rs = { workspace = true, default-features = false, features = ["build", "metadata"] }
|
||||
|
||||
@@ -233,11 +233,17 @@ pub struct NsScannerCapabilityRequest {
|
||||
#[async_trait]
|
||||
pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug {
|
||||
async fn open_read(&self, request: ReadStreamRequest) -> Result<FileReader>;
|
||||
async fn open_read_fresh(&self, request: ReadStreamRequest) -> Result<FileReader> {
|
||||
self.open_read(request).await
|
||||
}
|
||||
/// Opens an owned-chunk stream when this transport can retain receive-buffer
|
||||
/// ownership. `None` preserves the established `open_read` fallback.
|
||||
async fn open_read_chunks(&self, _request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn open_read_chunks_fresh(&self, request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
|
||||
self.open_read_chunks(request).await
|
||||
}
|
||||
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter>;
|
||||
async fn open_walk_dir(&self, request: WalkDirStreamRequest) -> Result<FileReader>;
|
||||
async fn open_ns_scanner(&self, _request: NsScannerStreamRequest) -> Result<FileReader> {
|
||||
@@ -269,6 +275,15 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
|
||||
))
|
||||
}
|
||||
|
||||
async fn open_read_fresh(&self, request: ReadStreamRequest) -> Result<FileReader> {
|
||||
let url = build_read_file_stream_url(&request);
|
||||
let mut headers = json_headers();
|
||||
build_auth_headers(&url, &Method::GET, &mut headers)?;
|
||||
Ok(Box::new(
|
||||
HttpReader::new_fresh_connection_with_stall_timeout(url, Method::GET, headers, None, request.stall_timeout).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn open_read_chunks(&self, request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
|
||||
let url = build_read_file_stream_url(&request);
|
||||
let mut headers = json_headers();
|
||||
@@ -278,6 +293,16 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
|
||||
)))
|
||||
}
|
||||
|
||||
async fn open_read_chunks_fresh(&self, request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
|
||||
let url = build_read_file_stream_url(&request);
|
||||
let mut headers = json_headers();
|
||||
build_auth_headers(&url, &Method::GET, &mut headers)?;
|
||||
Ok(Some(Box::new(
|
||||
HttpChunkReader::new_fresh_connection_with_stall_timeout(url, Method::GET, headers, None, request.stall_timeout)
|
||||
.await?,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter> {
|
||||
let server_epoch = self.put_file_auth_capability(&request.endpoint).await?;
|
||||
let nonce = server_epoch.map(|_| Uuid::new_v4());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -418,6 +418,17 @@ pub struct DiskHealthTracker {
|
||||
pub last_capacity_free: AtomicU64,
|
||||
/// Last successful capacity probe timestamp
|
||||
pub last_capacity_probe_unix_secs: AtomicI64,
|
||||
/// Authoritative atomically published runtime/status pair.
|
||||
state_snapshot: AtomicU64,
|
||||
transition_lock: std::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
fn pack_health_state(runtime_state: RuntimeDriveHealthState, status: u32) -> u64 {
|
||||
(u64::from(runtime_state as u32) << 32) | u64::from(status)
|
||||
}
|
||||
|
||||
fn unpack_health_state(snapshot: u64) -> (RuntimeDriveHealthState, u32) {
|
||||
(RuntimeDriveHealthState::from_u32((snapshot >> 32) as u32), snapshot as u32)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -739,6 +750,8 @@ impl DiskHealthTracker {
|
||||
last_capacity_used: AtomicU64::new(0),
|
||||
last_capacity_free: AtomicU64::new(0),
|
||||
last_capacity_probe_unix_secs: AtomicI64::new(0),
|
||||
state_snapshot: AtomicU64::new(pack_health_state(RuntimeDriveHealthState::Online, DISK_HEALTH_OK)),
|
||||
transition_lock: std::sync::Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,39 +788,52 @@ impl DiskHealthTracker {
|
||||
|
||||
/// Check if disk is faulty
|
||||
pub fn is_faulty(&self) -> bool {
|
||||
self.status.load(Ordering::Acquire) == DISK_HEALTH_FAULTY
|
||||
unpack_health_state(self.state_snapshot.load(Ordering::Acquire)).1 == DISK_HEALTH_FAULTY
|
||||
}
|
||||
|
||||
fn publish_state(&self, runtime_state: RuntimeDriveHealthState, status: u32) {
|
||||
self.state_snapshot
|
||||
.store(pack_health_state(runtime_state, status), Ordering::Release);
|
||||
self.runtime_state.store(runtime_state as u32, Ordering::Release);
|
||||
self.status.store(status, Ordering::Release);
|
||||
}
|
||||
|
||||
/// Set disk as faulty
|
||||
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
|
||||
pub fn set_faulty(&self) {
|
||||
self.status.store(DISK_HEALTH_FAULTY, Ordering::Release);
|
||||
let _guard = self.transition_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
self.publish_state(RuntimeDriveHealthState::Offline, DISK_HEALTH_FAULTY);
|
||||
}
|
||||
|
||||
/// Set disk as OK
|
||||
pub fn set_ok(&self) {
|
||||
self.status.store(DISK_HEALTH_OK, Ordering::Release);
|
||||
let _guard = self.transition_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
self.publish_state(RuntimeDriveHealthState::Online, DISK_HEALTH_OK);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn force_runtime_state_for_test(&self, state: RuntimeDriveHealthState) {
|
||||
self.runtime_state.store(state as u32, Ordering::Release);
|
||||
match state {
|
||||
RuntimeDriveHealthState::Offline => self.set_faulty(),
|
||||
RuntimeDriveHealthState::Online | RuntimeDriveHealthState::Suspect | RuntimeDriveHealthState::Returning => {
|
||||
self.set_ok();
|
||||
}
|
||||
}
|
||||
let _guard = self.transition_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let status = if state == RuntimeDriveHealthState::Offline {
|
||||
DISK_HEALTH_FAULTY
|
||||
} else {
|
||||
DISK_HEALTH_OK
|
||||
};
|
||||
self.publish_state(state, status);
|
||||
}
|
||||
|
||||
pub fn swap_ok_to_faulty(&self) -> bool {
|
||||
self.status
|
||||
.compare_exchange(DISK_HEALTH_OK, DISK_HEALTH_FAULTY, Ordering::AcqRel, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
let _guard = self.transition_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let (_, status) = unpack_health_state(self.state_snapshot.load(Ordering::Acquire));
|
||||
if status != DISK_HEALTH_OK {
|
||||
return false;
|
||||
}
|
||||
self.publish_state(RuntimeDriveHealthState::Offline, DISK_HEALTH_FAULTY);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn runtime_state(&self) -> RuntimeDriveHealthState {
|
||||
RuntimeDriveHealthState::from_u32(self.runtime_state.load(Ordering::Acquire))
|
||||
unpack_health_state(self.state_snapshot.load(Ordering::Acquire)).0
|
||||
}
|
||||
|
||||
pub fn offline_duration(&self) -> Option<Duration> {
|
||||
@@ -823,6 +849,7 @@ impl DiskHealthTracker {
|
||||
}
|
||||
|
||||
pub fn mark_failure(&self, endpoint: &Endpoint, reason: &'static str) -> bool {
|
||||
let _guard = self.transition_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let current = self.runtime_state();
|
||||
let now = current_unix_secs();
|
||||
let next = match current {
|
||||
@@ -851,24 +878,19 @@ impl DiskHealthTracker {
|
||||
};
|
||||
|
||||
let became_offline = next == RuntimeDriveHealthState::Offline && current != RuntimeDriveHealthState::Offline;
|
||||
if next == RuntimeDriveHealthState::Offline {
|
||||
self.status.store(DISK_HEALTH_FAULTY, Ordering::Release);
|
||||
} else {
|
||||
self.status.store(DISK_HEALTH_OK, Ordering::Release);
|
||||
}
|
||||
self.transition_state(endpoint, current, next, reason);
|
||||
became_offline
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
|
||||
pub fn mark_offline(&self, endpoint: &Endpoint, reason: &'static str) -> bool {
|
||||
let _guard = self.transition_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let current = self.runtime_state();
|
||||
if current == RuntimeDriveHealthState::Offline {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.consecutive_successes.store(0, Ordering::Release);
|
||||
self.status.store(DISK_HEALTH_FAULTY, Ordering::Release);
|
||||
self.transition_state(endpoint, current, RuntimeDriveHealthState::Offline, reason);
|
||||
true
|
||||
}
|
||||
@@ -882,11 +904,10 @@ impl DiskHealthTracker {
|
||||
}
|
||||
|
||||
fn reset_for_store_init_retry_at(&self, endpoint: &Endpoint, now: Duration) {
|
||||
let _guard = self.transition_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let now_nanos = unix_nanos(now);
|
||||
let now_secs = unix_secs_i64(now);
|
||||
self.status.store(DISK_HEALTH_OK, Ordering::Release);
|
||||
self.runtime_state
|
||||
.store(RuntimeDriveHealthState::Online as u32, Ordering::Release);
|
||||
self.publish_state(RuntimeDriveHealthState::Online, DISK_HEALTH_OK);
|
||||
self.consecutive_failures.store(0, Ordering::Release);
|
||||
self.consecutive_successes.store(0, Ordering::Release);
|
||||
self.offline_since_unix_secs.store(0, Ordering::Release);
|
||||
@@ -898,6 +919,7 @@ impl DiskHealthTracker {
|
||||
}
|
||||
|
||||
pub fn mark_recovery_success(&self, endpoint: &Endpoint, reason: &'static str) -> bool {
|
||||
let _guard = self.transition_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let current = self.runtime_state();
|
||||
let next = match current {
|
||||
RuntimeDriveHealthState::Online => RuntimeDriveHealthState::Online,
|
||||
@@ -918,7 +940,6 @@ impl DiskHealthTracker {
|
||||
|
||||
let became_online = next == RuntimeDriveHealthState::Online;
|
||||
if became_online {
|
||||
self.status.store(DISK_HEALTH_OK, Ordering::Release);
|
||||
self.consecutive_failures.store(0, Ordering::Release);
|
||||
self.consecutive_successes.store(0, Ordering::Release);
|
||||
}
|
||||
@@ -948,7 +969,13 @@ impl DiskHealthTracker {
|
||||
return;
|
||||
}
|
||||
|
||||
self.runtime_state.store(next as u32, Ordering::Release);
|
||||
let current_status = unpack_health_state(self.state_snapshot.load(Ordering::Acquire)).1;
|
||||
let status = match next {
|
||||
RuntimeDriveHealthState::Offline => DISK_HEALTH_FAULTY,
|
||||
RuntimeDriveHealthState::Returning => current_status,
|
||||
RuntimeDriveHealthState::Online | RuntimeDriveHealthState::Suspect => DISK_HEALTH_OK,
|
||||
};
|
||||
self.publish_state(next, status);
|
||||
self.last_transition_unix_secs
|
||||
.store(current_unix_secs() as i64, Ordering::Release);
|
||||
|
||||
@@ -1217,7 +1244,7 @@ impl LocalDiskWrapper {
|
||||
return;
|
||||
}
|
||||
|
||||
if health.status.load(Ordering::Relaxed) != DISK_HEALTH_OK {
|
||||
if health.is_faulty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2909,6 +2936,57 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn concurrent_failure_and_recovery_publish_one_health_snapshot() {
|
||||
temp_env::with_var(rustfs_config::ENV_DRIVE_SUSPECT_FAILURE_THRESHOLD, Some("2"), || {
|
||||
let endpoint = Endpoint::try_from("/tmp/concurrent-health-snapshot").expect("endpoint should parse");
|
||||
let health = Arc::new(DiskHealthTracker::new());
|
||||
let transition_guard = health
|
||||
.transition_lock
|
||||
.lock()
|
||||
.expect("health transition lock should not be poisoned");
|
||||
let start = Arc::new(std::sync::Barrier::new(3));
|
||||
let (completed_tx, completed_rx) = std::sync::mpsc::channel();
|
||||
let workers = (0..2)
|
||||
.map(|_| {
|
||||
let health = Arc::clone(&health);
|
||||
let endpoint = endpoint.clone();
|
||||
let start = Arc::clone(&start);
|
||||
let completed_tx = completed_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
start.wait();
|
||||
health.mark_failure(&endpoint, "concurrent_test");
|
||||
completed_tx.send(()).expect("completion receiver should remain available");
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
start.wait();
|
||||
assert!(
|
||||
matches!(
|
||||
completed_rx.recv_timeout(Duration::from_millis(250)),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
),
|
||||
"concurrent transitions must wait for the serialization lock"
|
||||
);
|
||||
drop(transition_guard);
|
||||
completed_rx
|
||||
.recv_timeout(Duration::from_secs(1))
|
||||
.expect("first failure transition should complete after lock release");
|
||||
completed_rx
|
||||
.recv_timeout(Duration::from_secs(1))
|
||||
.expect("second failure transition should complete after lock release");
|
||||
for worker in workers {
|
||||
worker.join().expect("health transition worker should not panic");
|
||||
}
|
||||
|
||||
assert_eq!(health.runtime_state(), RuntimeDriveHealthState::Offline);
|
||||
assert!(health.is_faulty());
|
||||
assert_eq!(health.consecutive_failures.load(Ordering::Acquire), 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_success_recovers_suspect_drive_without_faulting() {
|
||||
let endpoint = Endpoint::try_from("/tmp/runtime-state-suspect-success").expect("endpoint should parse");
|
||||
|
||||
@@ -66,21 +66,37 @@ struct HealTaskStatusPayload<'a> {
|
||||
summary: &'a str,
|
||||
items: &'a [HealResultItem],
|
||||
truncated: bool,
|
||||
/// Cursor for incremental consumption (HS-06): sequence of the next item
|
||||
/// to be produced. Absent on responses without sequencing (0).
|
||||
#[serde(skip_serializing_if = "u64_is_zero")]
|
||||
next_seq: u64,
|
||||
/// Oldest sequence still retained; with `truncated`, tells a lagging
|
||||
/// client where to restart its cursor.
|
||||
#[serde(skip_serializing_if = "u64_is_zero")]
|
||||
min_seq: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
progress: Option<&'a HealProgress>,
|
||||
}
|
||||
|
||||
fn u64_is_zero(value: &u64) -> bool {
|
||||
*value == 0
|
||||
}
|
||||
|
||||
fn encode_heal_task_status_payload(
|
||||
summary: &str,
|
||||
mut items: Vec<HealResultItem>,
|
||||
progress: Option<&HealProgress>,
|
||||
mut truncated: bool,
|
||||
next_seq: u64,
|
||||
min_seq: u64,
|
||||
) -> Result<(Vec<u8>, bool)> {
|
||||
loop {
|
||||
let data = serde_json::to_vec(&HealTaskStatusPayload {
|
||||
summary,
|
||||
items: &items,
|
||||
truncated,
|
||||
next_seq,
|
||||
min_seq,
|
||||
progress,
|
||||
})
|
||||
.map_err(|e| Error::Serialization(format!("failed to serialize heal task status: {e}")))?;
|
||||
@@ -109,8 +125,10 @@ fn encode_heal_status_response(
|
||||
progress: Option<&HealProgress>,
|
||||
detail: Option<String>,
|
||||
truncated: bool,
|
||||
next_seq: u64,
|
||||
min_seq: u64,
|
||||
) -> Result<(Vec<u8>, Option<String>)> {
|
||||
let (data, truncated) = encode_heal_task_status_payload(summary, items, progress, truncated)?;
|
||||
let (data, truncated) = encode_heal_task_status_payload(summary, items, progress, truncated, next_seq, min_seq)?;
|
||||
Ok((data, heal_status_detail(detail, truncated)))
|
||||
}
|
||||
|
||||
@@ -138,8 +156,19 @@ impl HealChannelProcessor {
|
||||
|
||||
/// Execute a token query directly against the manager.
|
||||
pub async fn execute_query_request(&self, heal_path: String, client_token: String) -> Result<HealChannelResponse> {
|
||||
self.execute_query_request_since(heal_path, client_token, None).await
|
||||
}
|
||||
|
||||
/// Incremental variant of [`Self::execute_query_request`] (HS-06).
|
||||
pub async fn execute_query_request_since(
|
||||
&self,
|
||||
heal_path: String,
|
||||
client_token: String,
|
||||
since_seq: Option<u64>,
|
||||
) -> Result<HealChannelResponse> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
self.process_query_request(heal_path, client_token, response_tx).await?;
|
||||
self.process_query_request(heal_path, client_token, since_seq, response_tx)
|
||||
.await?;
|
||||
response_rx
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("heal query channel closed: {err}")))?
|
||||
@@ -262,8 +291,12 @@ impl HealChannelProcessor {
|
||||
HealChannelCommand::Query {
|
||||
heal_path,
|
||||
client_token,
|
||||
since_seq,
|
||||
response_tx,
|
||||
} => self.process_query_request(heal_path, client_token, response_tx).await,
|
||||
} => {
|
||||
self.process_query_request(heal_path, client_token, since_seq, response_tx)
|
||||
.await
|
||||
}
|
||||
HealChannelCommand::Cancel {
|
||||
heal_path,
|
||||
client_token,
|
||||
@@ -384,6 +417,7 @@ impl HealChannelProcessor {
|
||||
&self,
|
||||
heal_path: String,
|
||||
client_token: String,
|
||||
since_seq: Option<u64>,
|
||||
response_tx: oneshot::Sender<std::result::Result<HealChannelResponse, String>>,
|
||||
) -> Result<()> {
|
||||
debug!(
|
||||
@@ -398,72 +432,118 @@ impl HealChannelProcessor {
|
||||
);
|
||||
|
||||
let report = if heal_path.trim_matches('/').is_empty() {
|
||||
self.heal_manager.get_task_report(&client_token).await
|
||||
self.heal_manager.get_task_report_since(&client_token, since_seq).await
|
||||
} else {
|
||||
self.heal_manager.get_task_report_for_path(&heal_path, &client_token).await
|
||||
self.heal_manager
|
||||
.get_task_report_for_path_since(&heal_path, &client_token, since_seq)
|
||||
.await
|
||||
};
|
||||
|
||||
let (summary, detail, items, truncated, progress) = match report {
|
||||
let (summary, detail, items, truncated, progress, next_seq, min_seq) = match report {
|
||||
Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Pending | HealTaskStatus::Running,
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
}) => ("running".to_string(), None, result_items, result_items_truncated, progress),
|
||||
next_seq,
|
||||
min_seq,
|
||||
}) => (
|
||||
"running".to_string(),
|
||||
None,
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
next_seq,
|
||||
min_seq,
|
||||
),
|
||||
Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Retrying { error, retry_attempt },
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
next_seq,
|
||||
min_seq,
|
||||
}) => (
|
||||
"running".to_string(),
|
||||
Some(format!("heal task retrying after recoverable failure, attempt {retry_attempt}: {error}")),
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
next_seq,
|
||||
min_seq,
|
||||
),
|
||||
Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Completed,
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
}) => ("finished".to_string(), None, result_items, result_items_truncated, progress),
|
||||
next_seq,
|
||||
min_seq,
|
||||
}) => (
|
||||
"finished".to_string(),
|
||||
None,
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
next_seq,
|
||||
min_seq,
|
||||
),
|
||||
Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Cancelled,
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
next_seq,
|
||||
min_seq,
|
||||
}) => (
|
||||
"stopped".to_string(),
|
||||
Some("heal task cancelled".to_string()),
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
next_seq,
|
||||
min_seq,
|
||||
),
|
||||
Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Timeout,
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
next_seq,
|
||||
min_seq,
|
||||
}) => (
|
||||
"stopped".to_string(),
|
||||
Some("heal task timed out".to_string()),
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
next_seq,
|
||||
min_seq,
|
||||
),
|
||||
Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Failed { error },
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
}) => ("stopped".to_string(), Some(error), result_items, result_items_truncated, progress),
|
||||
next_seq,
|
||||
min_seq,
|
||||
}) => (
|
||||
"stopped".to_string(),
|
||||
Some(error),
|
||||
result_items,
|
||||
result_items_truncated,
|
||||
progress,
|
||||
next_seq,
|
||||
min_seq,
|
||||
),
|
||||
Err(crate::Error::TaskNotFound { .. }) => (
|
||||
"notFound".to_string(),
|
||||
Some("heal task not found or expired".to_string()),
|
||||
Vec::new(),
|
||||
false,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
Err(crate::Error::InvalidClientToken) => {
|
||||
let response = HealChannelResponse {
|
||||
@@ -490,7 +570,8 @@ impl HealChannelProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
let (data, detail) = encode_heal_status_response(&summary, items, progress.as_ref(), detail, truncated)?;
|
||||
let (data, detail) =
|
||||
encode_heal_status_response(&summary, items, progress.as_ref(), detail, truncated, next_seq, min_seq)?;
|
||||
|
||||
let response = HealChannelResponse {
|
||||
request_id: client_token,
|
||||
@@ -805,7 +886,7 @@ mod tests {
|
||||
..Default::default()
|
||||
}];
|
||||
|
||||
let (data, detail) = encode_heal_status_response("running", items, None, None, false).unwrap();
|
||||
let (data, detail) = encode_heal_status_response("running", items, None, None, false, 0, 0).unwrap();
|
||||
|
||||
assert!(data.len() <= MAX_HEAL_STATUS_PAYLOAD_SIZE);
|
||||
let payload: serde_json::Value = serde_json::from_slice(&data).unwrap();
|
||||
@@ -1575,7 +1656,7 @@ mod tests {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
processor
|
||||
.process_query_request("bucket".to_string(), "completed-token".to_string(), tx)
|
||||
.process_query_request("bucket".to_string(), "completed-token".to_string(), None, tx)
|
||||
.await
|
||||
.expect("query should process");
|
||||
|
||||
@@ -1610,7 +1691,7 @@ mod tests {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
processor
|
||||
.process_query_request("bucket".to_string(), task_id.clone(), tx)
|
||||
.process_query_request("bucket".to_string(), task_id.clone(), None, tx)
|
||||
.await
|
||||
.expect("query should process");
|
||||
|
||||
@@ -1643,7 +1724,7 @@ mod tests {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
processor
|
||||
.process_query_request("bucket".to_string(), "wrong-token".to_string(), tx)
|
||||
.process_query_request("bucket".to_string(), "wrong-token".to_string(), None, tx)
|
||||
.await
|
||||
.expect("query should process");
|
||||
|
||||
@@ -1668,7 +1749,7 @@ mod tests {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
processor
|
||||
.process_query_request(String::new(), "wrong-token".to_string(), tx)
|
||||
.process_query_request(String::new(), "wrong-token".to_string(), None, tx)
|
||||
.await
|
||||
.expect("query should process");
|
||||
|
||||
@@ -1705,7 +1786,7 @@ mod tests {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
processor
|
||||
.process_query_request(String::new(), task_id.clone(), tx)
|
||||
.process_query_request(String::new(), task_id.clone(), None, tx)
|
||||
.await
|
||||
.expect("query should process");
|
||||
|
||||
|
||||
+435
-62
@@ -220,6 +220,11 @@ struct CompletedHealStatus {
|
||||
result_items: Vec<HealResultItem>,
|
||||
result_items_truncated: bool,
|
||||
completed_at: SystemTime,
|
||||
/// Sequence-stamped retained window, archived with the completion so
|
||||
/// incremental consumers keep their cursor across the transition (HS-06).
|
||||
seqed_items: Vec<(u64, HealResultItem)>,
|
||||
next_seq: u64,
|
||||
min_seq: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -240,6 +245,65 @@ pub struct HealTaskReport {
|
||||
pub result_items: Vec<HealResultItem>,
|
||||
pub result_items_truncated: bool,
|
||||
pub progress: Option<HealProgress>,
|
||||
/// Cursor for incremental consumption: sequence number of the next item
|
||||
/// to be produced. `0` on reports from sources without sequencing.
|
||||
pub next_seq: u64,
|
||||
/// Oldest sequence still retained (`0` together with `next_seq` when
|
||||
/// sequencing is unavailable).
|
||||
pub min_seq: u64,
|
||||
}
|
||||
|
||||
/// Report from a live task, honoring the client's incremental cursor.
|
||||
async fn active_task_report(task: &HealTask, since: Option<u64>) -> HealTaskReport {
|
||||
let window = task.get_result_items_since(since).await;
|
||||
HealTaskReport {
|
||||
status: task.get_status().await,
|
||||
result_items: window.items,
|
||||
// The legacy flag stays set once anything was evicted; a lagging
|
||||
// incremental cursor additionally marks this response truncated so
|
||||
// the client knows to restart from `min_seq`.
|
||||
result_items_truncated: task.result_items_truncated() || window.lagged,
|
||||
progress: Some(task.get_progress().await),
|
||||
next_seq: window.next_seq,
|
||||
min_seq: window.min_seq,
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_task_report(status: HealTaskStatus) -> HealTaskReport {
|
||||
HealTaskReport {
|
||||
status,
|
||||
result_items: Vec::new(),
|
||||
result_items_truncated: false,
|
||||
progress: None,
|
||||
next_seq: 0,
|
||||
min_seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn completed_task_report(completed: &CompletedHealStatus, since: Option<u64>) -> HealTaskReport {
|
||||
let mut lagged = false;
|
||||
let result_items = match since {
|
||||
None => completed.result_items.clone(),
|
||||
Some(cursor) => {
|
||||
if cursor + 1 < completed.min_seq {
|
||||
lagged = true;
|
||||
}
|
||||
completed
|
||||
.seqed_items
|
||||
.iter()
|
||||
.filter(|(seq, _)| *seq > cursor)
|
||||
.map(|(_, item)| item.clone())
|
||||
.collect()
|
||||
}
|
||||
};
|
||||
HealTaskReport {
|
||||
status: completed.status.clone(),
|
||||
result_items,
|
||||
result_items_truncated: completed.result_items_truncated || lagged,
|
||||
progress: None,
|
||||
next_seq: completed.next_seq,
|
||||
min_seq: completed.min_seq,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
|
||||
@@ -531,6 +595,11 @@ impl PriorityHealQueue {
|
||||
self.dedup_keys.contains_key(&key)
|
||||
}
|
||||
|
||||
/// Iterate queued requests (used by the admin overlap check).
|
||||
fn requests(&self) -> impl Iterator<Item = &HealRequest> {
|
||||
self.heap.iter().map(|item| &item.request)
|
||||
}
|
||||
|
||||
fn contains_request_id(&self, request_id: &str) -> bool {
|
||||
self.heap.iter().any(|item| item.request.id == request_id)
|
||||
}
|
||||
@@ -689,6 +758,80 @@ fn recoverable_heal_retry_delay(retry_attempt: u32) -> Duration {
|
||||
}
|
||||
|
||||
/// Heal config
|
||||
/// HS-06 admin overlap policy.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum HealOverlapPolicy {
|
||||
/// Default: overlapping admin starts merge into the existing task
|
||||
/// (today's dedup semantics).
|
||||
#[default]
|
||||
Merge,
|
||||
/// Return a typed already-running / overlapping-paths rejection like
|
||||
/// madmin's ErrHealAlreadyRunning / ErrHealOverlappingPaths.
|
||||
MinioError,
|
||||
}
|
||||
|
||||
/// Path view of a heal type for overlap comparison: a bucket plus a
|
||||
/// prefix/object path inside it (`None` bucket = cluster-wide, overlaps
|
||||
/// everything).
|
||||
fn heal_type_path_view(heal_type: &HealType) -> (Option<&str>, &str) {
|
||||
match heal_type {
|
||||
HealType::Cluster => (None, ""),
|
||||
HealType::Bucket { bucket } => (Some(bucket), ""),
|
||||
HealType::Prefix { bucket, prefix } => (Some(bucket), prefix),
|
||||
HealType::Object { bucket, object, .. }
|
||||
| HealType::Metadata { bucket, object }
|
||||
| HealType::ECDecode { bucket, object, .. } => (Some(bucket), object),
|
||||
// MRF/MetaPath heal keys on a meta path; treat the whole set of
|
||||
// buckets as one namespace so it only overlaps itself exactly.
|
||||
HealType::MRF { meta_path } => (Some("\u{0}mrf"), meta_path),
|
||||
// Erasure-set heal: the set id is the overlap dimension.
|
||||
HealType::ErasureSet { set_disk_id, .. } => (Some("\u{0}set"), set_disk_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// How two heal paths relate for the admin overlap check (HS-06).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum OverlapVerdict {
|
||||
/// Distinct targets: no conflict.
|
||||
Disjoint,
|
||||
/// Same target: an identical heal is already in flight.
|
||||
SameTarget,
|
||||
/// One target contains the other.
|
||||
Overlapping,
|
||||
}
|
||||
|
||||
fn prefix_paths_overlap(a: &str, b: &str) -> OverlapVerdict {
|
||||
if a == b {
|
||||
return OverlapVerdict::SameTarget;
|
||||
}
|
||||
if a.is_empty() || b.is_empty() || a.starts_with(b) || b.starts_with(a) {
|
||||
return OverlapVerdict::Overlapping;
|
||||
}
|
||||
OverlapVerdict::Disjoint
|
||||
}
|
||||
|
||||
fn heal_types_overlap(left: &HealType, right: &HealType) -> OverlapVerdict {
|
||||
let (left_bucket, left_path) = heal_type_path_view(left);
|
||||
let (right_bucket, right_path) = heal_type_path_view(right);
|
||||
match (left_bucket, right_bucket) {
|
||||
// Cluster-wide overlaps everything (but an exact cluster match is
|
||||
// SameTarget).
|
||||
(None, _) | (_, None) => {
|
||||
if matches!(left, HealType::Cluster) && matches!(right, HealType::Cluster) {
|
||||
OverlapVerdict::SameTarget
|
||||
} else {
|
||||
OverlapVerdict::Overlapping
|
||||
}
|
||||
}
|
||||
(Some(lb), Some(rb)) => {
|
||||
if lb != rb {
|
||||
return OverlapVerdict::Disjoint;
|
||||
}
|
||||
prefix_paths_overlap(left_path, right_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HealConfig {
|
||||
/// Whether to enable auto heal
|
||||
@@ -709,6 +852,9 @@ pub struct HealConfig {
|
||||
pub low_priority_drop_when_full: bool,
|
||||
/// Whether notify-driven scheduler wakeups are enabled.
|
||||
pub event_driven_scheduler_enable: bool,
|
||||
/// How admin heal starts behave on path overlap (HS-06): merge into the
|
||||
/// existing task (default) or return a typed already-running rejection.
|
||||
pub overlap_policy: HealOverlapPolicy,
|
||||
/// Whether per-set bulkhead scheduling is enabled.
|
||||
pub set_bulkhead_enable: bool,
|
||||
/// Whether erasure-set page parallelism is enabled.
|
||||
@@ -757,6 +903,14 @@ impl Default for HealConfig {
|
||||
rustfs_config::ENV_HEAL_EVENT_DRIVEN_SCHEDULER_ENABLE,
|
||||
rustfs_config::DEFAULT_HEAL_EVENT_DRIVEN_SCHEDULER_ENABLE,
|
||||
);
|
||||
let overlap_policy =
|
||||
match rustfs_utils::get_env_str(rustfs_config::ENV_HEAL_OVERLAP_POLICY, rustfs_config::DEFAULT_HEAL_OVERLAP_POLICY)
|
||||
.to_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"minio_error" => HealOverlapPolicy::MinioError,
|
||||
_ => HealOverlapPolicy::Merge,
|
||||
};
|
||||
let set_bulkhead_enable = rustfs_utils::get_env_bool(
|
||||
rustfs_config::ENV_HEAL_SET_BULKHEAD_ENABLE,
|
||||
rustfs_config::DEFAULT_HEAL_SET_BULKHEAD_ENABLE,
|
||||
@@ -793,6 +947,7 @@ impl Default for HealConfig {
|
||||
low_priority_merge_enable,
|
||||
low_priority_drop_when_full,
|
||||
event_driven_scheduler_enable,
|
||||
overlap_policy,
|
||||
set_bulkhead_enable,
|
||||
page_parallel_enable,
|
||||
mainline_throttle_enable,
|
||||
@@ -1759,6 +1914,50 @@ impl HealManager {
|
||||
request: HealRequest,
|
||||
preserve_alias: bool,
|
||||
) -> Result<HealAdmissionReceipt> {
|
||||
// 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
|
||||
// fresh task is never merged into the one being replaced.
|
||||
if request.source == HealRequestSource::Admin && request.force_start {
|
||||
let overlapping: Vec<String> = {
|
||||
let active_heals = self.active_heals.lock().await;
|
||||
active_heals
|
||||
.iter()
|
||||
.filter(|(task_id, task)| {
|
||||
task.source == HealRequestSource::Admin
|
||||
&& heal_types_overlap(&request.heal_type, &task.heal_type) != OverlapVerdict::Disjoint
|
||||
&& *task_id != &request.id
|
||||
})
|
||||
.map(|(task_id, _)| task_id.clone())
|
||||
.collect()
|
||||
};
|
||||
for task_id in overlapping {
|
||||
match self.cancel_task(&task_id).await {
|
||||
Ok(_) => info!(
|
||||
target: "rustfs::heal::manager",
|
||||
event = EVENT_HEAL_QUEUE_ADMISSION,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_MANAGER,
|
||||
request_id = %request.id,
|
||||
cancelled_task_id = %task_id,
|
||||
result = "force_start_cancelled_overlap",
|
||||
"Admin forceStart cancelled an overlapping heal task"
|
||||
),
|
||||
Err(err) => warn!(
|
||||
target: "rustfs::heal::manager",
|
||||
event = EVENT_HEAL_QUEUE_ADMISSION,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_MANAGER,
|
||||
request_id = %request.id,
|
||||
cancelled_task_id = %task_id,
|
||||
error = %err,
|
||||
result = "force_start_cancel_failed",
|
||||
"Admin forceStart failed to cancel an overlapping heal task"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let config = self.config.read().await;
|
||||
let dedup_key = PriorityHealQueue::make_dedup_key(&request);
|
||||
|
||||
@@ -1781,7 +1980,15 @@ impl HealManager {
|
||||
.or_else(|| retrying_heal_for_dedup_key(&retrying_heals, &dedup_key).map(|(task_id, _)| (task_id, "retrying")))
|
||||
});
|
||||
if let Some((merged_task_id, duplicate_state)) = duplicate.flatten() {
|
||||
let admission = Self::duplicate_admission_for_request(&request, &config);
|
||||
// HS-06: under the minio_error overlap policy an exact duplicate
|
||||
// admin start reports the typed AlreadyRunning rejection instead
|
||||
// of the silent merge (MinIO's ErrHealAlreadyRunning).
|
||||
let admission =
|
||||
if request.source == HealRequestSource::Admin && config.overlap_policy == HealOverlapPolicy::MinioError {
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
|
||||
} else {
|
||||
Self::duplicate_admission_for_request(&request, &config)
|
||||
};
|
||||
drop(retrying_heals);
|
||||
drop(queue);
|
||||
drop(active_heals);
|
||||
@@ -1827,6 +2034,62 @@ impl HealManager {
|
||||
});
|
||||
}
|
||||
|
||||
// HS-06 typed overlap rejection (admin only, minio_error policy):
|
||||
// paths containing or contained by an active/queued task reject with
|
||||
// AlreadyRunning / OverlappingPaths instead of merging. Exact
|
||||
// duplicates already merged above; scanner/autoheal/read-repair
|
||||
// sources never take this path.
|
||||
if request.source == HealRequestSource::Admin && config.overlap_policy == HealOverlapPolicy::MinioError {
|
||||
let mut rejection = None;
|
||||
for (task_id, task) in active_heals.iter() {
|
||||
match heal_types_overlap(&request.heal_type, &task.heal_type) {
|
||||
OverlapVerdict::SameTarget => {
|
||||
rejection = Some((HealAdmissionDropReason::AlreadyRunning, task_id.clone()));
|
||||
break;
|
||||
}
|
||||
OverlapVerdict::Overlapping => {
|
||||
rejection = Some((HealAdmissionDropReason::OverlappingPaths, task_id.clone()));
|
||||
}
|
||||
OverlapVerdict::Disjoint => {}
|
||||
}
|
||||
}
|
||||
if rejection.is_none() {
|
||||
for queued in queue.requests() {
|
||||
match heal_types_overlap(&request.heal_type, &queued.heal_type) {
|
||||
OverlapVerdict::SameTarget => {
|
||||
rejection = Some((HealAdmissionDropReason::AlreadyRunning, queued.id.clone()));
|
||||
break;
|
||||
}
|
||||
OverlapVerdict::Overlapping => {
|
||||
rejection = Some((HealAdmissionDropReason::OverlappingPaths, queued.id.clone()));
|
||||
}
|
||||
OverlapVerdict::Disjoint => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((reason, overlap_task_id)) = rejection {
|
||||
drop(retrying_heals);
|
||||
drop(queue);
|
||||
drop(active_heals);
|
||||
Self::record_admission_metric(request.source, HealAdmissionResult::Dropped(reason), "overlap_rejected");
|
||||
warn!(
|
||||
target: "rustfs::heal::manager",
|
||||
event = EVENT_HEAL_QUEUE_ADMISSION,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_MANAGER,
|
||||
request_id = %request.id,
|
||||
overlap_task_id = %overlap_task_id,
|
||||
reason = reason.as_str(),
|
||||
result = "overlap_rejected",
|
||||
"Admin heal start rejected by overlap policy"
|
||||
);
|
||||
return Ok(HealAdmissionReceipt {
|
||||
result: HealAdmissionResult::Dropped(reason),
|
||||
task_id: overlap_task_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut task_id = request.id.clone();
|
||||
let admission = Self::admit_request_to_queue(&mut queue, request, &config, "submit");
|
||||
if admission == HealAdmissionResult::Merged
|
||||
@@ -1899,28 +2162,25 @@ impl HealManager {
|
||||
}
|
||||
|
||||
pub async fn get_task_report(&self, task_id: &str) -> Result<HealTaskReport> {
|
||||
self.get_task_report_since(task_id, None).await
|
||||
}
|
||||
|
||||
/// Incremental variant of [`Self::get_task_report`] (HS-06): `since` is
|
||||
/// the client's last seen sequence number; `None` keeps the legacy
|
||||
/// full-snapshot semantics.
|
||||
pub async fn get_task_report_since(&self, task_id: &str, since: Option<u64>) -> Result<HealTaskReport> {
|
||||
let canonical_task_id = self.canonical_task_id(task_id).await;
|
||||
{
|
||||
let active_heals = self.active_heals.lock().await;
|
||||
if let Some(task) = active_heals.get(&canonical_task_id) {
|
||||
return Ok(HealTaskReport {
|
||||
status: task.get_status().await,
|
||||
result_items: task.get_result_items().await,
|
||||
result_items_truncated: task.result_items_truncated(),
|
||||
progress: Some(task.get_progress().await),
|
||||
});
|
||||
return Ok(active_task_report(task, since).await);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let retrying_heals = self.retrying_heals.lock().await;
|
||||
if let Some(retrying) = retrying_heals.get(&canonical_task_id) {
|
||||
return Ok(HealTaskReport {
|
||||
status: retrying.status(),
|
||||
result_items: Vec::new(),
|
||||
result_items_truncated: false,
|
||||
progress: None,
|
||||
});
|
||||
return Ok(empty_task_report(retrying.status()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1930,36 +2190,21 @@ impl HealManager {
|
||||
if let Some(completed) = completed_heals.get(&canonical_task_id)
|
||||
&& completed_status_is_retrying(&completed.status)
|
||||
{
|
||||
return Ok(HealTaskReport {
|
||||
status: completed.status.clone(),
|
||||
result_items: completed.result_items.clone(),
|
||||
result_items_truncated: completed.result_items_truncated,
|
||||
progress: None,
|
||||
});
|
||||
return Ok(completed_task_report(completed, since));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let queue = self.heal_queue.lock().await;
|
||||
if queue.contains_request_id(&canonical_task_id) {
|
||||
return Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Pending,
|
||||
result_items: Vec::new(),
|
||||
result_items_truncated: false,
|
||||
progress: None,
|
||||
});
|
||||
return Ok(empty_task_report(HealTaskStatus::Pending));
|
||||
}
|
||||
}
|
||||
|
||||
let mut completed_heals = self.completed_heals.lock().await;
|
||||
prune_completed_heal_statuses(&mut completed_heals);
|
||||
if let Some(completed) = completed_heals.get(&canonical_task_id) {
|
||||
return Ok(HealTaskReport {
|
||||
status: completed.status.clone(),
|
||||
result_items: completed.result_items.clone(),
|
||||
result_items_truncated: completed.result_items_truncated,
|
||||
progress: None,
|
||||
});
|
||||
return Ok(completed_task_report(completed, since));
|
||||
}
|
||||
|
||||
Err(Error::TaskNotFound {
|
||||
@@ -1968,18 +2213,23 @@ impl HealManager {
|
||||
}
|
||||
|
||||
pub async fn get_task_report_for_path(&self, heal_path: &str, task_id: &str) -> Result<HealTaskReport> {
|
||||
self.get_task_report_for_path_since(heal_path, task_id, None).await
|
||||
}
|
||||
|
||||
/// Incremental variant of [`Self::get_task_report_for_path`] (HS-06).
|
||||
pub async fn get_task_report_for_path_since(
|
||||
&self,
|
||||
heal_path: &str,
|
||||
task_id: &str,
|
||||
since: Option<u64>,
|
||||
) -> Result<HealTaskReport> {
|
||||
let canonical_task_id = self.canonical_task_id(task_id).await;
|
||||
{
|
||||
let active_heals = self.active_heals.lock().await;
|
||||
if let Some(task) = active_heals.get(&canonical_task_id)
|
||||
&& heal_type_matches_path(&task.heal_type, heal_path)
|
||||
{
|
||||
return Ok(HealTaskReport {
|
||||
status: task.get_status().await,
|
||||
result_items: task.get_result_items().await,
|
||||
result_items_truncated: task.result_items_truncated(),
|
||||
progress: Some(task.get_progress().await),
|
||||
});
|
||||
return Ok(active_task_report(task, since).await);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1988,12 +2238,7 @@ impl HealManager {
|
||||
if let Some(retrying) = retrying_heals.get(&canonical_task_id)
|
||||
&& heal_type_matches_path(&retrying.request.heal_type, heal_path)
|
||||
{
|
||||
return Ok(HealTaskReport {
|
||||
status: retrying.status(),
|
||||
result_items: Vec::new(),
|
||||
result_items_truncated: false,
|
||||
progress: None,
|
||||
});
|
||||
return Ok(empty_task_report(retrying.status()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2004,24 +2249,14 @@ impl HealManager {
|
||||
&& heal_type_matches_path(&completed.heal_type, heal_path)
|
||||
&& completed_status_is_retrying(&completed.status)
|
||||
{
|
||||
return Ok(HealTaskReport {
|
||||
status: completed.status.clone(),
|
||||
result_items: completed.result_items.clone(),
|
||||
result_items_truncated: completed.result_items_truncated,
|
||||
progress: None,
|
||||
});
|
||||
return Ok(completed_task_report(completed, since));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let queue = self.heal_queue.lock().await;
|
||||
if queue.contains_request_id_matching_path(&canonical_task_id, heal_path) {
|
||||
return Ok(HealTaskReport {
|
||||
status: HealTaskStatus::Pending,
|
||||
result_items: Vec::new(),
|
||||
result_items_truncated: false,
|
||||
progress: None,
|
||||
});
|
||||
return Ok(empty_task_report(HealTaskStatus::Pending));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2031,12 +2266,7 @@ impl HealManager {
|
||||
if let Some(completed) = completed_heals.get(&canonical_task_id)
|
||||
&& heal_type_matches_path(&completed.heal_type, heal_path)
|
||||
{
|
||||
return Ok(HealTaskReport {
|
||||
status: completed.status.clone(),
|
||||
result_items: completed.result_items.clone(),
|
||||
result_items_truncated: completed.result_items_truncated,
|
||||
progress: None,
|
||||
});
|
||||
return Ok(completed_task_report(completed, since));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3231,12 +3461,16 @@ impl HealManager {
|
||||
completed_task.get_status().await
|
||||
};
|
||||
let completed_progress = completed_task.get_progress().await;
|
||||
let final_window = completed_task.get_result_items_since(None).await;
|
||||
let completed_status_entry = CompletedHealStatus {
|
||||
heal_type: completed_task.heal_type.clone(),
|
||||
status: completed_status.clone(),
|
||||
result_items: completed_task.get_result_items().await,
|
||||
result_items: final_window.items.clone(),
|
||||
result_items_truncated: completed_task.result_items_truncated(),
|
||||
completed_at: SystemTime::now(),
|
||||
seqed_items: completed_task.get_seqed_result_items().await,
|
||||
next_seq: final_window.next_seq,
|
||||
min_seq: final_window.min_seq,
|
||||
};
|
||||
let mut completed_heals_guard = completed_heals_clone.lock().await;
|
||||
prune_completed_heal_statuses(&mut completed_heals_guard);
|
||||
@@ -5008,6 +5242,9 @@ mod tests {
|
||||
},
|
||||
result_items: Vec::new(),
|
||||
result_items_truncated: false,
|
||||
seqed_items: Vec::new(),
|
||||
next_seq: 0,
|
||||
min_seq: 0,
|
||||
completed_at: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
@@ -5289,6 +5526,136 @@ mod tests {
|
||||
assert_eq!(snapshot.queued_by_source.internal, 0);
|
||||
}
|
||||
|
||||
// HS-06 (backlog#1870): overlap policy + forceStart semantics.
|
||||
fn manager_with_policy(policy: HealOverlapPolicy) -> HealManager {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
HealManager::new(
|
||||
storage,
|
||||
Some(HealConfig {
|
||||
overlap_policy: policy,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn admin_prefix_request(bucket: &str, prefix: &str) -> HealRequest {
|
||||
let mut request = HealRequest::new(
|
||||
HealType::Prefix {
|
||||
bucket: bucket.to_string(),
|
||||
prefix: prefix.to_string(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::Normal,
|
||||
);
|
||||
request.source = HealRequestSource::Admin;
|
||||
request
|
||||
}
|
||||
|
||||
async fn insert_active_task(manager: &HealManager, request: HealRequest) -> String {
|
||||
let task = Arc::new(HealTask::from_request(request, manager.storage.clone()));
|
||||
let task_id = task.id.clone();
|
||||
manager.active_heals.lock().await.insert(task_id.clone(), task);
|
||||
task_id
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overlap_policy_minio_error_rejects_same_and_containing_paths() {
|
||||
let manager = manager_with_policy(HealOverlapPolicy::MinioError);
|
||||
insert_active_task(&manager, admin_prefix_request("bucket-a", "logs/")).await;
|
||||
|
||||
// Same target: typed AlreadyRunning.
|
||||
let same = manager
|
||||
.submit_heal_request(admin_prefix_request("bucket-a", "logs/"))
|
||||
.await
|
||||
.expect("admission must decide");
|
||||
assert_eq!(
|
||||
same,
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning),
|
||||
"an identical target must reject with already-running"
|
||||
);
|
||||
|
||||
// Contained path: typed OverlappingPaths.
|
||||
let nested = manager
|
||||
.submit_heal_request(admin_prefix_request("bucket-a", "logs/app/"))
|
||||
.await
|
||||
.expect("admission must decide");
|
||||
assert_eq!(
|
||||
nested,
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::OverlappingPaths),
|
||||
"a path inside the active task's path must reject with overlapping-paths"
|
||||
);
|
||||
|
||||
// Containing path (bucket-wide vs nested active): also overlapping.
|
||||
let wide = manager
|
||||
.submit_heal_request(admin_prefix_request("bucket-a", ""))
|
||||
.await
|
||||
.expect("admission must decide");
|
||||
assert_eq!(
|
||||
wide,
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::OverlappingPaths),
|
||||
"a bucket-wide start overlapping a nested active heal must reject"
|
||||
);
|
||||
|
||||
// Disjoint bucket: unaffected.
|
||||
let disjoint = manager
|
||||
.submit_heal_request(admin_prefix_request("bucket-b", "logs/"))
|
||||
.await
|
||||
.expect("admission must decide");
|
||||
assert_eq!(disjoint, HealAdmissionResult::Accepted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overlap_policy_default_merge_keeps_today_semantics() {
|
||||
let manager = manager_with_policy(HealOverlapPolicy::Merge);
|
||||
insert_active_task(&manager, admin_prefix_request("bucket-a", "logs/")).await;
|
||||
|
||||
// Different-dedup-key overlap still merges under the default policy:
|
||||
// the nested path dedups to its own key but nothing rejects it.
|
||||
let nested = manager
|
||||
.submit_heal_request(admin_prefix_request("bucket-a", "logs/app/"))
|
||||
.await
|
||||
.expect("admission must decide");
|
||||
assert_eq!(nested, HealAdmissionResult::Accepted, "default policy must not reject overlaps");
|
||||
|
||||
// Non-admin sources never get overlap rejections even under minio_error.
|
||||
let manager = manager_with_policy(HealOverlapPolicy::MinioError);
|
||||
insert_active_task(&manager, admin_prefix_request("bucket-a", "logs/")).await;
|
||||
let mut scanner_request = admin_prefix_request("bucket-a", "logs/app/");
|
||||
scanner_request.source = HealRequestSource::Scanner;
|
||||
let admitted = manager
|
||||
.submit_heal_request(scanner_request)
|
||||
.await
|
||||
.expect("admission must decide");
|
||||
assert_eq!(admitted, HealAdmissionResult::Accepted, "scanner sources must never be overlap-rejected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_force_start_cancels_overlapping_active_task_first() {
|
||||
let manager = manager_with_policy(HealOverlapPolicy::Merge);
|
||||
let old_id = insert_active_task(&manager, admin_prefix_request("bucket-a", "logs/")).await;
|
||||
|
||||
let mut replacement = admin_prefix_request("bucket-a", "logs/");
|
||||
replacement.force_start = true;
|
||||
let receipt = manager
|
||||
.submit_heal_request_with_receipt(replacement)
|
||||
.await
|
||||
.expect("force-start submission must decide");
|
||||
|
||||
assert!(receipt.result.is_admitted(), "the new task must be admitted (Accepted or Merged)");
|
||||
let old_task_gone = {
|
||||
let active_heals = manager.active_heals.lock().await;
|
||||
!active_heals.contains_key(&old_id)
|
||||
};
|
||||
assert!(
|
||||
old_task_gone,
|
||||
"the overlapping admin task must be cancelled (removed from the active table) before the new one starts"
|
||||
);
|
||||
assert!(
|
||||
matches!(manager.get_task_status(&old_id).await, Err(Error::TaskNotFound { .. })),
|
||||
"a cancelled task must no longer resolve as an active heal"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_operations_snapshot_counts_active_by_source_and_priority() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
@@ -5591,6 +5958,9 @@ mod tests {
|
||||
status: HealTaskStatus::Completed,
|
||||
result_items: Vec::new(),
|
||||
result_items_truncated: false,
|
||||
seqed_items: Vec::new(),
|
||||
next_seq: 0,
|
||||
min_seq: 0,
|
||||
completed_at: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
@@ -5625,6 +5995,9 @@ mod tests {
|
||||
..Default::default()
|
||||
}],
|
||||
result_items_truncated: true,
|
||||
seqed_items: Vec::new(),
|
||||
next_seq: 0,
|
||||
min_seq: 0,
|
||||
completed_at: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ use std::{
|
||||
future::Future,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
time::{Duration, Instant, SystemTime},
|
||||
};
|
||||
@@ -351,6 +351,20 @@ impl HealRequest {
|
||||
}
|
||||
|
||||
/// Heal task
|
||||
/// Incremental view over a task's retained result items (HS-06).
|
||||
///
|
||||
/// `next_seq` is the cursor a client should pass on its next poll; `min_seq`
|
||||
/// is the oldest sequence still retained; `lagged` means the client's cursor
|
||||
/// fell behind `min_seq` and items were skipped — the client should restart
|
||||
/// from `min_seq`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HealResultWindow {
|
||||
pub items: Vec<HealResultItem>,
|
||||
pub next_seq: u64,
|
||||
pub min_seq: u64,
|
||||
pub lagged: bool,
|
||||
}
|
||||
|
||||
pub struct HealTask {
|
||||
/// Task ID
|
||||
pub id: String,
|
||||
@@ -373,8 +387,16 @@ pub struct HealTask {
|
||||
pub status: Arc<RwLock<HealTaskStatus>>,
|
||||
/// Progress tracking
|
||||
pub progress: Arc<RwLock<HealProgress>>,
|
||||
/// Result items collected from storage heal calls.
|
||||
pub result_items: Arc<RwLock<Vec<HealResultItem>>>,
|
||||
/// Result items collected from storage heal calls, each stamped with a
|
||||
/// monotonically increasing sequence number for incremental consumption
|
||||
/// (the client passes the last seen seq back and receives only newer
|
||||
/// items; see `get_result_items_since`).
|
||||
pub result_items: Arc<RwLock<Vec<(u64, HealResultItem)>>>,
|
||||
/// Next sequence number to assign; starts at 1.
|
||||
next_item_seq: Arc<AtomicU64>,
|
||||
/// Sequence number of the oldest item still inside the retention window;
|
||||
/// equals `next_item_seq` while the window is empty.
|
||||
min_available_seq: Arc<AtomicU64>,
|
||||
result_items_truncated: Arc<AtomicBool>,
|
||||
batch_failure: Arc<RwLock<Option<BatchHealFailure>>>,
|
||||
batch_failure_recorded: Arc<AtomicBool>,
|
||||
@@ -426,6 +448,8 @@ impl HealTask {
|
||||
status: Arc::new(RwLock::new(HealTaskStatus::Pending)),
|
||||
progress: Arc::new(RwLock::new(HealProgress::new())),
|
||||
result_items: Arc::new(RwLock::new(Vec::new())),
|
||||
next_item_seq: Arc::new(AtomicU64::new(1)),
|
||||
min_available_seq: Arc::new(AtomicU64::new(1)),
|
||||
result_items_truncated: Arc::new(AtomicBool::new(false)),
|
||||
batch_failure: Arc::new(RwLock::new(None)),
|
||||
batch_failure_recorded: Arc::new(AtomicBool::new(false)),
|
||||
@@ -911,18 +935,63 @@ impl HealTask {
|
||||
}
|
||||
|
||||
pub async fn get_result_items(&self) -> Vec<HealResultItem> {
|
||||
self.result_items.read().await.iter().map(|(_, item)| item.clone()).collect()
|
||||
}
|
||||
|
||||
/// Sequence-stamped retained window, used when archiving a completed
|
||||
/// task so incremental cursors survive the transition (HS-06).
|
||||
pub async fn get_seqed_result_items(&self) -> Vec<(u64, HealResultItem)> {
|
||||
self.result_items.read().await.clone()
|
||||
}
|
||||
|
||||
/// Incremental result window (HS-06): `since = None` returns the full
|
||||
/// retained window (legacy snapshot semantics); `since = Some(seq)`
|
||||
/// returns only items stamped with a sequence greater than `seq`.
|
||||
/// `lagged` warns that the caller's cursor fell behind the window start
|
||||
/// and items were skipped (the response carries `min_seq` as the catch-up
|
||||
/// cursor).
|
||||
pub async fn get_result_items_since(&self, since: Option<u64>) -> HealResultWindow {
|
||||
let result_items = self.result_items.read().await;
|
||||
let next_seq = self.next_item_seq.load(Ordering::Relaxed);
|
||||
let min_seq = self.min_available_seq.load(Ordering::Relaxed);
|
||||
let mut lagged = false;
|
||||
let items = match since {
|
||||
None => result_items.iter().map(|(_, item)| item.clone()).collect::<Vec<_>>(),
|
||||
Some(cursor) => {
|
||||
if cursor + 1 < min_seq {
|
||||
lagged = true;
|
||||
}
|
||||
result_items
|
||||
.iter()
|
||||
.filter(|(seq, _)| *seq > cursor)
|
||||
.map(|(_, item)| item.clone())
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
};
|
||||
HealResultWindow {
|
||||
items,
|
||||
next_seq,
|
||||
min_seq,
|
||||
lagged,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn result_items_truncated(&self) -> bool {
|
||||
self.result_items_truncated.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
async fn record_result_item(&self, result: HealResultItem) {
|
||||
let seq = self.next_item_seq.fetch_add(1, Ordering::Relaxed);
|
||||
let mut result_items = self.result_items.write().await;
|
||||
if result_items.len() < MAX_RETAINED_HEAL_RESULT_ITEMS {
|
||||
result_items.push(result);
|
||||
result_items.push((seq, result));
|
||||
} else {
|
||||
// Slide the window: the oldest item leaves and the cursor for the
|
||||
// oldest still-available item moves forward with it.
|
||||
result_items.remove(0);
|
||||
self.min_available_seq
|
||||
.store(result_items.first().map_or(seq, |(oldest, _)| *oldest), Ordering::Relaxed);
|
||||
result_items.push((seq, result));
|
||||
self.result_items_truncated.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -3880,6 +3949,69 @@ mod tests {
|
||||
assert!(task.result_items_truncated());
|
||||
}
|
||||
|
||||
// HS-06 (backlog#1870): incremental result windows.
|
||||
#[tokio::test]
|
||||
async fn result_items_seq_is_monotonic_and_incremental_slices_work() {
|
||||
let storage = Arc::new(MockStorage::default());
|
||||
let task = HealTask::from_request(HealRequest::bucket("bucket-a".to_string()), storage);
|
||||
|
||||
for round in 0..5u64 {
|
||||
let item = HealResultItem {
|
||||
object_size: round as usize,
|
||||
..Default::default()
|
||||
};
|
||||
task.record_result_item(item).await;
|
||||
}
|
||||
|
||||
let full = task.get_result_items_since(None).await;
|
||||
assert_eq!(full.items.len(), 5, "None keeps the full-snapshot semantics");
|
||||
assert_eq!(full.next_seq, 6, "next_seq is one past the last assigned");
|
||||
assert_eq!(full.min_seq, 1, "nothing was evicted yet");
|
||||
assert!(!full.lagged);
|
||||
|
||||
// Incremental: only items newer than the cursor.
|
||||
let incremental = task.get_result_items_since(Some(3)).await;
|
||||
assert_eq!(
|
||||
incremental.items.iter().map(|item| item.object_size).collect::<Vec<_>>(),
|
||||
vec![3, 4],
|
||||
"only sequences greater than the cursor are returned"
|
||||
);
|
||||
assert_eq!(incremental.next_seq, 6);
|
||||
|
||||
// A cursor at the head is not lagging.
|
||||
assert!(!task.get_result_items_since(Some(0)).await.lagged);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn result_items_window_slide_moves_min_seq_and_flags_lagging_cursors() {
|
||||
let storage = Arc::new(MockStorage::default());
|
||||
let task = HealTask::from_request(HealRequest::bucket("bucket-a".to_string()), storage);
|
||||
|
||||
// Fill the window completely, then push two more items: seq 1 and 2
|
||||
// are evicted by the slide.
|
||||
for _ in 0..(MAX_RETAINED_HEAL_RESULT_ITEMS + 2) {
|
||||
task.record_result_item(HealResultItem::default()).await;
|
||||
}
|
||||
|
||||
let full = task.get_result_items_since(None).await;
|
||||
assert_eq!(full.items.len(), MAX_RETAINED_HEAL_RESULT_ITEMS);
|
||||
assert_eq!(full.min_seq, 3, "each evicted head item moved the oldest-available cursor");
|
||||
assert!(task.result_items_truncated());
|
||||
|
||||
// A client still polling from before the eviction is lagging.
|
||||
let lagging = task.get_result_items_since(Some(0)).await;
|
||||
assert!(lagging.lagged, "a cursor behind min_seq must be flagged");
|
||||
assert_eq!(lagging.min_seq, 3, "the response tells the client where to restart");
|
||||
|
||||
// A cursor inside the window is fine.
|
||||
assert!(!task.get_result_items_since(Some(3)).await.lagged);
|
||||
|
||||
// The lagging client restarts from min_seq and gets the full window.
|
||||
let catch_up = task.get_result_items_since(Some(3)).await;
|
||||
assert_eq!(catch_up.items.len(), MAX_RETAINED_HEAL_RESULT_ITEMS - 1);
|
||||
assert!(!catch_up.lagged);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_recursive_bucket_heal_skips_object_dir_candidates() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
|
||||
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Removed
|
||||
|
||||
#### rustfs-io-core
|
||||
- **Zero-consumer modules** (added in 0.0.5): `reader`, `writer`, `bufreader_optimizer`, `shared_memory`, `direct_io`, `timeout_wrapper`, `io_priority_queue`, and `scheduler` had no caller in the workspace and were removed (rustfs/backlog#1824). The scheduling algorithm and the request timeout wrapper that RustFS actually runs live in `rustfs/src/storage/`; this crate keeps the config shapes they project into. `OperationProgress` moved to the new `progress` module and is still exported as `rustfs_io_core::OperationProgress`.
|
||||
|
||||
#### rustfs-io-metrics
|
||||
- **Unified configuration** (added in 0.0.5): the zero-consumer `IoConfig`, `CacheSettings`, `IoSchedulerSettings`, `BackpressureSettings`, `TimeoutSettings`, `DeadlockDetectionSettings` types and their `DEFAULT_*` constants were removed (rustfs/rustfs#6008); rustfs-io-core's `IoSchedulerConfig`/`BackpressureConfig` remain the canonical configuration types.
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "Buffered I/O reader and writer implementations for RustFS (mmap-then-copy, aligned pread)"
|
||||
keywords = ["io", "reader", "writer", "rustfs", "mmap"]
|
||||
description = "Shared I/O primitives for RustFS (buffer pool, storage profiling, backpressure, deadlock detection)"
|
||||
keywords = ["io", "buffer", "pool", "rustfs", "backpressure"]
|
||||
categories = ["development-tools", "filesystem"]
|
||||
|
||||
[lints]
|
||||
@@ -38,7 +38,6 @@ hotpath.workspace = true
|
||||
bytes = { workspace = true, features = ["serde"] }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["io-util", "fs", "sync", "rt-multi-thread"] }
|
||||
memmap2 = { workspace = true }
|
||||
rustfs-io-metrics = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
|
||||
+18
-120
@@ -23,67 +23,20 @@
|
||||
|
||||
## Overview
|
||||
|
||||
**rustfs-io-core** is the core I/O scheduling module for [RustFS](https://rustfs.com), a distributed object storage system. It provides:
|
||||
**rustfs-io-core** holds the shared I/O primitives for [RustFS](https://rustfs.com), a distributed object storage system. It provides:
|
||||
|
||||
- **I/O Scheduler**: Adaptive buffer size calculation and load management
|
||||
- **Priority Queue**: Request priority scheduling with starvation prevention
|
||||
- **Buffer Pool**: Tiered `BytesPool` for buffer reuse
|
||||
- **Storage Profiling**: Storage-media and access-pattern model (`io_profile`)
|
||||
- **Scheduler Configuration**: The `IoSchedulerConfig` / `IoPriorityQueueConfig` shapes the storage layer projects into
|
||||
- **Backpressure Control**: System overload protection with graceful degradation
|
||||
- **Deadlock Detection**: Wait-for graph based deadlock detection algorithm
|
||||
- **Lock Optimizer**: Adaptive spin lock optimization
|
||||
- **Timeout Wrapper**: Dynamic timeout calculation and operation progress tracking
|
||||
- **Progress Tracking**: Byte progress and staleness for long-running operations
|
||||
|
||||
The scheduling algorithm itself lives in `rustfs/src/storage/concurrency/io_schedule.rs`; this crate carries the configuration shapes it projects into, not a second implementation.
|
||||
|
||||
## Features
|
||||
|
||||
### I/O Scheduler
|
||||
|
||||
Adaptive I/O scheduling with dynamic buffer size calculation based on file size, access pattern, and system load:
|
||||
|
||||
```rust
|
||||
use rustfs_io_core::{IoScheduler, IoSchedulerConfig, IoLoadLevel};
|
||||
use rustfs_io_core::io_profile::{StorageMedia, AccessPattern};
|
||||
|
||||
// Create scheduler
|
||||
let config = IoSchedulerConfig {
|
||||
max_concurrent_reads: 64,
|
||||
base_buffer_size: 64 * 1024, // 64 KB
|
||||
max_buffer_size: 1024 * 1024, // 1 MB
|
||||
..Default::default()
|
||||
};
|
||||
let scheduler = IoScheduler::new(config);
|
||||
|
||||
// Calculate optimal buffer size
|
||||
let buffer_size = calculate_optimal_buffer_size(
|
||||
10 * 1024 * 1024, // 10 MB file
|
||||
64 * 1024, // base buffer
|
||||
true, // sequential access
|
||||
4, // concurrent requests
|
||||
StorageMedia::Ssd,
|
||||
IoLoadLevel::Low,
|
||||
);
|
||||
```
|
||||
|
||||
### Priority Queue
|
||||
|
||||
Priority queue with starvation prevention:
|
||||
|
||||
```rust
|
||||
use rustfs_io_core::{IoPriorityQueue, IoPriority, IoQueueStatus};
|
||||
|
||||
let queue = IoPriorityQueue::<()>::new(100);
|
||||
|
||||
// Enqueue request
|
||||
let request_id = queue.enqueue(IoPriority::High, (), 1024);
|
||||
|
||||
// Dequeue request
|
||||
if let Some((priority, data)) = queue.dequeue() {
|
||||
println!("Processing priority {:?} request", priority);
|
||||
}
|
||||
|
||||
// Check queue status
|
||||
let status = queue.status();
|
||||
println!("High priority waiting: {}", status.high_priority_waiting);
|
||||
```
|
||||
|
||||
### Backpressure Control
|
||||
|
||||
System overload protection:
|
||||
@@ -148,71 +101,23 @@ let stats = optimizer.stats();
|
||||
println!("Locks acquired: {}", stats.total_acquired());
|
||||
```
|
||||
|
||||
### Timeout Wrapper
|
||||
### Progress Tracking
|
||||
|
||||
Dynamic timeout calculation:
|
||||
Byte progress and staleness for long-running operations:
|
||||
|
||||
```rust
|
||||
use rustfs_io_core::{RequestTimeoutWrapper, TimeoutConfig};
|
||||
use rustfs_io_core::OperationProgress;
|
||||
use std::time::Duration;
|
||||
|
||||
let config = TimeoutConfig {
|
||||
base_timeout: Duration::from_secs(5),
|
||||
timeout_per_mb: Duration::from_millis(100),
|
||||
max_timeout: Duration::from_secs(300),
|
||||
..Default::default()
|
||||
};
|
||||
let wrapper = RequestTimeoutWrapper::new(config);
|
||||
let progress = OperationProgress::new(Some(1000), Duration::from_secs(5));
|
||||
|
||||
// Calculate operation timeout
|
||||
let timeout = wrapper.calculate_timeout(10 * 1024 * 1024); // 10 MB
|
||||
```
|
||||
|
||||
## Buffer Size Calculation
|
||||
|
||||
Multiple buffer size calculation functions are provided:
|
||||
|
||||
```rust
|
||||
use rustfs_io_core::{
|
||||
get_concurrency_aware_buffer_size,
|
||||
get_advanced_buffer_size,
|
||||
get_buffer_size_for_media,
|
||||
calculate_optimal_buffer_size,
|
||||
KI_B, MI_B,
|
||||
};
|
||||
use rustfs_io_core::io_profile::StorageMedia;
|
||||
|
||||
// Basic calculation
|
||||
let size1 = get_concurrency_aware_buffer_size(1024 * 1024, 64 * 1024);
|
||||
|
||||
// Advanced calculation (considering access pattern)
|
||||
let size2 = get_advanced_buffer_size(10 * 1024 * 1024, 64 * 1024, true);
|
||||
|
||||
// Media type optimization
|
||||
let size3 = get_buffer_size_for_media(64 * 1024, StorageMedia::Ssd);
|
||||
|
||||
// Comprehensive calculation
|
||||
let size4 = calculate_optimal_buffer_size(
|
||||
100 * 1024 * 1024, // 100 MB file
|
||||
64 * 1024, // base buffer
|
||||
true, // sequential access
|
||||
4, // concurrent requests
|
||||
StorageMedia::Nvme,
|
||||
IoLoadLevel::Low,
|
||||
);
|
||||
progress.update(500);
|
||||
assert_eq!(progress.progress_percent(), Some(50.0));
|
||||
assert!(!progress.is_stale());
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `RUSTFS_MAX_CONCURRENT_READS` | Max concurrent reads | 64 |
|
||||
| `RUSTFS_BASE_BUFFER_SIZE` | Base buffer size | 65536 |
|
||||
| `RUSTFS_MAX_BUFFER_SIZE` | Max buffer size | 1048576 |
|
||||
| `RUSTFS_IO_TIMEOUT_SECS` | I/O timeout seconds | 30 |
|
||||
|
||||
### Code Configuration
|
||||
|
||||
```rust
|
||||
@@ -240,12 +145,11 @@ rustfs-io-core/
|
||||
├── src/
|
||||
│ ├── lib.rs # Module entry
|
||||
│ ├── config.rs # Configuration types
|
||||
│ ├── scheduler.rs # I/O scheduler
|
||||
│ ├── io_priority_queue.rs # Priority queue
|
||||
│ ├── pool.rs # Tiered buffer pool
|
||||
│ ├── backpressure.rs # Backpressure control
|
||||
│ ├── deadlock_detector.rs # Deadlock detection
|
||||
│ ├── lock_optimizer.rs # Lock optimization
|
||||
│ ├── timeout_wrapper.rs # Timeout wrapper
|
||||
│ ├── progress.rs # Operation progress tracking
|
||||
│ └── io_profile.rs # I/O profile
|
||||
└── Cargo.toml
|
||||
```
|
||||
@@ -254,21 +158,15 @@ rustfs-io-core/
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
cargo test --package rustfs-io-core
|
||||
cargo nextest run --package rustfs-io-core
|
||||
|
||||
# Run specific tests
|
||||
cargo test --package rustfs-io-core --lib scheduler
|
||||
|
||||
# Run benchmarks
|
||||
cargo bench --package rustfs-io-core
|
||||
cargo nextest run --package rustfs-io-core -E 'test(backpressure)'
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [API Documentation](https://docs.rs/rustfs-io-core)
|
||||
- [I/O Scheduler Design](./docs/scheduler-design.md)
|
||||
- [Backpressure Control Design](./docs/backpressure-design.md)
|
||||
- [Deadlock Detection Algorithm](./docs/deadlock-detection.md)
|
||||
|
||||
## Related Modules
|
||||
|
||||
|
||||
+18
-131
@@ -23,71 +23,20 @@
|
||||
|
||||
## 📖 概述
|
||||
|
||||
**rustfs-io-core** 是 [RustFS](https://rustfs.com) 分布式对象存储系统的核心 I/O 调度模块。它提供了:
|
||||
**rustfs-io-core** 是 [RustFS](https://rustfs.com) 分布式对象存储系统的共享 I/O 基础组件。它提供了:
|
||||
|
||||
- **I/O 调度器**:自适应缓冲区大小计算和负载管理
|
||||
- **优先级队列**:支持饥饿预防的请求优先级调度
|
||||
- **缓冲池**:分级复用的 `BytesPool`
|
||||
- **存储画像**:存储介质与访问模式模型(`io_profile`)
|
||||
- **调度配置**:存储层投影使用的 `IoSchedulerConfig` / `IoPriorityQueueConfig`
|
||||
- **背压控制**:系统过载保护和优雅降级
|
||||
- **死锁检测**:基于等待图的死锁检测算法
|
||||
- **锁优化**:自适应自旋锁优化
|
||||
- **超时包装器**:动态超时计算和操作进度追踪
|
||||
- **进度追踪**:长耗时操作的字节进度与停滞判定
|
||||
|
||||
调度算法本身位于 `rustfs/src/storage/concurrency/io_schedule.rs`;本 crate 只承载它投影使用的配置形状,不是第二套实现。
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
### I/O 调度器 (IoScheduler)
|
||||
|
||||
自适应 I/O 调度,根据文件大小、访问模式和系统负载动态调整缓冲区大小:
|
||||
|
||||
```rust
|
||||
use rustfs_io_core::{IoScheduler, IoSchedulerConfig, IoLoadLevel};
|
||||
use rustfs_io_core::io_profile::{StorageMedia, AccessPattern};
|
||||
|
||||
// 创建调度器
|
||||
let config = IoSchedulerConfig {
|
||||
max_concurrent_reads: 64,
|
||||
base_buffer_size: 64 * 1024, // 64 KB
|
||||
max_buffer_size: 1024 * 1024, // 1 MB
|
||||
..Default::default()
|
||||
};
|
||||
let scheduler = IoScheduler::new(config);
|
||||
|
||||
// 计算最优缓冲区大小
|
||||
let buffer_size = scheduler.calculate_buffer_size(
|
||||
10 * 1024 * 1024, // 10 MB 文件
|
||||
true, // 顺序访问
|
||||
StorageMedia::Ssd,
|
||||
IoLoadLevel::Low,
|
||||
);
|
||||
println!("缓冲区大小: {} bytes", buffer_size);
|
||||
```
|
||||
|
||||
### 优先级队列 (IoPriorityQueue)
|
||||
|
||||
支持饥饿预防的优先级队列:
|
||||
|
||||
```rust
|
||||
use rustfs_io_core::{IoPriorityQueue, IoPriority, IoQueueStatus};
|
||||
|
||||
let queue = IoPriorityQueue::<()>::new(100);
|
||||
|
||||
// 入队请求
|
||||
let request_id = queue.enqueue(
|
||||
IoPriority::High,
|
||||
(), // 请求数据
|
||||
1024, // 请求大小
|
||||
);
|
||||
|
||||
// 出队请求
|
||||
if let Some((priority, data)) = queue.dequeue() {
|
||||
println!("处理优先级 {:?} 的请求", priority);
|
||||
}
|
||||
|
||||
// 检查队列状态
|
||||
let status = queue.status();
|
||||
println!("高优先级等待: {}", status.high_priority_waiting);
|
||||
println!("低优先级等待: {}", status.low_priority_waiting);
|
||||
```
|
||||
|
||||
### 背压控制 (BackpressureMonitor)
|
||||
|
||||
系统过载保护:
|
||||
@@ -165,78 +114,23 @@ let stats = optimizer.stats();
|
||||
println!("获取锁次数: {}", stats.locks_acquired.load(std::sync::atomic::Ordering::Relaxed));
|
||||
```
|
||||
|
||||
### 超时包装器 (RequestTimeoutWrapper)
|
||||
### 进度追踪 (OperationProgress)
|
||||
|
||||
动态超时计算:
|
||||
长耗时操作的字节进度与停滞判定:
|
||||
|
||||
```rust
|
||||
use rustfs_io_core::{RequestTimeoutWrapper, TimeoutConfig};
|
||||
use rustfs_io_core::OperationProgress;
|
||||
use std::time::Duration;
|
||||
|
||||
let config = TimeoutConfig {
|
||||
base_timeout: Duration::from_secs(5),
|
||||
timeout_per_mb: Duration::from_millis(100),
|
||||
max_timeout: Duration::from_secs(300),
|
||||
..Default::default()
|
||||
};
|
||||
let wrapper = RequestTimeoutWrapper::new(config);
|
||||
let progress = OperationProgress::new(Some(1000), Duration::from_secs(5));
|
||||
|
||||
// 计算操作超时
|
||||
let timeout = wrapper.calculate_timeout(10 * 1024 * 1024); // 10 MB
|
||||
println!("超时时间: {:?}", timeout);
|
||||
|
||||
// 执行带超时的操作
|
||||
let result = wrapper.execute_with_timeout(async {
|
||||
// 异步操作
|
||||
Ok::<_, std::io::Error>(())
|
||||
}, timeout).await;
|
||||
```
|
||||
|
||||
## 📊 缓冲区大小计算
|
||||
|
||||
模块提供了多种缓冲区大小计算函数:
|
||||
|
||||
```rust
|
||||
use rustfs_io_core::{
|
||||
get_concurrency_aware_buffer_size,
|
||||
get_advanced_buffer_size,
|
||||
get_buffer_size_for_media,
|
||||
calculate_optimal_buffer_size,
|
||||
KI_B, MI_B,
|
||||
};
|
||||
use rustfs_io_core::io_profile::StorageMedia;
|
||||
|
||||
// 基础计算
|
||||
let size1 = get_concurrency_aware_buffer_size(1024 * 1024, 64 * 1024);
|
||||
|
||||
// 高级计算(考虑访问模式)
|
||||
let size2 = get_advanced_buffer_size(10 * 1024 * 1024, 64 * 1024, true);
|
||||
|
||||
// 媒体类型优化
|
||||
let size3 = get_buffer_size_for_media(64 * 1024, StorageMedia::Ssd);
|
||||
|
||||
// 综合计算
|
||||
let size4 = calculate_optimal_buffer_size(
|
||||
100 * 1024 * 1024, // 100 MB 文件
|
||||
64 * 1024, // 基础缓冲区
|
||||
true, // 顺序访问
|
||||
4, // 并发请求数
|
||||
StorageMedia::Nvme,
|
||||
IoLoadLevel::Low,
|
||||
);
|
||||
progress.update(500);
|
||||
assert_eq!(progress.progress_percent(), Some(50.0));
|
||||
assert!(!progress.is_stale());
|
||||
```
|
||||
|
||||
## 🔧 配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量名 | 描述 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `RUSTFS_MAX_CONCURRENT_READS` | 最大并发读数 | 64 |
|
||||
| `RUSTFS_BASE_BUFFER_SIZE` | 基础缓冲区大小 | 65536 |
|
||||
| `RUSTFS_MAX_BUFFER_SIZE` | 最大缓冲区大小 | 1048576 |
|
||||
| `RUSTFS_IO_TIMEOUT_SECS` | I/O 超时秒数 | 30 |
|
||||
|
||||
### 代码配置
|
||||
|
||||
```rust
|
||||
@@ -264,12 +158,11 @@ rustfs-io-core/
|
||||
├── src/
|
||||
│ ├── lib.rs # 模块入口
|
||||
│ ├── config.rs # 配置类型
|
||||
│ ├── scheduler.rs # I/O 调度器
|
||||
│ ├── io_priority_queue.rs # 优先级队列
|
||||
│ ├── pool.rs # 分级缓冲池
|
||||
│ ├── backpressure.rs # 背压控制
|
||||
│ ├── deadlock_detector.rs # 死锁检测
|
||||
│ ├── lock_optimizer.rs # 锁优化
|
||||
│ ├── timeout_wrapper.rs # 超时包装器
|
||||
│ ├── progress.rs # 操作进度追踪
|
||||
│ └── io_profile.rs # I/O 配置文件
|
||||
└── Cargo.toml
|
||||
```
|
||||
@@ -278,21 +171,15 @@ rustfs-io-core/
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
cargo test --package rustfs-io-core
|
||||
cargo nextest run --package rustfs-io-core
|
||||
|
||||
# 运行特定测试
|
||||
cargo test --package rustfs-io-core --lib scheduler
|
||||
|
||||
# 运行基准测试
|
||||
cargo bench --package rustfs-io-core
|
||||
cargo nextest run --package rustfs-io-core -E 'test(backpressure)'
|
||||
```
|
||||
|
||||
## 📚 文档
|
||||
|
||||
- [API 文档](https://docs.rs/rustfs-io-core)
|
||||
- [I/O 调度器设计](./docs/scheduler-design.md)
|
||||
- [背压控制原理](./docs/backpressure-design.md)
|
||||
- [死锁检测算法](./docs/deadlock-detection.md)
|
||||
|
||||
## 🔗 相关模块
|
||||
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
// 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.
|
||||
|
||||
//! Example demonstrating I/O scheduler usage.
|
||||
|
||||
use rustfs_io_core::io_profile::StorageMedia;
|
||||
use rustfs_io_core::{
|
||||
BackpressureMonitor, BackpressureState, DeadlockDetector, IoLoadLevel, IoScheduler, IoSchedulerConfig, KI_B, LockOptimizer,
|
||||
LockType, MI_B, calculate_optimal_buffer_size, get_buffer_size_for_media,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() {
|
||||
println!("=== rustfs-io-core Example ===\n");
|
||||
|
||||
// 1. I/O scheduler example
|
||||
io_scheduler_example();
|
||||
|
||||
// 2. Buffer size calculation example
|
||||
buffer_size_example();
|
||||
|
||||
// 3. Backpressure control example
|
||||
backpressure_example();
|
||||
|
||||
// 4. Deadlock detection example
|
||||
deadlock_detection_example();
|
||||
|
||||
// 5. Lock optimizer example
|
||||
lock_optimizer_example();
|
||||
}
|
||||
|
||||
fn io_scheduler_example() {
|
||||
println!("--- I/O Scheduler ---");
|
||||
|
||||
// Create scheduler with configuration
|
||||
let config = IoSchedulerConfig {
|
||||
max_concurrent_reads: 64,
|
||||
base_buffer_size: 64 * KI_B,
|
||||
max_buffer_size: MI_B,
|
||||
..Default::default()
|
||||
};
|
||||
let scheduler = IoScheduler::new(config);
|
||||
|
||||
println!(" Max concurrent reads: {}", scheduler.config().max_concurrent_reads);
|
||||
println!(" Base buffer size: {} KB", scheduler.config().base_buffer_size / KI_B);
|
||||
println!(" Max buffer size: {} KB", scheduler.config().max_buffer_size / KI_B);
|
||||
|
||||
// Calculate buffer sizes for different scenarios
|
||||
let scenarios = [
|
||||
("Small file", 10 * KI_B as i64, true, StorageMedia::Ssd),
|
||||
("Medium file", MI_B as i64, true, StorageMedia::Ssd),
|
||||
("Large sequential", 100 * MI_B as i64, true, StorageMedia::Ssd),
|
||||
("Large random", 100 * MI_B as i64, false, StorageMedia::Ssd),
|
||||
("NVMe large", 100 * MI_B as i64, true, StorageMedia::Nvme),
|
||||
("HDD large", 100 * MI_B as i64, true, StorageMedia::Hdd),
|
||||
];
|
||||
|
||||
for (name, size, sequential, media) in scenarios {
|
||||
let buffer = calculate_optimal_buffer_size(size, 64 * KI_B, sequential, 4, media, IoLoadLevel::Low);
|
||||
println!(" {}: {} bytes ({} KB)", name, buffer, buffer / KI_B);
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
fn buffer_size_example() {
|
||||
println!("--- Buffer Size Calculation ---");
|
||||
|
||||
// Comprehensive calculation
|
||||
let size1 = calculate_optimal_buffer_size(10 * MI_B as i64, 64 * KI_B, true, 4, StorageMedia::Ssd, IoLoadLevel::Low);
|
||||
println!(" Comprehensive (10MB, sequential, SSD): {} KB", size1 / KI_B);
|
||||
|
||||
// Media type optimization
|
||||
let media_types = [
|
||||
StorageMedia::Nvme,
|
||||
StorageMedia::Ssd,
|
||||
StorageMedia::Hdd,
|
||||
StorageMedia::Unknown,
|
||||
];
|
||||
for media in media_types {
|
||||
let size = get_buffer_size_for_media(64 * KI_B, media);
|
||||
println!(" {} optimized: {} KB", media.as_str(), size / KI_B);
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
fn backpressure_example() {
|
||||
println!("--- Backpressure Control ---");
|
||||
|
||||
let monitor = BackpressureMonitor::with_defaults();
|
||||
|
||||
// Check initial state
|
||||
let state = monitor.state();
|
||||
let state_str = match state {
|
||||
BackpressureState::Normal => "Normal",
|
||||
BackpressureState::Warning => "Warning",
|
||||
BackpressureState::Critical => "Critical",
|
||||
};
|
||||
println!(" Initial state: {}", state_str);
|
||||
|
||||
// Check if active
|
||||
let is_active = monitor.is_active();
|
||||
println!(" Backpressure active: {}", is_active);
|
||||
|
||||
// Try to acquire permit
|
||||
if monitor.try_acquire() {
|
||||
println!(" Successfully acquired permit");
|
||||
monitor.release();
|
||||
println!(" Released permit");
|
||||
}
|
||||
|
||||
// View statistics
|
||||
println!(" Total processed: {}", monitor.total_processed());
|
||||
println!(" Total rejected: {}", monitor.total_rejected());
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
fn deadlock_detection_example() {
|
||||
println!("--- Deadlock Detection ---");
|
||||
|
||||
let detector = DeadlockDetector::with_defaults();
|
||||
|
||||
// Register locks
|
||||
let mutex1 = detector.register_lock(LockType::Mutex);
|
||||
let mutex2 = detector.register_lock(LockType::Mutex);
|
||||
println!(" Registered locks: mutex1={}, mutex2={}", mutex1, mutex2);
|
||||
|
||||
// Simulate normal operation
|
||||
detector.record_acquire(mutex1, 1); // Thread 1 acquires mutex1
|
||||
detector.record_acquire(mutex2, 2); // Thread 2 acquires mutex2
|
||||
println!(" Normal operation: no deadlock");
|
||||
|
||||
// Detect deadlock
|
||||
if detector.detect_deadlock().is_none() {
|
||||
println!(" Detection result: no deadlock");
|
||||
}
|
||||
|
||||
// Simulate deadlock scenario
|
||||
detector.record_wait(mutex2, 1); // Thread 1 waits for mutex2
|
||||
detector.record_wait(mutex1, 2); // Thread 2 waits for mutex1
|
||||
|
||||
// Detect deadlock
|
||||
if let Some(deadlock) = detector.detect_deadlock() {
|
||||
println!(" Detection result: deadlock found {:?}", deadlock);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
detector.unregister_lock(mutex1);
|
||||
detector.unregister_lock(mutex2);
|
||||
println!();
|
||||
}
|
||||
|
||||
fn lock_optimizer_example() {
|
||||
println!("--- Lock Optimizer ---");
|
||||
|
||||
let optimizer = LockOptimizer::with_defaults();
|
||||
|
||||
// Simulate lock operations
|
||||
for _i in 0..5 {
|
||||
optimizer.on_acquire();
|
||||
// Simulate work
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
optimizer.on_release(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
// View statistics
|
||||
let stats = optimizer.stats();
|
||||
let acquired = stats.total_acquired();
|
||||
let avg_hold = stats.avg_hold_time();
|
||||
let contention = stats.contention_rate();
|
||||
|
||||
println!(" Locks acquired: {}", acquired);
|
||||
println!(" Average hold time: {:?}", avg_hold);
|
||||
println!(" Contention rate: {:.2}%", contention * 100.0);
|
||||
|
||||
println!();
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
// 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.
|
||||
|
||||
//! BufReader layer optimizer for minimizing redundant buffering layers.
|
||||
//!
|
||||
//! This module provides optimization for BufReader usage in data paths,
|
||||
//! including layer count limiting and dynamic buffer size adjustment.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// BufReader optimization configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BufReaderConfig {
|
||||
/// Maximum number of nested BufReader layers (default: 2)
|
||||
pub max_layers: u32,
|
||||
|
||||
/// Buffer size for small files (default: 8KB)
|
||||
pub small_file_buffer: usize,
|
||||
|
||||
/// Buffer size for large files (default: 64KB)
|
||||
pub large_file_buffer: usize,
|
||||
|
||||
/// Threshold for large file classification (default: 1MB)
|
||||
pub large_file_threshold: usize,
|
||||
}
|
||||
|
||||
impl Default for BufReaderConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_layers: 2,
|
||||
small_file_buffer: 8 * 1024, // 8KB
|
||||
large_file_buffer: 64 * 1024, // 64KB
|
||||
large_file_threshold: 1024 * 1024, // 1MB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BufReader optimization statistics.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct BufReaderStats {
|
||||
/// Total number of readers created
|
||||
pub total_readers: AtomicU64,
|
||||
|
||||
/// Number of redundant layers eliminated
|
||||
pub eliminated_layers: AtomicU64,
|
||||
|
||||
/// Number of buffer size adjustments
|
||||
pub buffer_size_adjustments: AtomicU64,
|
||||
}
|
||||
|
||||
/// BufReader layer optimizer.
|
||||
///
|
||||
/// Analyzes and optimizes BufReader nesting in data paths,
|
||||
/// dynamically adjusting buffer sizes based on data characteristics.
|
||||
pub struct BufReaderOptimizer {
|
||||
config: BufReaderConfig,
|
||||
stats: BufReaderStats,
|
||||
}
|
||||
|
||||
impl BufReaderOptimizer {
|
||||
/// Create a new BufReader optimizer with the given configuration.
|
||||
pub fn new(config: BufReaderConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
stats: BufReaderStats::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new BufReader optimizer with default configuration.
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(BufReaderConfig::default())
|
||||
}
|
||||
|
||||
/// Calculate the optimal buffer size based on data size.
|
||||
///
|
||||
/// Returns the appropriate buffer size based on whether the data
|
||||
/// is classified as a small or large file.
|
||||
pub fn optimal_buffer_size(&self, data_size: Option<usize>) -> usize {
|
||||
match data_size {
|
||||
Some(size) if size >= self.config.large_file_threshold => self.config.large_file_buffer,
|
||||
Some(_) => self.config.small_file_buffer,
|
||||
None => self.config.small_file_buffer,
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimize a reader by wrapping it with an appropriately sized BufReader.
|
||||
///
|
||||
/// This method applies the optimal buffer size based on the expected
|
||||
/// data size and tracks statistics.
|
||||
pub fn optimize<R: tokio::io::AsyncRead + Unpin>(&self, reader: R, data_size: Option<usize>) -> tokio::io::BufReader<R> {
|
||||
let buffer_size = self.optimal_buffer_size(data_size);
|
||||
self.stats.total_readers.fetch_add(1, Ordering::Relaxed);
|
||||
tokio::io::BufReader::with_capacity(buffer_size, reader)
|
||||
}
|
||||
|
||||
/// Get the statistics for this optimizer.
|
||||
pub fn stats(&self) -> &BufReaderStats {
|
||||
&self.stats
|
||||
}
|
||||
|
||||
/// Get the configuration for this optimizer.
|
||||
pub fn config(&self) -> &BufReaderConfig {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker trait for buffered sources.
|
||||
///
|
||||
/// Types implementing this trait are considered already buffered
|
||||
/// and should not be wrapped with additional BufReader layers.
|
||||
pub trait BufferedSource: tokio::io::AsyncRead {}
|
||||
|
||||
impl BufReaderOptimizer {
|
||||
/// Check if a reader is already a buffered source.
|
||||
///
|
||||
/// Returns true if the reader implements `BufferedSource`,
|
||||
/// indicating it should not be wrapped with BufReader.
|
||||
pub fn is_buffered_source<R: BufferedSource + ?Sized>(&self, _reader: &R) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Eliminate redundant BufReader layers if possible.
|
||||
///
|
||||
/// This method attempts to reduce the nesting depth of BufReader
|
||||
/// layers to improve performance.
|
||||
pub fn eliminate_redundant_layers<R: tokio::io::AsyncRead + Unpin>(&self, reader: R) -> R {
|
||||
// For now, just return the reader as-is
|
||||
// Future implementation could detect and unwrap nested BufReaders
|
||||
self.stats.eliminated_layers.fetch_add(0, Ordering::Relaxed);
|
||||
reader
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = BufReaderConfig::default();
|
||||
assert_eq!(config.max_layers, 2);
|
||||
assert_eq!(config.small_file_buffer, 8 * 1024);
|
||||
assert_eq!(config.large_file_buffer, 64 * 1024);
|
||||
assert_eq!(config.large_file_threshold, 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optimal_buffer_size_small_file() {
|
||||
let optimizer = BufReaderOptimizer::with_defaults();
|
||||
|
||||
// Small file (< 1MB)
|
||||
assert_eq!(optimizer.optimal_buffer_size(Some(100)), 8 * 1024);
|
||||
assert_eq!(optimizer.optimal_buffer_size(Some(1024)), 8 * 1024);
|
||||
assert_eq!(optimizer.optimal_buffer_size(Some(512 * 1024)), 8 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optimal_buffer_size_large_file() {
|
||||
let optimizer = BufReaderOptimizer::with_defaults();
|
||||
|
||||
// Large file (>= 1MB)
|
||||
assert_eq!(optimizer.optimal_buffer_size(Some(1024 * 1024)), 64 * 1024);
|
||||
assert_eq!(optimizer.optimal_buffer_size(Some(10 * 1024 * 1024)), 64 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optimal_buffer_size_unknown() {
|
||||
let optimizer = BufReaderOptimizer::with_defaults();
|
||||
|
||||
// Unknown size
|
||||
assert_eq!(optimizer.optimal_buffer_size(None), 8 * 1024);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_optimize_creates_bufreader() {
|
||||
let optimizer = BufReaderOptimizer::with_defaults();
|
||||
let data = vec![1u8, 2, 3, 4, 5];
|
||||
let cursor = std::io::Cursor::new(data.clone());
|
||||
|
||||
let mut reader = optimizer.optimize(cursor, Some(5));
|
||||
|
||||
let mut buf = vec![0u8; 5];
|
||||
let n = reader.read(&mut buf).await.unwrap();
|
||||
|
||||
assert_eq!(n, 5);
|
||||
assert_eq!(buf, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stats_tracking() {
|
||||
let optimizer = BufReaderOptimizer::with_defaults();
|
||||
|
||||
assert_eq!(optimizer.stats().total_readers.load(Ordering::Relaxed), 0);
|
||||
|
||||
let cursor = std::io::Cursor::new(vec![1u8, 2, 3]);
|
||||
let _reader = optimizer.optimize(cursor, Some(3));
|
||||
|
||||
assert_eq!(optimizer.stats().total_readers.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_config() {
|
||||
let config = BufReaderConfig {
|
||||
max_layers: 3,
|
||||
small_file_buffer: 4 * 1024,
|
||||
large_file_buffer: 128 * 1024,
|
||||
large_file_threshold: 2 * 1024 * 1024,
|
||||
};
|
||||
|
||||
let optimizer = BufReaderOptimizer::new(config);
|
||||
|
||||
assert_eq!(optimizer.optimal_buffer_size(Some(1024 * 1024)), 4 * 1024);
|
||||
assert_eq!(optimizer.optimal_buffer_size(Some(3 * 1024 * 1024)), 128 * 1024);
|
||||
}
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
// 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.
|
||||
|
||||
//! Aligned pread-based file reader.
|
||||
//!
|
||||
//! This module provides an aligned, position-based file reader that uses
|
||||
//! `pread`/`FileExt::read_at` for I/O operations. It performs reads at
|
||||
//! 512-byte-aligned offsets and sizes, making it suitable as a foundation
|
||||
//! for workloads where alignment matters.
|
||||
//!
|
||||
//! Note: This reader does **not** set the `O_DIRECT` flag and therefore does
|
||||
//! not bypass the OS page cache. It is an aligned `pread`-based reader, not
|
||||
//! true Direct I/O. To implement true O_DIRECT on Linux, the file must be
|
||||
//! opened with `O_DIRECT` via `libc::open`.
|
||||
//!
|
||||
//! # Platform Support
|
||||
//!
|
||||
//! The `read_at` implementation is only available on Unix-like platforms.
|
||||
//! On other platforms, this reader will return an error.
|
||||
|
||||
use std::io::{self};
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, ReadBuf};
|
||||
|
||||
/// Errors that can occur during aligned pread operations.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AlignedPreadError {
|
||||
/// Platform doesn't support `read_at`-based I/O
|
||||
UnsupportedPlatform,
|
||||
/// File descriptor doesn't support this reader
|
||||
UnsupportedFile,
|
||||
/// I/O error occurred
|
||||
Io(String),
|
||||
/// Invalid alignment (reads require 512-byte-aligned offset and size)
|
||||
AlignmentError { offset: u64, size: usize },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AlignedPreadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::UnsupportedPlatform => write!(f, "Aligned pread not supported on this platform"),
|
||||
Self::UnsupportedFile => write!(f, "File doesn't support this reader"),
|
||||
Self::Io(msg) => write!(f, "I/O error: {}", msg),
|
||||
Self::AlignmentError { offset, size } => {
|
||||
write!(f, "Alignment error: offset={}, size={}", offset, size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AlignedPreadError {}
|
||||
|
||||
impl From<io::Error> for AlignedPreadError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
Self::Io(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Aligned pread-based file reader for Unix platforms.
|
||||
///
|
||||
/// This reader performs I/O using `pread`/`FileExt::read_at` at
|
||||
/// 512-byte-aligned offsets and sizes, without modifying the file's
|
||||
/// current position.
|
||||
///
|
||||
/// **Note:** This reader does **not** set the `O_DIRECT` flag and therefore
|
||||
/// does **not** bypass the OS page cache. It is an aligned `pread`-based
|
||||
/// reader. To implement true O_DIRECT, the file must be opened with
|
||||
/// `O_DIRECT` via `libc::open`.
|
||||
///
|
||||
/// # Platform Support
|
||||
///
|
||||
/// Only available on Linux (uses `FileExt::read_at`). On other platforms,
|
||||
/// use `BytesBufferedReader` instead.
|
||||
///
|
||||
/// # Alignment Requirements
|
||||
///
|
||||
/// Reads have strict alignment requirements:
|
||||
/// - File offset must be aligned to 512 bytes
|
||||
/// - Buffer size must be a multiple of 512 bytes
|
||||
/// - Buffer address must be aligned (handled internally)
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// use rustfs_io_core::AlignedPreadReader;
|
||||
///
|
||||
/// // Linux only
|
||||
/// #[cfg(target_os = "linux")]
|
||||
/// let reader = AlignedPreadReader::new(file, offset, size)?;
|
||||
/// ```
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct AlignedPreadReader {
|
||||
/// Underlying file handle used for aligned pread I/O
|
||||
file: std::fs::File,
|
||||
/// Current read position
|
||||
pos: u64,
|
||||
/// Remaining bytes to read
|
||||
remaining: usize,
|
||||
/// Buffer for aligned reads
|
||||
buffer: Vec<u8>,
|
||||
/// Current position in the buffer
|
||||
buffer_pos: usize,
|
||||
/// Amount of data in the buffer
|
||||
buffer_len: usize,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl AlignedPreadReader {
|
||||
/// Alignment requirement for reads (512 bytes for most systems)
|
||||
pub const ALIGNMENT: usize = 512;
|
||||
|
||||
/// Create a new aligned pread-based reader.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `file` - File to read from
|
||||
/// * `offset` - Starting offset in the file (must be 512-byte aligned)
|
||||
/// * `size` - Number of bytes to read (must be 512-byte aligned)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// An `AlignedPreadReader` that reads the file at the given offset.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if offset or size are not 512-byte aligned.
|
||||
pub fn new(file: std::fs::File, offset: u64, size: usize) -> Result<Self, AlignedPreadError> {
|
||||
// Check alignment
|
||||
if !offset.is_multiple_of(Self::ALIGNMENT as u64) {
|
||||
return Err(AlignedPreadError::AlignmentError { offset, size });
|
||||
}
|
||||
if !size.is_multiple_of(Self::ALIGNMENT) {
|
||||
return Err(AlignedPreadError::AlignmentError { offset, size });
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
file,
|
||||
pos: offset,
|
||||
remaining: size,
|
||||
buffer: Vec::new(),
|
||||
buffer_pos: 0,
|
||||
buffer_len: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a chunk of data using aligned pread.
|
||||
///
|
||||
/// This method performs aligned reads and handles the buffering required
|
||||
/// by this aligned pread implementation. It does not use `O_DIRECT`.
|
||||
fn read_chunk(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
// If buffer is exhausted, read more data
|
||||
if self.buffer_pos >= self.buffer_len {
|
||||
if self.remaining == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Allocate aligned buffer
|
||||
let chunk_size = (self.remaining).min(64 * 1024); // 64KB chunks
|
||||
let aligned_size = chunk_size.div_ceil(Self::ALIGNMENT) * Self::ALIGNMENT;
|
||||
|
||||
self.buffer = vec![0u8; aligned_size];
|
||||
|
||||
// Use pread for atomic read at position (no file offset modification)
|
||||
use std::os::unix::fs::FileExt;
|
||||
let n = self.file.read_at(&mut self.buffer, self.pos)?;
|
||||
|
||||
self.buffer_pos = 0;
|
||||
self.buffer_len = n;
|
||||
self.pos += n as u64;
|
||||
self.remaining -= n;
|
||||
|
||||
if n == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy from buffer to user buffer
|
||||
let available = self.buffer_len - self.buffer_pos;
|
||||
let to_copy = buf.len().min(available);
|
||||
buf[..to_copy].copy_from_slice(&self.buffer[self.buffer_pos..self.buffer_pos + to_copy]);
|
||||
self.buffer_pos += to_copy;
|
||||
|
||||
Ok(to_copy)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl AsyncRead for AlignedPreadReader {
|
||||
fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
||||
let filled = buf.filled().len();
|
||||
let mut remaining = buf.initialize_unfilled();
|
||||
|
||||
while !remaining.is_empty() {
|
||||
match self.read_chunk(remaining) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
remaining = &mut remaining[n..];
|
||||
}
|
||||
Err(e) => return Poll::Ready(Err(e)),
|
||||
}
|
||||
}
|
||||
|
||||
let _n_read = buf.filled().len() - filled;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Aligned pread reader stub for non-Linux platforms.
|
||||
///
|
||||
/// On non-Linux platforms, `read_at`-based I/O is not available through this
|
||||
/// type. This stub exists to provide a consistent API across platforms.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub struct AlignedPreadReader {
|
||||
_priv: (),
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl AlignedPreadReader {
|
||||
/// Create a new aligned pread reader (not supported on this platform).
|
||||
///
|
||||
/// Always returns an error on non-Linux platforms.
|
||||
pub fn new(_file: std::fs::File, _offset: u64, _size: usize) -> Result<Self, AlignedPreadError> {
|
||||
Err(AlignedPreadError::UnsupportedPlatform)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl AsyncRead for AlignedPreadReader {
|
||||
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"Aligned pread-based I/O not supported on this platform",
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AlignedPreadReader {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
f.debug_struct("AlignedPreadReader")
|
||||
.field("pos", &self.pos)
|
||||
.field("remaining", &self.remaining)
|
||||
.field("buffer_len", &self.buffer_len)
|
||||
.finish()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
f.debug_struct("AlignedPreadReader")
|
||||
.field("platform", &"unsupported")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Historical name for aligned pread errors.
|
||||
#[deprecated(since = "1.0.0-beta.8", note = "use AlignedPreadError; this reader does not set O_DIRECT")]
|
||||
pub type DirectIoError = AlignedPreadError;
|
||||
|
||||
/// Historical name for the aligned pread-based reader.
|
||||
#[deprecated(since = "1.0.0-beta.8", note = "use AlignedPreadReader; this reader does not set O_DIRECT")]
|
||||
pub type DirectIoReader = AlignedPreadReader;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_alignment_check() {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Valid alignment
|
||||
let file = std::fs::File::open("/dev/zero").unwrap();
|
||||
assert!(
|
||||
AlignedPreadReader::new(file, 0, 512).is_ok(),
|
||||
"Should succeed with aligned offset and size"
|
||||
);
|
||||
|
||||
let file = std::fs::File::open("/dev/zero").expect("open /dev/zero for alias");
|
||||
assert!(
|
||||
AlignedPreadReader::new(file, 0, 512).is_ok(),
|
||||
"Should succeed through aligned pread alias"
|
||||
);
|
||||
|
||||
// Invalid offset
|
||||
let file = std::fs::File::open("/dev/zero").unwrap();
|
||||
assert!(AlignedPreadReader::new(file, 1, 512).is_err(), "Should fail with unaligned offset");
|
||||
|
||||
// Invalid size
|
||||
let file = std::fs::File::open("/dev/zero").unwrap();
|
||||
assert!(AlignedPreadReader::new(file, 0, 511).is_err(), "Should fail with unaligned size");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
// Non-Linux should return UnsupportedPlatform
|
||||
let file = std::fs::File::open(std::env::current_exe().unwrap()).unwrap();
|
||||
assert!(matches!(
|
||||
AlignedPreadReader::new(file, 0, 512),
|
||||
Err(AlignedPreadError::UnsupportedPlatform)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn test_legacy_direct_io_alias() {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let file = std::fs::File::open("/dev/zero").unwrap();
|
||||
assert!(DirectIoReader::new(file, 0, 512).is_ok());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let file = std::fs::File::open(std::env::current_exe().unwrap()).unwrap();
|
||||
assert!(matches!(DirectIoReader::new(file, 0, 512), Err(AlignedPreadError::UnsupportedPlatform)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
// 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.
|
||||
|
||||
//! I/O priority queue for scheduling I/O operations.
|
||||
//!
|
||||
//! This module provides a priority queue implementation for I/O operations
|
||||
//! with support for starvation prevention and fair scheduling.
|
||||
|
||||
use crate::config::IoPriorityQueueConfig;
|
||||
use crate::scheduler::IoPriority;
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A queued I/O request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IoRequest {
|
||||
/// Request ID.
|
||||
pub id: u64,
|
||||
/// Request priority.
|
||||
pub priority: IoPriority,
|
||||
/// Request size in bytes.
|
||||
pub size: usize,
|
||||
/// Queue time.
|
||||
pub queued_at: Instant,
|
||||
/// Whether this is a sequential read.
|
||||
pub is_sequential: bool,
|
||||
}
|
||||
|
||||
impl IoRequest {
|
||||
/// Create a new I/O request.
|
||||
pub fn new(id: u64, priority: IoPriority, size: usize, is_sequential: bool) -> Self {
|
||||
Self {
|
||||
id,
|
||||
priority,
|
||||
size,
|
||||
queued_at: Instant::now(),
|
||||
is_sequential,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the wait time in the queue.
|
||||
pub fn wait_time(&self) -> Duration {
|
||||
self.queued_at.elapsed()
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue status for a priority level.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct IoQueueStatus {
|
||||
/// Number of requests in the queue.
|
||||
pub count: usize,
|
||||
/// Total size of all requests.
|
||||
pub total_size: usize,
|
||||
/// Oldest request wait time.
|
||||
pub oldest_wait: Option<Duration>,
|
||||
/// Number of requests processed.
|
||||
pub processed: u64,
|
||||
}
|
||||
|
||||
impl IoQueueStatus {
|
||||
/// Create new queue status.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// I/O priority queue.
|
||||
pub struct IoPriorityQueue {
|
||||
/// Queue configuration.
|
||||
config: IoPriorityQueueConfig,
|
||||
/// High priority queue.
|
||||
high: VecDeque<IoRequest>,
|
||||
/// Normal priority queue.
|
||||
normal: VecDeque<IoRequest>,
|
||||
/// Low priority queue.
|
||||
low: VecDeque<IoRequest>,
|
||||
/// Next request ID.
|
||||
next_id: u64,
|
||||
/// Last dequeue time for each priority (for starvation prevention).
|
||||
last_dequeue: [Option<Instant>; 3],
|
||||
/// Statistics for each queue.
|
||||
stats: [IoQueueStatus; 3],
|
||||
}
|
||||
|
||||
impl IoPriorityQueue {
|
||||
/// Create a new priority queue with the given configuration.
|
||||
pub fn new(config: IoPriorityQueueConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
high: VecDeque::with_capacity(100),
|
||||
normal: VecDeque::with_capacity(500),
|
||||
low: VecDeque::with_capacity(200),
|
||||
next_id: 0,
|
||||
last_dequeue: [None, None, None],
|
||||
stats: [IoQueueStatus::new(), IoQueueStatus::new(), IoQueueStatus::new()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with default configuration.
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(IoPriorityQueueConfig::default())
|
||||
}
|
||||
|
||||
/// Get the configuration.
|
||||
pub fn config(&self) -> &IoPriorityQueueConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Enqueue a request.
|
||||
pub fn enqueue(&mut self, priority: IoPriority, size: usize, is_sequential: bool) -> u64 {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
|
||||
let request = IoRequest::new(id, priority, size, is_sequential);
|
||||
|
||||
match priority {
|
||||
IoPriority::High => {
|
||||
if self.high.len() < self.config.high_capacity {
|
||||
self.high.push_back(request);
|
||||
}
|
||||
}
|
||||
IoPriority::Normal => {
|
||||
if self.normal.len() < self.config.normal_capacity {
|
||||
self.normal.push_back(request);
|
||||
}
|
||||
}
|
||||
IoPriority::Low => {
|
||||
if self.low.len() < self.config.low_capacity {
|
||||
self.low.push_back(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
/// Dequeue the next request.
|
||||
///
|
||||
/// Uses weighted fair queuing with starvation prevention.
|
||||
pub fn dequeue(&mut self) -> Option<IoRequest> {
|
||||
let now = Instant::now();
|
||||
|
||||
// Check for starvation: if a lower priority queue hasn't been served in a while,
|
||||
// give it priority
|
||||
let normal_starved = self.is_starved(IoPriority::Normal, now);
|
||||
let low_starved = self.is_starved(IoPriority::Low, now);
|
||||
|
||||
// Priority order with starvation consideration
|
||||
// Check conditions first, then dequeue
|
||||
let dequeue_high = !self.high.is_empty() && !low_starved && !normal_starved;
|
||||
let dequeue_normal = !self.normal.is_empty() && !low_starved;
|
||||
let dequeue_low = !self.low.is_empty();
|
||||
let dequeue_high_fallback = !self.high.is_empty();
|
||||
let dequeue_normal_fallback = !self.normal.is_empty();
|
||||
|
||||
if dequeue_high {
|
||||
let request = self.high.pop_front();
|
||||
if request.is_some() {
|
||||
self.last_dequeue[0] = Some(Instant::now());
|
||||
self.stats[0].processed += 1;
|
||||
}
|
||||
request
|
||||
} else if dequeue_normal {
|
||||
let request = self.normal.pop_front();
|
||||
if request.is_some() {
|
||||
self.last_dequeue[1] = Some(Instant::now());
|
||||
self.stats[1].processed += 1;
|
||||
}
|
||||
request
|
||||
} else if dequeue_low {
|
||||
let request = self.low.pop_front();
|
||||
if request.is_some() {
|
||||
self.last_dequeue[2] = Some(Instant::now());
|
||||
self.stats[2].processed += 1;
|
||||
}
|
||||
request
|
||||
} else if dequeue_high_fallback {
|
||||
let request = self.high.pop_front();
|
||||
if request.is_some() {
|
||||
self.last_dequeue[0] = Some(Instant::now());
|
||||
self.stats[0].processed += 1;
|
||||
}
|
||||
request
|
||||
} else if dequeue_normal_fallback {
|
||||
let request = self.normal.pop_front();
|
||||
if request.is_some() {
|
||||
self.last_dequeue[1] = Some(Instant::now());
|
||||
self.stats[1].processed += 1;
|
||||
}
|
||||
request
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a priority level is starved.
|
||||
fn is_starved(&self, priority: IoPriority, now: Instant) -> bool {
|
||||
let idx = match priority {
|
||||
IoPriority::High => 0,
|
||||
IoPriority::Normal => 1,
|
||||
IoPriority::Low => 2,
|
||||
};
|
||||
|
||||
if let Some(last) = self.last_dequeue[idx] {
|
||||
now.duration_since(last) > self.config.starvation_threshold
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the total number of queued requests.
|
||||
pub fn len(&self) -> usize {
|
||||
self.high.len() + self.normal.len() + self.low.len()
|
||||
}
|
||||
|
||||
/// Check if the queue is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.high.is_empty() && self.normal.is_empty() && self.low.is_empty()
|
||||
}
|
||||
|
||||
/// Get queue status for a priority level.
|
||||
pub fn status(&self, priority: IoPriority) -> IoQueueStatus {
|
||||
let (queue, idx) = match priority {
|
||||
IoPriority::High => (&self.high, 0),
|
||||
IoPriority::Normal => (&self.normal, 1),
|
||||
IoPriority::Low => (&self.low, 2),
|
||||
};
|
||||
|
||||
let mut status = self.stats[idx].clone();
|
||||
status.count = queue.len();
|
||||
status.total_size = queue.iter().map(|r| r.size).sum();
|
||||
status.oldest_wait = queue.front().map(|r| r.wait_time());
|
||||
status
|
||||
}
|
||||
|
||||
/// Get the total queue status.
|
||||
pub fn total_status(&self) -> IoQueueStatus {
|
||||
let mut total = IoQueueStatus::new();
|
||||
total.count = self.len();
|
||||
total.total_size = self
|
||||
.high
|
||||
.iter()
|
||||
.chain(self.normal.iter())
|
||||
.chain(self.low.iter())
|
||||
.map(|r| r.size)
|
||||
.sum();
|
||||
total.processed = self.stats.iter().map(|s| s.processed).sum();
|
||||
total.oldest_wait = self
|
||||
.high
|
||||
.front()
|
||||
.map(|r| r.wait_time())
|
||||
.or_else(|| self.normal.front().map(|r| r.wait_time()))
|
||||
.or_else(|| self.low.front().map(|r| r.wait_time()));
|
||||
total
|
||||
}
|
||||
|
||||
/// Clear all queues.
|
||||
pub fn clear(&mut self) {
|
||||
self.high.clear();
|
||||
self.normal.clear();
|
||||
self.low.clear();
|
||||
}
|
||||
|
||||
/// Peek at the next request without removing it.
|
||||
pub fn peek(&self) -> Option<&IoRequest> {
|
||||
if !self.high.is_empty() {
|
||||
self.high.front()
|
||||
} else if !self.normal.is_empty() {
|
||||
self.normal.front()
|
||||
} else {
|
||||
self.low.front()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_enqueue_dequeue() {
|
||||
let mut queue = IoPriorityQueue::with_defaults();
|
||||
|
||||
let id1 = queue.enqueue(IoPriority::High, 1024, true);
|
||||
let id2 = queue.enqueue(IoPriority::Normal, 2048, false);
|
||||
let id3 = queue.enqueue(IoPriority::Low, 4096, true);
|
||||
|
||||
assert_eq!(queue.len(), 3);
|
||||
|
||||
// High priority should be dequeued first
|
||||
let req1 = queue.dequeue().unwrap();
|
||||
assert_eq!(req1.id, id1);
|
||||
assert_eq!(req1.priority, IoPriority::High);
|
||||
|
||||
let req2 = queue.dequeue().unwrap();
|
||||
assert_eq!(req2.id, id2);
|
||||
assert_eq!(req2.priority, IoPriority::Normal);
|
||||
|
||||
let req3 = queue.dequeue().unwrap();
|
||||
assert_eq!(req3.id, id3);
|
||||
assert_eq!(req3.priority, IoPriority::Low);
|
||||
|
||||
assert!(queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_status() {
|
||||
let mut queue = IoPriorityQueue::with_defaults();
|
||||
|
||||
queue.enqueue(IoPriority::High, 1024, true);
|
||||
queue.enqueue(IoPriority::High, 2048, true);
|
||||
queue.enqueue(IoPriority::Normal, 4096, false);
|
||||
|
||||
let high_status = queue.status(IoPriority::High);
|
||||
assert_eq!(high_status.count, 2);
|
||||
assert_eq!(high_status.total_size, 3072);
|
||||
|
||||
let normal_status = queue.status(IoPriority::Normal);
|
||||
assert_eq!(normal_status.count, 1);
|
||||
assert_eq!(normal_status.total_size, 4096);
|
||||
|
||||
let total = queue.total_status();
|
||||
assert_eq!(total.count, 3);
|
||||
assert_eq!(total.total_size, 7168);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_capacity() {
|
||||
let config = IoPriorityQueueConfig {
|
||||
high_capacity: 2,
|
||||
normal_capacity: 2,
|
||||
low_capacity: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let mut queue = IoPriorityQueue::new(config);
|
||||
|
||||
queue.enqueue(IoPriority::High, 1024, true);
|
||||
queue.enqueue(IoPriority::High, 1024, true);
|
||||
queue.enqueue(IoPriority::High, 1024, true); // Should be dropped
|
||||
|
||||
assert_eq!(queue.status(IoPriority::High).count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear() {
|
||||
let mut queue = IoPriorityQueue::with_defaults();
|
||||
|
||||
queue.enqueue(IoPriority::High, 1024, true);
|
||||
queue.enqueue(IoPriority::Normal, 2048, false);
|
||||
queue.enqueue(IoPriority::Low, 4096, true);
|
||||
|
||||
assert_eq!(queue.len(), 3);
|
||||
queue.clear();
|
||||
assert!(queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peek() {
|
||||
let mut queue = IoPriorityQueue::with_defaults();
|
||||
|
||||
queue.enqueue(IoPriority::Normal, 2048, false);
|
||||
queue.enqueue(IoPriority::High, 1024, true);
|
||||
|
||||
let peeked = queue.peek().unwrap();
|
||||
assert_eq!(peeked.priority, IoPriority::High);
|
||||
|
||||
// Peek shouldn't remove the item
|
||||
assert_eq!(queue.len(), 2);
|
||||
}
|
||||
}
|
||||
+12
-61
@@ -12,85 +12,39 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Buffered I/O reader and writer implementations for RustFS.
|
||||
//! Shared I/O primitives for RustFS.
|
||||
//!
|
||||
//! This crate provides buffered readers and writers for I/O operations.
|
||||
//! Prefer `BytesBufferedReader`, `BytesMutWriter`, and `AlignedPreadReader`
|
||||
//! for new code. Historical `ZeroCopy*` and `DirectIo*` names remain exported
|
||||
//! for backward compatibility.
|
||||
//! This crate holds the buffer pool and the concurrency-control primitives
|
||||
//! that the storage layer builds on:
|
||||
//!
|
||||
//! # Features
|
||||
//!
|
||||
//! - Memory-mapped file reading (mmap-then-copy) on Unix platforms
|
||||
//! - Bytes-based buffered wrapping
|
||||
//! - AsyncRead trait implementations
|
||||
//! - Tiered BytesPool for buffer management
|
||||
//! - Aligned pread-based reader (NOT true Direct I/O / O_DIRECT)
|
||||
//! - Tiered `BytesPool` for buffer management
|
||||
//! - Storage-media and access-pattern profiling (`io_profile`)
|
||||
//! - Scheduler and priority-queue configuration shapes
|
||||
//! - Backpressure admission, deadlock detection, lock optimization
|
||||
//! - Progress tracking for long-running operations
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use rustfs_io_core::{BytesBufferedReader, BytesPool};
|
||||
//! use bytes::Bytes;
|
||||
//! use rustfs_io_core::BytesPool;
|
||||
//!
|
||||
//! // Create from existing bytes (zero-copy)
|
||||
//! let data = Bytes::from("hello world");
|
||||
//! let reader = BytesBufferedReader::from_bytes(data);
|
||||
//!
|
||||
//! // Create from file using buffered reads
|
||||
//! let reader = BytesBufferedReader::from_file_read(&file, 0, 1024).await?;
|
||||
//!
|
||||
//! // Use BytesPool
|
||||
//! let pool = BytesPool::new_tiered();
|
||||
//! let mut buffer = pool.acquire_buffer(8192).await;
|
||||
//! ```
|
||||
|
||||
pub mod backpressure;
|
||||
pub mod bufreader_optimizer;
|
||||
pub mod config;
|
||||
pub mod deadlock_detector;
|
||||
pub mod direct_io;
|
||||
pub mod io_priority_queue;
|
||||
pub mod io_profile;
|
||||
pub mod lock_optimizer;
|
||||
pub mod pool;
|
||||
pub mod reader;
|
||||
pub mod scheduler;
|
||||
pub mod shared_memory;
|
||||
pub mod timeout_wrapper;
|
||||
pub mod writer;
|
||||
pub mod progress;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use direct_io::{AlignedPreadError, AlignedPreadReader};
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(deprecated)]
|
||||
pub use direct_io::{DirectIoError, DirectIoReader};
|
||||
pub use pool::{BytesPool, BytesPoolConfig, BytesPoolMetrics, PooledBuffer};
|
||||
#[allow(deprecated)]
|
||||
pub use reader::ZeroCopyObjectReader;
|
||||
pub use reader::{BytesBufferedReader, ZeroCopyReadError};
|
||||
#[allow(deprecated)]
|
||||
pub use writer::ZeroCopyObjectWriter;
|
||||
pub use writer::{BytesMutWriter, ZeroCopyWriteError};
|
||||
|
||||
// BufReader optimizer exports
|
||||
pub use bufreader_optimizer::{BufReaderConfig, BufReaderOptimizer, BufReaderStats, BufferedSource};
|
||||
|
||||
// Shared memory exports
|
||||
pub use shared_memory::{ArcData, ArcMetadata, SharedMemoryConfig, SharedMemoryPool, SharedMemoryStats};
|
||||
|
||||
// Config exports
|
||||
pub use config::{ConfigError, IoPriorityQueueConfig, IoSchedulerConfig};
|
||||
|
||||
// Scheduler exports
|
||||
pub use scheduler::{
|
||||
BandwidthTier, IoLoadLevel, IoLoadMetrics, IoPriority, IoScheduler, IoSchedulingContext, IoStrategy, KI_B, MI_B,
|
||||
calculate_optimal_buffer_size, get_advanced_buffer_size, get_buffer_size_for_media, get_concurrency_aware_buffer_size,
|
||||
};
|
||||
|
||||
// Priority queue exports
|
||||
pub use io_priority_queue::{IoPriorityQueue, IoQueueStatus, IoRequest};
|
||||
|
||||
// Backpressure exports
|
||||
pub use backpressure::{BackpressureConfig, BackpressureError, BackpressureMonitor, BackpressureState};
|
||||
|
||||
@@ -100,8 +54,5 @@ pub use deadlock_detector::{DeadlockDetector, DeadlockDetectorConfig, LockInfo,
|
||||
// Lock optimizer exports
|
||||
pub use lock_optimizer::{LockGuard, LockOptimizeConfig, LockOptimizer, LockStats};
|
||||
|
||||
// Timeout wrapper exports
|
||||
pub use timeout_wrapper::{
|
||||
OperationProgress, RequestTimeoutWrapper, TimeoutConfig, TimeoutError, TimeoutStats, calculate_adaptive_timeout,
|
||||
estimate_bytes_per_second,
|
||||
};
|
||||
// Progress tracking exports
|
||||
pub use progress::OperationProgress;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// 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.
|
||||
|
||||
//! Progress tracking for long-running I/O operations.
|
||||
//!
|
||||
//! Re-exported as `rustfs_concurrency::OperationProgress` for the storage
|
||||
//! timeout implementation, which uses `is_stale` to tell a slow transfer
|
||||
//! apart from a stalled one.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Operation progress tracker.
|
||||
#[derive(Debug)]
|
||||
pub struct OperationProgress {
|
||||
/// Total size (if known).
|
||||
pub total_size: Option<u64>,
|
||||
/// Bytes processed.
|
||||
bytes_processed: AtomicU64,
|
||||
/// Last update time.
|
||||
last_update: std::sync::Mutex<Instant>,
|
||||
/// Stale timeout.
|
||||
stale_timeout: Duration,
|
||||
/// Start time for transfer rate calculation.
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl OperationProgress {
|
||||
/// Create new operation progress.
|
||||
pub fn new(total_size: Option<u64>, stale_timeout: Duration) -> Self {
|
||||
Self {
|
||||
total_size,
|
||||
bytes_processed: AtomicU64::new(0),
|
||||
last_update: std::sync::Mutex::new(Instant::now()),
|
||||
stale_timeout,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update progress.
|
||||
pub fn update(&self, bytes: u64) {
|
||||
self.bytes_processed.store(bytes, Ordering::Relaxed);
|
||||
if let Ok(mut last) = self.last_update.lock() {
|
||||
*last = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Add to progress.
|
||||
pub fn add(&self, bytes: u64) {
|
||||
self.bytes_processed.fetch_add(bytes, Ordering::Relaxed);
|
||||
if let Ok(mut last) = self.last_update.lock() {
|
||||
*last = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current progress.
|
||||
pub fn current(&self) -> u64 {
|
||||
self.bytes_processed.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Check if progress is stale.
|
||||
pub fn is_stale(&self) -> bool {
|
||||
if let Ok(last) = self.last_update.lock() {
|
||||
last.elapsed() > self.stale_timeout
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Get progress percentage.
|
||||
pub fn progress_percent(&self) -> Option<f64> {
|
||||
self.total_size.map(|total| {
|
||||
if total == 0 {
|
||||
100.0
|
||||
} else {
|
||||
let processed = self.bytes_processed.load(Ordering::Relaxed);
|
||||
(processed as f64 / total as f64 * 100.0).min(100.0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Get remaining bytes.
|
||||
pub fn remaining(&self) -> Option<u64> {
|
||||
self.total_size.map(|total| {
|
||||
let processed = self.bytes_processed.load(Ordering::Relaxed);
|
||||
total.saturating_sub(processed)
|
||||
})
|
||||
}
|
||||
|
||||
/// Calculate transfer rate in bytes per second.
|
||||
///
|
||||
/// Returns 0 if no time has elapsed or no data transferred.
|
||||
pub fn transfer_rate(&self) -> u64 {
|
||||
let processed = self.bytes_processed.load(Ordering::Relaxed);
|
||||
if processed == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
if elapsed > 0.0 {
|
||||
(processed as f64 / elapsed) as u64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_operation_progress() {
|
||||
let progress = OperationProgress::new(Some(1000), Duration::from_secs(5));
|
||||
|
||||
assert_eq!(progress.current(), 0);
|
||||
assert_eq!(progress.progress_percent(), Some(0.0));
|
||||
|
||||
progress.update(500);
|
||||
assert_eq!(progress.current(), 500);
|
||||
assert_eq!(progress.progress_percent(), Some(50.0));
|
||||
|
||||
progress.add(300);
|
||||
assert_eq!(progress.current(), 800);
|
||||
assert_eq!(progress.remaining(), Some(200));
|
||||
}
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
// 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.
|
||||
|
||||
//! Bytes-backed object reader implementation.
|
||||
|
||||
use bytes::Bytes;
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, ReadBuf};
|
||||
|
||||
/// Errors that can occur during Bytes-backed read operations.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ZeroCopyReadError {
|
||||
/// I/O error occurred.
|
||||
Io(String),
|
||||
/// Memory mapping error.
|
||||
Mmap(String),
|
||||
/// Invalid offset or size.
|
||||
InvalidRange,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ZeroCopyReadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(msg) => write!(f, "I/O error: {}", msg),
|
||||
Self::Mmap(msg) => write!(f, "Mmap error: {}", msg),
|
||||
Self::InvalidRange => write!(f, "Invalid offset or size"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ZeroCopyReadError {}
|
||||
|
||||
impl From<io::Error> for ZeroCopyReadError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
Self::Io(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes-backed object reader.
|
||||
///
|
||||
/// `from_bytes` wraps existing `Bytes` without copying, but file constructors
|
||||
/// copy file data into owned `Bytes` after mmap or normal reads.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// use bytes::Bytes;
|
||||
/// use rustfs_io_core::BytesBufferedReader;
|
||||
///
|
||||
/// // Create from bytes without copying the `Bytes` buffer
|
||||
/// let data = Bytes::from("hello world");
|
||||
/// let reader = BytesBufferedReader::from_bytes(data);
|
||||
///
|
||||
/// // Read using AsyncRead trait
|
||||
/// let mut buf = vec![0u8; 1024];
|
||||
/// let n = reader.read(&mut buf[..]).await?;
|
||||
/// ```
|
||||
pub struct BytesBufferedReader {
|
||||
/// Internal data source (could be mmap or owned bytes)
|
||||
data: Bytes,
|
||||
/// Current read position
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
/// Historical name for the bytes-backed object reader.
|
||||
#[deprecated(
|
||||
since = "1.0.0-beta.8",
|
||||
note = "use BytesBufferedReader; file constructors copy into owned Bytes"
|
||||
)]
|
||||
pub type ZeroCopyObjectReader = BytesBufferedReader;
|
||||
|
||||
impl BytesBufferedReader {
|
||||
/// Create a reader from existing bytes.
|
||||
///
|
||||
/// This is a true zero-copy operation - the Bytes are wrapped
|
||||
/// without any allocation or copying.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `data` - Bytes to wrap
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// let data = Bytes::from("hello world");
|
||||
/// let reader = BytesBufferedReader::from_bytes(data);
|
||||
/// ```
|
||||
pub fn from_bytes(data: Bytes) -> Self {
|
||||
Self { data, pos: 0 }
|
||||
}
|
||||
|
||||
/// Create a Bytes-backed reader from a file using mmap-then-copy.
|
||||
///
|
||||
/// This maps the requested file range and copies it into owned `Bytes`
|
||||
/// before returning. It does not expose the mmap as a zero-copy buffer.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Path to the file to memory map
|
||||
/// * `offset` - Offset within the file to start reading
|
||||
/// * `size` - Number of bytes to read
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A reader backed by copied file data.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the file cannot be memory mapped.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// let reader = BytesBufferedReader::from_file_mmap_path("large_file.bin", 0, 1024).await?;
|
||||
/// ```
|
||||
#[cfg(unix)]
|
||||
// SAFETY: The mmap is created from a read-only file handle for the
|
||||
// caller-provided range, then copied into owned `Bytes` before the file and
|
||||
// mapping are dropped.
|
||||
#[allow(unsafe_code)]
|
||||
pub async fn from_file_mmap_path(path: &std::path::Path, offset: u64, size: usize) -> Result<Self, ZeroCopyReadError> {
|
||||
use memmap2::MmapOptions;
|
||||
|
||||
let path = path.to_path_buf();
|
||||
let (offset, size) = (offset, size);
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Open the file in sync context
|
||||
let std_file = std::fs::File::open(&path).map_err(|e| ZeroCopyReadError::Io(e.to_string()))?;
|
||||
|
||||
// SAFETY: `std_file` remains open while the mapping is created and
|
||||
// copied, and the mapped bytes are not exposed beyond this closure.
|
||||
let mmap = unsafe { MmapOptions::new().offset(offset).len(size).map(&std_file) }
|
||||
.map_err(|e| ZeroCopyReadError::Mmap(e.to_string()))?;
|
||||
|
||||
// Convert to Bytes (this is a copy, but only done once)
|
||||
Ok(Self {
|
||||
data: Bytes::copy_from_slice(&mmap),
|
||||
pos: 0,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ZeroCopyReadError::Io(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Create a Bytes-backed reader from a file using normal reads.
|
||||
///
|
||||
/// This path reads the requested range into an owned buffer and wraps it in
|
||||
/// `Bytes`. It does not perform mmap or zero-copy file I/O.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `file` - File to read from
|
||||
/// * `offset` - Offset within the file to start reading
|
||||
/// * `size` - Number of bytes to map
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A reader backed by copied file data.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the file cannot be read.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// let file = tokio::fs::File::open("large_file.bin").await?;
|
||||
/// let reader = BytesBufferedReader::from_file_read(&file, 0, 1024).await?;
|
||||
/// ```
|
||||
#[cfg(unix)]
|
||||
pub async fn from_file_read(file: &tokio::fs::File, offset: u64, size: usize) -> Result<Self, ZeroCopyReadError> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
|
||||
|
||||
let mut cloned = file.try_clone().await?;
|
||||
cloned.seek(SeekFrom::Start(offset)).await?;
|
||||
|
||||
let mut buffer = vec![0u8; size];
|
||||
cloned.read_exact(&mut buffer).await?;
|
||||
|
||||
Ok(Self {
|
||||
data: Bytes::from(buffer),
|
||||
pos: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a Bytes-backed reader from a file (non-Unix fallback).
|
||||
///
|
||||
/// On platforms that don't support mmap, this falls back to regular file I/O.
|
||||
#[cfg(not(unix))]
|
||||
pub async fn from_file_read(file: &tokio::fs::File, offset: u64, size: usize) -> Result<Self, ZeroCopyReadError> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
|
||||
|
||||
let mut cloned = file.try_clone().await?;
|
||||
cloned.seek(SeekFrom::Start(offset)).await?;
|
||||
|
||||
let mut buffer = vec![0u8; size];
|
||||
cloned.read_exact(&mut buffer).await?;
|
||||
|
||||
Ok(Self {
|
||||
data: Bytes::from(buffer),
|
||||
pos: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Historical name for `from_file_read`.
|
||||
#[deprecated(
|
||||
since = "1.0.0-beta.8",
|
||||
note = "use from_file_read; this method performs normal reads into owned Bytes"
|
||||
)]
|
||||
pub async fn from_file_mmap(file: &tokio::fs::File, offset: u64, size: usize) -> Result<Self, ZeroCopyReadError> {
|
||||
Self::from_file_read(file, offset, size).await
|
||||
}
|
||||
|
||||
/// Get the remaining data as Bytes (zero-copy).
|
||||
///
|
||||
/// This returns a slice of the remaining data without copying.
|
||||
/// The returned Bytes shares the underlying memory with this reader.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// let remaining = reader.remaining_bytes();
|
||||
/// println!("Remaining: {} bytes", remaining.len());
|
||||
/// ```
|
||||
pub fn remaining_bytes(&self) -> Bytes {
|
||||
self.data.slice(self.pos..)
|
||||
}
|
||||
|
||||
/// Get the total length of the data.
|
||||
pub fn len(&self) -> usize {
|
||||
self.data.len()
|
||||
}
|
||||
|
||||
/// Check if the reader has reached the end.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.pos >= self.data.len()
|
||||
}
|
||||
|
||||
/// Get the current read position.
|
||||
pub fn position(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for BytesBufferedReader {
|
||||
fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
||||
let remaining = self.data.len() - self.pos;
|
||||
if remaining == 0 {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
let to_read = std::cmp::min(remaining, buf.remaining());
|
||||
let slice = &self.data[self.pos..self.pos + to_read];
|
||||
buf.put_slice(slice);
|
||||
self.pos += to_read;
|
||||
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BytesBufferedReader {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("BytesBufferedReader")
|
||||
.field("data_len", &self.data.len())
|
||||
.field("pos", &self.pos)
|
||||
.field("remaining", &(self.data.len() - self.pos))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
fn temp_file_path(test_name: &str) -> PathBuf {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system time should be after unix epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("rustfs-io-core-{test_name}-{}-{nonce}", std::process::id()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_from_bytes() {
|
||||
let data = Bytes::from("hello world");
|
||||
let mut reader = BytesBufferedReader::from_bytes(data.clone());
|
||||
|
||||
let mut buf = [0u8; 11];
|
||||
let n = reader.read(&mut buf[..]).await.unwrap();
|
||||
|
||||
assert_eq!(n, 11);
|
||||
assert_eq!(&buf[..n], b"hello world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_preferred_reader_alias() {
|
||||
let data = Bytes::from("hello world");
|
||||
let mut reader = BytesBufferedReader::from_bytes(data);
|
||||
|
||||
let mut buf = [0u8; 5];
|
||||
let n = reader.read(&mut buf[..]).await.expect("read bytes from alias");
|
||||
|
||||
assert_eq!(n, 5);
|
||||
assert_eq!(&buf[..n], b"hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_from_file_read_reads_requested_range() {
|
||||
let path = temp_file_path("from-file-read");
|
||||
tokio::fs::write(&path, b"hello world")
|
||||
.await
|
||||
.expect("write temp file for reader test");
|
||||
|
||||
let file = tokio::fs::File::open(&path).await.expect("open temp file for reader test");
|
||||
let mut reader = BytesBufferedReader::from_file_read(&file, 6, 5)
|
||||
.await
|
||||
.expect("read requested range into Bytes");
|
||||
|
||||
let mut output = Vec::new();
|
||||
reader.read_to_end(&mut output).await.expect("drain reader output");
|
||||
|
||||
assert_eq!(output, b"world");
|
||||
|
||||
let _ = tokio::fs::remove_file(path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(deprecated)]
|
||||
async fn test_from_file_mmap_legacy_alias_reads_requested_range() {
|
||||
let path = temp_file_path("from-file-mmap");
|
||||
tokio::fs::write(&path, b"hello world")
|
||||
.await
|
||||
.expect("write temp file for legacy reader test");
|
||||
|
||||
let file = tokio::fs::File::open(&path)
|
||||
.await
|
||||
.expect("open temp file for legacy reader test");
|
||||
let mut reader = BytesBufferedReader::from_file_mmap(&file, 0, 5)
|
||||
.await
|
||||
.expect("read requested range through legacy alias");
|
||||
|
||||
let mut output = Vec::new();
|
||||
reader.read_to_end(&mut output).await.expect("drain legacy reader output");
|
||||
|
||||
assert_eq!(output, b"hello");
|
||||
|
||||
let _ = tokio::fs::remove_file(path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remaining_bytes() {
|
||||
let data = Bytes::from("hello world");
|
||||
let reader = BytesBufferedReader::from_bytes(data);
|
||||
|
||||
let remaining = reader.remaining_bytes();
|
||||
assert_eq!(remaining.len(), 11);
|
||||
assert_eq!(&remaining[..], b"hello world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_position() {
|
||||
let data = Bytes::from("hello world");
|
||||
let mut reader = BytesBufferedReader::from_bytes(data);
|
||||
|
||||
assert_eq!(reader.position(), 0);
|
||||
|
||||
let mut buf = [0u8; 5];
|
||||
reader.read_exact(&mut buf[..]).await.unwrap();
|
||||
|
||||
assert_eq!(reader.position(), 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_is_empty() {
|
||||
let data = Bytes::from("");
|
||||
let reader = BytesBufferedReader::from_bytes(data);
|
||||
assert!(reader.is_empty());
|
||||
|
||||
let data = Bytes::from("hello");
|
||||
let reader = BytesBufferedReader::from_bytes(data);
|
||||
assert!(!reader.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(deprecated)]
|
||||
async fn test_legacy_reader_alias() {
|
||||
let data = Bytes::from("hello world");
|
||||
let mut reader = ZeroCopyObjectReader::from_bytes(data);
|
||||
|
||||
let mut buf = [0u8; 5];
|
||||
let n = reader.read(&mut buf[..]).await.expect("read bytes through legacy alias");
|
||||
|
||||
assert_eq!(n, 5);
|
||||
assert_eq!(&buf[..n], b"hello");
|
||||
}
|
||||
}
|
||||
@@ -1,882 +0,0 @@
|
||||
// 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.
|
||||
|
||||
//! I/O scheduler for adaptive buffer sizing and load management.
|
||||
//!
|
||||
//! This module provides the core I/O scheduling logic that determines
|
||||
//! optimal buffer sizes, I/O strategies, and load management decisions.
|
||||
|
||||
use crate::config::IoSchedulerConfig;
|
||||
use crate::io_profile::{AccessPattern, StorageMedia, StorageProfile};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
/// I/O priority levels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum IoPriority {
|
||||
/// High priority for small, latency-sensitive operations.
|
||||
High,
|
||||
/// Normal priority for standard operations.
|
||||
#[default]
|
||||
Normal,
|
||||
/// Low priority for large, throughput-oriented operations.
|
||||
Low,
|
||||
}
|
||||
|
||||
impl IoPriority {
|
||||
/// Determine priority based on request size.
|
||||
///
|
||||
/// A negative `size` means the size is unknown (-1 by convention) and maps
|
||||
/// to `Normal`; casting it to `usize` would wrap to a huge value and
|
||||
/// misclassify the request as `Low`.
|
||||
pub fn from_size(size: i64, high_threshold: usize, low_threshold: usize) -> Self {
|
||||
if size < 0 {
|
||||
return IoPriority::Normal;
|
||||
}
|
||||
let size = size as usize;
|
||||
if size < high_threshold {
|
||||
IoPriority::High
|
||||
} else if size > low_threshold {
|
||||
IoPriority::Low
|
||||
} else {
|
||||
IoPriority::Normal
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the priority as a string for metrics labels.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
IoPriority::High => "high",
|
||||
IoPriority::Normal => "normal",
|
||||
IoPriority::Low => "low",
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is high priority.
|
||||
pub fn is_high(&self) -> bool {
|
||||
matches!(self, IoPriority::High)
|
||||
}
|
||||
|
||||
/// Check if this is normal priority.
|
||||
pub fn is_normal(&self) -> bool {
|
||||
matches!(self, IoPriority::Normal)
|
||||
}
|
||||
|
||||
/// Check if this is low priority.
|
||||
pub fn is_low(&self) -> bool {
|
||||
matches!(self, IoPriority::Low)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IoPriority {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// I/O load level.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Default)]
|
||||
pub enum IoLoadLevel {
|
||||
/// Low load - system is underutilized.
|
||||
Low,
|
||||
/// Medium load - system is moderately utilized.
|
||||
#[default]
|
||||
Medium,
|
||||
/// High load - system is heavily utilized.
|
||||
High,
|
||||
/// Critical load - system is overloaded.
|
||||
Critical,
|
||||
}
|
||||
|
||||
impl IoLoadLevel {
|
||||
/// Get the load level as a string for metrics labels.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
IoLoadLevel::Low => "low",
|
||||
IoLoadLevel::Medium => "medium",
|
||||
IoLoadLevel::High => "high",
|
||||
IoLoadLevel::Critical => "critical",
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine load level from wait time.
|
||||
pub fn from_wait_time(wait_time: Duration, low_threshold: Duration, high_threshold: Duration) -> Self {
|
||||
if wait_time <= low_threshold {
|
||||
IoLoadLevel::Low
|
||||
} else if wait_time <= high_threshold {
|
||||
IoLoadLevel::Medium
|
||||
} else if wait_time <= high_threshold * 2 {
|
||||
IoLoadLevel::High
|
||||
} else {
|
||||
IoLoadLevel::Critical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IoLoadLevel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Bandwidth tier classification.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum BandwidthTier {
|
||||
/// Low bandwidth (< 100 MB/s).
|
||||
Low,
|
||||
/// Medium bandwidth (100-500 MB/s).
|
||||
#[default]
|
||||
Medium,
|
||||
/// High bandwidth (> 500 MB/s).
|
||||
High,
|
||||
/// Unknown bandwidth.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl BandwidthTier {
|
||||
/// Determine bandwidth tier from bytes per second.
|
||||
pub fn from_bps(bps: u64) -> Self {
|
||||
const MB: u64 = 1024 * 1024;
|
||||
if bps < 100 * MB {
|
||||
BandwidthTier::Low
|
||||
} else if bps < 500 * MB {
|
||||
BandwidthTier::Medium
|
||||
} else {
|
||||
BandwidthTier::High
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the tier as a string for metrics labels.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
BandwidthTier::Low => "low",
|
||||
BandwidthTier::Medium => "medium",
|
||||
BandwidthTier::High => "high",
|
||||
BandwidthTier::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// I/O strategy decision.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IoStrategy {
|
||||
/// Buffer size to use for I/O operations.
|
||||
pub buffer_size: usize,
|
||||
/// Buffer multiplier based on storage media.
|
||||
pub buffer_multiplier: f64,
|
||||
/// Whether to enable readahead.
|
||||
pub enable_readahead: bool,
|
||||
/// Whether to use buffered I/O.
|
||||
pub use_buffered_io: bool,
|
||||
|
||||
// Performance state
|
||||
/// Current number of concurrent requests.
|
||||
pub concurrent_requests: usize,
|
||||
/// Observed bandwidth in bytes per second.
|
||||
pub observed_bandwidth_bps: Option<u64>,
|
||||
/// Bandwidth tier classification.
|
||||
pub bandwidth_tier: BandwidthTier,
|
||||
/// Current load level.
|
||||
pub load_level: IoLoadLevel,
|
||||
|
||||
// Priority
|
||||
/// I/O priority for this operation.
|
||||
pub priority: IoPriority,
|
||||
|
||||
// Decision flags
|
||||
/// Whether to throttle random I/O.
|
||||
pub should_throttle_random_io: bool,
|
||||
/// Whether to expand buffer for sequential access.
|
||||
pub should_expand_for_sequential: bool,
|
||||
/// Whether to reduce buffer due to concurrency.
|
||||
pub should_reduce_for_concurrency: bool,
|
||||
/// Whether to reduce buffer due to low bandwidth.
|
||||
pub should_reduce_for_bandwidth: bool,
|
||||
}
|
||||
|
||||
impl Default for IoStrategy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
buffer_size: 128 * 1024,
|
||||
buffer_multiplier: 1.0,
|
||||
enable_readahead: true,
|
||||
use_buffered_io: true,
|
||||
concurrent_requests: 0,
|
||||
observed_bandwidth_bps: None,
|
||||
bandwidth_tier: BandwidthTier::Medium,
|
||||
load_level: IoLoadLevel::Low,
|
||||
priority: IoPriority::Normal,
|
||||
should_throttle_random_io: false,
|
||||
should_expand_for_sequential: false,
|
||||
should_reduce_for_concurrency: false,
|
||||
should_reduce_for_bandwidth: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IoStrategy {
|
||||
/// Create a new strategy with default values.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create a strategy for sequential access.
|
||||
pub fn sequential(buffer_size: usize) -> Self {
|
||||
Self {
|
||||
buffer_size,
|
||||
enable_readahead: true,
|
||||
should_expand_for_sequential: true,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a strategy for random access.
|
||||
pub fn random(buffer_size: usize) -> Self {
|
||||
Self {
|
||||
buffer_size,
|
||||
enable_readahead: false,
|
||||
should_throttle_random_io: true,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// I/O load metrics.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct IoLoadMetrics {
|
||||
/// Number of samples in the current window.
|
||||
pub sample_count: usize,
|
||||
/// Total wait time in the window.
|
||||
pub total_wait_time: Duration,
|
||||
/// Maximum wait time in the window.
|
||||
pub max_wait_time: Duration,
|
||||
/// Average wait time.
|
||||
pub avg_wait_time: Duration,
|
||||
/// Current load level.
|
||||
pub load_level: IoLoadLevel,
|
||||
}
|
||||
|
||||
impl IoLoadMetrics {
|
||||
/// Create new load metrics.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add a wait time sample.
|
||||
pub fn add_sample(&mut self, wait_time: Duration) {
|
||||
self.sample_count += 1;
|
||||
self.total_wait_time += wait_time;
|
||||
if wait_time > self.max_wait_time {
|
||||
self.max_wait_time = wait_time;
|
||||
}
|
||||
self.avg_wait_time = if self.sample_count > 0 {
|
||||
self.total_wait_time / self.sample_count as u32
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
}
|
||||
|
||||
/// Update load level based on thresholds.
|
||||
pub fn update_load_level(&mut self, low_threshold: Duration, high_threshold: Duration) {
|
||||
self.load_level = IoLoadLevel::from_wait_time(self.avg_wait_time, low_threshold, high_threshold);
|
||||
}
|
||||
|
||||
/// Reset the metrics.
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// I/O scheduler.
|
||||
pub struct IoScheduler {
|
||||
/// Scheduler configuration.
|
||||
config: IoSchedulerConfig,
|
||||
/// Active request counter.
|
||||
active_requests: AtomicUsize,
|
||||
/// Load metrics.
|
||||
load_metrics: std::sync::Mutex<IoLoadMetrics>,
|
||||
}
|
||||
|
||||
impl IoScheduler {
|
||||
/// Create a new I/O scheduler with the given configuration.
|
||||
pub fn new(config: IoSchedulerConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
active_requests: AtomicUsize::new(0),
|
||||
load_metrics: std::sync::Mutex::new(IoLoadMetrics::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new I/O scheduler with default configuration.
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(IoSchedulerConfig::default())
|
||||
}
|
||||
|
||||
/// Get the scheduler configuration.
|
||||
pub fn config(&self) -> &IoSchedulerConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Get the current number of active requests.
|
||||
pub fn active_requests(&self) -> usize {
|
||||
self.active_requests.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Increment the active request count.
|
||||
pub fn increment_requests(&self) {
|
||||
self.active_requests.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Decrement the active request count.
|
||||
pub fn decrement_requests(&self) {
|
||||
self.active_requests.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Calculate I/O strategy for a request.
|
||||
pub fn calculate_strategy(&self, file_size: i64, permit_wait_time: Duration, is_sequential: bool) -> IoStrategy {
|
||||
let concurrent_requests = self.active_requests.load(Ordering::Relaxed);
|
||||
|
||||
// Determine priority based on file size
|
||||
let priority = IoPriority::from_size(
|
||||
file_size,
|
||||
self.config.high_priority_size_threshold,
|
||||
self.config.low_priority_size_threshold,
|
||||
);
|
||||
|
||||
// Determine load level
|
||||
let load_level =
|
||||
IoLoadLevel::from_wait_time(permit_wait_time, self.config.load_low_threshold(), self.config.load_high_threshold());
|
||||
|
||||
// Calculate base buffer size
|
||||
let base_buffer = self.config.base_buffer_size;
|
||||
|
||||
// Adjust for concurrency
|
||||
let concurrency_factor = match concurrent_requests {
|
||||
0..=2 => 1.0,
|
||||
3..=4 => 0.75,
|
||||
5..=8 => 0.5,
|
||||
_ => 0.4,
|
||||
};
|
||||
|
||||
// Adjust for load level
|
||||
let load_factor = match load_level {
|
||||
IoLoadLevel::Low => 1.2,
|
||||
IoLoadLevel::Medium => 1.0,
|
||||
IoLoadLevel::High => 0.7,
|
||||
IoLoadLevel::Critical => 0.5,
|
||||
};
|
||||
|
||||
// Adjust for access pattern
|
||||
let sequential_factor = if is_sequential { 1.5 } else { 1.0 };
|
||||
|
||||
// Calculate final buffer size
|
||||
let buffer_size = (base_buffer as f64 * concurrency_factor * load_factor * sequential_factor) as usize;
|
||||
let buffer_size = buffer_size.clamp(self.config.min_buffer_size, self.config.max_buffer_size);
|
||||
|
||||
IoStrategy {
|
||||
buffer_size,
|
||||
buffer_multiplier: concurrency_factor * load_factor * sequential_factor,
|
||||
enable_readahead: is_sequential && load_level != IoLoadLevel::Critical,
|
||||
use_buffered_io: true,
|
||||
concurrent_requests,
|
||||
observed_bandwidth_bps: None,
|
||||
bandwidth_tier: BandwidthTier::Unknown,
|
||||
load_level,
|
||||
priority,
|
||||
should_throttle_random_io: !is_sequential && load_level >= IoLoadLevel::High,
|
||||
should_expand_for_sequential: is_sequential && load_level <= IoLoadLevel::Medium,
|
||||
should_reduce_for_concurrency: concurrent_requests > 4,
|
||||
should_reduce_for_bandwidth: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate multi-factor I/O strategy.
|
||||
pub fn calculate_multi_factor_strategy(
|
||||
&self,
|
||||
file_size: i64,
|
||||
permit_wait_time: Duration,
|
||||
is_sequential: bool,
|
||||
storage_profile: Option<&StorageProfile>,
|
||||
) -> IoStrategy {
|
||||
let mut strategy = self.calculate_strategy(file_size, permit_wait_time, is_sequential);
|
||||
|
||||
// Apply storage profile adjustments
|
||||
if let Some(profile) = storage_profile {
|
||||
// Adjust buffer size based on storage media
|
||||
let media_factor = match profile.media {
|
||||
StorageMedia::Nvme => 1.5,
|
||||
StorageMedia::Ssd => 1.2,
|
||||
StorageMedia::Hdd => 0.8,
|
||||
StorageMedia::Unknown => 1.0,
|
||||
};
|
||||
|
||||
strategy.buffer_size = (strategy.buffer_size as f64 * media_factor).min(self.config.max_buffer_size as f64) as usize;
|
||||
|
||||
// Apply sequential boost if applicable
|
||||
if is_sequential {
|
||||
strategy.buffer_size = (strategy.buffer_size as f64 * profile.sequential_boost_multiplier)
|
||||
.min(self.config.max_buffer_size as f64) as usize;
|
||||
}
|
||||
|
||||
// Apply random penalty if applicable
|
||||
if !is_sequential {
|
||||
strategy.buffer_size = (strategy.buffer_size as f64 * profile.random_penalty_multiplier)
|
||||
.max(self.config.min_buffer_size as f64) as usize;
|
||||
}
|
||||
|
||||
// Update readahead preference
|
||||
strategy.enable_readahead = strategy.enable_readahead && profile.prefers_readahead;
|
||||
}
|
||||
|
||||
strategy
|
||||
}
|
||||
|
||||
/// Record a wait time sample for load tracking.
|
||||
pub fn record_wait_time(&self, wait_time: Duration) {
|
||||
if let Ok(mut metrics) = self.load_metrics.lock() {
|
||||
metrics.add_sample(wait_time);
|
||||
metrics.update_load_level(self.config.load_low_threshold(), self.config.load_high_threshold());
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current load metrics.
|
||||
pub fn load_metrics(&self) -> IoLoadMetrics {
|
||||
if let Ok(metrics) = self.load_metrics.lock() {
|
||||
metrics.clone()
|
||||
} else {
|
||||
IoLoadMetrics::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IoScheduler {
|
||||
fn default() -> Self {
|
||||
Self::with_defaults()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Buffer Size Calculation Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Constants for buffer size calculations.
|
||||
pub const KI_B: usize = 1024;
|
||||
pub const MI_B: usize = 1024 * 1024;
|
||||
|
||||
/// Get concurrency-aware buffer size.
|
||||
///
|
||||
/// Adjusts buffer size based on the current level of concurrent requests.
|
||||
/// Higher concurrency leads to smaller buffers to reduce memory pressure.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `file_size` - Size of the file being read (-1 if unknown)
|
||||
/// * `base_buffer_size` - Base buffer size from workload profile
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Adjusted buffer size in bytes
|
||||
pub fn get_concurrency_aware_buffer_size(file_size: i64, base_buffer_size: usize) -> usize {
|
||||
// Get current concurrency level from global counter
|
||||
let concurrent_requests = 1; // Default to 1 if no global counter available
|
||||
|
||||
// Define concurrency thresholds
|
||||
let medium_threshold = 4;
|
||||
let high_threshold = 8;
|
||||
|
||||
// Calculate adaptive multiplier based on concurrency
|
||||
let adaptive_multiplier = if concurrent_requests <= 2 {
|
||||
// Low concurrency (1-2): use full buffer size
|
||||
1.0
|
||||
} else if concurrent_requests <= medium_threshold {
|
||||
// Medium concurrency (3-4): slightly reduce buffer size (75% of base)
|
||||
0.75
|
||||
} else if concurrent_requests <= high_threshold {
|
||||
// Higher concurrency (5-8): more aggressive reduction (50% of base)
|
||||
0.5
|
||||
} else {
|
||||
// Very high concurrency (>8): minimize memory per request (40% of base)
|
||||
0.4
|
||||
};
|
||||
|
||||
// Calculate the adjusted buffer size
|
||||
let adjusted_size = (base_buffer_size as f64 * adaptive_multiplier) as usize;
|
||||
|
||||
// Ensure we stay within reasonable bounds
|
||||
let min_buffer = if file_size > 0 && file_size < 100 * KI_B as i64 {
|
||||
32 * KI_B // For very small files, use minimum buffer
|
||||
} else {
|
||||
64 * KI_B // Standard minimum buffer size
|
||||
};
|
||||
|
||||
let max_buffer = if concurrent_requests > high_threshold {
|
||||
256 * KI_B // Cap at 256KB for high concurrency
|
||||
} else {
|
||||
MI_B // Cap at 1MB for lower concurrency
|
||||
};
|
||||
|
||||
adjusted_size.clamp(min_buffer, max_buffer)
|
||||
}
|
||||
|
||||
/// Advanced concurrency-aware buffer sizing with file size optimization.
|
||||
///
|
||||
/// This enhanced version considers both concurrency level and file size patterns
|
||||
/// to provide even better performance characteristics.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `file_size` - Size of the file being read (-1 if unknown)
|
||||
/// * `base_buffer_size` - Baseline buffer size from workload profile
|
||||
/// * `is_sequential` - Whether this is a sequential read (hint for optimization)
|
||||
/// * `concurrent_requests` - Current number of concurrent requests
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Optimized buffer size in bytes
|
||||
pub fn get_advanced_buffer_size(
|
||||
file_size: i64,
|
||||
base_buffer_size: usize,
|
||||
is_sequential: bool,
|
||||
concurrent_requests: usize,
|
||||
) -> usize {
|
||||
// For very small files, use smaller buffers regardless of concurrency
|
||||
if file_size > 0 && file_size < 256 * KI_B as i64 {
|
||||
return (file_size as usize / 4).clamp(16 * KI_B, 64 * KI_B);
|
||||
}
|
||||
|
||||
// Base calculation from standard function
|
||||
let standard_size = get_concurrency_aware_buffer_size(file_size, base_buffer_size);
|
||||
|
||||
let medium_threshold = 4;
|
||||
let high_threshold = 8;
|
||||
|
||||
// For sequential reads, we can be more aggressive with buffer sizes
|
||||
if is_sequential && concurrent_requests <= medium_threshold {
|
||||
// Boost buffer size for sequential reads under low concurrency
|
||||
let boosted = (standard_size as f64 * 1.5) as usize;
|
||||
return boosted.min(MI_B);
|
||||
}
|
||||
|
||||
// For random reads under high concurrency, reduce buffer size
|
||||
if !is_sequential && concurrent_requests > high_threshold {
|
||||
let reduced = (standard_size as f64 * 0.7) as usize;
|
||||
return reduced.max(32 * KI_B);
|
||||
}
|
||||
|
||||
standard_size
|
||||
}
|
||||
|
||||
/// Get buffer size with storage media optimization.
|
||||
///
|
||||
/// Adjusts buffer size based on storage media characteristics.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `base_size` - Base buffer size
|
||||
/// * `media` - Storage media type
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Optimized buffer size for the storage media
|
||||
pub fn get_buffer_size_for_media(base_size: usize, media: StorageMedia) -> usize {
|
||||
let multiplier = match media {
|
||||
StorageMedia::Nvme => 1.5, // NVMe can handle larger buffers
|
||||
StorageMedia::Ssd => 1.2, // SSD benefits from moderate buffers
|
||||
StorageMedia::Hdd => 0.8, // HDD prefers smaller buffers to reduce seek overhead
|
||||
StorageMedia::Unknown => 1.0,
|
||||
};
|
||||
|
||||
(base_size as f64 * multiplier).min(MI_B as f64) as usize
|
||||
}
|
||||
|
||||
/// Calculate optimal buffer size using multi-factor analysis.
|
||||
///
|
||||
/// This is the main entry point for buffer size calculation, considering
|
||||
/// all factors: concurrency, storage media, access pattern, and load.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `file_size` - Size of the file being read
|
||||
/// * `base_buffer_size` - Base buffer size
|
||||
/// * `is_sequential` - Whether access is sequential
|
||||
/// * `concurrent_requests` - Current concurrency level
|
||||
/// * `media` - Storage media type
|
||||
/// * `load_level` - Current I/O load level
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Optimally calculated buffer size
|
||||
pub fn calculate_optimal_buffer_size(
|
||||
file_size: i64,
|
||||
base_buffer_size: usize,
|
||||
is_sequential: bool,
|
||||
concurrent_requests: usize,
|
||||
media: StorageMedia,
|
||||
load_level: IoLoadLevel,
|
||||
) -> usize {
|
||||
// Start with advanced buffer size calculation
|
||||
let mut buffer_size = get_advanced_buffer_size(file_size, base_buffer_size, is_sequential, concurrent_requests);
|
||||
|
||||
// Apply storage media optimization
|
||||
buffer_size = get_buffer_size_for_media(buffer_size, media);
|
||||
|
||||
// Apply load-based adjustment
|
||||
let load_multiplier = match load_level {
|
||||
IoLoadLevel::Low => 1.2,
|
||||
IoLoadLevel::Medium => 1.0,
|
||||
IoLoadLevel::High => 0.7,
|
||||
IoLoadLevel::Critical => 0.5,
|
||||
};
|
||||
|
||||
buffer_size = (buffer_size as f64 * load_multiplier) as usize;
|
||||
|
||||
// Final bounds check
|
||||
buffer_size.clamp(32 * KI_B, MI_B)
|
||||
}
|
||||
|
||||
/// I/O scheduling context for multi-factor strategy calculation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IoSchedulingContext {
|
||||
/// File size in bytes (-1 if unknown).
|
||||
pub file_size: i64,
|
||||
/// Base buffer size from configuration.
|
||||
pub base_buffer_size: usize,
|
||||
/// Time spent waiting for permit.
|
||||
pub permit_wait_duration: Duration,
|
||||
/// Whether access is sequential.
|
||||
pub is_sequential_hint: bool,
|
||||
/// Detected access pattern.
|
||||
pub access_pattern: AccessPattern,
|
||||
/// Detected storage media.
|
||||
pub storage_media: StorageMedia,
|
||||
/// Observed bandwidth in bytes per second.
|
||||
pub observed_bandwidth_bps: Option<u64>,
|
||||
/// Current concurrent request count.
|
||||
pub concurrent_requests: usize,
|
||||
}
|
||||
|
||||
impl Default for IoSchedulingContext {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
file_size: -1,
|
||||
base_buffer_size: 128 * KI_B,
|
||||
permit_wait_duration: Duration::ZERO,
|
||||
is_sequential_hint: true,
|
||||
access_pattern: AccessPattern::Unknown,
|
||||
storage_media: StorageMedia::Unknown,
|
||||
observed_bandwidth_bps: None,
|
||||
concurrent_requests: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IoSchedulingContext {
|
||||
/// Create a new scheduling context.
|
||||
pub fn new(file_size: i64, base_buffer_size: usize) -> Self {
|
||||
Self {
|
||||
file_size,
|
||||
base_buffer_size,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder pattern: set sequential hint.
|
||||
pub fn with_sequential(mut self, is_sequential: bool) -> Self {
|
||||
self.is_sequential_hint = is_sequential;
|
||||
self.access_pattern = if is_sequential {
|
||||
AccessPattern::Sequential
|
||||
} else {
|
||||
AccessPattern::Random
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder pattern: set storage media.
|
||||
pub fn with_media(mut self, media: StorageMedia) -> Self {
|
||||
self.storage_media = media;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder pattern: set bandwidth.
|
||||
pub fn with_bandwidth(mut self, bps: u64) -> Self {
|
||||
self.observed_bandwidth_bps = Some(bps);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder pattern: set concurrency.
|
||||
pub fn with_concurrency(mut self, count: usize) -> Self {
|
||||
self.concurrent_requests = count;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder pattern: set wait duration.
|
||||
pub fn with_wait_duration(mut self, duration: Duration) -> Self {
|
||||
self.permit_wait_duration = duration;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_io_priority() {
|
||||
assert_eq!(IoPriority::from_size(1024, 64 * 1024, 4 * 1024 * 1024), IoPriority::High);
|
||||
assert_eq!(IoPriority::from_size(1024 * 1024, 64 * 1024, 4 * 1024 * 1024), IoPriority::Normal);
|
||||
assert_eq!(IoPriority::from_size(10 * 1024 * 1024, 64 * 1024, 4 * 1024 * 1024), IoPriority::Low);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_priority_unknown_size_is_normal() {
|
||||
// -1 means "size unknown" and must not wrap to usize::MAX (=> Low).
|
||||
assert_eq!(IoPriority::from_size(-1, 64 * 1024, 4 * 1024 * 1024), IoPriority::Normal);
|
||||
assert_eq!(IoPriority::from_size(i64::MIN, 64 * 1024, 4 * 1024 * 1024), IoPriority::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_load_level() {
|
||||
let low = Duration::from_millis(5);
|
||||
let high = Duration::from_millis(50);
|
||||
|
||||
assert_eq!(IoLoadLevel::from_wait_time(Duration::from_millis(1), low, high), IoLoadLevel::Low);
|
||||
assert_eq!(IoLoadLevel::from_wait_time(Duration::from_millis(20), low, high), IoLoadLevel::Medium);
|
||||
assert_eq!(IoLoadLevel::from_wait_time(Duration::from_millis(60), low, high), IoLoadLevel::High);
|
||||
assert_eq!(IoLoadLevel::from_wait_time(Duration::from_millis(150), low, high), IoLoadLevel::Critical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bandwidth_tier() {
|
||||
assert_eq!(BandwidthTier::from_bps(50 * 1024 * 1024), BandwidthTier::Low);
|
||||
assert_eq!(BandwidthTier::from_bps(200 * 1024 * 1024), BandwidthTier::Medium);
|
||||
assert_eq!(BandwidthTier::from_bps(600 * 1024 * 1024), BandwidthTier::High);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_strategy_default() {
|
||||
let strategy = IoStrategy::default();
|
||||
assert!(strategy.buffer_size > 0);
|
||||
assert!(strategy.enable_readahead);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_scheduler() {
|
||||
let scheduler = IoScheduler::with_defaults();
|
||||
|
||||
let strategy = scheduler.calculate_strategy(1024 * 1024, Duration::from_millis(5), true);
|
||||
assert!(strategy.buffer_size > 0);
|
||||
assert!(strategy.enable_readahead);
|
||||
assert_eq!(strategy.load_level, IoLoadLevel::Low);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_scheduler_with_concurrency() {
|
||||
let scheduler = IoScheduler::with_defaults();
|
||||
|
||||
// Simulate concurrent requests
|
||||
scheduler.increment_requests();
|
||||
scheduler.increment_requests();
|
||||
scheduler.increment_requests();
|
||||
|
||||
let strategy = scheduler.calculate_strategy(1024 * 1024, Duration::from_millis(5), true);
|
||||
assert_eq!(strategy.concurrent_requests, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_metrics() {
|
||||
let mut metrics = IoLoadMetrics::new();
|
||||
|
||||
metrics.add_sample(Duration::from_millis(10));
|
||||
metrics.add_sample(Duration::from_millis(20));
|
||||
metrics.add_sample(Duration::from_millis(30));
|
||||
|
||||
assert_eq!(metrics.sample_count, 3);
|
||||
assert_eq!(metrics.avg_wait_time, Duration::from_millis(20));
|
||||
assert_eq!(metrics.max_wait_time, Duration::from_millis(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_concurrency_aware_buffer_size() {
|
||||
// Test with default concurrency (1)
|
||||
let size = get_concurrency_aware_buffer_size(1024 * 1024, 128 * KI_B);
|
||||
assert!(size >= 64 * KI_B);
|
||||
assert!(size <= MI_B);
|
||||
|
||||
// Test with small file
|
||||
let size = get_concurrency_aware_buffer_size(50 * KI_B as i64, 128 * KI_B);
|
||||
assert!(size >= 32 * KI_B);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_advanced_buffer_size() {
|
||||
// Sequential read with low concurrency
|
||||
let size = get_advanced_buffer_size(10 * MI_B as i64, 128 * KI_B, true, 2);
|
||||
assert!(size >= 128 * KI_B);
|
||||
|
||||
// Random read with high concurrency
|
||||
let size = get_advanced_buffer_size(10 * MI_B as i64, 128 * KI_B, false, 10);
|
||||
assert!(size >= 32 * KI_B);
|
||||
|
||||
// Very small file
|
||||
let size = get_advanced_buffer_size(100 * KI_B as i64, 128 * KI_B, true, 1);
|
||||
assert!(size <= 64 * KI_B);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_buffer_size_for_media() {
|
||||
let base = 128 * KI_B;
|
||||
|
||||
// NVMe should get larger buffers
|
||||
let nvme_size = get_buffer_size_for_media(base, StorageMedia::Nvme);
|
||||
assert!(nvme_size > base);
|
||||
|
||||
// SSD should get slightly larger buffers
|
||||
let ssd_size = get_buffer_size_for_media(base, StorageMedia::Ssd);
|
||||
assert!(ssd_size > base);
|
||||
|
||||
// HDD should get smaller buffers
|
||||
let hdd_size = get_buffer_size_for_media(base, StorageMedia::Hdd);
|
||||
assert!(hdd_size < base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_optimal_buffer_size() {
|
||||
// Low load, sequential, NVMe
|
||||
let size = calculate_optimal_buffer_size(10 * MI_B as i64, 128 * KI_B, true, 2, StorageMedia::Nvme, IoLoadLevel::Low);
|
||||
assert!(size >= 32 * KI_B);
|
||||
assert!(size <= MI_B);
|
||||
|
||||
// Critical load, random, HDD
|
||||
let size =
|
||||
calculate_optimal_buffer_size(10 * MI_B as i64, 128 * KI_B, false, 10, StorageMedia::Hdd, IoLoadLevel::Critical);
|
||||
assert!(size >= 32 * KI_B);
|
||||
assert!(size <= MI_B);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_io_scheduling_context() {
|
||||
let ctx = IoSchedulingContext::new(10 * MI_B as i64, 256 * KI_B)
|
||||
.with_sequential(true)
|
||||
.with_media(StorageMedia::Nvme)
|
||||
.with_bandwidth(500 * MI_B as u64)
|
||||
.with_concurrency(4);
|
||||
|
||||
assert_eq!(ctx.file_size, 10 * MI_B as i64);
|
||||
assert_eq!(ctx.base_buffer_size, 256 * KI_B);
|
||||
assert!(ctx.is_sequential_hint);
|
||||
assert_eq!(ctx.storage_media, StorageMedia::Nvme);
|
||||
assert_eq!(ctx.observed_bandwidth_bps, Some(500 * MI_B as u64));
|
||||
assert_eq!(ctx.concurrent_requests, 4);
|
||||
}
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
// 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.
|
||||
|
||||
//! Shared memory pool for zero-copy data sharing.
|
||||
//!
|
||||
//! This module provides Arc-based shared memory management for
|
||||
//! efficient cross-task data passing without serialization.
|
||||
|
||||
use std::convert::AsRef;
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Shared memory pool configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SharedMemoryConfig {
|
||||
/// Whether shared memory is enabled
|
||||
pub enabled: bool,
|
||||
|
||||
/// Maximum pool size in bytes
|
||||
pub max_pool_size: usize,
|
||||
|
||||
/// Maximum object size in bytes
|
||||
pub max_object_size: usize,
|
||||
}
|
||||
|
||||
impl Default for SharedMemoryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
max_pool_size: 100 * 1024 * 1024, // 100MB
|
||||
max_object_size: 10 * 1024 * 1024, // 10MB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared memory pool statistics.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SharedMemoryStats {
|
||||
/// Total number of objects created
|
||||
pub total_objects: AtomicU64,
|
||||
|
||||
/// Total number of shared references
|
||||
pub total_shared_refs: AtomicU64,
|
||||
|
||||
/// Current memory usage in bytes
|
||||
pub current_memory: AtomicU64,
|
||||
|
||||
/// Peak memory usage in bytes
|
||||
pub peak_memory: AtomicU64,
|
||||
}
|
||||
|
||||
/// Arc data metadata.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArcMetadata {
|
||||
/// Size of the data (if measurable)
|
||||
pub size: Option<usize>,
|
||||
|
||||
/// Creation timestamp
|
||||
pub created_at: Instant,
|
||||
}
|
||||
|
||||
/// Arc-based data wrapper for zero-copy sharing.
|
||||
///
|
||||
/// This wrapper uses Arc to enable shared ownership of data
|
||||
/// across multiple tasks without copying.
|
||||
pub struct ArcData<T> {
|
||||
/// The wrapped data
|
||||
inner: Arc<T>,
|
||||
|
||||
/// Metadata about the data
|
||||
metadata: ArcMetadata,
|
||||
}
|
||||
|
||||
impl<T> Clone for ArcData<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: Arc::clone(&self.inner),
|
||||
metadata: self.metadata.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ArcData<T> {
|
||||
/// Create a new ArcData wrapper.
|
||||
pub fn new(data: T) -> Self {
|
||||
ArcData {
|
||||
inner: Arc::new(data),
|
||||
metadata: ArcMetadata {
|
||||
size: None,
|
||||
created_at: Instant::now(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new ArcData wrapper with known size.
|
||||
pub fn with_size(data: T, size: usize) -> Self {
|
||||
ArcData {
|
||||
inner: Arc::new(data),
|
||||
metadata: ArcMetadata {
|
||||
size: Some(size),
|
||||
created_at: Instant::now(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the reference count.
|
||||
pub fn ref_count(&self) -> usize {
|
||||
Arc::strong_count(&self.inner)
|
||||
}
|
||||
|
||||
/// Convert into the underlying Arc.
|
||||
pub fn into_arc(self) -> Arc<T> {
|
||||
self.inner
|
||||
}
|
||||
|
||||
/// Get the metadata.
|
||||
pub fn metadata(&self) -> &ArcMetadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
/// Get the size if known.
|
||||
pub fn size(&self) -> Option<usize> {
|
||||
self.metadata.size
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> AsRef<T> for ArcData<T> {
|
||||
fn as_ref(&self) -> &T {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Deref for ArcData<T> {
|
||||
type Target = T;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::fmt::Debug for ArcData<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ArcData")
|
||||
.field("ref_count", &self.ref_count())
|
||||
.field("metadata", &self.metadata)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared memory pool for managing Arc-based shared data.
|
||||
pub struct SharedMemoryPool {
|
||||
config: SharedMemoryConfig,
|
||||
stats: SharedMemoryStats,
|
||||
}
|
||||
|
||||
impl SharedMemoryPool {
|
||||
/// Create a new shared memory pool with the given configuration.
|
||||
pub fn new(config: SharedMemoryConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
stats: SharedMemoryStats::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new shared memory pool with default configuration.
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(SharedMemoryConfig::default())
|
||||
}
|
||||
|
||||
/// Create shared data.
|
||||
///
|
||||
/// This method wraps the data in an ArcData for zero-copy sharing.
|
||||
pub fn create<T>(&self, data: T) -> ArcData<T> {
|
||||
self.stats.total_objects.fetch_add(1, Ordering::Relaxed);
|
||||
ArcData::new(data)
|
||||
}
|
||||
|
||||
/// Create shared data with known size.
|
||||
///
|
||||
/// This method tracks memory usage for statistics.
|
||||
pub fn create_with_size<T>(&self, data: T, size: usize) -> ArcData<T> {
|
||||
self.stats.total_objects.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
// Update memory statistics
|
||||
self.stats.current_memory.fetch_add(size as u64, Ordering::Relaxed);
|
||||
|
||||
// Update peak memory
|
||||
let current = self.stats.current_memory.load(Ordering::Relaxed);
|
||||
let mut peak = self.stats.peak_memory.load(Ordering::Relaxed);
|
||||
if current > peak {
|
||||
peak = current;
|
||||
self.stats.peak_memory.store(peak, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
ArcData::with_size(data, size)
|
||||
}
|
||||
|
||||
/// Share data by increasing reference count.
|
||||
///
|
||||
/// This method creates a new ArcData that shares the underlying data
|
||||
/// without copying.
|
||||
pub fn share<T>(&self, data: &ArcData<T>) -> ArcData<T> {
|
||||
self.stats.total_shared_refs.fetch_add(1, Ordering::Relaxed);
|
||||
data.clone()
|
||||
}
|
||||
|
||||
/// Get the statistics for this pool.
|
||||
pub fn stats(&self) -> &SharedMemoryStats {
|
||||
&self.stats
|
||||
}
|
||||
|
||||
/// Get the configuration for this pool.
|
||||
pub fn config(&self) -> &SharedMemoryConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Check if the pool is enabled.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.config.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_arc_data_new() {
|
||||
let data = vec![1u8, 2, 3, 4, 5];
|
||||
let arc_data = ArcData::new(data.clone());
|
||||
|
||||
assert_eq!(arc_data.as_ref(), &data);
|
||||
assert_eq!(arc_data.ref_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arc_data_clone() {
|
||||
let data = vec![1u8, 2, 3, 4, 5];
|
||||
let arc_data = ArcData::new(data);
|
||||
|
||||
assert_eq!(arc_data.ref_count(), 1);
|
||||
|
||||
let arc_data2 = arc_data.clone();
|
||||
assert_eq!(arc_data.ref_count(), 2);
|
||||
assert_eq!(arc_data2.ref_count(), 2);
|
||||
|
||||
let arc_data3 = arc_data.clone();
|
||||
assert_eq!(arc_data.ref_count(), 3);
|
||||
assert_eq!(arc_data2.ref_count(), 3);
|
||||
assert_eq!(arc_data3.ref_count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arc_data_deref() {
|
||||
let data = vec![1u8, 2, 3, 4, 5];
|
||||
let arc_data = ArcData::new(data);
|
||||
|
||||
// Test Deref trait
|
||||
assert_eq!(arc_data.len(), 5);
|
||||
assert_eq!(arc_data[0], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_memory_pool_create() {
|
||||
let pool = SharedMemoryPool::with_defaults();
|
||||
let data = vec![1u8, 2, 3, 4, 5];
|
||||
|
||||
let arc_data = pool.create(data.clone());
|
||||
|
||||
assert_eq!(arc_data.as_ref(), &data);
|
||||
assert_eq!(pool.stats().total_objects.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_memory_pool_share() {
|
||||
let pool = SharedMemoryPool::with_defaults();
|
||||
let data = vec![1u8, 2, 3, 4, 5];
|
||||
|
||||
let arc_data = pool.create(data);
|
||||
assert_eq!(arc_data.ref_count(), 1);
|
||||
|
||||
let shared = pool.share(&arc_data);
|
||||
assert_eq!(arc_data.ref_count(), 2);
|
||||
assert_eq!(shared.ref_count(), 2);
|
||||
assert_eq!(pool.stats().total_shared_refs.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_memory_pool_with_size() {
|
||||
let pool = SharedMemoryPool::with_defaults();
|
||||
let data = vec![1u8; 1024];
|
||||
|
||||
let arc_data = pool.create_with_size(data, 1024);
|
||||
|
||||
assert_eq!(arc_data.size(), Some(1024));
|
||||
assert_eq!(pool.stats().current_memory.load(Ordering::Relaxed), 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = SharedMemoryConfig::default();
|
||||
|
||||
assert!(config.enabled);
|
||||
assert_eq!(config.max_pool_size, 100 * 1024 * 1024);
|
||||
assert_eq!(config.max_object_size, 10 * 1024 * 1024);
|
||||
}
|
||||
}
|
||||
@@ -1,501 +0,0 @@
|
||||
// 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.
|
||||
|
||||
//! Timeout wrapper for I/O operations.
|
||||
//!
|
||||
//! This module provides timeout management for I/O operations with
|
||||
//! dynamic timeout calculation based on operation size.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Timeout configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TimeoutConfig {
|
||||
/// Base timeout for small operations.
|
||||
pub base_timeout: Duration,
|
||||
/// Timeout per MB of data.
|
||||
pub timeout_per_mb: Duration,
|
||||
/// Maximum timeout.
|
||||
pub max_timeout: Duration,
|
||||
/// Minimum timeout.
|
||||
pub min_timeout: Duration,
|
||||
/// GetObject operation timeout.
|
||||
pub get_object_timeout: Duration,
|
||||
/// PutObject operation timeout.
|
||||
pub put_object_timeout: Duration,
|
||||
/// ListObjects operation timeout.
|
||||
pub list_objects_timeout: Duration,
|
||||
/// Whether dynamic timeout is enabled.
|
||||
pub enable_dynamic_timeout: bool,
|
||||
}
|
||||
|
||||
impl Default for TimeoutConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_timeout: Duration::from_secs(5),
|
||||
timeout_per_mb: Duration::from_millis(100),
|
||||
max_timeout: Duration::from_secs(300),
|
||||
min_timeout: Duration::from_secs(1),
|
||||
get_object_timeout: Duration::from_secs(30),
|
||||
put_object_timeout: Duration::from_secs(60),
|
||||
list_objects_timeout: Duration::from_secs(10),
|
||||
enable_dynamic_timeout: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TimeoutConfig {
|
||||
/// Create new timeout configuration.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Calculate dynamic timeout based on size.
|
||||
pub fn calculate_timeout(&self, size_bytes: u64) -> Duration {
|
||||
if !self.enable_dynamic_timeout {
|
||||
return self.base_timeout;
|
||||
}
|
||||
|
||||
let mb = size_bytes as f64 / (1024.0 * 1024.0);
|
||||
let timeout = self.base_timeout + self.timeout_per_mb.mul_f64(mb);
|
||||
timeout.clamp(self.min_timeout, self.max_timeout)
|
||||
}
|
||||
|
||||
/// Validate the configuration.
|
||||
pub fn validate(&self) -> Result<(), TimeoutError> {
|
||||
if self.min_timeout > self.max_timeout {
|
||||
return Err(TimeoutError::InvalidConfig("min_timeout must be <= max_timeout".to_string()));
|
||||
}
|
||||
if self.base_timeout < self.min_timeout || self.base_timeout > self.max_timeout {
|
||||
return Err(TimeoutError::InvalidConfig(
|
||||
"base_timeout must be between min_timeout and max_timeout".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeout error.
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum TimeoutError {
|
||||
/// Operation timed out.
|
||||
#[error("Operation timed out after {0:?}")]
|
||||
TimedOut(Duration),
|
||||
/// Invalid configuration.
|
||||
#[error("Invalid timeout config: {0}")]
|
||||
InvalidConfig(String),
|
||||
}
|
||||
|
||||
/// Operation progress tracker.
|
||||
#[derive(Debug)]
|
||||
pub struct OperationProgress {
|
||||
/// Total size (if known).
|
||||
pub total_size: Option<u64>,
|
||||
/// Bytes processed.
|
||||
bytes_processed: AtomicU64,
|
||||
/// Last update time.
|
||||
last_update: std::sync::Mutex<Instant>,
|
||||
/// Stale timeout.
|
||||
stale_timeout: Duration,
|
||||
/// Start time for transfer rate calculation.
|
||||
start_time: Instant,
|
||||
}
|
||||
|
||||
impl OperationProgress {
|
||||
/// Create new operation progress.
|
||||
pub fn new(total_size: Option<u64>, stale_timeout: Duration) -> Self {
|
||||
Self {
|
||||
total_size,
|
||||
bytes_processed: AtomicU64::new(0),
|
||||
last_update: std::sync::Mutex::new(Instant::now()),
|
||||
stale_timeout,
|
||||
start_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update progress.
|
||||
pub fn update(&self, bytes: u64) {
|
||||
self.bytes_processed.store(bytes, Ordering::Relaxed);
|
||||
if let Ok(mut last) = self.last_update.lock() {
|
||||
*last = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Add to progress.
|
||||
pub fn add(&self, bytes: u64) {
|
||||
self.bytes_processed.fetch_add(bytes, Ordering::Relaxed);
|
||||
if let Ok(mut last) = self.last_update.lock() {
|
||||
*last = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current progress.
|
||||
pub fn current(&self) -> u64 {
|
||||
self.bytes_processed.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Check if progress is stale.
|
||||
pub fn is_stale(&self) -> bool {
|
||||
if let Ok(last) = self.last_update.lock() {
|
||||
last.elapsed() > self.stale_timeout
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Get progress percentage.
|
||||
pub fn progress_percent(&self) -> Option<f64> {
|
||||
self.total_size.map(|total| {
|
||||
if total == 0 {
|
||||
100.0
|
||||
} else {
|
||||
let processed = self.bytes_processed.load(Ordering::Relaxed);
|
||||
(processed as f64 / total as f64 * 100.0).min(100.0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Get remaining bytes.
|
||||
pub fn remaining(&self) -> Option<u64> {
|
||||
self.total_size.map(|total| {
|
||||
let processed = self.bytes_processed.load(Ordering::Relaxed);
|
||||
total.saturating_sub(processed)
|
||||
})
|
||||
}
|
||||
|
||||
/// Calculate transfer rate in bytes per second.
|
||||
///
|
||||
/// Returns 0 if no time has elapsed or no data transferred.
|
||||
pub fn transfer_rate(&self) -> u64 {
|
||||
let processed = self.bytes_processed.load(Ordering::Relaxed);
|
||||
if processed == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let elapsed = self.start_time.elapsed().as_secs_f64();
|
||||
if elapsed > 0.0 {
|
||||
(processed as f64 / elapsed) as u64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request timeout wrapper.
|
||||
pub struct RequestTimeoutWrapper {
|
||||
/// Configuration.
|
||||
config: TimeoutConfig,
|
||||
/// Start time.
|
||||
start_time: Instant,
|
||||
/// Operation progress.
|
||||
progress: Option<OperationProgress>,
|
||||
}
|
||||
|
||||
impl RequestTimeoutWrapper {
|
||||
/// Create a new timeout wrapper.
|
||||
pub fn new(config: TimeoutConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
start_time: Instant::now(),
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with progress tracking.
|
||||
pub fn with_progress(config: TimeoutConfig, total_size: Option<u64>, stale_timeout: Duration) -> Self {
|
||||
Self {
|
||||
config,
|
||||
start_time: Instant::now(),
|
||||
progress: Some(OperationProgress::new(total_size, stale_timeout)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the configuration.
|
||||
pub fn config(&self) -> &TimeoutConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Get elapsed time.
|
||||
pub fn elapsed(&self) -> Duration {
|
||||
self.start_time.elapsed()
|
||||
}
|
||||
|
||||
/// Get remaining time.
|
||||
pub fn remaining(&self, timeout: Duration) -> Option<Duration> {
|
||||
let elapsed = self.elapsed();
|
||||
if elapsed >= timeout { None } else { Some(timeout - elapsed) }
|
||||
}
|
||||
|
||||
/// Check if timed out.
|
||||
pub fn is_timed_out(&self, size: Option<u64>) -> bool {
|
||||
let timeout = self.get_timeout(size);
|
||||
self.elapsed() > timeout
|
||||
}
|
||||
|
||||
/// Get the timeout for a given size.
|
||||
pub fn get_timeout(&self, size: Option<u64>) -> Duration {
|
||||
if self.config.enable_dynamic_timeout {
|
||||
if let Some(s) = size {
|
||||
self.config.calculate_timeout(s)
|
||||
} else {
|
||||
self.config.base_timeout
|
||||
}
|
||||
} else {
|
||||
self.config.base_timeout
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if timed out and return error if so.
|
||||
pub fn check_timeout(&self, size: Option<u64>) -> Result<(), TimeoutError> {
|
||||
if self.is_timed_out(size) {
|
||||
Err(TimeoutError::TimedOut(self.get_timeout(size)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get progress.
|
||||
pub fn progress(&self) -> Option<&OperationProgress> {
|
||||
self.progress.as_ref()
|
||||
}
|
||||
|
||||
/// Update progress.
|
||||
pub fn update_progress(&self, bytes: u64) {
|
||||
if let Some(ref progress) = self.progress {
|
||||
progress.update(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if operation is stalled (no progress for a while).
|
||||
pub fn is_stalled(&self) -> bool {
|
||||
self.progress.as_ref().is_some_and(|p| p.is_stale())
|
||||
}
|
||||
|
||||
/// Get progress percentage.
|
||||
pub fn progress_percent(&self) -> Option<f64> {
|
||||
self.progress.as_ref().and_then(|p| p.progress_percent())
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeout statistics.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TimeoutStats {
|
||||
/// Total operations.
|
||||
pub total_operations: AtomicU64,
|
||||
/// Timed out operations.
|
||||
pub timed_out: AtomicU64,
|
||||
/// Total wait time in nanoseconds.
|
||||
pub total_wait_time_ns: AtomicU64,
|
||||
/// Maximum wait time in nanoseconds.
|
||||
pub max_wait_time_ns: AtomicU64,
|
||||
}
|
||||
|
||||
impl TimeoutStats {
|
||||
/// Create new timeout statistics.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Record an operation.
|
||||
pub fn record_operation(&self, wait_time: Duration) {
|
||||
self.total_operations.fetch_add(1, Ordering::Relaxed);
|
||||
let ns = wait_time.as_nanos() as u64;
|
||||
self.total_wait_time_ns.fetch_add(ns, Ordering::Relaxed);
|
||||
|
||||
let mut current = self.max_wait_time_ns.load(Ordering::Relaxed);
|
||||
while ns > current {
|
||||
match self
|
||||
.max_wait_time_ns
|
||||
.compare_exchange_weak(current, ns, Ordering::Relaxed, Ordering::Relaxed)
|
||||
{
|
||||
Ok(_) => break,
|
||||
Err(actual) => current = actual,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a timeout.
|
||||
pub fn record_timeout(&self) {
|
||||
self.timed_out.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Get timeout rate.
|
||||
pub fn timeout_rate(&self) -> f64 {
|
||||
let total = self.total_operations.load(Ordering::Relaxed);
|
||||
let timed_out = self.timed_out.load(Ordering::Relaxed);
|
||||
if total == 0 { 0.0 } else { timed_out as f64 / total as f64 }
|
||||
}
|
||||
|
||||
/// Get average wait time.
|
||||
pub fn avg_wait_time(&self) -> Duration {
|
||||
let total = self.total_wait_time_ns.load(Ordering::Relaxed);
|
||||
let count = self.total_operations.load(Ordering::Relaxed);
|
||||
total.checked_div(count).map(Duration::from_nanos).unwrap_or(Duration::ZERO)
|
||||
}
|
||||
|
||||
/// Reset statistics.
|
||||
pub fn reset(&self) {
|
||||
self.total_operations.store(0, Ordering::Relaxed);
|
||||
self.timed_out.store(0, Ordering::Relaxed);
|
||||
self.total_wait_time_ns.store(0, Ordering::Relaxed);
|
||||
self.max_wait_time_ns.store(0, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate adaptive timeout based on historical data and current conditions.
|
||||
///
|
||||
/// This function adjusts the timeout based on:
|
||||
/// - Historical transfer rate
|
||||
/// - Recent timeout count
|
||||
/// - Object size
|
||||
pub fn calculate_adaptive_timeout(
|
||||
base_timeout: Duration,
|
||||
historical_rate_bps: Option<u64>,
|
||||
recent_timeout_count: u32,
|
||||
object_size: u64,
|
||||
) -> Duration {
|
||||
// If we have recent timeouts, increase timeout
|
||||
let timeout_multiplier = if recent_timeout_count > 3 {
|
||||
2.0 // Double timeout if many recent timeouts
|
||||
} else if recent_timeout_count > 1 {
|
||||
1.5 // 50% increase if some timeouts
|
||||
} else {
|
||||
1.0 // No adjustment
|
||||
};
|
||||
|
||||
// Adaptive timeout bounds: 5 seconds minimum, 10 minutes maximum.
|
||||
const MIN_SECS: f64 = 5.0;
|
||||
const MAX_SECS: f64 = 600.0;
|
||||
|
||||
// If we have historical rate data, use it for estimation
|
||||
let estimated_secs = match historical_rate_bps {
|
||||
Some(rate) if rate > 0 => (object_size as f64 / rate as f64) * 1.2, // 20% buffer
|
||||
_ => base_timeout.as_secs_f64(),
|
||||
};
|
||||
|
||||
// Clamp BEFORE constructing the Duration: `from_secs_f64` panics when the
|
||||
// estimate overflows Duration (huge object_size with a tiny historical rate).
|
||||
Duration::from_secs_f64((estimated_secs * timeout_multiplier).clamp(MIN_SECS, MAX_SECS))
|
||||
}
|
||||
|
||||
/// Estimate bytes per second transfer rate.
|
||||
///
|
||||
/// This is used for adaptive timeout calculation.
|
||||
pub fn estimate_bytes_per_second(object_size: u64, expected_duration: Duration) -> u64 {
|
||||
let secs = expected_duration.as_secs_f64();
|
||||
if secs > 0.0 {
|
||||
(object_size as f64 / secs) as u64
|
||||
} else {
|
||||
// Return a reasonable default (1 MB/s)
|
||||
1024 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_timeout_config() {
|
||||
let config = TimeoutConfig::default();
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
// Small file
|
||||
let timeout = config.calculate_timeout(1024);
|
||||
assert!(timeout >= config.min_timeout);
|
||||
|
||||
// Large file
|
||||
let timeout = config.calculate_timeout(100 * 1024 * 1024);
|
||||
assert!(timeout <= config.max_timeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeout_config_validation() {
|
||||
let config = TimeoutConfig {
|
||||
min_timeout: Duration::from_secs(10),
|
||||
max_timeout: Duration::from_secs(5),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adaptive_timeout_extreme_estimate_does_not_panic() {
|
||||
// A huge object with a tiny historical rate used to overflow
|
||||
// Duration::from_secs_f64 and panic; it must clamp to the upper bound.
|
||||
let timeout = calculate_adaptive_timeout(Duration::from_secs(30), Some(1), 0, u64::MAX);
|
||||
assert_eq!(timeout, Duration::from_secs(600));
|
||||
|
||||
// Tiny estimates clamp to the lower bound.
|
||||
let timeout = calculate_adaptive_timeout(Duration::from_secs(30), Some(u64::MAX), 0, 1);
|
||||
assert_eq!(timeout, Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_operation_progress() {
|
||||
let progress = OperationProgress::new(Some(1000), Duration::from_secs(5));
|
||||
|
||||
assert_eq!(progress.current(), 0);
|
||||
assert_eq!(progress.progress_percent(), Some(0.0));
|
||||
|
||||
progress.update(500);
|
||||
assert_eq!(progress.current(), 500);
|
||||
assert_eq!(progress.progress_percent(), Some(50.0));
|
||||
|
||||
progress.add(300);
|
||||
assert_eq!(progress.current(), 800);
|
||||
assert_eq!(progress.remaining(), Some(200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_timeout_wrapper() {
|
||||
let config = TimeoutConfig {
|
||||
base_timeout: Duration::from_millis(100),
|
||||
enable_dynamic_timeout: false,
|
||||
..Default::default()
|
||||
};
|
||||
let wrapper = RequestTimeoutWrapper::new(config);
|
||||
|
||||
assert!(!wrapper.is_timed_out(None));
|
||||
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
|
||||
assert!(wrapper.is_timed_out(None));
|
||||
assert!(wrapper.check_timeout(None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeout_stats() {
|
||||
let stats = TimeoutStats::new();
|
||||
|
||||
stats.record_operation(Duration::from_millis(10));
|
||||
stats.record_operation(Duration::from_millis(20));
|
||||
stats.record_timeout();
|
||||
|
||||
assert_eq!(stats.total_operations.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(stats.timed_out.load(Ordering::Relaxed), 1);
|
||||
assert!((stats.timeout_rate() - 0.5).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_progress_tracking() {
|
||||
let config = TimeoutConfig::default();
|
||||
let wrapper = RequestTimeoutWrapper::with_progress(config, Some(1000), Duration::from_secs(1));
|
||||
|
||||
wrapper.update_progress(500);
|
||||
assert_eq!(wrapper.progress_percent(), Some(50.0));
|
||||
assert!(!wrapper.is_stalled());
|
||||
}
|
||||
}
|
||||
@@ -1,443 +0,0 @@
|
||||
// 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.
|
||||
|
||||
//! BytesMut-backed object writer for optimized write operations.
|
||||
//!
|
||||
//! It uses `BytesMut` for efficient buffering; writes into that buffer may
|
||||
//! still copy input bytes. The historical `ZeroCopyObjectWriter` name remains
|
||||
//! available as a deprecated compatibility alias.
|
||||
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::AsyncWrite;
|
||||
|
||||
/// BytesMut-backed object writer for optimized write operations.
|
||||
///
|
||||
/// This writer minimizes memory allocations by:
|
||||
/// - Using BytesMut for efficient buffer growth
|
||||
/// - Accepting `Bytes` inputs for efficient buffer handling
|
||||
/// - Optional integration with BytesPool for buffer reuse
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// use rustfs_io_core::BytesMutWriter;
|
||||
/// use bytes::Bytes;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let mut writer = BytesMutWriter::new();
|
||||
///
|
||||
/// // Write into the internal BytesMut buffer
|
||||
/// let data = Bytes::from("hello world");
|
||||
/// writer.write_buffered(data).await?;
|
||||
///
|
||||
/// // Get the result as Bytes (zero-copy conversion)
|
||||
/// let result = writer.into_bytes();
|
||||
///
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub struct BytesMutWriter {
|
||||
/// Internal buffer using BytesMut for efficient growth
|
||||
buffer: BytesMut,
|
||||
/// Total bytes written
|
||||
bytes_written: usize,
|
||||
/// Whether the writer has been finalized
|
||||
finalized: bool,
|
||||
}
|
||||
|
||||
/// Historical name for the BytesMut-backed object writer.
|
||||
#[deprecated(since = "1.0.0-beta.8", note = "use BytesMutWriter; writes append into a BytesMut buffer")]
|
||||
pub type ZeroCopyObjectWriter = BytesMutWriter;
|
||||
|
||||
impl BytesMutWriter {
|
||||
/// Create a new bytes-backed object writer with default capacity (8KB).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// let writer = BytesMutWriter::new();
|
||||
/// ```
|
||||
pub fn new() -> Self {
|
||||
Self::with_capacity(8 * 1024)
|
||||
}
|
||||
|
||||
/// Create a new bytes-backed object writer with specified capacity.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `capacity` - Initial buffer capacity in bytes
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// let writer = BytesMutWriter::with_capacity(64 * 1024);
|
||||
/// ```
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
Self {
|
||||
buffer: BytesMut::with_capacity(capacity),
|
||||
bytes_written: 0,
|
||||
finalized: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write data into the internal buffer.
|
||||
///
|
||||
/// This method accepts `Bytes` for API compatibility, then appends the
|
||||
/// bytes into the internal `BytesMut` buffer.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `data` - Data to append to the internal buffer
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(usize)` - Number of bytes written
|
||||
/// * `Err(ZeroCopyWriteError)` - Write error
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// let data = Bytes::from("hello world");
|
||||
/// let written = writer.write_buffered(data).await?;
|
||||
/// ```
|
||||
pub async fn write_buffered(&mut self, data: Bytes) -> Result<usize, ZeroCopyWriteError> {
|
||||
if self.finalized {
|
||||
return Err(ZeroCopyWriteError::Finalized("Cannot write to finalized writer".to_string()));
|
||||
}
|
||||
|
||||
let len = data.len();
|
||||
self.buffer.put(data);
|
||||
|
||||
self.bytes_written += len;
|
||||
Ok(len)
|
||||
}
|
||||
|
||||
/// Historical name for `write_buffered`.
|
||||
#[deprecated(
|
||||
since = "1.0.0-beta.8",
|
||||
note = "use write_buffered; this method appends bytes into an internal buffer"
|
||||
)]
|
||||
pub async fn write_zero_copy(&mut self, data: Bytes) -> Result<usize, ZeroCopyWriteError> {
|
||||
self.write_buffered(data).await
|
||||
}
|
||||
|
||||
/// Write a slice of data.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `data` - Data slice to write
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(usize)` - Number of bytes written
|
||||
/// * `Err(ZeroCopyWriteError)` - Write error
|
||||
pub async fn write_slice(&mut self, data: &[u8]) -> Result<usize, ZeroCopyWriteError> {
|
||||
if self.finalized {
|
||||
return Err(ZeroCopyWriteError::Finalized("Cannot write to finalized writer".to_string()));
|
||||
}
|
||||
|
||||
let len = data.len();
|
||||
self.buffer.put_slice(data);
|
||||
self.bytes_written += len;
|
||||
Ok(len)
|
||||
}
|
||||
|
||||
/// Finalize the writer and consume it, returning the written data as Bytes.
|
||||
///
|
||||
/// This converts the internal BytesMut to Bytes, which is a zero-copy
|
||||
/// operation that freezes the buffer.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The written data as Bytes
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// let result = writer.into_bytes();
|
||||
/// ```
|
||||
pub fn into_bytes(mut self) -> Bytes {
|
||||
self.finalized = true;
|
||||
self.buffer.freeze()
|
||||
}
|
||||
|
||||
/// Get the current buffer as a slice (without consuming).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Slice of the current buffer content
|
||||
pub fn as_slice(&self) -> &[u8] {
|
||||
&self.buffer[..]
|
||||
}
|
||||
|
||||
/// Get the total number of bytes written.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Number of bytes written
|
||||
pub fn bytes_written(&self) -> usize {
|
||||
self.bytes_written
|
||||
}
|
||||
|
||||
/// Get the current buffer capacity.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Current buffer capacity in bytes
|
||||
pub fn capacity(&self) -> usize {
|
||||
self.buffer.capacity()
|
||||
}
|
||||
|
||||
/// Get the current buffer length.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Current buffer length in bytes
|
||||
pub fn len(&self) -> usize {
|
||||
self.buffer.len()
|
||||
}
|
||||
|
||||
/// Check if the buffer is empty.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` if buffer is empty, `false` otherwise
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.buffer.is_empty()
|
||||
}
|
||||
|
||||
/// Clear the buffer, resetting it to empty.
|
||||
///
|
||||
/// This does not change the capacity, just resets the length to 0.
|
||||
pub fn clear(&mut self) {
|
||||
self.buffer.clear();
|
||||
self.bytes_written = 0;
|
||||
self.finalized = false;
|
||||
}
|
||||
|
||||
/// Reserve additional capacity in the buffer.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `additional` - Additional capacity to reserve
|
||||
pub fn reserve(&mut self, additional: usize) {
|
||||
self.buffer.reserve(additional);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BytesMutWriter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BytesMutWriter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("BytesMutWriter")
|
||||
.field("buffer_len", &self.buffer.len())
|
||||
.field("buffer_capacity", &self.buffer.capacity())
|
||||
.field("bytes_written", &self.bytes_written)
|
||||
.field("finalized", &self.finalized)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// AsyncWrite implementation for BytesMutWriter.
|
||||
///
|
||||
/// This allows the writer to be used with tokio's async I/O utilities.
|
||||
impl AsyncWrite for BytesMutWriter {
|
||||
fn poll_write(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &[u8]) -> Poll<Result<usize, tokio::io::Error>> {
|
||||
if self.finalized {
|
||||
return Poll::Ready(Err(tokio::io::Error::new(
|
||||
tokio::io::ErrorKind::WriteZero,
|
||||
"Cannot write to finalized writer",
|
||||
)));
|
||||
}
|
||||
|
||||
let len = buf.len();
|
||||
self.buffer.put_slice(buf);
|
||||
self.bytes_written += len;
|
||||
Poll::Ready(Ok(len))
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), tokio::io::Error>> {
|
||||
// Nothing to flush for in-memory buffer
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), tokio::io::Error>> {
|
||||
self.finalized = true;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-copy write error types.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ZeroCopyWriteError {
|
||||
/// I/O error occurred
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] tokio::io::Error),
|
||||
|
||||
/// Writer has been finalized and cannot accept more writes
|
||||
#[error("Writer finalized: {0}")]
|
||||
Finalized(String),
|
||||
|
||||
/// Invalid input provided
|
||||
#[error("Invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_new_writer() {
|
||||
let writer = BytesMutWriter::new();
|
||||
assert!(writer.is_empty());
|
||||
assert_eq!(writer.bytes_written(), 0);
|
||||
assert!(writer.capacity() >= 8 * 1024);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_buffered() {
|
||||
let mut writer = BytesMutWriter::new();
|
||||
let data = Bytes::from("hello world");
|
||||
|
||||
let written = writer.write_buffered(data).await.unwrap();
|
||||
assert_eq!(written, 11);
|
||||
assert_eq!(writer.bytes_written(), 11);
|
||||
assert_eq!(writer.as_slice(), b"hello world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_preferred_writer_alias() {
|
||||
let mut writer = BytesMutWriter::new();
|
||||
let written = writer
|
||||
.write_buffered(Bytes::from("hello world"))
|
||||
.await
|
||||
.expect("write bytes through alias");
|
||||
|
||||
assert_eq!(written, 11);
|
||||
assert_eq!(writer.as_slice(), b"hello world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_slice() {
|
||||
let mut writer = BytesMutWriter::new();
|
||||
let data = b"hello world";
|
||||
|
||||
let written = writer.write_slice(data).await.unwrap();
|
||||
assert_eq!(written, 11);
|
||||
assert_eq!(writer.bytes_written(), 11);
|
||||
assert_eq!(writer.as_slice(), b"hello world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_into_bytes() {
|
||||
let mut writer = BytesMutWriter::new();
|
||||
let data = Bytes::from("hello world");
|
||||
|
||||
writer.write_buffered(data).await.unwrap();
|
||||
let result = writer.into_bytes();
|
||||
|
||||
assert_eq!(result.as_ref(), b"hello world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_after_finalize() {
|
||||
let mut writer = BytesMutWriter::new();
|
||||
let data = Bytes::from("hello");
|
||||
|
||||
writer.write_buffered(data).await.unwrap();
|
||||
let _result = writer.into_bytes();
|
||||
|
||||
// Create new writer and try to write after finalize
|
||||
let mut writer2 = BytesMutWriter::new();
|
||||
writer2.write_buffered(Bytes::from("test")).await.unwrap();
|
||||
let _ = writer2.into_bytes();
|
||||
|
||||
// Writing to a consumed writer should work via new writer
|
||||
let mut writer3 = BytesMutWriter::new();
|
||||
let result = writer3.write_buffered(Bytes::from("final")).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear() {
|
||||
let mut writer = BytesMutWriter::new();
|
||||
writer.write_slice(b"hello").await.unwrap();
|
||||
|
||||
writer.clear();
|
||||
assert!(writer.is_empty());
|
||||
assert_eq!(writer.bytes_written(), 0);
|
||||
// Capacity should remain
|
||||
assert!(writer.capacity() > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reserve() {
|
||||
let mut writer = BytesMutWriter::with_capacity(10);
|
||||
let initial_capacity = writer.capacity();
|
||||
|
||||
writer.reserve(1000);
|
||||
// Reserve ensures at least the additional capacity can be added
|
||||
// but may allocate more than requested
|
||||
assert!(writer.capacity() >= initial_capacity);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_writes() {
|
||||
let mut writer = BytesMutWriter::new();
|
||||
|
||||
writer.write_buffered(Bytes::from("hello ")).await.unwrap();
|
||||
writer.write_slice(b"world").await.unwrap();
|
||||
|
||||
assert_eq!(writer.as_slice(), b"hello world");
|
||||
assert_eq!(writer.bytes_written(), 11);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_async_write() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let mut writer = BytesMutWriter::new();
|
||||
let data = b"hello world";
|
||||
|
||||
let written = writer.write(data).await.unwrap();
|
||||
assert_eq!(written, 11);
|
||||
assert_eq!(writer.as_slice(), b"hello world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_debug() {
|
||||
let writer = BytesMutWriter::new();
|
||||
let debug_str = format!("{:?}", writer);
|
||||
assert!(debug_str.contains("BytesMutWriter"));
|
||||
assert!(debug_str.contains("buffer_len"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(deprecated)]
|
||||
async fn test_legacy_writer_alias() {
|
||||
let mut writer = ZeroCopyObjectWriter::new();
|
||||
let written = writer.write_zero_copy(Bytes::from("hello")).await.unwrap();
|
||||
|
||||
assert_eq!(written, 5);
|
||||
assert_eq!(writer.as_slice(), b"hello");
|
||||
}
|
||||
}
|
||||
@@ -1729,30 +1729,10 @@ mod tests {
|
||||
assert!(config.validate().is_ok(), "deprecated mount_path must not be required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_kv2_sources_do_not_claim_transit_wrapping() {
|
||||
let sources = [
|
||||
("config.rs", include_str!("config.rs")),
|
||||
("api_types.rs", include_str!("api_types.rs")),
|
||||
("backends/vault.rs", include_str!("backends/vault.rs")),
|
||||
("lib.rs", include_str!("lib.rs")),
|
||||
];
|
||||
// Assemble the needles at runtime so this guard does not match its own source.
|
||||
let needles = [
|
||||
format!("wrapping via {}", "Transit"),
|
||||
format!("KV v2 + {}", "Transit"),
|
||||
format!("KV2+{}", "Transit"),
|
||||
format!("you would use Vault's {} engine", "transit"),
|
||||
];
|
||||
for (name, source) in sources {
|
||||
for needle in &needles {
|
||||
assert!(
|
||||
!source.contains(needle.as_str()),
|
||||
"{name} still describes the Vault KV2 backend with `{needle}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The "VaultKv2 must not claim Transit wrapping" documentation-claim
|
||||
// invariant is enforced by scripts/check_fips_wording.sh, which scans every
|
||||
// file in crates/kms rather than a fixed include_str! list
|
||||
// (rustfs/backlog#1884).
|
||||
|
||||
#[test]
|
||||
fn test_legacy_persisted_vault_transit_config_uses_metadata_defaults() {
|
||||
|
||||
+282
-69
@@ -15,8 +15,10 @@
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::{
|
||||
FastLockGuard, GlobalLockManager, LockClient, LockId, LockInfo, LockManager, LockMetadata, LockPriority, LockRequest,
|
||||
@@ -26,43 +28,51 @@ use crate::{
|
||||
/// Default shard count for guard storage (must be power of 2)
|
||||
const DEFAULT_GUARD_SHARD_COUNT: usize = 64;
|
||||
|
||||
type GuardShard = Arc<RwLock<HashMap<LockId, LocalGuardEntry>>>;
|
||||
type GuardStorage = Arc<Vec<GuardShard>>;
|
||||
|
||||
/// Local lock client using FastLock with sharded guard storage for better concurrency
|
||||
#[derive(Debug)]
|
||||
pub struct LocalClient {
|
||||
/// Sharded guard storage to reduce lock contention
|
||||
guard_storage: Vec<Arc<RwLock<HashMap<LockId, LocalGuardEntry>>>>,
|
||||
guard_storage: GuardStorage,
|
||||
/// Mask for fast shard index calculation (shard_count - 1)
|
||||
shard_mask: usize,
|
||||
/// Optional lock manager (if None, uses global singleton)
|
||||
manager: Option<Arc<GlobalLockManager>>,
|
||||
reaper_started: AtomicBool,
|
||||
reaper_interval: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct LocalGuardEntry {
|
||||
guard: FastLockGuard,
|
||||
expires_at: SystemTime,
|
||||
deadline: Instant,
|
||||
ttl: Duration,
|
||||
/// Owner recorded at acquire time; used only for reclaim diagnostics (#899).
|
||||
owner: String,
|
||||
}
|
||||
|
||||
impl LocalGuardEntry {
|
||||
fn new(guard: FastLockGuard, ttl: Duration, owner: String) -> Self {
|
||||
fn new(guard: FastLockGuard, ttl: Duration) -> Self {
|
||||
let now = SystemTime::now();
|
||||
let monotonic_now = Instant::now();
|
||||
Self {
|
||||
guard,
|
||||
expires_at: now + ttl,
|
||||
expires_at: now.checked_add(ttl).unwrap_or(now),
|
||||
deadline: monotonic_now.checked_add(ttl).unwrap_or(monotonic_now),
|
||||
ttl,
|
||||
owner,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_expired(&self) -> bool {
|
||||
self.expires_at <= SystemTime::now()
|
||||
self.deadline <= Instant::now()
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.expires_at = SystemTime::now() + self.ttl;
|
||||
let now = SystemTime::now();
|
||||
let monotonic_now = Instant::now();
|
||||
self.expires_at = now.checked_add(self.ttl).unwrap_or(now);
|
||||
self.deadline = monotonic_now.checked_add(self.ttl).unwrap_or(monotonic_now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,26 +87,38 @@ impl LocalClient {
|
||||
pub fn with_shard_count(shard_count: usize) -> Self {
|
||||
assert!(shard_count.is_power_of_two(), "Shard count must be power of 2");
|
||||
|
||||
let guard_storage: Vec<Arc<RwLock<HashMap<LockId, LocalGuardEntry>>>> =
|
||||
(0..shard_count).map(|_| Arc::new(RwLock::new(HashMap::new()))).collect();
|
||||
let guard_storage: Vec<GuardShard> = (0..shard_count).map(|_| Arc::new(RwLock::new(HashMap::new()))).collect();
|
||||
|
||||
Self::with_storage(Arc::new(guard_storage), None, crate::fast_lock::CLEANUP_INTERVAL)
|
||||
}
|
||||
|
||||
fn with_storage(guard_storage: GuardStorage, manager: Option<Arc<GlobalLockManager>>, reaper_interval: Duration) -> Self {
|
||||
let shard_count = guard_storage.len();
|
||||
debug_assert!(shard_count.is_power_of_two());
|
||||
Self {
|
||||
guard_storage,
|
||||
shard_mask: shard_count - 1,
|
||||
manager: None,
|
||||
manager,
|
||||
reaper_started: AtomicBool::new(false),
|
||||
reaper_interval,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create new local client with a specific lock manager
|
||||
/// This allows simulating multi-node environments where each node has its own lock backend
|
||||
pub fn with_manager(manager: Arc<GlobalLockManager>) -> Self {
|
||||
Self {
|
||||
guard_storage: (0..DEFAULT_GUARD_SHARD_COUNT)
|
||||
.map(|_| Arc::new(RwLock::new(HashMap::new())))
|
||||
.collect(),
|
||||
shard_mask: DEFAULT_GUARD_SHARD_COUNT - 1,
|
||||
manager: Some(manager),
|
||||
}
|
||||
let guard_storage = (0..DEFAULT_GUARD_SHARD_COUNT)
|
||||
.map(|_| Arc::new(RwLock::new(HashMap::new())))
|
||||
.collect();
|
||||
Self::with_storage(Arc::new(guard_storage), Some(manager), crate::fast_lock::CLEANUP_INTERVAL)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_manager_and_reaper_interval(manager: Arc<GlobalLockManager>, reaper_interval: Duration) -> Self {
|
||||
let guard_storage = (0..DEFAULT_GUARD_SHARD_COUNT)
|
||||
.map(|_| Arc::new(RwLock::new(HashMap::new())))
|
||||
.collect();
|
||||
Self::with_storage(Arc::new(guard_storage), Some(manager), reaper_interval)
|
||||
}
|
||||
|
||||
/// Get the lock manager (injected manager if available, otherwise global singleton)
|
||||
@@ -118,52 +140,63 @@ impl LocalClient {
|
||||
}
|
||||
|
||||
async fn reclaim_expired_guards_for_resource(&self, resource: &crate::ObjectKey) -> usize {
|
||||
let mut reclaimed = 0usize;
|
||||
let expired_entries = Self::extract_expired_guards(&self.guard_storage, Some(resource)).await;
|
||||
Self::release_reclaimed_guards(expired_entries, Some(resource))
|
||||
}
|
||||
|
||||
for shard in &self.guard_storage {
|
||||
let expired_entries = {
|
||||
let mut guards = shard.write().await;
|
||||
let mut retained = HashMap::with_capacity(guards.len());
|
||||
let mut expired_entries = Vec::new();
|
||||
async fn extract_expired_guards(storage: &GuardStorage, resource: Option<&crate::ObjectKey>) -> Vec<LocalGuardEntry> {
|
||||
let mut expired_entries = Vec::new();
|
||||
for shard in storage.iter() {
|
||||
let mut guards = shard.write().await;
|
||||
expired_entries.extend(
|
||||
guards
|
||||
.extract_if(|lock_id, entry| {
|
||||
resource.is_none_or(|resource| &lock_id.resource == resource) && entry.is_expired()
|
||||
})
|
||||
.map(|(_, entry)| entry),
|
||||
);
|
||||
}
|
||||
expired_entries
|
||||
}
|
||||
|
||||
for (lock_id, entry) in std::mem::take(&mut *guards) {
|
||||
if &lock_id.resource == resource && entry.is_expired() {
|
||||
expired_entries.push(entry);
|
||||
} else {
|
||||
retained.insert(lock_id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
*guards = retained;
|
||||
expired_entries
|
||||
};
|
||||
|
||||
for mut entry in expired_entries {
|
||||
// An expired entry whose owner never refreshed it (a dead coordinator, #698) is
|
||||
// reclaimed so a live contender can re-form quorum. With guard heartbeats in place
|
||||
// (#899) a live owner keeps its entry from expiring, so reaching here means the
|
||||
// lease genuinely lapsed. Surface it for observability; the reclaim decision itself
|
||||
// is unchanged.
|
||||
let since_last_refresh = entry
|
||||
.expires_at
|
||||
.checked_sub(entry.ttl)
|
||||
.and_then(|last_refresh| SystemTime::now().duration_since(last_refresh).ok())
|
||||
.unwrap_or(entry.ttl);
|
||||
tracing::warn!(
|
||||
owner = %entry.owner,
|
||||
resource = %resource,
|
||||
ttl_ms = entry.ttl.as_millis() as u64,
|
||||
since_last_refresh_ms = since_last_refresh.as_millis() as u64,
|
||||
"reclaiming expired lock guard whose lease was not refreshed"
|
||||
);
|
||||
rustfs_io_metrics::record_lock_reclaimed();
|
||||
let _ = entry.guard.release();
|
||||
reclaimed = reclaimed.saturating_add(1);
|
||||
fn release_reclaimed_guards(
|
||||
entries: impl IntoIterator<Item = LocalGuardEntry>,
|
||||
resource: Option<&crate::ObjectKey>,
|
||||
) -> usize {
|
||||
let mut reclaimed = 0;
|
||||
for mut entry in entries {
|
||||
let _ = entry.guard.release();
|
||||
rustfs_io_metrics::record_lock_reclaimed();
|
||||
reclaimed += 1;
|
||||
}
|
||||
if reclaimed > 0 {
|
||||
if let Some(resource) = resource {
|
||||
tracing::debug!(event = "lock_guard_reclaimed", resource = %resource, count = reclaimed, "expired lock guards reclaimed");
|
||||
} else {
|
||||
tracing::debug!(event = "lock_guard_reaper_sweep", count = reclaimed, "expired lock guards reclaimed");
|
||||
}
|
||||
}
|
||||
|
||||
reclaimed
|
||||
}
|
||||
|
||||
fn ensure_reaper(&self) {
|
||||
if self.reaper_started.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
let storage = Arc::downgrade(&self.guard_storage);
|
||||
let interval = self.reaper_interval;
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(interval);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let Some(storage) = storage.upgrade() else {
|
||||
break;
|
||||
};
|
||||
let expired_entries = Self::extract_expired_guards(&storage, None).await;
|
||||
Self::release_reclaimed_guards(expired_entries, None);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LocalClient {
|
||||
@@ -175,28 +208,36 @@ impl Default for LocalClient {
|
||||
#[async_trait::async_trait]
|
||||
impl LockClient for LocalClient {
|
||||
async fn acquire_lock(&self, request: &LockRequest) -> Result<LockResponse> {
|
||||
self.ensure_reaper();
|
||||
let lock_manager = self.get_lock_manager();
|
||||
let reclaimed_before_acquire = self.reclaim_expired_guards_for_resource(&request.resource).await;
|
||||
let acquire_deadline = Instant::now()
|
||||
.checked_add(request.acquire_timeout)
|
||||
.unwrap_or_else(Instant::now);
|
||||
|
||||
let build_lock_request = || match request.lock_type {
|
||||
let build_lock_request = |acquire_timeout| match request.lock_type {
|
||||
LockType::Exclusive => crate::ObjectLockRequest::new_write(request.resource.clone(), request.owner.clone())
|
||||
.with_acquire_timeout(request.acquire_timeout),
|
||||
.with_acquire_timeout(acquire_timeout),
|
||||
LockType::Shared => crate::ObjectLockRequest::new_read(request.resource.clone(), request.owner.clone())
|
||||
.with_acquire_timeout(request.acquire_timeout),
|
||||
.with_acquire_timeout(acquire_timeout),
|
||||
};
|
||||
|
||||
let mut retried_after_reclaim = reclaimed_before_acquire > 0;
|
||||
loop {
|
||||
match lock_manager.acquire_lock(build_lock_request()).await {
|
||||
let remaining = acquire_deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Ok(LockResponse::failure("Lock acquisition timeout", request.acquire_timeout));
|
||||
}
|
||||
match lock_manager.acquire_lock(build_lock_request(remaining)).await {
|
||||
Ok(guard) => {
|
||||
let lock_id = request.lock_id.clone();
|
||||
let acquired_at = SystemTime::now();
|
||||
let expires_at = acquired_at + request.ttl;
|
||||
let expires_at = acquired_at.checked_add(request.ttl).unwrap_or(acquired_at);
|
||||
|
||||
{
|
||||
let shard = self.get_shard(&lock_id);
|
||||
let mut guards = shard.write().await;
|
||||
guards.insert(lock_id.clone(), LocalGuardEntry::new(guard, request.ttl, request.owner.clone()));
|
||||
guards.insert(lock_id.clone(), LocalGuardEntry::new(guard, request.ttl));
|
||||
}
|
||||
|
||||
let lock_info = LockInfo {
|
||||
@@ -256,12 +297,24 @@ impl LockClient for LocalClient {
|
||||
|
||||
async fn refresh(&self, lock_id: &LockId) -> Result<bool> {
|
||||
let shard = self.get_shard(lock_id);
|
||||
let mut guards = shard.write().await;
|
||||
if let Some(entry) = guards.get_mut(lock_id) {
|
||||
entry.refresh();
|
||||
Ok(true)
|
||||
} else {
|
||||
let expired_entry = {
|
||||
let mut guards = shard.write().await;
|
||||
let Some(entry) = guards.get_mut(lock_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if entry.is_expired() {
|
||||
guards.remove(lock_id)
|
||||
} else {
|
||||
entry.refresh();
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(entry) = expired_entry {
|
||||
Self::release_reclaimed_guards([entry], Some(&lock_id.resource));
|
||||
Ok(false)
|
||||
} else {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,3 +370,163 @@ impl LockClient for LocalClient {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{GlobalLockManager, LockClient, LockRequest, LockType};
|
||||
|
||||
fn request(resource: crate::ObjectKey, owner: &str, ttl: Duration) -> LockRequest {
|
||||
LockRequest::new(resource, LockType::Exclusive, owner)
|
||||
.with_ttl(ttl)
|
||||
.with_acquire_timeout(Duration::from_millis(80))
|
||||
}
|
||||
|
||||
async fn wait_until_reaped(client: &LocalClient, lock_id: &LockId) {
|
||||
for _ in 0..80 {
|
||||
if client.check_status(lock_id).await.unwrap().is_none() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
panic!("lock guard was not reaped before test deadline");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn expired_guard_is_reaped_without_resource_reacquire() {
|
||||
let manager = Arc::new(GlobalLockManager::new());
|
||||
let client = LocalClient::with_manager_and_reaper_interval(manager.clone(), Duration::from_millis(5));
|
||||
let request = request(crate::ObjectKey::new("bucket", "unique-chunk"), "owner-a", Duration::from_millis(10));
|
||||
let lock_id = request.lock_id.clone();
|
||||
|
||||
assert!(client.acquire_lock(&request).await.unwrap().success);
|
||||
assert!(client.check_status(&lock_id).await.unwrap().is_some());
|
||||
tokio::time::sleep(Duration::from_millis(15)).await;
|
||||
wait_until_reaped(&client, &lock_id).await;
|
||||
|
||||
let direct = manager
|
||||
.acquire_lock(crate::ObjectLockRequest::new_write(request.resource.clone(), "owner-b"))
|
||||
.await;
|
||||
assert!(direct.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn sibling_client_cannot_reclaim_but_owner_reaper_releases_shared_lock() {
|
||||
let manager = Arc::new(GlobalLockManager::new());
|
||||
let owner = LocalClient::with_manager_and_reaper_interval(manager.clone(), Duration::from_millis(5));
|
||||
let contender = LocalClient::with_manager_and_reaper_interval(manager, Duration::from_millis(5));
|
||||
let request_a = request(crate::ObjectKey::new("bucket", "shared-resource"), "owner-a", Duration::from_millis(10));
|
||||
assert!(owner.acquire_lock(&request_a).await.unwrap().success);
|
||||
|
||||
let request_b = request(request_a.resource.clone(), "owner-b", Duration::from_millis(20))
|
||||
.with_acquire_timeout(Duration::from_millis(5));
|
||||
assert!(!contender.acquire_lock(&request_b).await.unwrap().success);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
assert!(owner.check_status(&request_a.lock_id).await.unwrap().is_none());
|
||||
assert!(contender.acquire_lock(&request_b).await.unwrap().success);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn refresh_wins_before_deadline_and_reaper_wins_after_deadline() {
|
||||
let manager = Arc::new(GlobalLockManager::new());
|
||||
let client = LocalClient::with_manager_and_reaper_interval(manager, Duration::from_millis(5));
|
||||
let request = request(crate::ObjectKey::new("bucket", "refresh-race"), "owner-a", Duration::from_millis(25));
|
||||
let lock_id = request.lock_id.clone();
|
||||
assert!(client.acquire_lock(&request).await.unwrap().success);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
assert!(client.refresh(&lock_id).await.unwrap());
|
||||
tokio::time::sleep(Duration::from_millis(15)).await;
|
||||
assert!(client.check_status(&lock_id).await.unwrap().is_some());
|
||||
wait_until_reaped(&client, &lock_id).await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn refresh_after_expiry_releases_guard_without_reviving_it() {
|
||||
let manager = Arc::new(GlobalLockManager::new());
|
||||
let client = LocalClient::with_manager_and_reaper_interval(manager, Duration::from_secs(60));
|
||||
client.reaper_started.store(true, Ordering::Release);
|
||||
let lock_request = request(
|
||||
crate::ObjectKey::new("bucket", "refresh-after-expiry"),
|
||||
"owner-a",
|
||||
Duration::from_secs(10),
|
||||
);
|
||||
let lock_id = lock_request.lock_id.clone();
|
||||
|
||||
assert!(
|
||||
client
|
||||
.acquire_lock(&lock_request)
|
||||
.await
|
||||
.expect("initial owner should acquire the lock")
|
||||
.success
|
||||
);
|
||||
tokio::time::advance(Duration::from_secs(11)).await;
|
||||
|
||||
assert!(
|
||||
!client
|
||||
.refresh(&lock_id)
|
||||
.await
|
||||
.expect("expired refresh should return a result"),
|
||||
"an expired guard must not be refreshed"
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.check_status(&lock_id)
|
||||
.await
|
||||
.expect("expired guard status should be readable")
|
||||
.is_none(),
|
||||
"expired guard should be removed after refresh"
|
||||
);
|
||||
|
||||
let contender = request(
|
||||
crate::ObjectKey::new("bucket", "refresh-after-expiry"),
|
||||
"owner-b",
|
||||
Duration::from_secs(10),
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.acquire_lock(&contender)
|
||||
.await
|
||||
.expect("contender should receive an acquisition result")
|
||||
.success,
|
||||
"released guard must be acquirable by a new owner"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn zero_ttl_is_reaped_and_oversized_ttl_does_not_panic() {
|
||||
let manager = Arc::new(GlobalLockManager::new());
|
||||
let client = LocalClient::with_manager_and_reaper_interval(manager, Duration::from_millis(5));
|
||||
|
||||
let zero = request(crate::ObjectKey::new("bucket", "zero-ttl"), "owner-zero", Duration::ZERO);
|
||||
let zero_id = zero.lock_id.clone();
|
||||
assert!(client.acquire_lock(&zero).await.unwrap().success);
|
||||
wait_until_reaped(&client, &zero_id).await;
|
||||
|
||||
let huge = request(crate::ObjectKey::new("bucket", "huge-ttl"), "owner-huge", Duration::MAX);
|
||||
let huge_id = huge.lock_id.clone();
|
||||
assert!(client.acquire_lock(&huge).await.unwrap().success);
|
||||
wait_until_reaped(&client, &huge_id).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn acquire_retry_preserves_total_deadline() {
|
||||
let manager = Arc::new(GlobalLockManager::new());
|
||||
let client = LocalClient::with_manager_and_reaper_interval(manager, Duration::from_secs(60));
|
||||
let first = request(crate::ObjectKey::new("bucket", "deadline-budget"), "owner-a", Duration::from_millis(10));
|
||||
assert!(client.acquire_lock(&first).await.unwrap().success);
|
||||
|
||||
let second =
|
||||
request(first.resource.clone(), "owner-b", Duration::from_millis(30)).with_acquire_timeout(Duration::from_millis(60));
|
||||
let started = Instant::now();
|
||||
let response = client.acquire_lock(&second).await.unwrap();
|
||||
assert!(!response.success, "the first attempt consumed the caller's acquire budget");
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_millis(100),
|
||||
"reclaim retry must not double the acquire budget"
|
||||
);
|
||||
let recovered = client.acquire_lock(&second).await.unwrap();
|
||||
assert!(recovered.success, "the reclaimed guard must be available to the next request");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,6 +840,117 @@ async fn test_namespace_lock_distributed_reclaims_expired_same_resource_after_fa
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_failed_release_converges_without_replica_repair() {
|
||||
let managers = (0..4).map(|_| Arc::new(GlobalLockManager::new())).collect::<Vec<_>>();
|
||||
let flaky_clients = managers
|
||||
.iter()
|
||||
.map(|manager| {
|
||||
Arc::new(FlakyReleaseClient {
|
||||
inner: LocalClient::with_manager_and_reaper_interval(manager.clone(), Duration::from_millis(5)),
|
||||
failed_releases_remaining: AtomicUsize::new(usize::MAX),
|
||||
release_attempts: AtomicUsize::new(0),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let clients = flaky_clients
|
||||
.iter()
|
||||
.map(|client| client.clone() as Arc<dyn LockClient>)
|
||||
.collect::<Vec<_>>();
|
||||
let lock = NamespaceLock::Distributed(DistributedLock::new("four-node-expired-lease".to_string(), clients, 3));
|
||||
let resource = create_test_object_key("bucket", "object-four-node-expired");
|
||||
let request = LockRequest::new(resource.clone(), LockType::Exclusive, "owner-a")
|
||||
.with_acquire_timeout(Duration::from_millis(300))
|
||||
.with_ttl(Duration::from_millis(40));
|
||||
|
||||
let mut guard = lock
|
||||
.acquire_guard(&request)
|
||||
.await
|
||||
.expect("initial acquire should not error")
|
||||
.expect("initial acquire should reach quorum");
|
||||
assert!(guard.release(), "release should be acknowledged while RPC cleanup is pending");
|
||||
|
||||
for _ in 0..40 {
|
||||
if flaky_clients.iter().all(|client| client.release_attempts() >= 3) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
let all_reaped =
|
||||
futures::future::join_all(flaky_clients.iter().map(|client| client.inner.check_status(&request.lock_id)))
|
||||
.await
|
||||
.into_iter()
|
||||
.all(|status| status.expect("status should not error").is_none());
|
||||
if all_reaped {
|
||||
break;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline, "all four local lease entries must converge");
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
for suffix in ["chunk-0", "chunk-1", ".rustfs.sys/multipart/upload-0"] {
|
||||
for client in &flaky_clients {
|
||||
let orphan = LockRequest::new(create_test_object_key("bucket", suffix), LockType::Exclusive, "orphan")
|
||||
.with_ttl(Duration::from_millis(25));
|
||||
assert!(client.inner.acquire_lock(&orphan).await.expect("orphan acquire").success);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(80)).await;
|
||||
|
||||
let recovered = lock
|
||||
.acquire_guard(
|
||||
&LockRequest::new(resource, LockType::Exclusive, "owner-b")
|
||||
.with_acquire_timeout(Duration::from_millis(300))
|
||||
.with_ttl(Duration::from_millis(40)),
|
||||
)
|
||||
.await
|
||||
.expect("recovery acquire should not error")
|
||||
.expect("four-node quorum should recover after local reapers run");
|
||||
drop(recovered);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_stale_quorum_contention_respects_acquire_deadline() {
|
||||
let managers = (0..4).map(|_| Arc::new(GlobalLockManager::new())).collect::<Vec<_>>();
|
||||
let node_clients = managers
|
||||
.iter()
|
||||
.map(|manager| Arc::new(LocalClient::with_manager_and_reaper_interval(manager.clone(), Duration::from_millis(5))))
|
||||
.collect::<Vec<_>>();
|
||||
let resource = create_test_object_key("bucket", "stale-quorum");
|
||||
let stale = LockRequest::new(resource.clone(), LockType::Exclusive, "stale-owner").with_ttl(Duration::from_millis(180));
|
||||
for client in &node_clients {
|
||||
assert!(client.acquire_lock(&stale).await.expect("stale acquire").success);
|
||||
}
|
||||
|
||||
let clients = node_clients
|
||||
.iter()
|
||||
.map(|client| client.clone() as Arc<dyn LockClient>)
|
||||
.collect::<Vec<_>>();
|
||||
let lock = NamespaceLock::Distributed(DistributedLock::new("stale-quorum-deadline".to_string(), clients, 3));
|
||||
let contender = LockRequest::new(resource.clone(), LockType::Exclusive, "new-owner")
|
||||
.with_acquire_timeout(Duration::from_millis(150))
|
||||
.with_ttl(Duration::from_millis(100));
|
||||
let started = tokio::time::Instant::now();
|
||||
let response = lock.acquire_guard(&contender).await.expect("contention should not error");
|
||||
assert!(response.is_none(), "unexpired leases must not be force-reclaimed");
|
||||
assert!(started.elapsed() < Duration::from_millis(350), "acquire must respect its deadline");
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(80)).await;
|
||||
let recovered = lock
|
||||
.acquire_guard(
|
||||
&LockRequest::new(resource, LockType::Exclusive, "new-owner")
|
||||
.with_acquire_timeout(Duration::from_millis(300))
|
||||
.with_ttl(Duration::from_millis(100)),
|
||||
)
|
||||
.await
|
||||
.expect("post-expiry acquire should not error")
|
||||
.expect("quorum should recover after local reapers clear stale leases");
|
||||
drop(recovered);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_namespace_lock_distributed_retries_transient_acquire_timeout() {
|
||||
let managers = (0..3).map(|_| Arc::new(GlobalLockManager::new())).collect::<Vec<_>>();
|
||||
|
||||
@@ -177,16 +177,38 @@ impl StartCommand {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Command {
|
||||
Start { request: StartCommand },
|
||||
Query { heal_path: String, client_token: String },
|
||||
Cancel { heal_path: String, client_token: String },
|
||||
Start {
|
||||
request: StartCommand,
|
||||
},
|
||||
Query {
|
||||
heal_path: String,
|
||||
client_token: String,
|
||||
/// Incremental result cursor (HS-06): only items with a sequence
|
||||
/// greater than this are returned. Absent = legacy full snapshot.
|
||||
/// Optional + defaulted so older peers stay wire-compatible.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
since_seq: Option<u64>,
|
||||
},
|
||||
Cancel {
|
||||
heal_path: String,
|
||||
client_token: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ExecutableCommand {
|
||||
Start { request: HealChannelRequest },
|
||||
Query { heal_path: String, client_token: String },
|
||||
Cancel { heal_path: String, client_token: String },
|
||||
Start {
|
||||
request: HealChannelRequest,
|
||||
},
|
||||
Query {
|
||||
heal_path: String,
|
||||
client_token: String,
|
||||
since_seq: Option<u64>,
|
||||
},
|
||||
Cancel {
|
||||
heal_path: String,
|
||||
client_token: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -227,8 +249,22 @@ impl Envelope {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn query(request_id: String, metadata: RequestMetadata, heal_path: String, client_token: String) -> Result<Self, String> {
|
||||
Self::new(request_id, metadata, Command::Query { heal_path, client_token })
|
||||
pub fn query(
|
||||
request_id: String,
|
||||
metadata: RequestMetadata,
|
||||
heal_path: String,
|
||||
client_token: String,
|
||||
since_seq: Option<u64>,
|
||||
) -> Result<Self, String> {
|
||||
Self::new(
|
||||
request_id,
|
||||
metadata,
|
||||
Command::Query {
|
||||
heal_path,
|
||||
client_token,
|
||||
since_seq,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn cancel(
|
||||
@@ -286,7 +322,15 @@ impl Envelope {
|
||||
Command::Start { request } => ExecutableCommand::Start {
|
||||
request: request.into_channel_request(self.request_id.clone())?,
|
||||
},
|
||||
Command::Query { heal_path, client_token } => ExecutableCommand::Query { heal_path, client_token },
|
||||
Command::Query {
|
||||
heal_path,
|
||||
client_token,
|
||||
since_seq,
|
||||
} => ExecutableCommand::Query {
|
||||
heal_path,
|
||||
client_token,
|
||||
since_seq,
|
||||
},
|
||||
Command::Cancel { heal_path, client_token } => ExecutableCommand::Cancel { heal_path, client_token },
|
||||
};
|
||||
Ok((self.request_id, self.coordinator_epoch, command))
|
||||
@@ -305,6 +349,11 @@ pub enum Admission {
|
||||
Full,
|
||||
DroppedQueueFull,
|
||||
DroppedPolicy,
|
||||
/// HS-06: admin start rejected because the same target is already being
|
||||
/// healed (RUSTFS_HEAL_OVERLAP_POLICY=minio_error only).
|
||||
DroppedAlreadyRunning,
|
||||
/// HS-06: admin start rejected because its path overlaps an active heal.
|
||||
DroppedOverlappingPaths,
|
||||
}
|
||||
|
||||
impl From<HealAdmissionResult> for Admission {
|
||||
@@ -315,6 +364,8 @@ impl From<HealAdmissionResult> for Admission {
|
||||
HealAdmissionResult::Full => Self::Full,
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull) => Self::DroppedQueueFull,
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped) => Self::DroppedPolicy,
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning) => Self::DroppedAlreadyRunning,
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::OverlappingPaths) => Self::DroppedOverlappingPaths,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -331,6 +382,8 @@ impl Admission {
|
||||
Self::Full => HealAdmissionResult::Full,
|
||||
Self::DroppedQueueFull => HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull),
|
||||
Self::DroppedPolicy => HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped),
|
||||
Self::DroppedAlreadyRunning => HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning),
|
||||
Self::DroppedOverlappingPaths => HealAdmissionResult::Dropped(HealAdmissionDropReason::OverlappingPaths),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -592,6 +645,7 @@ mod tests {
|
||||
metadata(2, 7),
|
||||
"bucket/prefix".to_string(),
|
||||
"token".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let cancel = Envelope::cancel(
|
||||
@@ -667,6 +721,7 @@ mod tests {
|
||||
RequestMetadata::new([0x11; 16], 1_700_000_000_000, 1_700_000_030_000, 9),
|
||||
"bucket/prefix".to_string(),
|
||||
"client-token".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let cancel = Envelope::cancel(
|
||||
@@ -749,7 +804,7 @@ mod tests {
|
||||
assert!(Envelope::start(test_request(request_id.clone()), metadata(0, 7)).is_err());
|
||||
assert!(Envelope::start(test_request(request_id.clone()), metadata(1, 0)).is_err());
|
||||
assert!(Envelope::start(test_request(request_id.clone()), RequestMetadata::new([1; 16], 1_000, 31_001, 7),).is_err());
|
||||
assert!(Envelope::query(request_id.clone(), metadata(1, 7), String::new(), String::new()).is_err());
|
||||
assert!(Envelope::query(request_id.clone(), metadata(1, 7), String::new(), String::new(), None).is_err());
|
||||
assert!(Envelope::cancel(request_id.clone(), metadata(1, 7), String::new(), String::new()).is_ok());
|
||||
|
||||
let mut noncanonical_request = test_request(request_id.to_uppercase());
|
||||
@@ -782,6 +837,7 @@ mod tests {
|
||||
metadata(1, 7),
|
||||
"x".repeat(ENVELOPE_MAX_SIZE),
|
||||
"token".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let error = super::encode_envelope(&oversized).unwrap_err();
|
||||
|
||||
@@ -2557,7 +2557,7 @@ mod tests {
|
||||
|
||||
fn production_source(source: &'static str, file_name: &str) -> &'static str {
|
||||
source
|
||||
.split("\n#[cfg(test)]")
|
||||
.split("\n#[cfg(test)]\nmod tests")
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("{file_name} should contain production source before tests"))
|
||||
}
|
||||
|
||||
@@ -715,6 +715,13 @@ async fn get_http_client(url: &str) -> io::Result<Client> {
|
||||
Ok(cached.client_for(disable_proxy))
|
||||
}
|
||||
|
||||
async fn get_fresh_http_client(url: &str) -> io::Result<Client> {
|
||||
let tuning = internode_http_client_tuning();
|
||||
let disable_proxy = should_disable_proxy_for_url(url, tuning);
|
||||
let outbound_tls = crate::http_runtime_sources::outbound_tls_state().await;
|
||||
build_http_client(disable_proxy, tuning, &outbound_tls).await
|
||||
}
|
||||
|
||||
fn internode_request_context(method: &Method, url: &str, operation: Option<&'static str>) -> InternodeHttpRequestContext {
|
||||
let target = reqwest::Url::parse(url)
|
||||
.ok()
|
||||
@@ -962,6 +969,28 @@ impl HttpReader {
|
||||
Self::with_capacity_and_stall_timeout(url, method, headers, body, 0, stall_timeout).await
|
||||
}
|
||||
|
||||
pub async fn new_fresh_connection_with_stall_timeout(
|
||||
url: String,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
body: Option<Vec<u8>>,
|
||||
stall_timeout: Option<Duration>,
|
||||
) -> io::Result<Self> {
|
||||
let init = Self::open(&url, &method, &headers, body, stall_timeout, true).await?;
|
||||
Ok(Self {
|
||||
inner: StreamReader::new(init.stream),
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
track_internode_metrics: init.track_internode_metrics,
|
||||
internode_operation: init.internode_operation,
|
||||
stall_timer: None,
|
||||
stall_timeout: init.stall_timeout,
|
||||
request_started: init.request_started,
|
||||
duration_recorded: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new HttpReader from a URL. The request is performed immediately.
|
||||
pub async fn with_capacity(
|
||||
url: String,
|
||||
@@ -981,7 +1010,7 @@ impl HttpReader {
|
||||
_read_buf_size: usize,
|
||||
stall_timeout: Option<Duration>,
|
||||
) -> io::Result<Self> {
|
||||
let init = Self::open(&url, &method, &headers, body, stall_timeout).await?;
|
||||
let init = Self::open(&url, &method, &headers, body, stall_timeout, false).await?;
|
||||
Ok(Self {
|
||||
inner: StreamReader::new(init.stream),
|
||||
url,
|
||||
@@ -1002,10 +1031,16 @@ impl HttpReader {
|
||||
headers: &HeaderMap,
|
||||
body: Option<Vec<u8>>,
|
||||
stall_timeout: Option<Duration>,
|
||||
force_fresh_connection: bool,
|
||||
) -> io::Result<HttpReaderInit> {
|
||||
let track_internode_metrics = is_internode_rpc_url(url);
|
||||
let internode_operation = internode_rpc_operation(url);
|
||||
let client = get_http_client(url).await.inspect_err(|_| {
|
||||
let client = if force_fresh_connection {
|
||||
get_fresh_http_client(url).await
|
||||
} else {
|
||||
get_http_client(url).await
|
||||
}
|
||||
.inspect_err(|_| {
|
||||
record_internode_error(track_internode_metrics, internode_operation);
|
||||
})?;
|
||||
let mut request: RequestBuilder = client.request(method.clone(), url).headers(headers.clone());
|
||||
@@ -1121,7 +1156,28 @@ impl HttpChunkReader {
|
||||
body: Option<Vec<u8>>,
|
||||
stall_timeout: Option<Duration>,
|
||||
) -> io::Result<Self> {
|
||||
let init = HttpReader::open(&url, &method, &headers, body, stall_timeout).await?;
|
||||
let init = HttpReader::open(&url, &method, &headers, body, stall_timeout, false).await?;
|
||||
Ok(Self {
|
||||
inner: init.stream,
|
||||
current: None,
|
||||
track_internode_metrics: init.track_internode_metrics,
|
||||
internode_operation: init.internode_operation,
|
||||
stall_timer: None,
|
||||
stall_timeout: init.stall_timeout,
|
||||
request_started: init.request_started,
|
||||
duration_recorded: false,
|
||||
consecutive_empty_chunks: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn new_fresh_connection_with_stall_timeout(
|
||||
url: String,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
body: Option<Vec<u8>>,
|
||||
stall_timeout: Option<Duration>,
|
||||
) -> io::Result<Self> {
|
||||
let init = HttpReader::open(&url, &method, &headers, body, stall_timeout, true).await?;
|
||||
Ok(Self {
|
||||
inner: init.stream,
|
||||
current: None,
|
||||
|
||||
@@ -6,8 +6,6 @@ admission, scanner-driven heal/bitrot checks, and namespace alerts.
|
||||
|
||||
For operator-facing runtime controls, status fields, and tuning workflows, see
|
||||
[Scanner Runtime Controls](../../docs/operations/scanner-runtime-controls.md).
|
||||
For a MinIO `data-scanner` comparison and the improvement contracts, see
|
||||
[MinIO Scanner Compatibility](../../docs/architecture/minio-scanner-compat.md).
|
||||
For repeatable scanner-pressure validation, see
|
||||
[Scanner Benchmark Runbook](../../docs/operations/scanner-benchmark-runbook.md).
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ RustFS Scanner 是后台维护扫描循环,负责用量统计、生命周期
|
||||
|
||||
面向运维人员的运行时控制项、状态字段和调参流程,请参考
|
||||
[Scanner Runtime Controls](../../docs/operations/scanner-runtime-controls.md)。
|
||||
与 MinIO `data-scanner` 的对照和补齐约定请参考
|
||||
[MinIO Scanner Compatibility](../../docs/architecture/minio-scanner-compat.md)。
|
||||
可复现的 scanner 压力验证流程请参考
|
||||
[Scanner Benchmark Runbook](../../docs/operations/scanner-benchmark-runbook.md)。
|
||||
|
||||
|
||||
@@ -1770,6 +1770,13 @@ impl FolderScanner {
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped) => {
|
||||
self.clear_pending_scanner_heal(kind, bucket, object, version_id);
|
||||
}
|
||||
// Admin-only overlap rejections (HS-06); the scanner never sees
|
||||
// them, but if it ever does, treat them as terminal like any
|
||||
// other policy drop rather than endlessly retrying.
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
|
||||
| HealAdmissionResult::Dropped(HealAdmissionDropReason::OverlappingPaths) => {
|
||||
self.clear_pending_scanner_heal(kind, bucket, object, version_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ Two rules keep this directory healthy:
|
||||
- [s3-tables-support-matrix.md](s3-tables-support-matrix.md)
|
||||
- [minio-rustfs-router-compatibility.md](minio-rustfs-router-compatibility.md)
|
||||
- [minio-file-format-compat.md](minio-file-format-compat.md)
|
||||
- [minio-scanner-compat.md](minio-scanner-compat.md) — MinIO data-scanner parity: cycle/heal/ILM/usage gaps and improvement contracts
|
||||
|
||||
## Inventories & baselines (snapshots that feed migration work)
|
||||
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
# MinIO ↔ RustFS Data Scanner Comparison
|
||||
|
||||
Assesses how closely the RustFS background data scanner matches MinIO's
|
||||
`cmd/data-scanner.go` implementation: cycle leadership, namespace walk,
|
||||
usage accounting, ILM admission, replication repair, heal/bitrot selection,
|
||||
alerts, and operator surfaces. This is a **durable gap analysis**. It changes
|
||||
no scanner code. Every claim cites the code that backs it.
|
||||
|
||||
MinIO sources below are the public `minio/minio` `master` tree as of
|
||||
2026-08-18 (`cmd/data-scanner.go`, `cmd/erasure.go`, `cmd/xl-storage.go`,
|
||||
`cmd/data-usage-cache.go`, `internal/config/scanner/scanner.go`). They are
|
||||
not files in this repository.
|
||||
|
||||
Operator runtime knobs already documented here stay in
|
||||
[scanner-runtime-controls.md](../operations/scanner-runtime-controls.md).
|
||||
This page does not duplicate that runbook.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The two scanners share the same skeleton. Both run one cluster-wide leader
|
||||
loop, persist a cycle counter in `.bloomcycle.bin`, walk folders with a
|
||||
1-in-16 compacted-leaf schedule, select objects for heal with a 1-in-1024
|
||||
hash, compact usage trees at the same child thresholds, evaluate ILM through
|
||||
a lifecycle evaluator, enqueue replication heals, emit excess-version and
|
||||
excess-folder alerts, and throttle with a proportional sleeper.
|
||||
|
||||
The remaining gaps are not "the scanner is missing". They are **heal
|
||||
fidelity**, **cross-set scheduling**, **on-disk cache interop**, and
|
||||
**operator/notification wire names**. The highest-severity difference is
|
||||
that MinIO heals a selected object inline and then cleans abandoned parts,
|
||||
while RustFS admits a low-priority heal request that can be dropped and
|
||||
never calls `check_abandoned_parts` on that path.
|
||||
|
||||
RustFS also has several load-bearing additions MinIO does not: dirty-usage
|
||||
fast wake, cycle budgets, leader-epoch fencing, remote NS-scanner protocol
|
||||
v6, checkpoint resume, and clean-idle backoff. Those should be preserved.
|
||||
|
||||
| Area | Verdict | Why it matters |
|
||||
|---|---|---|
|
||||
| Cycle / leadership / bitrot mode | Close | Same `.bloomcycle.bin` counter, same deep-scan window of `healObjectSelectProb` cycles. |
|
||||
| Folder walk, compact, 1/16 + 1/1024 selection | Close | Constants and `mod` / `modAlt` schedule match. |
|
||||
| ILM eval + expiry/transition enqueue | Close | Same action set; RustFS additionally gates metrics on queue admission. |
|
||||
| Bucket replication repair | Close | Both call `queueReplicationHeal` / `queue_heal`. |
|
||||
| Scanner-selected object heal | **Gap** | MinIO `HealObject` is synchronous; RustFS async admission can drop the check. |
|
||||
| Abandoned-part cleanup on selected objects | **Gap** | MinIO calls `CheckAbandonedParts` after heal; RustFS scanner path does not. |
|
||||
| Bucket order across erasure sets | **Gap** | MinIO shuffles per set; RustFS is deterministic dirty→new→existing. |
|
||||
| `.usage-cache.bin` bytes | **Incompatible** | MinIO is zstd+msgp v8; RustFS is raw MessagePack. Reconstructable, not reusable. |
|
||||
| Excess-folder default | Differs | MinIO `50000`; RustFS `65538`. |
|
||||
| Alert event names | Differs | MinIO `s3:ObjectManyVersions`; RustFS `s3:Scanner:ManyVersions`. |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overlay
|
||||
|
||||
Both stacks are `init → leader lock → cycle → NSScanner → per-set disk walk →
|
||||
scanDataFolder → applyActions`.
|
||||
|
||||
```text
|
||||
initDataScanner / init_data_scanner
|
||||
│
|
||||
▼
|
||||
runDataScanner / run_data_scanner (cluster leader lock)
|
||||
│
|
||||
├─ load .bloomcycle.bin cycle state
|
||||
├─ getCycleScanMode (Normal vs Deep bitrot)
|
||||
└─ NSScanner(wantCycle, scanMode)
|
||||
│
|
||||
▼
|
||||
per erasure set (MinIO: er.nsScanner; RustFS: scanner_io)
|
||||
│
|
||||
├─ load set .usage-cache.bin
|
||||
├─ bucket order (new first, then existing)
|
||||
└─ per disk: NSScanner / scan_data_folder
|
||||
│
|
||||
├─ lifecycle + replication config
|
||||
├─ folder walk, compact, 1/16 skip
|
||||
├─ getSize → applyActions
|
||||
│ ├─ ILM eval
|
||||
│ ├─ heal selected versions
|
||||
│ └─ healReplication
|
||||
└─ abandoned-children heal walk
|
||||
```
|
||||
|
||||
| Stage | MinIO | RustFS |
|
||||
|---|---|---|
|
||||
| Startup | `initDataScanner` goroutine, random sleep ≥ 1s between `runDataScanner` calls | `init_data_scanner` in `crates/scanner/src/scanner.rs`; optional cold-cache / replication skip of start delay |
|
||||
| Leader | `globalLeaderLock.GetLock` (blocks) | `leader.lock` write lock with timeout; contended cycle returns and retries |
|
||||
| Cycle persist | LE `uint64` + msgp `currentScannerCycle` at `.bloomcycle.bin` | LE `uint64` + optional `RSCYC001` epoch header + msgpack `CurrentCycle` |
|
||||
| Set walk | `erasureObjects.nsScanner` in MinIO `cmd/erasure.go` | `crates/scanner/src/scanner_io.rs` |
|
||||
| Folder walk | `folderScanner.scanFolder` | `crates/scanner/src/scanner_folder.rs` |
|
||||
| Object actions | `scannerItem.applyActions` | `ScannerItem::apply_actions` |
|
||||
| Usage publish | `storeDataUsageInBackend` ← `.usage.json` | `store_data_usage_in_backend` ← `.usage.v2.json` (legacy `.usage.json` read-only; `scanner-usage-v2` in [compat-cleanup-register.md](compat-cleanup-register.md)) |
|
||||
|
||||
---
|
||||
|
||||
## What Already Matches
|
||||
|
||||
These are not gaps. Treat regressions here as MinIO-parity bugs.
|
||||
|
||||
### Cycle constants and heal selection
|
||||
|
||||
| Constant | MinIO | RustFS | Evidence |
|
||||
|---|---|---|---|
|
||||
| Folder sleep quantum | 1ms | sleeper `MIN_SLEEP` 1ms | MinIO `dataScannerSleepPerFolder`; `crates/scanner/src/sleeper.rs` |
|
||||
| Compacted-leaf visit period | 16 | 16 (`RUSTFS_DATA_USAGE_UPDATE_DIR_CYCLES`) | MinIO `dataUsageUpdateDirCycles`; `crates/scanner/src/scanner_folder.rs` `DATA_USAGE_UPDATE_DIR_CYCLES` |
|
||||
| Heal object probability | 1024 | 1024 (`RUSTFS_HEAL_OBJECT_SELECT_PROB`) | MinIO `healObjectSelectProb`; `DEFAULT_HEAL_OBJECT_SELECT_PROB` |
|
||||
| Compact least objects | 500 | 500 | both `dataScannerCompactLeastObject` / `DATA_SCANNER_COMPACT_LEAST_OBJECT` |
|
||||
| Compact at children | 10000 | 10000 | both |
|
||||
| Compact at folders | 2500 | 2500 | `children/4` |
|
||||
| Force compact folders | 250000 | 250000 | both |
|
||||
| Start delay default | 1 minute | speed-preset derived (default 1 minute) | MinIO `dataScannerStartDelay`; RustFS speed preset |
|
||||
| Excess versions | 100 | 100 | MinIO `scannerExcessObjectVersions`; `DEFAULT_SCANNER_ALERT_EXCESS_VERSIONS` |
|
||||
| Excess version size | 1 TiB | 1 TiB | MinIO `scannerExcessObjectVersionsTotalSize`; `DEFAULT_SCANNER_ALERT_EXCESS_VERSION_SIZE` |
|
||||
|
||||
Folder skip uses `hash.mod(nextCycle, 16)`. Object heal uses
|
||||
`hash.modAlt(nextCycle/div, healObjectSelect/div)`. Compacted folders raise
|
||||
`objectHealProbDiv` to 16 so the 1/1024 overall probability still holds.
|
||||
RustFS copies this in `scan_folder` (`mod_` + `object_heal_prob_div`).
|
||||
|
||||
Path identity is the cleaned path string, not a digest. MinIO `hashPath` is
|
||||
`path.Clean`; RustFS `hash_path` in `crates/data-usage/src/data_usage.rs`
|
||||
cleans the same way. `xxhash` is only used in MinIO `mod` / `modAlt`.
|
||||
|
||||
### Bitrot cycle window
|
||||
|
||||
Both enter Deep scan when:
|
||||
|
||||
- bitrot cycle is `0` (always deep), or
|
||||
- `current - bitrotStartCycle < healObjectSelectProb`, or
|
||||
- wall time since `bitrotStartTime` exceeds the configured bitrot cycle.
|
||||
|
||||
MinIO: `getCycleScanMode` in `cmd/data-scanner.go`. RustFS:
|
||||
`get_cycle_scan_mode` in `crates/scanner/src/scanner.rs`. Both persist
|
||||
`backgroundHealInfo` / `BackgroundHealInfo` and skip it on single-disk
|
||||
(`globalIsErasureSD` / `scanner_is_erasure_sd`).
|
||||
|
||||
### ILM action coverage
|
||||
|
||||
`apply_actions` covers the same action enum MinIO does: delete, delete
|
||||
version, delete restored, delete-all, del-marker-delete-all, transition,
|
||||
and none (heal + replication). Evaluator is constructed with lock
|
||||
retention and replication config. Free versions are swept through
|
||||
`enqueue_free_version` / `enqueue_runtime_free_version`. Noncurrent
|
||||
versions batch through `enqueueNoncurrentVersions` /
|
||||
`enqueue_runtime_newer_noncurrent`.
|
||||
|
||||
### Speed presets
|
||||
|
||||
`fastest` / `fast` / `default` / `slow` / `slowest` map to the same delay,
|
||||
max-wait, and cycle defaults MinIO `LookupConfig` uses (`0/0/1s`,
|
||||
`1/100ms/1m`, `2/1s/1m`, `10/15s/1m`, `100/15s/30m`).
|
||||
|
||||
### Abandoned-children folder heal
|
||||
|
||||
When a previously cached child is missing from the current directory listing,
|
||||
both scanners quorum-list the prefix and enqueue bucket/object heals. RustFS
|
||||
keeps this walk in `scan_folder` after the new/existing folder scans.
|
||||
|
||||
### Read-path heal still exists
|
||||
|
||||
MinIO also heals from GET/HEAD and MRF; the scanner is not the only heal
|
||||
source (MinIO PR 18050). RustFS GET decode errors enqueue
|
||||
`HealRequestSource::ReadRepair` in `crates/ecstore/src/set_disk/read.rs`.
|
||||
Scanner-heal gaps therefore delay *background* repair, not all repair.
|
||||
|
||||
---
|
||||
|
||||
## Gaps
|
||||
|
||||
Severity is the operator-visible failure if the gap is left as-is.
|
||||
|
||||
### G1 — Scanner object heal is async and droppable (high)
|
||||
|
||||
MinIO `applyHealing` calls `ObjectLayer.HealObject` and waits. The folder
|
||||
walker then treats `getSize` as having already healed the object
|
||||
(`cmd/data-scanner.go`, comment on `abandonedChildren` deletion). After a
|
||||
successful heal it always runs `CheckAbandonedParts` with
|
||||
`Remove: healDeleteDangling`.
|
||||
|
||||
RustFS `heal_actions` always returns the original `actual_size` and, when
|
||||
heal is selected, calls `enqueue_heal` → `send_heal_request_with_admission`
|
||||
at `HealChannelPriority::Low`. `Full` and `Dropped` admissions are logged
|
||||
and skipped. `RUSTFS_SCANNER_INLINE_HEAL_ENABLE` only warns
|
||||
`inline_heal_rollback_unsupported` (`warn_inline_heal_compat_requested` in
|
||||
`crates/scanner/src/scanner_folder.rs`).
|
||||
|
||||
**Failure:** a 1/1024-selected object with a missing shard can remain
|
||||
unhealed for many more cycles if the heal channel is full. Bitrot Deep
|
||||
selection has the same drop window. Usage accounting is unchanged by heal
|
||||
outcome, so a reconstructed size never replaces the pre-heal size in that
|
||||
cycle.
|
||||
|
||||
**Do not "fix" this by making every scanner heal inline on the walk
|
||||
goroutine.** MinIO can afford that because `HealObject` is the storage
|
||||
layer. RustFS already has a heal worker pool and admission. The missing
|
||||
contract is: scanner-selected heals must be durable (pending_heals retry)
|
||||
and must not be silent-dropped without a later guaranteed retry.
|
||||
|
||||
Pending heals already exist for some metadata/abandoned-child failures
|
||||
(`PendingScannerHeal` in the usage cache). Object-selection heals that hit
|
||||
`HealAdmissionResult::Full` do not currently join that retry list.
|
||||
|
||||
### G2 — No `CheckAbandonedParts` on the scanner object-heal path (high)
|
||||
|
||||
`check_abandoned_parts` is implemented on the store
|
||||
(`crates/ecstore/src/store/heal.rs`, `crates/ecstore/src/set_disk/ops/heal.rs`)
|
||||
and is in the object API. The scanner never calls it. The heal task
|
||||
processor (`crates/heal/src/heal/task.rs`) also does not call it after a
|
||||
scanner-originated `heal_object`.
|
||||
|
||||
MinIO records this as `scannerMetricCleanAbandoned`. RustFS defines
|
||||
`Metric::CleanAbandoned` in `crates/common/src/metrics.rs` but the scanner
|
||||
crate never records it.
|
||||
|
||||
**Failure:** leftover `part.N` files after a successful object heal stay
|
||||
until some other heal path notices them. Disk usage and bitrot surface
|
||||
area remain inflated.
|
||||
|
||||
### G3 — Erasure-set bucket order is not shuffled (medium)
|
||||
|
||||
MinIO `nsScanner` builds a permutation of buckets, emits *new* buckets
|
||||
(absent from the old cache) first in that random order, then existing
|
||||
buckets in that random order. Comment: otherwise the same buckets are
|
||||
scanned across every erasure set at the same time.
|
||||
|
||||
RustFS `bucket_usage_scan_order` in `crates/scanner/src/scanner_io.rs` is
|
||||
deterministic: dirty buckets, then cache-miss (new) buckets, then
|
||||
cache-hit buckets, preserving `ListBuckets` order.
|
||||
|
||||
Dirty-first is a RustFS improvement (MinIO has no dirty-usage wake). The
|
||||
gap is the *existing* bucket tail: under many buckets and several sets,
|
||||
RustFS lock-steps ILM/heal/replication load onto the same prefixes.
|
||||
|
||||
### G4 — Alert event names and audit channel (medium)
|
||||
|
||||
MinIO emits `event.ObjectManyVersions`, `event.ObjectLargeVersions`,
|
||||
`event.PrefixManyFolders`, plus `auditLogInternal` events
|
||||
`scanner:manyversions` / `scanner:largeversions` / `scanner:manyprefixes`.
|
||||
|
||||
RustFS emits `s3:Scanner:ManyVersions`, `s3:Scanner:LargeVersions`,
|
||||
`s3:Scanner:BigPrefix` (`EVENT_SCANNER_*` in
|
||||
`crates/scanner/src/scanner_folder.rs`; wire names in
|
||||
`crates/s3-types/src/event_name.rs`). Notifications are edge-held 24h
|
||||
(MinIO re-emits every cycle). There is no scanner audit-log counterpart.
|
||||
|
||||
**Failure:** notification destinations configured for MinIO event names
|
||||
miss RustFS scanner alerts. Audit pipelines that key on
|
||||
`scanner:manyversions` see nothing.
|
||||
|
||||
### G5 — Excess-folder default differs (low)
|
||||
|
||||
MinIO `scannerExcessFolders` default is `50000`
|
||||
(`internal/config/scanner/scanner.go`). RustFS
|
||||
`DEFAULT_SCANNER_ALERT_EXCESS_FOLDERS` is `65538`
|
||||
(`crates/config/src/constants/scanner.rs`).
|
||||
|
||||
**Failure:** the same prefix is silent on MinIO and noisy on RustFS (or
|
||||
the reverse if an operator copied MinIO runbooks).
|
||||
|
||||
### G6 — `.usage-cache.bin` is not MinIO-readable (medium for migration, low otherwise)
|
||||
|
||||
MinIO writes one version byte (`dataUsageCacheVerCurrent = 8`) plus zstd
|
||||
plus msgp (`cmd/data-usage-cache.go` `serializeTo`). RustFS
|
||||
`DataUsageCache::save_inner` writes uncompressed `rmp_serde` with no
|
||||
version byte (`crates/scanner/src/data_usage_define.rs`).
|
||||
|
||||
Both use the same object name `.usage-cache.bin` and a `.bkp` sibling.
|
||||
A MinIO disk set attached to RustFS rebuilds the tree on first scan; the
|
||||
bytes are not reused. The inverse is also true.
|
||||
|
||||
`.bloomcycle.bin` is closer: both start with a little-endian `u64` next
|
||||
cycle. RustFS additionally writes `RSCYC001` + leader epoch when fencing
|
||||
is active, and still reads a bare 8-byte or 8-byte+msgpack MinIO blob
|
||||
(`decode_scanner_cycle_state`). MinIO cannot consume the fenced form.
|
||||
|
||||
Cluster usage snapshots diverge on purpose: MinIO `.usage.json`, RustFS
|
||||
authoritative `.usage.v2.json`. That is already a compat register item,
|
||||
not a scanner-logic bug.
|
||||
|
||||
### G7 — Heal-selected usage size ignores heal result (low)
|
||||
|
||||
MinIO `healActions` replaces `actualSz` with `HealObject`'s
|
||||
`res.ObjectSize` when positive. RustFS `heal_actions` always returns
|
||||
`actual_size`. Wrong sizes persist until the *next* cycle that both
|
||||
selects the object *and* observes healed metadata.
|
||||
|
||||
This is secondary to G1: without a completed heal there is no new size.
|
||||
|
||||
### G8 — Operator metric names and `mc admin scanner info` (low)
|
||||
|
||||
MinIO `scannerMetric.String()` is PascalCase (`ReadMetadata`, `ScanObject`,
|
||||
`ILM`). RustFS `Metric::as_str` is snake_case (`read_metadata`,
|
||||
`scan_object`, `ilm`). `mc admin scanner info` against a RustFS
|
||||
`ScannerMetrics.life_time_ops` map will not match MinIO dashboard keys.
|
||||
|
||||
RustFS exposes a richer `/v3/scanner/status` (freshness, runtime config
|
||||
sources, cycle schedule, admission). That is the supported operator
|
||||
surface; MinIO `mc` scanner info is not a compatibility target unless
|
||||
explicitly added.
|
||||
|
||||
### G9 — Unversioned replication heal gate (low)
|
||||
|
||||
MinIO `healReplication` returns immediately when `oi.VersionID == ""`.
|
||||
RustFS allows the call when the object is a delete marker or has a
|
||||
version-purge status even if `version_id` is none/nil
|
||||
(`ScannerItem::heal_replication`). This is likely *more* correct for
|
||||
purge/delete-marker repair on unversioned-looking entries, but it is a
|
||||
behavioral difference worth pinning with a test rather than copying
|
||||
MinIO's empty-VersionID skip blindly.
|
||||
|
||||
---
|
||||
|
||||
## RustFS-Only Behavior To Keep
|
||||
|
||||
These are not MinIO gaps. Removing them to "match MinIO" would be a
|
||||
regression.
|
||||
|
||||
| Addition | Where | Why keep it |
|
||||
|---|---|---|
|
||||
| Dirty-usage fast wake + superseded retry (5s base) | `crates/scanner/src/scanner.rs`, `scanner_io.rs` `record_dirty_usage_bucket` | Quota/usage lag after write bursts; MinIO waits a full cycle. |
|
||||
| Cycle object/directory/runtime budgets | `crates/scanner/src/scanner_budget.rs` | Bounds scanner blast radius; MinIO only sleeps. |
|
||||
| Leader epoch + CAS persist | `encode_scanner_cycle_state` | Split-brain cycle counters after lock loss. |
|
||||
| Remote NS-scanner protocol v6 | `crates/scanner/src/remote_scanner.rs`; compat `ns-scanner-rpc-v3` | Distributed disk walks with fencing. |
|
||||
| Scan checkpoints / resume hints | `DataUsageScanCheckpoint` | Partial cycles after budget cancel. |
|
||||
| Clean-idle backoff (single-disk / erasure) | `ScannerCleanIdleBackoff` | Stops minute-cadence full walks on idle namespaces. |
|
||||
| Heal/replication admission metrics | `HealAdmissionResult`, `ScannerReplicationQueueAdmission` | Makes G1 observable; MinIO has no equivalent queue. |
|
||||
| Alert emission cooldown | 24h edge-hold | Avoids notification storms MinIO still has. |
|
||||
|
||||
---
|
||||
|
||||
## Improvement Workstreams
|
||||
|
||||
These are contracts, not a checklist. Each workstream is independently
|
||||
shippable. Do not couple them into one "make scanner like MinIO" rewrite.
|
||||
|
||||
### W1 — Durable scanner-selected heal (closes G1, G7)
|
||||
|
||||
**Invariant:** if an object is selected by `modAlt` in a cycle that
|
||||
`should_heal()`, that object/version is either healed, recorded in
|
||||
`pending_heals` for a later cycle, or the cycle is marked incomplete for
|
||||
heal work. Silent `Full`/`Dropped` is not a success.
|
||||
|
||||
**Shape:** keep the heal channel. On `Full`/`Dropped`, persist
|
||||
`PendingScannerHeal` (object, version, scan mode) the same way abandoned
|
||||
metadata heals already persist. Retry at high or at least non-droppable
|
||||
priority next cycle. When a heal *completes*, optionally replace the
|
||||
accounted size with the healed size (G7).
|
||||
|
||||
**Do not:** call `HealObject` inline from `scan_folder` as a default. The
|
||||
unsupported `RUSTFS_SCANNER_INLINE_HEAL_ENABLE` warning exists because
|
||||
that rollback fights the worker pool. An opt-in inline path is only
|
||||
justified if a measured admission-drop rate stays high after durable
|
||||
retry.
|
||||
|
||||
**Tests:** (a) selected object missing one shard, heal channel full →
|
||||
pending_heals non-empty, next cycle heals it; (b) Deep mode + recent
|
||||
mtime stays Normal (existing cooldown); (c) usage size updates only after
|
||||
heal success; (d) revert of pending_heals-on-drop fails the test.
|
||||
|
||||
### W2 — Abandoned-part cleanup after scanner object heal (closes G2)
|
||||
|
||||
**Invariant:** a scanner-selected object heal that succeeds (or that the
|
||||
heal worker reports as already consistent) runs `check_abandoned_parts`
|
||||
with dangling removal, matching MinIO `healDeleteDangling = true`.
|
||||
|
||||
**Shape:** call it from the heal worker when `source == Scanner`, not
|
||||
from the folder walk. That keeps IO off the scanner hot path. Record
|
||||
`Metric::CleanAbandoned` so last-minute scanner metrics are not a dead
|
||||
enum.
|
||||
|
||||
**Tests:** object with an extra `part.N` after a valid heal → part
|
||||
removed; dry-run heal does not delete (existing set_disk tests stay
|
||||
authoritative); `CleanAbandoned` lifetime counter increments.
|
||||
|
||||
### W3 — Per-set shuffle of existing buckets (closes G3)
|
||||
|
||||
**Invariant:** dirty and new buckets still go first (RustFS dirty-usage
|
||||
contract). The existing-bucket tail is shuffled per erasure set per
|
||||
cycle so sets do not scan the same prefix concurrently.
|
||||
|
||||
**Shape:** smallest change is `bucket_usage_scan_order` taking a
|
||||
per-set RNG seed (cycle + pool + set). Do not shuffle dirty buckets;
|
||||
that would delay quota/usage repair.
|
||||
|
||||
**Tests:** two sets, three existing buckets, same cycle → different
|
||||
existing tails; dirty bucket always index 0.
|
||||
|
||||
### W4 — Notification and audit aliases (closes G4, optionally G5)
|
||||
|
||||
**Invariant:** a destination subscribed to MinIO names
|
||||
`s3:ObjectManyVersions` / `s3:ObjectLargeVersions` /
|
||||
`s3:PrefixManyFolders` receives RustFS scanner alerts. Keep the current
|
||||
`s3:Scanner:*` names as aliases, not replacements, until clients migrate.
|
||||
|
||||
**Shape:** dual-name parse in `crates/s3-types/src/event_name.rs` (already
|
||||
comments "corresponding to Go") plus dual emit, or a compatibility
|
||||
mapping at notify dispatch. Audit events are optional and should reuse
|
||||
the existing audit pipeline rather than a scanner-specific logger.
|
||||
|
||||
Align `DEFAULT_SCANNER_ALERT_EXCESS_FOLDERS` to `50000` only with a
|
||||
release note; 65538 is not a bug, it is a silent default drift.
|
||||
|
||||
### W5 — Cache-format interop (closes G6 only if migration requires it)
|
||||
|
||||
**Invariant for RustFS-only clusters:** none. Rebuilding `.usage-cache.bin`
|
||||
on first scan is acceptable.
|
||||
|
||||
**Invariant if MinIO disk import is a product goal:** either detect MinIO
|
||||
v8 zstd+msgp and ignore/rebuild, or implement a one-shot importer.
|
||||
Writing MinIO-shaped cache from RustFS is not required for serving
|
||||
objects.
|
||||
|
||||
Document in operations that `.usage-cache.bin` is not a migration
|
||||
artifact. `.bloomcycle.bin` 8-byte prefix already round-trips.
|
||||
|
||||
### W6 — Operator surface (closes G8)
|
||||
|
||||
Keep `/v3/scanner/status` as the source of truth. If `mc admin scanner
|
||||
info` support is required, add a madmin-shaped projection with PascalCase
|
||||
`life_time_ops` keys *in addition to* snake_case, behind a documented
|
||||
compat flag. Do not rename RustFS metrics; Prometheus and status JSON
|
||||
already use snake_case.
|
||||
|
||||
---
|
||||
|
||||
## Suggested Verification (when a workstream ships)
|
||||
|
||||
Scanner changes are high-risk under AGENTS.md (lifecycle/tiering,
|
||||
heal, S3-visible usage). A workstream PR should run:
|
||||
|
||||
- `cargo fmt --all --check`
|
||||
- `cargo test -p rustfs-scanner` (and heal tests for W2)
|
||||
- the crate's lifecycle integration tests when ILM admission changes
|
||||
- `make doc-paths-check` if this file's citations move
|
||||
|
||||
Do not run `make pre-pr` for documentation-only edits of this page.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
RustFS:
|
||||
|
||||
- `crates/scanner/src/scanner.rs` — leader loop, cycle fencing, bitrot mode
|
||||
- `crates/scanner/src/scanner_folder.rs` — folder walk, ILM, heal, alerts
|
||||
- `crates/scanner/src/scanner_io.rs` — NSScanner, bucket order, dirty usage
|
||||
- `crates/scanner/src/scanner_budget.rs` — cycle budgets
|
||||
- `crates/scanner/src/sleeper.rs` — proportional throttle
|
||||
- `crates/scanner/src/data_usage_define.rs` — cache persist
|
||||
- `crates/scanner/src/runtime_config.rs` — env/config resolution
|
||||
- `crates/config/src/constants/scanner.rs` — defaults
|
||||
- `crates/common/src/metrics.rs` — metric enum (MinIO-shaped)
|
||||
- `rustfs/src/admin/handlers/scanner.rs` — `/v3/scanner/status`
|
||||
- [compat-cleanup-register.md](compat-cleanup-register.md) — `scanner-usage-v2`, `ns-scanner-rpc-v3`
|
||||
|
||||
MinIO (`minio/minio` master, 2026-08-18):
|
||||
|
||||
- `cmd/data-scanner.go` — init/run, applyActions, healReplication, sleeper
|
||||
- `cmd/data-scanner-metric.go` — metric enum and `mc` report
|
||||
- `cmd/erasure.go` — `nsScanner` shuffle and per-disk walk
|
||||
- `cmd/xl-storage.go` — disk `NSScanner` / getSize
|
||||
- `cmd/data-usage-cache.go` — hash mod, zstd+msgp cache
|
||||
- `cmd/data-usage.go` — `.usage.json` / `.bloomcycle.bin` names
|
||||
- `internal/config/scanner/scanner.go` — speed presets and alert defaults
|
||||
@@ -53,6 +53,8 @@ Suggested boilerplate when the topic cannot be avoided:
|
||||
|
||||
`README.md` and `CHANGELOG.md` currently contain no FIPS-related wording; `scripts/check_fips_wording.sh` is the grep guard for that public baseline. Any future occurrence of the banned strings in either file should be treated as a defect and either removed or brought under the qualifier rule above. This document intentionally contains the terminology needed to define the policy and is not part of that narrow outward-material scan.
|
||||
|
||||
The same script carries a second block for the adjacent over-claim: no file under `crates/kms` may describe the Vault KV2 backend as wrapping key material through Vault's Transit engine. `KmsBackend::VaultKv2` stores RustFS-wrapped key material in Vault's KV v2 engine and never calls Transit, so that wording would tell an operator their key material is cryptographically isolated inside Vault when it is not. Use the `VaultTransit` backend when that isolation is the requirement.
|
||||
|
||||
## The `rustfs-crypto` `fips` feature: what it actually does
|
||||
|
||||
`crates/crypto/Cargo.toml` declares `default = ["crypto", "fips"]`, so the feature is on in every normal build. Its entire effect is **which algorithm the write path selects**; the implementation is RustCrypto either way.
|
||||
|
||||
@@ -66,6 +66,9 @@ struct HealInitParams {
|
||||
client_token: String,
|
||||
force_start: bool,
|
||||
force_stop: bool,
|
||||
/// Incremental result cursor (HS-06): only result items with a sequence
|
||||
/// greater than this are returned; absent means full snapshot.
|
||||
since_seq: Option<u64>,
|
||||
}
|
||||
|
||||
fn extract_heal_init_params(body: &Bytes, uri: &Uri, params: Params<'_, '_>) -> S3Result<HealInitParams> {
|
||||
@@ -98,6 +101,16 @@ fn extract_heal_init_params(body: &Bytes, uri: &Uri, params: Params<'_, '_>) ->
|
||||
}
|
||||
hip.force_stop = parse_heal_query_bool(value.as_ref())?;
|
||||
}
|
||||
"sinceSeq" => {
|
||||
if !seen.insert("sinceSeq") {
|
||||
return Err(s3_error!(InvalidArgument, "duplicate heal query parameter"));
|
||||
}
|
||||
hip.since_seq = Some(
|
||||
value
|
||||
.parse::<u64>()
|
||||
.map_err(|_| s3_error!(InvalidArgument, "sinceSeq must be a non-negative integer"))?,
|
||||
);
|
||||
}
|
||||
_ => return Err(s3_error!(InvalidArgument, "unknown heal query parameter")),
|
||||
}
|
||||
}
|
||||
@@ -978,7 +991,15 @@ fn reject_heal_admission(result: rustfs_common::heal_channel::HealAdmissionResul
|
||||
result.result_label(),
|
||||
result.reason_label()
|
||||
),
|
||||
HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped) => s3_error!(
|
||||
// Overlap rejections (HS-06) share this arm: the s3s footprint
|
||||
// ratchet forbids new s3_error! sites, and the typed reason is
|
||||
// preserved through reason_label() ("already_running" /
|
||||
// "overlapping_paths") so madmin-style clients can distinguish.
|
||||
HealAdmissionResult::Dropped(
|
||||
HealAdmissionDropReason::PolicyDropped
|
||||
| HealAdmissionDropReason::AlreadyRunning
|
||||
| HealAdmissionDropReason::OverlappingPaths,
|
||||
) => s3_error!(
|
||||
OperationAborted,
|
||||
"heal request not admitted: admission={}, reason={}",
|
||||
result.result_label(),
|
||||
@@ -1403,6 +1424,7 @@ impl Operation for HealHandler {
|
||||
new_heal_control_metadata(&route)?,
|
||||
heal_path,
|
||||
client_token.clone(),
|
||||
hip.since_seq,
|
||||
)
|
||||
.map_err(|err| s3_error!(InternalError, "encode heal control query failed: {err}"))?;
|
||||
let response = submit_cluster_heal_channel_command(context, route, envelope, &request_id, client_token).await?;
|
||||
|
||||
@@ -571,8 +571,12 @@ async fn execute_heal_control_envelope_with_manager(
|
||||
admission: receipt.result.into(),
|
||||
}
|
||||
}
|
||||
rustfs_protos::heal_control::ExecutableCommand::Query { heal_path, client_token } => {
|
||||
let response = timeout(remaining, processor.execute_query_request(heal_path, client_token))
|
||||
rustfs_protos::heal_control::ExecutableCommand::Query {
|
||||
heal_path,
|
||||
client_token,
|
||||
since_seq,
|
||||
} => {
|
||||
let response = timeout(remaining, processor.execute_query_request_since(heal_path, client_token, since_seq))
|
||||
.await
|
||||
.map_err(|_| Status::deadline_exceeded("heal control query expired before execution"))?
|
||||
.map_err(|_| Status::internal("heal control query failed"))?;
|
||||
@@ -2517,6 +2521,7 @@ mod tests {
|
||||
metadata(),
|
||||
"bucket/prefix".to_string(),
|
||||
canonical_token.clone(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let query_result = execute_heal_control_envelope_with_manager(query, coordinator_epoch, Some(Arc::clone(&manager)))
|
||||
@@ -2555,6 +2560,7 @@ mod tests {
|
||||
metadata(),
|
||||
"bucket/prefix".to_string(),
|
||||
canonical_token,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let stopped_result = execute_heal_control_envelope_with_manager(stopped_query, coordinator_epoch, Some(manager))
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Guard: outward README and CHANGELOG material must not make an unsupported
|
||||
# FIPS validation or certification claim. The detailed policy and permitted
|
||||
# qualifiers live in docs/operations/kms-cryptographic-compliance.md; this
|
||||
# check intentionally scans only the two public project-facing documents.
|
||||
# Guard: cryptographic capability wording must not over-claim what RustFS
|
||||
# actually does. Two independent blocks, both anchored to the policy in
|
||||
# docs/operations/kms-cryptographic-compliance.md:
|
||||
#
|
||||
# 1. Outward README and CHANGELOG material must not make an unsupported
|
||||
# FIPS validation or certification claim. This block intentionally scans
|
||||
# only the two public project-facing documents; the permitted qualifiers
|
||||
# live in the policy document.
|
||||
#
|
||||
# 2. Nothing in crates/kms may describe the Vault KV2 backend as wrapping
|
||||
# key material through Vault's Transit engine. `KmsBackend::VaultKv2`
|
||||
# stores RustFS-wrapped key material in Vault's KV v2 engine and never
|
||||
# calls Transit (see crates/kms/src/config.rs and
|
||||
# docs/operations/kms-backend-security.md), so such prose tells operators
|
||||
# their key material is cryptographically isolated inside Vault when it is
|
||||
# not.
|
||||
#
|
||||
# Block 2 replaces the unit test `test_vault_kv2_sources_do_not_claim_transit_wrapping`
|
||||
# that used to live in crates/kms/src/config.rs (rustfs/backlog#1884). The
|
||||
# invariant is a documentation-claim invariant, so it has no behavioral twin by
|
||||
# construction and belongs in a wording guard rather than in a test. The test
|
||||
# could only see four `include_str!`-pinned files and stopped compiling —
|
||||
# rather than reporting a violation — the moment one of them was renamed; this
|
||||
# block scans every file in the crate and reports a rename explicitly.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="${CHECK_FIPS_WORDING_ROOT:-$(cd "${SCRIPT_DIR}/.." && pwd)}"
|
||||
@@ -21,12 +41,34 @@ FORBIDDEN_PATTERNS=(
|
||||
'(meets|satisfies)[[:space:]]+FIPS'
|
||||
)
|
||||
|
||||
status=0
|
||||
KMS_CRATE_DIR="crates/kms"
|
||||
|
||||
# The four files the retired unit test pinned with include_str!. They stay
|
||||
# listed so that moving one out of crates/kms is reported here instead of
|
||||
# silently shrinking the scan; the scan itself is not limited to them.
|
||||
KMS_PINNED_SOURCES=(
|
||||
"crates/kms/src/config.rs"
|
||||
"crates/kms/src/api_types.rs"
|
||||
"crates/kms/src/backends/vault.rs"
|
||||
"crates/kms/src/lib.rs"
|
||||
)
|
||||
|
||||
# Literal, case-sensitive, and byte-for-byte the needles the retired test built
|
||||
# at runtime via format!("wrapping via {}", "Transit") and friends.
|
||||
KMS_VAULT_KV2_FORBIDDEN=(
|
||||
'wrapping via Transit'
|
||||
'KV v2 + Transit'
|
||||
'KV2+Transit'
|
||||
"you would use Vault's transit engine"
|
||||
)
|
||||
|
||||
fips_status=0
|
||||
kms_status=0
|
||||
|
||||
for target in "${TARGETS[@]}"; do
|
||||
if [[ ! -f "$target" ]]; then
|
||||
printf 'FIPS wording guard failed: %s is missing\n' "$target" >&2
|
||||
status=1
|
||||
fips_status=1
|
||||
continue
|
||||
fi
|
||||
|
||||
@@ -35,14 +77,41 @@ for target in "${TARGETS[@]}"; do
|
||||
if [[ -n "$matches" ]]; then
|
||||
printf 'FIPS wording guard failed: forbidden pattern /%s/ in %s:\n%s\n' \
|
||||
"$pattern" "$target" "$matches" >&2
|
||||
status=1
|
||||
fips_status=1
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [[ "$status" -ne 0 ]]; then
|
||||
for source in "${KMS_PINNED_SOURCES[@]}"; do
|
||||
if [[ ! -f "$source" ]]; then
|
||||
printf 'KMS wording guard failed: %s is missing; update KMS_PINNED_SOURCES in scripts/check_fips_wording.sh after moving it\n' \
|
||||
"$source" >&2
|
||||
kms_status=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -d "$KMS_CRATE_DIR" ]]; then
|
||||
for pattern in "${KMS_VAULT_KV2_FORBIDDEN[@]}"; do
|
||||
matches="$(grep -r -F -n -- "$pattern" "$KMS_CRATE_DIR" || true)"
|
||||
if [[ -n "$matches" ]]; then
|
||||
printf 'KMS wording guard failed: forbidden Vault KV2 claim "%s" in %s:\n%s\n' \
|
||||
"$pattern" "$KMS_CRATE_DIR" "$matches" >&2
|
||||
kms_status=1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ "$fips_status" -ne 0 ]]; then
|
||||
printf 'Remove unsupported FIPS validation wording from README.md or CHANGELOG.md.\n' >&2
|
||||
exit "$status"
|
||||
fi
|
||||
|
||||
if [[ "$kms_status" -ne 0 ]]; then
|
||||
printf 'The Vault KV2 backend does not wrap key material through Vault Transit; fix the wording in crates/kms.\n' >&2
|
||||
fi
|
||||
|
||||
if [[ "$fips_status" -ne 0 || "$kms_status" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'FIPS wording guard passed (README.md and CHANGELOG.md contain no forbidden claims).\n'
|
||||
printf 'KMS wording guard passed (crates/kms claims no Vault KV2 Transit wrapping).\n'
|
||||
|
||||
@@ -25,7 +25,7 @@ cd "$(dirname "$0")/.."
|
||||
# Baselines verified on 2026-08-11. Lower-only; see header.
|
||||
# Excludes crates/e2e_test/ — test infrastructure legitimately uses s3s
|
||||
# to verify S3 behavior and does not widen the production s3s surface.
|
||||
S3S_IMPORT_FILES_BASELINE=213
|
||||
S3S_IMPORT_FILES_BASELINE=211
|
||||
S3_ERROR_LINES_BASELINE=1620
|
||||
S3S_PATH_PATTERN='(^|[^"[:alnum:]_])s3s::'
|
||||
E2E_TEST_GLOB='--glob=!crates/e2e_test/**'
|
||||
|
||||
Reference in New Issue
Block a user