mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-27 22:37:13 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4eba959910 | |||
| e4c2a373d5 | |||
| b33cf840b5 | |||
| 2f8d82ac50 |
@@ -7,20 +7,16 @@
|
||||
gates; mode `none` must not pay the new fsync.
|
||||
- Keep blocking filesystem/CPU work off async runtime threads, but do not split
|
||||
one small operation into many `spawn_blocking` round trips; coalesce `open`,
|
||||
`read`/`write`, `fsync`, and `close` into one blocking closure per disk. When
|
||||
coalescing steps across execution lanes, route the combined closure to the
|
||||
dedicated fsync lane whenever any step performs file or directory sync so
|
||||
stalled syncs cannot starve foreground metadata reads.
|
||||
`read`/`write`, `fsync`, and `close` into one blocking closure per disk.
|
||||
- Check for per-disk syscall amplification (`disks × parts/files`): pin a
|
||||
directory once (`openat2` on Linux) and reuse the handle across sibling reads
|
||||
or same-directory renames instead of re-walking ancestor components per file
|
||||
or re-opening the same parent directory twice.
|
||||
- When the file size is already known from `fstat` metadata, pre-size the
|
||||
destination buffer (`len + 1`) before reading to EOF so small metadata reads
|
||||
complete in a single `read()` + EOF check rather than exponential-growth
|
||||
reallocations and syscalls.
|
||||
- Avoid redundant disk re-reads or existence checks under the same exclusive
|
||||
lock when the state or raw bytes were just loaded or persisted.
|
||||
instead of re-walking ancestor components per file or re-opening identical
|
||||
`source_parent == destination_parent` directories.
|
||||
- After `fstat`/`file.metadata()`, require `Vec::with_capacity(len + 1)` before
|
||||
`read_to_end` so small metadata reads do not trigger 6–7 exponential-growth
|
||||
`read()` syscalls.
|
||||
- Avoid redundant `store.load()` or `root_missing()` round-trips under the same
|
||||
exclusive lock when the state or original bytes were just loaded or persisted.
|
||||
- Measure lock hold time across I/O and compare acquisition order for ABBA.
|
||||
- Keep cleanup, extra stat/rename, and diagnostics out of the PUT commit critical
|
||||
section when they need not be there.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-linux=eb33067d90ee66c0a97e8f3658e46c152e47f897d2fbfdc6cb5ad93cfa55414b
|
||||
sha256-darwin=eb33067d90ee66c0a97e8f3658e46c152e47f897d2fbfdc6cb5ad93cfa55414b
|
||||
sha256-linux=6190506b9e4bdb60fd392544a7bc47be708077bb4fe7a2ef689d047455e1af86
|
||||
sha256-darwin=6190506b9e4bdb60fd392544a7bc47be708077bb4fe7a2ef689d047455e1af86
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-orphan-cleanup-requires-authoritative-evidence",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_orphan_listing_preserves_inputs"
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_orphan_cleanup_requires_authoritative_evidence"
|
||||
},
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-consumer-drop-joins-real-disk-producer",
|
||||
|
||||
@@ -54,7 +54,6 @@ jobs:
|
||||
packages=()
|
||||
command -v clang >/dev/null || packages+=(clang)
|
||||
command -v llvm-objcopy >/dev/null || packages+=(llvm)
|
||||
command -v gcc >/dev/null || packages+=(gcc)
|
||||
select_bpftool >/dev/null || packages+=(linux-tools-generic)
|
||||
if (( ${#packages[@]} )); then
|
||||
sudo apt-get update
|
||||
@@ -64,12 +63,8 @@ jobs:
|
||||
selected_bpftool="$(select_bpftool)"
|
||||
"$selected_bpftool" version
|
||||
printf 'BPFTOOL=%s\n' "$selected_bpftool" >> "$GITHUB_ENV"
|
||||
- name: Compare declarations and layouts with independent references
|
||||
- name: Compare compiled declarations with bpftool
|
||||
run: |
|
||||
clang -target bpf -O2 -g -c scripts/fixtures/btf_prototypes.c -o "$RUNNER_TEMP/prototypes.o"
|
||||
llvm-objcopy --dump-section ".BTF=$RUNNER_TEMP/prototypes.btf" "$RUNNER_TEMP/prototypes.o"
|
||||
gcc -std=c11 -Wall -Wextra -Werror -DRUSTFS_BTF_LAYOUT_REFERENCE \
|
||||
scripts/fixtures/btf_prototypes.c -o "$RUNNER_TEMP/layout-reference"
|
||||
"$RUNNER_TEMP/layout-reference" > "$RUNNER_TEMP/layout-reference.json"
|
||||
python3 -B scripts/test_inspect_btf_prototypes.py --native-reference "$RUNNER_TEMP/prototypes.btf" \
|
||||
--layout-reference "$RUNNER_TEMP/layout-reference.json"
|
||||
python3 -B scripts/test_inspect_btf_prototypes.py --native-reference "$RUNNER_TEMP/prototypes.btf"
|
||||
|
||||
@@ -100,6 +100,7 @@ when restoring `/rustfs/console` for a custom OEM build.
|
||||
## S3 API environment variables
|
||||
|
||||
- `RUSTFS_API_OBJECT_MAX_VERSIONS` caps the number of retained versions for a single object. It defaults to `9223372036854775807`, matching MinIO's practical-unlimited default. Set a positive integer to enforce a lower per-object metadata bound.
|
||||
- `RUSTFS_VEEAM_SOSAPI_ENABLE` serves Veeam SOSAPI `system.xml` and `capacity.xml` for an authenticated GET or HEAD of those exact bucket-root keys. It defaults to `true`. Set it to `false` to restore the normal object path, including deletion of a real object previously stored at that key.
|
||||
- `MINIO_API_OBJECT_MAX_VERSIONS` is accepted as a compatibility alias when the canonical RustFS variable is not set.
|
||||
|
||||
## Distributed endpoint locality
|
||||
|
||||
@@ -222,6 +222,16 @@ pub const ENV_RUSTFS_TLS_PATH: &str = "RUSTFS_TLS_PATH";
|
||||
/// Environment variable for server KMS enable.
|
||||
pub const ENV_RUSTFS_KMS_ENABLE: &str = "RUSTFS_KMS_ENABLE";
|
||||
|
||||
/// Environment variable for Veeam SOSAPI virtual system objects.
|
||||
///
|
||||
/// When true (the default), authenticated GET and HEAD of the two Veeam
|
||||
/// SOSAPI keys return generated XML. When false, those keys use the normal
|
||||
/// object path so an operator can remove a real object stored there earlier.
|
||||
pub const ENV_VEEAM_SOSAPI_ENABLE: &str = "RUSTFS_VEEAM_SOSAPI_ENABLE";
|
||||
|
||||
/// Default Veeam SOSAPI enablement.
|
||||
pub const DEFAULT_VEEAM_SOSAPI_ENABLE: bool = true;
|
||||
|
||||
/// Default KMS enable for server-side encryption
|
||||
/// This is the default value for enabling KMS encryption for server-side encryption.
|
||||
/// Default value: false
|
||||
|
||||
@@ -40,6 +40,5 @@ mod scanner_heal_topology_fixture;
|
||||
mod scanner_heal_topology_test;
|
||||
mod scanner_heal_topology_tier_test;
|
||||
mod site_replication_test;
|
||||
mod table_catalog_test;
|
||||
mod upgrade_test;
|
||||
mod versioning_test;
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{DistCluster, DistLayout, TestResult, get_object_bytes, put_object, unique_bucket};
|
||||
use crate::common::{init_logging, signed_request};
|
||||
use http::{Method, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn catalog_request(
|
||||
dist: &DistCluster,
|
||||
node: usize,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
expected: StatusCode,
|
||||
) -> TestResult<Value> {
|
||||
let response = signed_request(
|
||||
method.clone(),
|
||||
&format!("{}{path}", dist.cluster.nodes[node].url),
|
||||
&dist.cluster.access_key,
|
||||
&dist.cluster.secret_key,
|
||||
body.map(|value| serde_json::to_vec(&value)).transpose()?,
|
||||
Some("application/json"),
|
||||
)
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let text = response.text().await?;
|
||||
assert_eq!(status, expected, "node {node}: {method} {path}: {text}");
|
||||
if text.is_empty() {
|
||||
Ok(Value::Null)
|
||||
} else {
|
||||
Ok(serde_json::from_str(&text)?)
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_bucket_state(dist: &DistCluster, bucket: &str, enabled: bool) -> TestResult {
|
||||
for node in 0..dist.cluster.nodes.len() {
|
||||
let response =
|
||||
catalog_request(dist, node, Method::GET, &format!("/iceberg/v1/buckets/{bucket}"), None, StatusCode::OK).await?;
|
||||
assert_eq!(response["enabled"], enabled, "node {node}: {response}");
|
||||
assert_eq!(response["table-bucket"], bucket);
|
||||
let expected = if enabled { StatusCode::OK } else { StatusCode::BAD_REQUEST };
|
||||
catalog_request(dist, node, Method::GET, &format!("/iceberg/v1/{bucket}/namespaces"), None, expected).await?;
|
||||
assert_eq!(
|
||||
get_object_bytes(&dist.client(node)?, bucket, "ordinary.txt").await?,
|
||||
b"ordinary bucket object",
|
||||
"node {node}: toggling table support changed an ordinary object"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_table_state(dist: &DistCluster, table_path: &str, created: &Value, pointer: &Value) -> TestResult {
|
||||
for node in 0..dist.cluster.nodes.len() {
|
||||
let loaded = catalog_request(dist, node, Method::GET, table_path, None, StatusCode::OK).await?;
|
||||
assert_eq!(loaded["metadata-location"], created["metadata-location"], "node {node}");
|
||||
assert_eq!(loaded["metadata"], created["metadata"], "node {node}");
|
||||
let actual_pointer =
|
||||
catalog_request(dist, node, Method::GET, &format!("{table_path}/metadata-location"), None, StatusCode::OK).await?;
|
||||
assert_eq!(&actual_pointer, pointer, "node {node}: catalog pointer changed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn table_bucket_toggles_reload_cached_metadata_on_every_peer() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start_with_env(DistLayout::FourByFour, &[("RUSTFS_TABLE_CATALOG_BACKING", "object")]).await?;
|
||||
let bucket = unique_bucket("table-peer-reload");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
put_object(&dist.client(0)?, &bucket, "ordinary.txt", b"ordinary bucket object".to_vec()).await?;
|
||||
let bucket_path = format!("/iceberg/v1/buckets/{bucket}");
|
||||
|
||||
// Warm each node's false cache before changing the marker. Do not poll:
|
||||
// successful toggles must notify peers before their response is returned.
|
||||
assert_bucket_state(&dist, &bucket, false).await?;
|
||||
for (node, method, enabled) in [(0, Method::PUT, true), (1, Method::DELETE, false), (2, Method::PUT, true)] {
|
||||
let response = catalog_request(&dist, node, method, &bucket_path, None, StatusCode::OK).await?;
|
||||
assert_eq!(response["enabled"], enabled);
|
||||
assert_bucket_state(&dist, &bucket, enabled).await?;
|
||||
}
|
||||
|
||||
let namespaces = format!("/iceberg/v1/{bucket}/namespaces");
|
||||
catalog_request(&dist, 3, Method::POST, &namespaces, Some(json!({"namespace": ["lab"]})), StatusCode::OK).await?;
|
||||
let tables = format!("{namespaces}/lab/tables");
|
||||
let created = catalog_request(
|
||||
&dist,
|
||||
0,
|
||||
Method::POST,
|
||||
&tables,
|
||||
Some(json!({
|
||||
"name": "events",
|
||||
"schema": {
|
||||
"type": "struct",
|
||||
"fields": [{"id": 1, "name": "id", "required": false, "type": "long"}]
|
||||
},
|
||||
"stage-create": false
|
||||
})),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await?;
|
||||
let uuid = created["metadata"]["table-uuid"]
|
||||
.as_str()
|
||||
.ok_or("create response omitted table UUID")?;
|
||||
assert!(!uuid::Uuid::parse_str(uuid)?.is_nil());
|
||||
assert!(
|
||||
created["metadata-location"]
|
||||
.as_str()
|
||||
.is_some_and(|location| location.starts_with(&format!("s3://{bucket}/")))
|
||||
);
|
||||
let table_path = format!("{tables}/events");
|
||||
let pointer =
|
||||
catalog_request(&dist, 3, Method::GET, &format!("{table_path}/metadata-location"), None, StatusCode::OK).await?;
|
||||
assert_eq!(pointer["metadata-location"], created["metadata-location"]);
|
||||
assert!(pointer["version-token"].as_str().is_some_and(|token| !token.is_empty()));
|
||||
assert!(pointer["generation"].as_u64().is_some());
|
||||
assert_eq!(pointer["warehouse-location"], created["metadata"]["location"]);
|
||||
assert_table_state(&dist, &table_path, &created, &pointer).await?;
|
||||
|
||||
// A rejected toggle must preserve both peer protections and catalog state.
|
||||
catalog_request(&dist, 1, Method::DELETE, &bucket_path, None, StatusCode::CONFLICT).await?;
|
||||
assert_bucket_state(&dist, &bucket, true).await?;
|
||||
assert_table_state(&dist, &table_path, &created, &pointer).await?;
|
||||
|
||||
catalog_request(&dist, 2, Method::DELETE, &table_path, None, StatusCode::NO_CONTENT).await?;
|
||||
catalog_request(&dist, 3, Method::DELETE, &format!("{namespaces}/lab"), None, StatusCode::NO_CONTENT).await?;
|
||||
// The cluster fixture owns all remaining catalog objects and data roots.
|
||||
Ok(())
|
||||
}
|
||||
@@ -600,6 +600,142 @@ mod tests {
|
||||
info!("✅ PASSED: Veeam backup workflow simulation completed successfully");
|
||||
}
|
||||
|
||||
/// Veeam B&R discovers SOSAPI by GET/HEAD of two well-known keys.
|
||||
#[tokio::test]
|
||||
async fn test_veeam_sosapi_system_and_capacity() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use crate::common::local_http_client;
|
||||
|
||||
const SYSTEM_KEY: &str = ".system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/system.xml";
|
||||
const CAPACITY_KEY: &str = ".system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c/capacity.xml";
|
||||
|
||||
init_logging();
|
||||
info!("🧪 TEST: Veeam SOSAPI system.xml and capacity.xml");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "veeam-sosapi";
|
||||
create_bucket(&client, bucket).await?;
|
||||
|
||||
let system = client.get_object().bucket(bucket).key(SYSTEM_KEY).send().await?;
|
||||
let system_etag = system.e_tag().map(str::to_string);
|
||||
let system_len = system.content_length();
|
||||
assert_eq!(system.content_type(), Some("application/xml"));
|
||||
let system_body = system.body.collect().await?.into_bytes();
|
||||
let system_xml = std::str::from_utf8(&system_body)?;
|
||||
assert!(system_xml.contains("<ProtocolVersion>\"1.0\"</ProtocolVersion>"), "{system_xml}");
|
||||
assert!(
|
||||
system_xml.contains("<ModelName>\"RustFS ") && system_xml.contains("\"</ModelName>"),
|
||||
"{system_xml}"
|
||||
);
|
||||
assert!(system_xml.contains("<CapacityInfo>true</CapacityInfo>"));
|
||||
assert!(system_xml.contains("<UploadSessions>false</UploadSessions>"));
|
||||
assert!(system_xml.contains("<IAMSTS>false</IAMSTS>"));
|
||||
assert!(!system_xml.contains("SystemRecommendations"));
|
||||
assert!(!system_xml.contains("APIEndpoints"));
|
||||
assert!(!system_xml.contains("""));
|
||||
|
||||
let head = client.head_object().bucket(bucket).key(SYSTEM_KEY).send().await?;
|
||||
assert_eq!(head.content_type(), Some("application/xml"));
|
||||
assert_eq!(head.content_length(), system_len);
|
||||
assert_eq!(head.e_tag().map(str::to_string), system_etag);
|
||||
assert!(head.last_modified().is_some());
|
||||
|
||||
let ranged = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(SYSTEM_KEY)
|
||||
.range("bytes=0-3")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(ranged.content_length(), Some(4));
|
||||
let ranged_header = ranged.content_range().unwrap_or("");
|
||||
assert!(ranged_header.starts_with("bytes 0-3/"), "{ranged_header}");
|
||||
let ranged_body = ranged.body.collect().await?.into_bytes();
|
||||
assert_eq!(&ranged_body[..], &system_body[..4]);
|
||||
|
||||
let capacity = client.get_object().bucket(bucket).key(CAPACITY_KEY).send().await?;
|
||||
assert_eq!(capacity.content_type(), Some("application/xml"));
|
||||
let capacity_body = capacity.body.collect().await?.into_bytes();
|
||||
let capacity_xml = std::str::from_utf8(&capacity_body)?;
|
||||
let capacity_value = xml_u64(capacity_xml, "Capacity")?;
|
||||
let available_value = xml_u64(capacity_xml, "Available")?;
|
||||
let used_value = xml_u64(capacity_xml, "Used")?;
|
||||
assert!(used_value <= capacity_value, "{capacity_xml}");
|
||||
assert!(available_value <= capacity_value.saturating_sub(used_value), "{capacity_xml}");
|
||||
|
||||
let put_err = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(SYSTEM_KEY)
|
||||
.body(ByteStream::from_static(b"overwrite"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("SOSAPI put must fail");
|
||||
assert_eq!(put_err.code(), Some("MethodNotAllowed"));
|
||||
let delete_err = client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(CAPACITY_KEY)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("SOSAPI delete must fail");
|
||||
assert_eq!(delete_err.code(), Some("MethodNotAllowed"));
|
||||
|
||||
let normal_key = "Veeam/Backup/job.dat";
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(normal_key)
|
||||
.body(ByteStream::from_static(b"backup-bytes"))
|
||||
.send()
|
||||
.await?;
|
||||
let normal = client.get_object().bucket(bucket).key(normal_key).send().await?;
|
||||
let normal_body = normal.body.collect().await?.into_bytes();
|
||||
assert_eq!(&normal_body[..], b"backup-bytes");
|
||||
|
||||
let anon = local_http_client()
|
||||
.get(format!("{}/{bucket}/{SYSTEM_KEY}", env.url))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(anon.status(), reqwest::StatusCode::FORBIDDEN);
|
||||
let anon_body = anon.text().await?;
|
||||
assert!(!anon_body.contains("ProtocolVersion"), "{anon_body}");
|
||||
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let scope_date = format!("{:04}{:02}{:02}", now.year(), u8::from(now.month()), now.day());
|
||||
let amz_date = format!("{scope_date}T{:02}{:02}{:02}Z", now.hour(), now.minute(), now.second());
|
||||
let bad = local_http_client()
|
||||
.get(format!("{}/{bucket}/{SYSTEM_KEY}", env.url))
|
||||
.header("x-amz-date", &amz_date)
|
||||
.header("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
|
||||
.header(
|
||||
"authorization",
|
||||
format!(
|
||||
"AWS4-HMAC-SHA256 Credential=bad/{scope_date}/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=0000000000000000000000000000000000000000000000000000000000000000"
|
||||
),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(bad.status(), reqwest::StatusCode::FORBIDDEN);
|
||||
let bad_body = bad.text().await?;
|
||||
assert!(!bad_body.contains("ProtocolVersion"), "{bad_body}");
|
||||
|
||||
info!("✅ PASSED: Veeam SOSAPI system.xml and capacity.xml");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn xml_u64(xml: &str, element: &str) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let start = format!("<{element}>");
|
||||
let end = format!("</{element}>");
|
||||
let start_idx = xml.find(&start).ok_or_else(|| format!("missing {element} in {xml}"))? + start.len();
|
||||
let end_idx = xml[start_idx..]
|
||||
.find(&end)
|
||||
.ok_or_else(|| format!("unclosed {element} in {xml}"))?
|
||||
+ start_idx;
|
||||
Ok(xml[start_idx..end_idx].parse()?)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_terraform_put_after_delete() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -12,9 +12,9 @@ Applies to `crates/ecstore/`.
|
||||
|
||||
- Be careful with allocations and locking in hot paths.
|
||||
- Keep network and disk operations async-friendly; avoid introducing unnecessary blocking.
|
||||
- Coalesce related `open`, `read`/`write`, `fsync`, and `close` filesystem steps into a single per-disk blocking closure rather than chaining multiple async or blocking dispatches, and route any closure that performs file or directory sync to the disk's dedicated fsync lane so stalled syncs cannot starve foreground metadata reads.
|
||||
- Pin a parent directory once (`openat2(BENEATH | NO_SYMLINKS)` on Linux) and reuse the pinned handle across sibling reads or same-directory renames instead of re-walking ancestor components per file.
|
||||
- When the file size is already known from `fstat` metadata, pre-size the destination buffer (`len + 1`) before reading to EOF so small metadata reads complete in a single `read()` + EOF check rather than exponential-growth reallocations and syscalls.
|
||||
- Coalesce related `open`, `read`/`write`, `fsync`, and `close` filesystem steps into a single `blocking_queue` or `run_blocking_namespace_operation` closure per disk rather than chaining multiple `tokio::fs` or `spawn_blocking` dispatches.
|
||||
- Pin a parent directory once (`openat2(BENEATH | NO_SYMLINKS)` on Linux) and reuse the pinned guard across sibling metadata reads (`read_optional_in_pinned`, `part.N.meta` loops) instead of re-walking ancestor components per file or re-opening when `source_parent == destination_parent`.
|
||||
- After `file.metadata()`, pre-size read buffers (`Vec::with_capacity(len + 1)`) before `read_to_end` to avoid `std::io::read_to_end`'s 32-byte exponential growth (`6–7` `read()` syscalls per small metadata file).
|
||||
- Benchmark-sensitive changes should include measurable rationale.
|
||||
|
||||
## Allocation Discipline in Hot Paths
|
||||
|
||||
@@ -252,7 +252,7 @@ pub mod bucket {
|
||||
pub mod quota {
|
||||
pub use crate::bucket::quota::{
|
||||
BucketQuota, QUOTA_RESERVATION_PROTOCOL_V1, QUOTA_RESERVATION_PROTOCOL_V2, QuotaCheckResult, QuotaError,
|
||||
QuotaMigrationSkipReason, QuotaOperation, QuotaProtocolMigration,
|
||||
QuotaMigrationSkipReason, QuotaOperation, QuotaProtocolMigration, QuotaType,
|
||||
};
|
||||
|
||||
pub mod checker {
|
||||
@@ -341,9 +341,9 @@ pub mod cache {
|
||||
|
||||
pub mod capacity {
|
||||
pub use crate::core::pools::{
|
||||
DecommissionUnresolvedEntry, PoolAdminDiagnostic, PoolAvailability, PoolCapacityStatus, PoolDecommissionInfo,
|
||||
PoolDiagnosticStatus, PoolMeta, PoolStatus, get_total_usable_capacity, get_total_usable_capacity_free,
|
||||
is_pool_activation_fleet_proof_error, path2_bucket_object, path2_bucket_object_with_base_path,
|
||||
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolMeta, PoolStatus, get_total_usable_capacity,
|
||||
get_total_usable_capacity_free, is_pool_activation_fleet_proof_error, path2_bucket_object,
|
||||
path2_bucket_object_with_base_path,
|
||||
};
|
||||
pub use crate::store::utils::is_reserved_or_invalid_bucket;
|
||||
}
|
||||
|
||||
@@ -3720,46 +3720,6 @@ fn is_decommission_copy_quorum_deferral(err: &Error) -> bool {
|
||||
data_movement::data_movement_stage_source_as::<DecommissionCopyQuorumFailure>(err).is_some()
|
||||
}
|
||||
|
||||
/// The storage error of a version copy that kept timing out on a namespace
|
||||
/// lock through its in-place retries, kept typed so the entry failure policy
|
||||
/// defers it.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{0}")]
|
||||
struct DecommissionCopyLockTimeout(#[source] Error);
|
||||
|
||||
fn is_decommission_copy_lock_timeout_error(err: &Error) -> bool {
|
||||
let err = data_movement::data_movement_stage_source(err).unwrap_or(err);
|
||||
matches!(err, Error::Lock(rustfs_lock::LockError::Timeout { .. }))
|
||||
}
|
||||
|
||||
/// A version copy that timed out acquiring a namespace lock leaves the source
|
||||
/// in place; the next attempt copies the version again or finds an equivalent
|
||||
/// committed target. The lock is held by another mutation of the key or, when
|
||||
/// this node restarted mid-copy, by its previous incarnation until that lock's
|
||||
/// lease expires on the peers. Either clears by waiting, so the entry is
|
||||
/// deferred instead of being counted as a permanent per-object failure.
|
||||
fn decommission_copy_lock_timeout_deferral(stage: &str, bucket: &str, object: &str, err: Error) -> Error {
|
||||
data_movement::data_movement_context_error(
|
||||
format!("decommission {stage} timed out on a namespace lock for {bucket}/{object}: {err}"),
|
||||
DecommissionCopyLockTimeout(err),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_decommission_copy_lock_timeout_deferral(err: &Error) -> bool {
|
||||
data_movement::data_movement_stage_source_as::<DecommissionCopyLockTimeout>(err).is_some()
|
||||
}
|
||||
|
||||
/// The deferral of a version copy whose in-place retries ended on `error`, if
|
||||
/// that failure belongs to the pools at that moment rather than to the object.
|
||||
fn take_decommission_copy_deferral(stage: &str, bucket: &str, object: &str, error: &mut Option<Error>) -> Option<Error> {
|
||||
if let Some(err) = error.take_if(|err| is_decommission_copy_quorum_error(err)) {
|
||||
return Some(decommission_copy_quorum_deferral(stage, bucket, object, err));
|
||||
}
|
||||
error
|
||||
.take_if(|err| is_decommission_copy_lock_timeout_error(err))
|
||||
.map(|err| decommission_copy_lock_timeout_deferral(stage, bucket, object, err))
|
||||
}
|
||||
|
||||
fn resolve_decommission_entry_reload_result(result: Result<()>, bucket: &str, object_name: &str) -> Result<()> {
|
||||
result.map_err(|err| Error::other(format!("decommission reload_pool_meta failed for {bucket}/{object_name}: {err}")))
|
||||
}
|
||||
@@ -5822,14 +5782,11 @@ enum DecommissionEntryOutcome {
|
||||
/// entry is done and must not be counted as a failure or escalated. A quorum
|
||||
/// failure of the source cleanup is deferred too: the copied source is still
|
||||
/// in place and the next attempt cleans it up again. So is a version copy that
|
||||
/// lost drive quorum or timed out on a namespace lock through its in-place
|
||||
/// retries: the source still holds the version and the next attempt copies it
|
||||
/// again. Every other failure stays terminal.
|
||||
/// lost drive quorum through its in-place retries: the source still holds the
|
||||
/// version and the next attempt copies it again. Every other failure stays
|
||||
/// terminal.
|
||||
fn resolve_decommission_entry_failure(err: Error) -> Result<DecommissionEntryOutcome> {
|
||||
if is_decommission_source_cleanup_retryable_error(&err)
|
||||
|| is_decommission_copy_quorum_deferral(&err)
|
||||
|| is_decommission_copy_lock_timeout_deferral(&err)
|
||||
{
|
||||
if is_decommission_source_cleanup_retryable_error(&err) || is_decommission_copy_quorum_deferral(&err) {
|
||||
return Ok(DecommissionEntryOutcome::Deferred(err));
|
||||
}
|
||||
match classify_decommission_capacity_failure(&err) {
|
||||
@@ -11745,61 +11702,6 @@ pub struct PoolSpaceInfo {
|
||||
pub used: usize,
|
||||
}
|
||||
|
||||
/// Read-only observations used by the admin pool diagnostic surface. The HTTP
|
||||
/// layer converts these stable values to strings without changing the pool
|
||||
/// lifecycle status contract.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PoolAvailability {
|
||||
Online,
|
||||
Degraded,
|
||||
Offline,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl PoolAvailability {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Online => "online",
|
||||
Self::Degraded => "degraded",
|
||||
Self::Offline => "offline",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PoolCapacityStatus {
|
||||
Live,
|
||||
Cached,
|
||||
Partial,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl PoolCapacityStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Live => "live",
|
||||
Self::Cached => "cached",
|
||||
Self::Partial => "partial",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PoolDiagnosticStatus {
|
||||
pub availability: PoolAvailability,
|
||||
pub capacity_status: PoolCapacityStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PoolAdminDiagnostic {
|
||||
pub id: usize,
|
||||
pub cmd_line: String,
|
||||
pub pool: Option<PoolStatus>,
|
||||
pub diagnostic: PoolDiagnosticStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct DecommissionPoolCapacityInfo {
|
||||
pool_index: usize,
|
||||
@@ -11808,7 +11710,6 @@ pub(crate) struct DecommissionPoolCapacityInfo {
|
||||
physical_free: usize,
|
||||
physical_total: usize,
|
||||
physical_used: usize,
|
||||
diagnostic: PoolDiagnosticStatus,
|
||||
}
|
||||
|
||||
impl DecommissionPoolCapacityInfo {
|
||||
@@ -11831,10 +11732,6 @@ impl DecommissionPoolCapacityInfo {
|
||||
physical_free,
|
||||
physical_total,
|
||||
physical_used,
|
||||
diagnostic: PoolDiagnosticStatus {
|
||||
availability: PoolAvailability::Unknown,
|
||||
capacity_status: PoolCapacityStatus::Unknown,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11856,121 +11753,6 @@ where
|
||||
Ok(capacities)
|
||||
}
|
||||
|
||||
fn pool_diagnostic_status(
|
||||
disks: &[rustfs_madmin::Disk],
|
||||
pool_index: usize,
|
||||
set_count: usize,
|
||||
set_drive_count: usize,
|
||||
read_quorum: usize,
|
||||
) -> PoolDiagnosticStatus {
|
||||
if set_count == 0 || set_drive_count == 0 || read_quorum == 0 || read_quorum > set_drive_count {
|
||||
return PoolDiagnosticStatus {
|
||||
availability: PoolAvailability::Unknown,
|
||||
capacity_status: PoolCapacityStatus::Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
let mut live_capacity = true;
|
||||
let mut cached_capacity = false;
|
||||
let mut partial_capacity = false;
|
||||
let mut online_by_set = vec![0usize; set_count];
|
||||
let mut observed_disks = 0usize;
|
||||
let mut seen_slots = HashSet::new();
|
||||
|
||||
for disk in disks.iter().filter(|disk| disk.pool_index == pool_index as i32) {
|
||||
observed_disks = observed_disks.saturating_add(1);
|
||||
match disk.capacity_observation_source.as_deref() {
|
||||
Some("live_probe") => {}
|
||||
Some("snapshot") => {
|
||||
live_capacity = false;
|
||||
cached_capacity = true;
|
||||
}
|
||||
Some("missing") | None => {
|
||||
live_capacity = false;
|
||||
partial_capacity = true;
|
||||
}
|
||||
Some(_) => {
|
||||
live_capacity = false;
|
||||
partial_capacity = true;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(set_index) = usize::try_from(disk.set_index).ok() else {
|
||||
continue;
|
||||
};
|
||||
let Some(disk_index) = usize::try_from(disk.disk_index).ok() else {
|
||||
continue;
|
||||
};
|
||||
if set_index >= set_count
|
||||
|| disk_index >= set_drive_count
|
||||
|| !seen_slots.insert((set_index, disk_index))
|
||||
|| !disk_is_available_for_pool_diagnostic(disk)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
online_by_set[set_index] = online_by_set[set_index].saturating_add(1);
|
||||
}
|
||||
|
||||
let availability = if online_by_set.iter().any(|&online| online < read_quorum) {
|
||||
PoolAvailability::Offline
|
||||
} else if online_by_set.iter().all(|&online| online == set_drive_count) {
|
||||
PoolAvailability::Online
|
||||
} else {
|
||||
PoolAvailability::Degraded
|
||||
};
|
||||
let capacity_status = if observed_disks == 0 {
|
||||
PoolCapacityStatus::Unknown
|
||||
} else if partial_capacity {
|
||||
PoolCapacityStatus::Partial
|
||||
} else if cached_capacity {
|
||||
PoolCapacityStatus::Cached
|
||||
} else if live_capacity {
|
||||
PoolCapacityStatus::Live
|
||||
} else {
|
||||
PoolCapacityStatus::Unknown
|
||||
};
|
||||
|
||||
PoolDiagnosticStatus {
|
||||
availability,
|
||||
capacity_status,
|
||||
}
|
||||
}
|
||||
|
||||
fn disk_is_available_for_pool_diagnostic(disk: &rustfs_madmin::Disk) -> bool {
|
||||
let state_ok = disk.state.eq_ignore_ascii_case("ok") || disk.state.eq_ignore_ascii_case(rustfs_madmin::ITEM_ONLINE);
|
||||
let runtime_ok = disk
|
||||
.runtime_state
|
||||
.as_deref()
|
||||
.map(|state| state.eq_ignore_ascii_case(rustfs_madmin::ITEM_ONLINE))
|
||||
.unwrap_or(state_ok);
|
||||
state_ok && runtime_ok
|
||||
}
|
||||
|
||||
fn pool_diagnostic_metadata_status(err: &Error) -> &'static str {
|
||||
if let Some(failure) = err.pool_metadata_failure() {
|
||||
return match failure.kind {
|
||||
crate::error::PoolMetadataFailure::ReadUnavailable => "stale",
|
||||
crate::error::PoolMetadataFailure::RecoveryRequired
|
||||
| crate::error::PoolMetadataFailure::TransactionUnknown
|
||||
| crate::error::PoolMetadataFailure::FenceLost => "recovery_required",
|
||||
};
|
||||
}
|
||||
|
||||
match err {
|
||||
Error::Timeout
|
||||
| Error::Lock(_)
|
||||
| Error::DiskNotFound
|
||||
| Error::FaultyDisk
|
||||
| Error::FaultyRemoteDisk
|
||||
| Error::RemoteClientUnavailable(_)
|
||||
| Error::RemoteNotInitialized
|
||||
| Error::ErasureReadQuorum
|
||||
| Error::InsufficientReadQuorum(_, _)
|
||||
| Error::NamespaceLockQuorumUnavailable { .. } => "stale",
|
||||
_ => "recovery_required",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static DECOMMISSION_START_GATE_WAIT_OVERRIDES: std::sync::OnceLock<std::sync::Mutex<HashMap<uuid::Uuid, std::time::Duration>>> =
|
||||
std::sync::OnceLock::new();
|
||||
@@ -15343,70 +15125,6 @@ impl ECStore {
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Admin-only diagnostic snapshot. A transient metadata read failure is
|
||||
/// reported as stale without publishing the fallback or relaxing any
|
||||
/// mutation/recovery path.
|
||||
pub async fn admin_pool_diagnostic_snapshot(
|
||||
&self,
|
||||
target_pool_index: Option<usize>,
|
||||
) -> (Vec<PoolAdminDiagnostic>, &'static str) {
|
||||
let fallback = self.pool_meta.read().await.clone();
|
||||
let (pool_meta, mut metadata_status) = match self.pool_status_meta_snapshot().await {
|
||||
Ok(pool_meta) => (pool_meta, "fresh"),
|
||||
Err(err) => (fallback, pool_diagnostic_metadata_status(&err)),
|
||||
};
|
||||
if metadata_status == "stale" && pool_meta.pools.len() != self.pools.len() {
|
||||
metadata_status = "unknown";
|
||||
}
|
||||
|
||||
let backend = rustfs_madmin::StorageInfo {
|
||||
backend: StorageAdminApi::backend_info(self).await,
|
||||
..Default::default()
|
||||
};
|
||||
let indices = target_pool_index.map_or_else(|| (0..self.pools.len()).collect(), |idx| vec![idx]);
|
||||
let backend = &backend;
|
||||
let samples = futures::future::join_all(indices.into_iter().map(|idx| async move {
|
||||
(
|
||||
idx,
|
||||
self.get_decommission_pool_capacity_info_with_backend(idx, false, backend)
|
||||
.await,
|
||||
)
|
||||
}))
|
||||
.await;
|
||||
|
||||
let mut diagnostics = Vec::with_capacity(samples.len());
|
||||
for (idx, sample) in samples {
|
||||
let Some(sets) = self.pools.get(idx) else {
|
||||
continue;
|
||||
};
|
||||
let (pool, diagnostic) = match sample {
|
||||
Ok(capacity) => (
|
||||
pool_meta
|
||||
.pools
|
||||
.get(idx)
|
||||
.cloned()
|
||||
.map(|pool| apply_decommission_status_space_info(pool, capacity.space)),
|
||||
capacity.diagnostic,
|
||||
),
|
||||
Err(_) => (
|
||||
pool_meta.pools.get(idx).cloned(),
|
||||
PoolDiagnosticStatus {
|
||||
availability: PoolAvailability::Unknown,
|
||||
capacity_status: PoolCapacityStatus::Unknown,
|
||||
},
|
||||
),
|
||||
};
|
||||
diagnostics.push(PoolAdminDiagnostic {
|
||||
id: idx,
|
||||
cmd_line: sets.endpoints.cmd_line.clone(),
|
||||
pool,
|
||||
diagnostic,
|
||||
});
|
||||
}
|
||||
|
||||
(diagnostics, metadata_status)
|
||||
}
|
||||
|
||||
/// Load and publish under the movement writer, so no movement read sees
|
||||
/// the transition mid-operation and no transition interleaves the load.
|
||||
async fn publish_pool_status_refresh(&self, _movement_guard: tokio::sync::OwnedRwLockWriteGuard<()>) -> Result<()> {
|
||||
@@ -15483,7 +15201,6 @@ impl ECStore {
|
||||
};
|
||||
let (physical_total, physical_free, physical_used) =
|
||||
decommission_physical_pool_capacity(&info.disks, idx, layout, space, owned_physical_used.as_ref());
|
||||
let diagnostic = pool_diagnostic_status(&info.disks, idx, sets.set_count, sets.set_drive_count, layout.data);
|
||||
|
||||
Ok(DecommissionPoolCapacityInfo {
|
||||
pool_index: idx,
|
||||
@@ -15492,7 +15209,6 @@ impl ECStore {
|
||||
physical_free,
|
||||
physical_total,
|
||||
physical_used,
|
||||
diagnostic,
|
||||
})
|
||||
} else {
|
||||
Err(invalid_decommission_pool_index_error(self.pools.len(), idx))
|
||||
@@ -18434,11 +18150,13 @@ impl ECStore {
|
||||
}
|
||||
}
|
||||
|
||||
if failure
|
||||
&& let Some(err) =
|
||||
take_decommission_copy_deferral("delete_marker_copy", bucket.as_str(), version.name.as_str(), &mut error)
|
||||
{
|
||||
return Err(err);
|
||||
if failure && let Some(err) = error.take_if(|err| is_decommission_copy_quorum_error(err)) {
|
||||
return Err(decommission_copy_quorum_deferral(
|
||||
"delete_marker_copy",
|
||||
bucket.as_str(),
|
||||
version.name.as_str(),
|
||||
err,
|
||||
));
|
||||
}
|
||||
|
||||
if ignore {
|
||||
@@ -18824,14 +18542,14 @@ impl ECStore {
|
||||
|
||||
if failure
|
||||
&& !source_read_failed
|
||||
&& let Some(err) = take_decommission_copy_deferral(
|
||||
&& let Some(err) = error.take_if(|err| is_decommission_copy_quorum_error(err))
|
||||
{
|
||||
return Err(decommission_copy_quorum_deferral(
|
||||
DECOMMISSION_STAGE_MIGRATE_OBJECT,
|
||||
bucket.as_str(),
|
||||
version.name.as_str(),
|
||||
&mut error,
|
||||
)
|
||||
{
|
||||
return Err(err);
|
||||
err,
|
||||
));
|
||||
}
|
||||
|
||||
if ignore {
|
||||
@@ -22085,85 +21803,6 @@ mod tests {
|
||||
use crate::storage_api_contracts::multipart::MultipartOperations as _;
|
||||
use serde::Serialize;
|
||||
|
||||
fn diagnostic_disk(set_index: i32, disk_index: i32, online: bool) -> rustfs_madmin::Disk {
|
||||
rustfs_madmin::Disk {
|
||||
pool_index: 0,
|
||||
set_index,
|
||||
disk_index,
|
||||
state: if online { "ok" } else { "offline" }.to_string(),
|
||||
runtime_state: Some(if online { "online" } else { "offline" }.to_string()),
|
||||
capacity_observation_source: Some("live_probe".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_diagnostic_status_tracks_read_quorum_and_capacity_source() {
|
||||
let online = vec![
|
||||
diagnostic_disk(0, 0, true),
|
||||
diagnostic_disk(0, 1, true),
|
||||
diagnostic_disk(1, 0, true),
|
||||
diagnostic_disk(1, 1, true),
|
||||
];
|
||||
assert_eq!(
|
||||
pool_diagnostic_status(&online, 0, 2, 2, 1),
|
||||
PoolDiagnosticStatus {
|
||||
availability: PoolAvailability::Online,
|
||||
capacity_status: PoolCapacityStatus::Live,
|
||||
}
|
||||
);
|
||||
|
||||
let mut degraded = online.clone();
|
||||
degraded[0] = diagnostic_disk(0, 0, false);
|
||||
degraded[1].capacity_observation_source = Some("snapshot".to_string());
|
||||
assert_eq!(
|
||||
pool_diagnostic_status(°raded, 0, 2, 2, 1),
|
||||
PoolDiagnosticStatus {
|
||||
availability: PoolAvailability::Degraded,
|
||||
capacity_status: PoolCapacityStatus::Cached,
|
||||
}
|
||||
);
|
||||
|
||||
degraded[1] = diagnostic_disk(0, 1, false);
|
||||
assert_eq!(pool_diagnostic_status(°raded, 0, 2, 2, 1).availability, PoolAvailability::Offline);
|
||||
assert_eq!(
|
||||
pool_diagnostic_status(&[], 0, 2, 2, 1),
|
||||
PoolDiagnosticStatus {
|
||||
availability: PoolAvailability::Offline,
|
||||
capacity_status: PoolCapacityStatus::Unknown,
|
||||
}
|
||||
);
|
||||
assert_eq!(pool_diagnostic_status(&online, 0, 0, 2, 1).availability, PoolAvailability::Unknown);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn admin_pool_diagnostic_snapshot_lists_an_unavailable_nonfirst_pool() {
|
||||
let (_temp_dirs, store, _other_store) = Box::pin(crate::services::rebalance::test_two_pool_stores(None)).await;
|
||||
for set in &store.pools[1].disk_set {
|
||||
let mut disks = set.disks.write().await;
|
||||
let disk_count = disks.len();
|
||||
*disks = vec![None; disk_count];
|
||||
}
|
||||
|
||||
let (snapshot, metadata_status) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), store.admin_pool_diagnostic_snapshot(None))
|
||||
.await
|
||||
.expect("pool diagnostics should remain bounded");
|
||||
|
||||
assert_eq!(metadata_status, "stale");
|
||||
assert_eq!(snapshot.len(), 2, "configured topology must remain visible");
|
||||
assert_eq!(snapshot[0].id, 0);
|
||||
assert_eq!(snapshot[1].id, 1);
|
||||
assert_eq!(snapshot[1].diagnostic.availability, PoolAvailability::Offline);
|
||||
store
|
||||
.pool_meta_save_gate
|
||||
.lock()
|
||||
.await
|
||||
.ensure_write_safe("ordinary object write")
|
||||
.expect("a failed diagnostic read must not latch the writer gate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decommission_set_local_usage_cache_classification_is_exact() {
|
||||
for object in [
|
||||
@@ -28300,167 +27939,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// One version commits, then every in-place attempt to copy the next one
|
||||
/// times out on the target namespace lock. The entry is deferred, not
|
||||
/// failed, with both source versions retained; the resumed entry finds the
|
||||
/// committed version and moves both with their exact bytes.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn decommission_defers_target_lookup_lock_timeout_and_retries_without_losing_source() {
|
||||
use tokio::io::AsyncReadExt as _;
|
||||
|
||||
let fixture = NullVersionDecommission::new(NullVersionTop::NewerVersion).await;
|
||||
let object = fixture.object;
|
||||
let injected = Arc::new(AtomicUsize::new(0));
|
||||
let attempted = Arc::new(AtomicUsize::new(0));
|
||||
let fault = DecommissionTestFaultGuard::install_with_error(
|
||||
Arc::new({
|
||||
let injected = Arc::clone(&injected);
|
||||
let attempted = Arc::clone(&attempted);
|
||||
move |stage, _, faulted, _, success| {
|
||||
let target = stage == "before_target_commit" && faulted == object && success;
|
||||
let should_inject = target && attempted.fetch_add(1, Ordering::SeqCst) > 0;
|
||||
if should_inject {
|
||||
injected.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
should_inject
|
||||
}
|
||||
}),
|
||||
Some(Arc::new(|_, bucket, object| {
|
||||
data_movement::data_movement_stage_error_for_test(
|
||||
"decommission_object",
|
||||
"prepare_put_object",
|
||||
bucket,
|
||||
object,
|
||||
Error::Lock(rustfs_lock::LockError::timeout(
|
||||
format!("{bucket}/{object}@latest"),
|
||||
std::time::Duration::from_secs(5),
|
||||
)),
|
||||
)
|
||||
})),
|
||||
);
|
||||
let (outcome, _) = fixture.run_entry().await;
|
||||
assert_eq!(injected.load(Ordering::SeqCst), DECOMMISSION_VERSION_COPY_ATTEMPTS);
|
||||
fixture.assert_source_retained().await;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.target_versions()
|
||||
.await
|
||||
.expect("the first version was copied")
|
||||
.versions
|
||||
.len(),
|
||||
1,
|
||||
"one version must commit before the next version times out"
|
||||
);
|
||||
assert!(
|
||||
matches!(&outcome, DecommissionEntryOutcome::Deferred(err) if is_decommission_copy_lock_timeout_deferral(err)),
|
||||
"{outcome:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.store.pool_meta.read().await.pools[0]
|
||||
.decommission
|
||||
.as_ref()
|
||||
.expect("active decommission")
|
||||
.items_decommission_failed,
|
||||
0,
|
||||
"a deferred entry must not be counted as a permanent failure"
|
||||
);
|
||||
drop(fault);
|
||||
let (outcome, _) = fixture.run_entry().await;
|
||||
assert!(matches!(outcome, DecommissionEntryOutcome::Complete), "{outcome:?}");
|
||||
fixture.assert_moved().await;
|
||||
let mut reader = fixture.store.pools[1]
|
||||
.get_object_reader(
|
||||
fixture.bucket,
|
||||
fixture.object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
version_id: Some(fixture.seeded.versions[0].version_id.expect("newer version").to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the newer version must remain readable");
|
||||
let mut actual = Vec::new();
|
||||
reader.stream.read_to_end(&mut actual).await.expect("read the newer version");
|
||||
assert_eq!(actual, b"written after versioning was enabled");
|
||||
}
|
||||
|
||||
/// A namespace lock still held on the target key, as by a copy whose node
|
||||
/// was killed mid-write, makes target selection fail with a typed
|
||||
/// object-lock timeout. A real held lock, not an injected error, must defer
|
||||
/// the entry rather than fail it; once the lock is released selection
|
||||
/// recovers and the resumed entry moves both versions.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn decommission_target_selection_returns_timeout_for_a_held_object_lock() {
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT, Some("1"))], async {
|
||||
let fixture = NullVersionDecommission::new(NullVersionTop::NewerVersion).await;
|
||||
let target_set = fixture.store.pools[1].get_disks_by_key(fixture.object);
|
||||
let lock = target_set
|
||||
.new_ns_lock(fixture.bucket, fixture.object)
|
||||
.await
|
||||
.expect("target lock");
|
||||
let guard = lock
|
||||
.get_write_lock(std::time::Duration::from_secs(30))
|
||||
.await
|
||||
.expect("hold target lock");
|
||||
let opts = ObjectOptions {
|
||||
data_movement: true,
|
||||
src_pool_idx: 0,
|
||||
version_id: Some(uuid::Uuid::nil().to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let select = || {
|
||||
fixture
|
||||
.store
|
||||
.select_data_movement_pool_idx(fixture.bucket, fixture.object, 1, &opts, false)
|
||||
};
|
||||
let result = tokio::time::timeout(get_lock_acquire_timeout() + std::time::Duration::from_secs(15), select())
|
||||
.await
|
||||
.expect("target selection must terminate");
|
||||
match result {
|
||||
Err(Error::Lock(rustfs_lock::LockError::Timeout { resource, .. })) => {
|
||||
assert!(resource.contains(fixture.bucket) && resource.contains(fixture.object), "{resource}");
|
||||
}
|
||||
other => panic!("expected an object lock timeout, got {other:?}"),
|
||||
}
|
||||
|
||||
let (outcome, _) = fixture.run_entry().await;
|
||||
assert!(
|
||||
matches!(&outcome, DecommissionEntryOutcome::Deferred(err) if is_decommission_copy_lock_timeout_deferral(err)),
|
||||
"{outcome:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.store.pool_meta.read().await.pools[0]
|
||||
.decommission
|
||||
.as_ref()
|
||||
.expect("active decommission")
|
||||
.items_decommission_failed,
|
||||
0,
|
||||
"a held target lock must not be counted as a permanent failure"
|
||||
);
|
||||
fixture.assert_source_retained().await;
|
||||
assert!(
|
||||
fixture.target_versions().await.is_none(),
|
||||
"no version may commit while the target lock is held"
|
||||
);
|
||||
|
||||
drop(guard);
|
||||
assert_eq!(
|
||||
select()
|
||||
.await
|
||||
.expect("target selection must recover once the lock is released"),
|
||||
1
|
||||
);
|
||||
let (outcome, _) = fixture.run_entry().await;
|
||||
assert!(matches!(outcome, DecommissionEntryOutcome::Complete), "{outcome:?}");
|
||||
fixture.assert_moved().await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decommission_object_attempt_metric_keeps_the_peak() {
|
||||
let mut attempts = 0;
|
||||
@@ -28818,61 +28296,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// A restarted node's previous incarnation keeps its namespace locks on the
|
||||
/// peers until their lease expires, so a resumed copy of a key that was in
|
||||
/// flight at the crash times out acquiring the lock. That copy never
|
||||
/// committed: it is deferred, never counted as a failed object.
|
||||
#[test]
|
||||
fn decommission_copy_lock_timeout_is_deferred_not_failed() {
|
||||
let timeout = || {
|
||||
Error::Lock(rustfs_lock::LockError::timeout(
|
||||
"bucket-a/object-a@latest",
|
||||
std::time::Duration::from_secs(5),
|
||||
))
|
||||
};
|
||||
let prepare = data_movement::data_movement_stage_error_for_test(
|
||||
"decommission_object",
|
||||
"prepare_put_object",
|
||||
"bucket-a",
|
||||
"object-a",
|
||||
timeout(),
|
||||
);
|
||||
for err in [timeout(), prepare] {
|
||||
assert!(is_decommission_copy_lock_timeout_error(&err), "{err}");
|
||||
assert!(!is_decommission_copy_quorum_error(&err), "{err}");
|
||||
let mut error = Some(err);
|
||||
let deferred = take_decommission_copy_deferral("migrate_object", "bucket-a", "object-a", &mut error)
|
||||
.expect("a lock timeout copy must be deferred");
|
||||
assert!(error.is_none());
|
||||
assert!(is_decommission_copy_lock_timeout_deferral(&deferred));
|
||||
assert!(!is_decommission_copy_quorum_deferral(&deferred));
|
||||
let failure = data_movement::data_movement_stage_source_as::<DecommissionCopyLockTimeout>(&deferred)
|
||||
.expect("the deferred error must keep its lock-timeout origin");
|
||||
assert!(is_decommission_copy_lock_timeout_error(&failure.0));
|
||||
assert!(matches!(
|
||||
resolve_decommission_entry_failure(deferred),
|
||||
Ok(DecommissionEntryOutcome::Deferred(_))
|
||||
));
|
||||
}
|
||||
|
||||
let mut quorum = Some(Error::ErasureWriteQuorum);
|
||||
let deferred = take_decommission_copy_deferral("migrate_object", "bucket-a", "object-a", &mut quorum)
|
||||
.expect("a lost drive quorum copy stays deferred");
|
||||
assert!(is_decommission_copy_quorum_deferral(&deferred));
|
||||
|
||||
for other in [
|
||||
Error::Lock(rustfs_lock::LockError::internal("lock backend failed")),
|
||||
Error::other("Lock acquisition timeout for resource 'bucket-a/object-a@latest' after 5s"),
|
||||
] {
|
||||
let mut error = Some(other);
|
||||
assert!(
|
||||
take_decommission_copy_deferral("migrate_object", "bucket-a", "object-a", &mut error).is_none(),
|
||||
"{error:?}"
|
||||
);
|
||||
assert!(error.is_some(), "a per-object failure keeps its error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decommission_target_capacity_error_accepts_wrapped_capacity_errors() {
|
||||
let disk_full = Error::other(format!("decommission_object: put_object failed for bucket/object: {}", Error::DiskFull));
|
||||
|
||||
@@ -2480,93 +2480,6 @@ mod decommission_lock_order_tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// A node killed mid-copy leaves its namespace lock on the peers until the
|
||||
/// lock lease expires, so the resumed decommission times out acquiring the
|
||||
/// lock of the key it was copying through every in-place retry. The entry
|
||||
/// is deferred and copied once the lease is gone; it is never counted as a
|
||||
/// failed object, which would fail the whole decommission.
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn decommission_defers_a_copy_that_timed_out_on_a_stale_namespace_lock() {
|
||||
run_large_stack_async_test("decommission-copy-lock-timeout", || async {
|
||||
let (_temp_dirs, store, _other_store) = test_three_pool_stores_with_isolated_node_contexts(None).await;
|
||||
let bucket = test_bucket("copy-lock-timeout");
|
||||
let object = "external/005.bin";
|
||||
let payload = vec![0x3c; 2048];
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create the bucket");
|
||||
put_source_object(&store, &bucket, object, payload.clone()).await;
|
||||
|
||||
// The stale lease outlives every in-place attempt of the first
|
||||
// pass and has expired by the deferred replay.
|
||||
let stale_lease_calls = crate::core::pools::DECOMMISSION_VERSION_COPY_ATTEMPTS;
|
||||
let copy_calls = Arc::new(AtomicUsize::new(0));
|
||||
let _fault = DecommissionTestFaultGuard::install_with_error(
|
||||
Arc::new({
|
||||
let copy_calls = Arc::clone(©_calls);
|
||||
let bucket = bucket.clone();
|
||||
move |stage, entry_bucket, entry_object, _, _| {
|
||||
stage == "before_target_commit"
|
||||
&& entry_bucket == bucket
|
||||
&& entry_object == object
|
||||
&& copy_calls.fetch_add(1, Ordering::AcqRel) < stale_lease_calls
|
||||
}
|
||||
}),
|
||||
Some(Arc::new(|_, bucket, object| {
|
||||
// The shape the target write reports when the lock domain
|
||||
// still holds the previous incarnation's lock.
|
||||
data_movement::data_movement_stage_error_for_test(
|
||||
"decommission_object",
|
||||
"prepare_put_object",
|
||||
bucket,
|
||||
object,
|
||||
crate::error::Error::Lock(rustfs_lock::LockError::timeout(
|
||||
format!("{bucket}/{object}@latest"),
|
||||
Duration::from_secs(5),
|
||||
)),
|
||||
)
|
||||
})),
|
||||
);
|
||||
|
||||
start_transitioned_entry_decommission(&store, &bucket).await;
|
||||
spawn_decommission_routine(&store, 1)
|
||||
.await
|
||||
.expect("the decommission routine task should join")
|
||||
.expect("the decommission routine should return");
|
||||
|
||||
assert_eq!(
|
||||
copy_calls.load(Ordering::Acquire),
|
||||
stale_lease_calls + 1,
|
||||
"the first pass exhausts its in-place retries and the replay copies the object once"
|
||||
);
|
||||
let local = store.pool_meta.read().await;
|
||||
let info = local.pools[0]
|
||||
.decommission
|
||||
.as_ref()
|
||||
.expect("the decommission state should remain present");
|
||||
assert_eq!(
|
||||
info.items_decommission_failed, 0,
|
||||
"a lock held until its lease expires is not a permanent failure: {info:?}"
|
||||
);
|
||||
assert!(
|
||||
info.complete && !info.failed && !info.canceled && info.capacity_blocked_reason.is_none(),
|
||||
"the replayed entry must complete the decommission: {info:?}"
|
||||
);
|
||||
drop(local);
|
||||
|
||||
assert!(exact_versions(&store, 0, &bucket, object).await.is_empty(), "the source is cleaned up");
|
||||
let mut reader = store
|
||||
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the migrated object stays readable");
|
||||
let mut body = Vec::new();
|
||||
reader.stream.read_to_end(&mut body).await.expect("read the migrated object");
|
||||
assert_eq!(body, payload);
|
||||
});
|
||||
}
|
||||
|
||||
/// Seed `object` in pool 0 with three 2 KiB data versions and a newest
|
||||
/// delete marker; returns the data versions oldest first.
|
||||
async fn seed_versioned_entry_with_marker(
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::{
|
||||
error::{Error, classify_system_path_failure_reason},
|
||||
object_api::{ObjectInfo, ObjectOptions, PutObjReader},
|
||||
runtime::sources as runtime_sources,
|
||||
store::ECStore,
|
||||
store::{ECStore, list_objects::list_marker_key},
|
||||
};
|
||||
pub use local_snapshot::{LocalUsageSnapshot, read_snapshot as read_local_snapshot, snapshot_path};
|
||||
use rustfs_data_usage::{
|
||||
@@ -1966,8 +1966,8 @@ fn advance_version_listing_cursor(
|
||||
let next_version_marker = next_version_marker
|
||||
.filter(|next_version_marker| !next_version_marker.is_empty())
|
||||
.ok_or_else(|| Error::other(format!("bucket {bucket} version listing was truncated without a version marker")))?;
|
||||
let current_key_marker = marker.as_deref();
|
||||
let next_key_marker = next_marker.as_str();
|
||||
let current_key_marker = marker.as_deref().map(list_marker_key);
|
||||
let next_key_marker = list_marker_key(&next_marker);
|
||||
if current_key_marker == Some(next_key_marker) && version_marker.as_deref() == Some(next_version_marker.as_str()) {
|
||||
return Err(Error::other(format!(
|
||||
"bucket {bucket} version listing returned a repeated continuation marker"
|
||||
@@ -5167,20 +5167,20 @@ mod tests {
|
||||
);
|
||||
assert!(ensure_truncated_version_page_has_entries("bucket-a", 0).is_err());
|
||||
ensure_truncated_version_page_has_entries("bucket-a", 1).expect("a truncated page with an entry can advance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_listing_cursor_compares_literal_cache_shaped_keys() {
|
||||
let mut marker = Some("object-a".to_string());
|
||||
let mut version_marker = Some("version-a".to_string());
|
||||
advance_version_listing_cursor(
|
||||
"bucket-a",
|
||||
&mut marker,
|
||||
&mut version_marker,
|
||||
Some("object-a[rustfs_cache:v2,id:old]".to_string()),
|
||||
Some("version-a".to_string()),
|
||||
)
|
||||
.expect("a distinct literal key advances the cursor");
|
||||
let mut tagged_marker = Some("object-a[rustfs_cache:v2,id:old]".to_string());
|
||||
let mut tagged_version_marker = Some("version-a".to_string());
|
||||
assert!(
|
||||
advance_version_listing_cursor(
|
||||
"bucket-a",
|
||||
&mut tagged_marker,
|
||||
&mut tagged_version_marker,
|
||||
Some("object-a[rustfs_cache:v2,id:new]".to_string()),
|
||||
Some("version-a".to_string()),
|
||||
)
|
||||
.is_err(),
|
||||
"different cache tags for the same logical marker must not count as progress"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+186
-281
@@ -7762,6 +7762,10 @@ impl LocalDisk {
|
||||
// }
|
||||
|
||||
let multipart_check = self.multipart_delete_check(delete_path, true)?;
|
||||
if let Some(check) = &multipart_check {
|
||||
self.validate_multipart_delete_admission(check.clone(), namespace_owner.clone())
|
||||
.await?;
|
||||
}
|
||||
let err = if let Some(check) = &multipart_check {
|
||||
os::rename_all_with_precondition_and_parent_sync(
|
||||
delete_path,
|
||||
@@ -7838,15 +7842,13 @@ impl LocalDisk {
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
multipart_staging::reject_native_control_write(&self.root, self.io_root(), volume, path)?;
|
||||
let mut source_owner = self.acquire_multipart_delete_owner(volume, path, namespace_owner).await?;
|
||||
let source_owner = self.acquire_multipart_delete_owner(volume, path, namespace_owner).await?;
|
||||
let namespace_owner = source_owner.namespace_owner();
|
||||
if volume == RUSTFS_META_MULTIPART_BUCKET
|
||||
&& let Some(check) = self.multipart_delete_check(&self.io_get_object_path(volume, path)?, opt.recursive)?
|
||||
&& (opt.orphan_data_dir || (opt.recursive && opt.immediate))
|
||||
{
|
||||
self.validate_multipart_delete_admission(check, namespace_owner.clone())
|
||||
.await?;
|
||||
source_owner.mark_validated();
|
||||
}
|
||||
if pool_meta_native::reject_raw_path(&self.root, volume, path).is_err() {
|
||||
let owner = self.pool_meta_cleanup_owner(volume, path, &opt, namespace_owner).await?;
|
||||
@@ -8064,10 +8066,7 @@ impl LocalDisk {
|
||||
let file_path = self.io_get_object_path(volume, path)?;
|
||||
check_path_length(file_path.to_string_lossy().as_ref())?;
|
||||
let multipart_check = self.multipart_delete_check(&file_path, opt.recursive)?;
|
||||
if !source_owner.is_validated()
|
||||
&& (opt.orphan_data_dir || opt.old_data_dir.is_some() || !opt.recursive)
|
||||
&& let Some(check) = &multipart_check
|
||||
{
|
||||
if let Some(check) = &multipart_check {
|
||||
self.validate_multipart_delete_admission(check.clone(), namespace_owner.clone())
|
||||
.await?;
|
||||
}
|
||||
@@ -8314,6 +8313,8 @@ impl LocalDisk {
|
||||
// debug!("delete_file remove_dir done {:?}", &delete_path);
|
||||
} else if let Err(err) = {
|
||||
if let Some(check) = self.multipart_delete_check(delete_path, false)? {
|
||||
self.validate_multipart_delete_admission(check.clone(), namespace_owner.clone())
|
||||
.await?;
|
||||
let path = delete_path.clone();
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(delete_path, namespace_owner.clone()).await;
|
||||
os::run_blocking_namespace_operation(lease, move || {
|
||||
@@ -8326,10 +8327,6 @@ impl LocalDisk {
|
||||
}
|
||||
} {
|
||||
// debug!("remove_file err {:?} when {:?}", &err, &delete_path);
|
||||
let err = match err.downcast::<DiskError>() {
|
||||
Ok(disk_err) => return Err(disk_err),
|
||||
Err(err) => err,
|
||||
};
|
||||
match err.kind() {
|
||||
ErrorKind::NotFound => (),
|
||||
_ => {
|
||||
@@ -10012,7 +10009,9 @@ impl LocalDisk {
|
||||
} {
|
||||
meta.name.push_str(SLASH_SEPARATOR);
|
||||
// Conservative listings verify physical prefixes. Never-versioned
|
||||
// buckets first rule out the data dirs a deleted version leaves behind.
|
||||
// buckets use the bounded fast path, which only has to rule out
|
||||
// the data dirs a deleted version leaves behind; an empty listing
|
||||
// of such a prefix then reclaims committed residue.
|
||||
let listable = if opts.recursive || opts.incl_deleted {
|
||||
true
|
||||
} else if opts.skip_hidden_prefix_check {
|
||||
@@ -10166,19 +10165,17 @@ impl LocalDisk {
|
||||
/// Reads stay small on genuine prefixes: each directory is sampled with one
|
||||
/// bounded batch, the sampled children are probed depth-first, and the
|
||||
/// directory is only read in full once every sampled child proved to hold
|
||||
/// nothing listable. If the read budget is exhausted, verify the subtree
|
||||
/// before exposing the prefix. A directory that vanishes mid-probe holds
|
||||
/// nothing listable.
|
||||
/// nothing listable. A total read budget makes a residue tree larger than
|
||||
/// the budget surface and be hidden one level down instead, so the cost on
|
||||
/// a genuine prefix never exceeds its depth in small directory reads. A
|
||||
/// directory that vanishes mid-probe holds nothing listable.
|
||||
async fn directory_is_delete_residue(&self, bucket: &str, dir_name: &str, stall: Option<Duration>) -> Result<bool> {
|
||||
let dir_name = dir_name.trim_end_matches(SLASH_SEPARATOR);
|
||||
let mut reads = 0usize;
|
||||
let mut pending = vec![ResidueProbeStep::Sample(dir_name.to_owned())];
|
||||
while let Some(step) = pending.pop() {
|
||||
if reads >= DELETE_RESIDUE_PROBE_READ_BUDGET {
|
||||
return self
|
||||
.directory_has_listing_entry(bucket, dir_name, false, stall)
|
||||
.await
|
||||
.map(|listable| !listable);
|
||||
return Ok(false);
|
||||
}
|
||||
reads += 1;
|
||||
|
||||
@@ -12019,16 +12016,20 @@ impl DiskAPI for LocalDisk {
|
||||
// Publish the immutable generation before preparing the part switch.
|
||||
// Rollback retains the old generation; an unreferenced new index is
|
||||
// reclaimed with the upload directory if preparation is interrupted.
|
||||
os::rename_all_with_precondition_and_parent_sync(
|
||||
os::rename_all_with_precondition(
|
||||
&source,
|
||||
&destination,
|
||||
&dst_volume_dir,
|
||||
&self.publication_root,
|
||||
source_owner.clone(),
|
||||
admission.as_ref().map(|check| check.precondition()),
|
||||
durability.syncs_commit_metadata(),
|
||||
)
|
||||
.await?;
|
||||
if durability.syncs_commit_metadata() {
|
||||
os::fsync_dir_with_owner(destination.parent().ok_or(DiskError::FileCorrupt)?, source_owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
}
|
||||
let retained_owner = source_owner.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -12156,16 +12157,17 @@ impl DiskAPI for LocalDisk {
|
||||
PartTransactionAction::Commit => (PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META),
|
||||
PartTransactionAction::Rollback => (PART_TRANSACTION_OLD_META, PART_TRANSACTION_NEW_META),
|
||||
};
|
||||
if let Ok(obsolete) = std::fs::read(transaction_path.join(obsolete_name))
|
||||
if let (Ok(current), Ok(retained), Ok(obsolete)) = (
|
||||
std::fs::read(¤t_meta_path),
|
||||
std::fs::read(transaction_path.join(retained_name)),
|
||||
std::fs::read(transaction_path.join(obsolete_name)),
|
||||
) && current == retained
|
||||
&& let Ok(obsolete) = ObjectPartInfo::unmarshal(&obsolete)
|
||||
&& let Some(integrity) = obsolete.integrity
|
||||
&& integrity.validate().is_ok()
|
||||
&& usize::try_from(integrity.number).ok() == Some(obsolete.number)
|
||||
&& current_data_path.file_name().and_then(|name| name.to_str())
|
||||
== Some(format!("part.{}", obsolete.number).as_str())
|
||||
&& let (Ok(current), Ok(retained)) =
|
||||
(std::fs::read(¤t_meta_path), std::fs::read(transaction_path.join(retained_name)))
|
||||
&& current == retained
|
||||
&& ObjectPartInfo::unmarshal(&retained)
|
||||
.ok()
|
||||
.and_then(|part| part.integrity)
|
||||
@@ -12206,6 +12208,16 @@ impl DiskAPI for LocalDisk {
|
||||
let source_owner = self.multipart_source_owner(dst_volume, dst_path, true, false, None).await?;
|
||||
multipart_staging::reject_reserved_anchor(dst_path)?;
|
||||
let admission = self.multipart_source_admission(dst_volume, dst_path, true, false)?;
|
||||
if let Some(check) = admission.clone() {
|
||||
let retained = source_owner.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _owner = retained;
|
||||
check.validate()
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)?
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
pool_meta_native::reject_raw_path(&self.root, src_volume, src_path)?;
|
||||
pool_meta_native::reject_raw_path(&self.root, dst_volume, dst_path)?;
|
||||
let src_volume_dir = self.io_get_bucket_path(src_volume)?;
|
||||
@@ -12241,186 +12253,158 @@ impl DiskAPI for LocalDisk {
|
||||
check_path_length(src_file_path.to_string_lossy().as_ref())?;
|
||||
check_path_length(dst_file_path.to_string_lossy().as_ref())?;
|
||||
|
||||
let dst_meta_path = if src_is_dir {
|
||||
if src_is_dir {
|
||||
let meta_op = match lstat_std(&src_file_path).map_err(|e| to_file_error(e).into()) {
|
||||
Ok(meta) => Some(meta),
|
||||
Err(e) => {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(meta) = meta_op
|
||||
&& !meta.is_dir()
|
||||
{
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "src_expected_dir_missing",
|
||||
path = ?src_file_path,
|
||||
"Disk local rename rejected"
|
||||
);
|
||||
return Err(DiskError::FileAccessDenied);
|
||||
}
|
||||
|
||||
// Clear any stale destination before the directory rename. An absent
|
||||
// destination is the normal case when renaming a directory to a new
|
||||
// location, so tolerate NotFound instead of aborting the whole rename
|
||||
// (MinIO's RenameFile ignores osIsNotExist here).
|
||||
if let Err(e) = remove_std(&dst_file_path)
|
||||
&& e.kind() != ErrorKind::NotFound
|
||||
{
|
||||
return Err(to_file_error(e).into());
|
||||
}
|
||||
} else {
|
||||
let meta = lstat_std(&src_file_path).map_err(|e| -> DiskError { to_file_error(e).into() })?;
|
||||
if meta.is_dir() {
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "src_unexpected_dir",
|
||||
path = ?src_file_path,
|
||||
"Disk local rename rejected"
|
||||
);
|
||||
return Err(DiskError::FileAccessDenied);
|
||||
}
|
||||
}
|
||||
|
||||
let transaction_publish_meta = if src_is_dir {
|
||||
None
|
||||
} else {
|
||||
let path = self.io_get_object_path(dst_volume, &format!("{dst_path}.meta"))?;
|
||||
check_path_length(path.to_string_lossy().as_ref())?;
|
||||
Some(path)
|
||||
};
|
||||
let transaction_path = if src_is_dir {
|
||||
None
|
||||
} else {
|
||||
Some(self.io_get_object_path(dst_volume, &crate::disk::part_transaction_path(dst_path))?)
|
||||
let transaction_path = self.io_get_object_path(dst_volume, &crate::disk::part_transaction_path(dst_path))?;
|
||||
let transaction_meta_path = transaction_path.join(PART_TRANSACTION_NEW_META);
|
||||
match fs::read(&transaction_meta_path).await {
|
||||
Ok(expected_meta) => {
|
||||
if expected_meta.as_slice() != meta.as_ref() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
|
||||
let publish_meta_path = transaction_path.join(PART_TRANSACTION_PUBLISH_META);
|
||||
let source_meta_path = transaction_meta_path.clone();
|
||||
let publish_path = publish_meta_path.clone();
|
||||
let retained_owner = source_owner.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _owner = retained_owner;
|
||||
remove_file_if_exists(&publish_path)?;
|
||||
std::fs::hard_link(source_meta_path, &publish_path)
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)?
|
||||
.map_err(to_file_error)?;
|
||||
Some(publish_meta_path)
|
||||
}
|
||||
// Old peers know only RenamePart. The new coordinator never
|
||||
// reaches rename unless prepare succeeded, so an absent
|
||||
// transaction directory identifies the rolling-upgrade legacy
|
||||
// path. A present directory without new.meta is corruption.
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => match fs::metadata(&transaction_path).await {
|
||||
Ok(_) => return Err(DiskError::FileCorrupt),
|
||||
Err(meta_err) if meta_err.kind() == ErrorKind::NotFound => None,
|
||||
Err(meta_err) => return Err(to_file_error(meta_err).into()),
|
||||
},
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
}
|
||||
};
|
||||
|
||||
// UploadPart is acknowledged once this rename lands, so the part data and
|
||||
// its directory entry must be durable before we return. Relaxed keeps the
|
||||
// part payload fdatasync but leaves the directory entry to the page cache.
|
||||
let durability = effective_durability(dst_volume);
|
||||
let sync = (durability.syncs_data_shards() && !src_is_dir) || durability.syncs_commit_metadata();
|
||||
let publication_root = self.publication_root.clone();
|
||||
let src_file_for_cleanup = src_file_path.clone();
|
||||
let src_volume_for_cleanup = src_volume_dir.clone();
|
||||
let meta_for_legacy = meta.clone();
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&dst_file_path, source_owner.clone()).await;
|
||||
|
||||
let published_transaction_meta =
|
||||
os::run_blocking_namespace_operation_with_sync(lease, sync, move || -> std::io::Result<bool> {
|
||||
if let Some(check) = &admission {
|
||||
check.validate().map_err(to_file_error)?;
|
||||
}
|
||||
if src_is_dir {
|
||||
let meta_op = lstat_std(&src_file_path).map_err(to_file_error)?;
|
||||
if !meta_op.is_dir() {
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "src_expected_dir_missing",
|
||||
path = ?src_file_path,
|
||||
"Disk local rename rejected"
|
||||
);
|
||||
return Err(DiskError::FileAccessDenied.into());
|
||||
}
|
||||
|
||||
// Clear any stale destination before the directory rename. An absent
|
||||
// destination is the normal case when renaming a directory to a new
|
||||
// location, so tolerate NotFound instead of aborting the whole rename
|
||||
// (MinIO's RenameFile ignores osIsNotExist here).
|
||||
if let Err(e) = remove_std(&dst_file_path)
|
||||
&& e.kind() != ErrorKind::NotFound
|
||||
{
|
||||
return Err(to_file_error(e));
|
||||
}
|
||||
} else {
|
||||
let meta_op = lstat_std(&src_file_path).map_err(to_file_error)?;
|
||||
if meta_op.is_dir() {
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "src_unexpected_dir",
|
||||
path = ?src_file_path,
|
||||
"Disk local rename rejected"
|
||||
);
|
||||
return Err(DiskError::FileAccessDenied.into());
|
||||
}
|
||||
}
|
||||
|
||||
let transaction_publish_meta = if let Some(transaction_path) = transaction_path.as_ref() {
|
||||
let transaction_meta_path = transaction_path.join(PART_TRANSACTION_NEW_META);
|
||||
match std::fs::File::open(&transaction_meta_path) {
|
||||
Ok(mut file) => {
|
||||
let file_len = file.metadata().map_err(to_file_error)?.len();
|
||||
if file_len != meta.len() as u64 {
|
||||
return Err(DiskError::FileCorrupt.into());
|
||||
}
|
||||
let mut expected_meta = Vec::with_capacity(meta.len());
|
||||
std::io::Read::read_to_end(&mut std::io::Read::take(&mut file, file_len), &mut expected_meta)
|
||||
.map_err(to_file_error)?;
|
||||
drop(file);
|
||||
if expected_meta.as_slice() != meta.as_ref() {
|
||||
return Err(DiskError::FileCorrupt.into());
|
||||
}
|
||||
|
||||
let publish_meta_path = transaction_path.join(PART_TRANSACTION_PUBLISH_META);
|
||||
remove_file_if_exists(&publish_meta_path).map_err(to_file_error)?;
|
||||
std::fs::hard_link(&transaction_meta_path, &publish_meta_path).map_err(to_file_error)?;
|
||||
Some(publish_meta_path)
|
||||
}
|
||||
// Old peers know only RenamePart. The new coordinator never
|
||||
// reaches rename unless prepare succeeded, so an absent
|
||||
// transaction directory identifies the rolling-upgrade legacy
|
||||
// path. A present directory without new.meta is corruption.
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => match std::fs::metadata(transaction_path) {
|
||||
Ok(_) => return Err(DiskError::FileCorrupt.into()),
|
||||
Err(meta_err) if meta_err.kind() == ErrorKind::NotFound => None,
|
||||
Err(meta_err) => return Err(to_file_error(meta_err)),
|
||||
},
|
||||
Err(err) => return Err(to_file_error(err)),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if durability.syncs_data_shards() && !src_is_dir {
|
||||
os::sync_file(&src_file_path).map_err(to_file_error)?;
|
||||
}
|
||||
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
let precondition = admission.as_ref().map(|check| check.precondition());
|
||||
#[cfg(not(all(any(test, feature = "test-util"), not(windows))))]
|
||||
let precondition: Option<Arc<dyn Fn() -> std::io::Result<()> + Send + Sync>> = None;
|
||||
|
||||
os::rename_all_with_precondition_and_parent_sync_std(
|
||||
&src_file_path,
|
||||
&dst_file_path,
|
||||
&dst_volume_dir,
|
||||
&publication_root,
|
||||
precondition.as_deref(),
|
||||
durability.syncs_commit_metadata() && transaction_publish_meta.is_none(),
|
||||
)
|
||||
.map_err(to_file_error)?;
|
||||
|
||||
let dst_meta = lstat_std(&dst_file_path).map_err(to_file_error)?;
|
||||
if src_is_dir != dst_meta.is_dir() {
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "dst_type_changed_after_rename",
|
||||
path = ?dst_file_path,
|
||||
"Disk local rename rejected"
|
||||
);
|
||||
return Err(DiskError::FileAccessDenied.into());
|
||||
}
|
||||
|
||||
if let Some(transaction_publish_meta) = transaction_publish_meta {
|
||||
let dst_meta_path = dst_meta_path.as_ref().ok_or(DiskError::InvalidPath)?;
|
||||
os::rename_all_with_precondition_and_parent_sync_std(
|
||||
&transaction_publish_meta,
|
||||
dst_meta_path,
|
||||
&dst_volume_dir,
|
||||
&publication_root,
|
||||
precondition.as_deref(),
|
||||
durability.syncs_commit_metadata(),
|
||||
)
|
||||
.map_err(to_file_error)?;
|
||||
|
||||
for dir in src_file_path.ancestors().skip(1) {
|
||||
if dir == src_volume_dir.as_path() || !dir.starts_with(&src_volume_dir) || is_root_path(dir) {
|
||||
break;
|
||||
}
|
||||
match std::fs::remove_dir(dir) {
|
||||
Ok(()) => {}
|
||||
Err(err) if is_dir_not_empty_error(&err) || is_benign_object_rmdir_error(&err) => break,
|
||||
Err(err) => {
|
||||
return Err(Error::other(FileAccessDeniedWithContext {
|
||||
path: dir.to_path_buf(),
|
||||
source: err,
|
||||
})
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
if durability.syncs_data_shards() && !src_is_dir {
|
||||
let src = src_file_path.clone();
|
||||
let retained_owner = source_owner.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _owner = retained_owner;
|
||||
os::sync_file(&src)
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)?;
|
||||
.map_err(DiskError::from)?
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
|
||||
if !published_transaction_meta {
|
||||
self.write_all_meta_with_namespace_owner(
|
||||
dst_volume,
|
||||
&format!("{dst_path}.meta"),
|
||||
&meta_for_legacy,
|
||||
true,
|
||||
os::rename_all_with_precondition(
|
||||
&src_file_path,
|
||||
&dst_file_path,
|
||||
&dst_volume_dir,
|
||||
&self.publication_root,
|
||||
source_owner.clone(),
|
||||
admission.as_ref().map(|check| check.precondition()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if durability.syncs_commit_metadata()
|
||||
&& let Some(parent) = dst_file_path.parent()
|
||||
{
|
||||
os::fsync_dir_with_owner(parent, source_owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
|
||||
let dst_meta = lstat_std(&dst_file_path).map_err(|e| -> DiskError { to_file_error(e).into() })?;
|
||||
if src_is_dir != dst_meta.is_dir() {
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "dst_type_changed_after_rename",
|
||||
path = ?dst_file_path,
|
||||
"Disk local rename rejected"
|
||||
);
|
||||
return Err(DiskError::FileAccessDenied);
|
||||
}
|
||||
|
||||
if let Some(transaction_publish_meta) = transaction_publish_meta {
|
||||
let dst_meta_path = self.io_get_object_path(dst_volume, &format!("{dst_path}.meta"))?;
|
||||
os::rename_all_with_precondition(
|
||||
&transaction_publish_meta,
|
||||
&dst_meta_path,
|
||||
&dst_volume_dir,
|
||||
&self.publication_root,
|
||||
source_owner.clone(),
|
||||
admission.as_ref().map(|check| check.precondition()),
|
||||
)
|
||||
.await?;
|
||||
if durability.syncs_commit_metadata()
|
||||
&& let Some(parent) = dst_meta_path.parent()
|
||||
{
|
||||
os::fsync_dir_with_owner(parent, source_owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
} else {
|
||||
self.write_all_meta_with_namespace_owner(dst_volume, &format!("{dst_path}.meta"), &meta, true, source_owner.clone())
|
||||
.await?;
|
||||
}
|
||||
|
||||
if src_is_dir {
|
||||
@@ -12434,9 +12418,8 @@ impl DiskAPI for LocalDisk {
|
||||
.await;
|
||||
}
|
||||
|
||||
if !published_transaction_meta && let Some(parent) = src_file_for_cleanup.parent() {
|
||||
self.delete_file(&src_volume_for_cleanup, &parent.to_path_buf(), false, false)
|
||||
.await?;
|
||||
if let Some(parent) = src_file_path.parent() {
|
||||
self.delete_file(&src_volume_dir, &parent.to_path_buf(), false, false).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -12912,26 +12895,12 @@ impl DiskAPI for LocalDisk {
|
||||
.map_err(|e| to_access_error(e, DiskError::VolumeAccessDenied))?;
|
||||
}
|
||||
|
||||
let mut cached_owner: Option<(String, Option<Arc<dyn Send + Sync>>)> = None;
|
||||
for path in paths.iter() {
|
||||
let file_path = self.io_get_object_path(volume, path)?;
|
||||
|
||||
check_path_length(file_path.to_string_lossy().as_ref())?;
|
||||
|
||||
let owner = if volume == RUSTFS_META_MULTIPART_BUCKET {
|
||||
let scope_key = multipart_staging::upload_scope_root(path, true).to_ascii_lowercase();
|
||||
if let Some((cached_key, cached)) = &cached_owner
|
||||
&& cached_key == &scope_key
|
||||
{
|
||||
cached.clone()
|
||||
} else {
|
||||
let acquired = self.multipart_source_owner(volume, path, true, true, None).await?;
|
||||
cached_owner = Some((scope_key, acquired.clone()));
|
||||
acquired
|
||||
}
|
||||
} else {
|
||||
self.multipart_source_owner(volume, path, true, true, None).await?
|
||||
};
|
||||
let owner = self.multipart_source_owner(volume, path, true, true, None).await?;
|
||||
self.move_to_trash_with_namespace_owner(&file_path, false, false, owner)
|
||||
.await?;
|
||||
|
||||
@@ -25276,13 +25245,11 @@ mod test {
|
||||
)
|
||||
.await;
|
||||
|
||||
// A prefix larger than the fast-probe budget still needs a complete
|
||||
// read-only check before it can appear in a delimiter listing.
|
||||
// A prefix with more residue directories than the probe budget stays
|
||||
// visible rather than costing an unbounded walk.
|
||||
for hour in 0..(DELETE_RESIDUE_PROBE_READ_BUDGET + 1) {
|
||||
write_residue(&bucket_dir.join(format!("bulk/2026/08/28/{hour:02}/a.parquet")), false).await;
|
||||
write_residue(&bucket_dir.join(format!("bulk-live/2026/08/28/{hour:02}/a.parquet")), false).await;
|
||||
}
|
||||
write_object(&bucket_dir.join("bulk-live/live.parquet"), "bulk-live/live.parquet").await;
|
||||
// Exactly at the budget: `edge/` with N object dirs costs one sampled
|
||||
// read of `edge`, two reads per object dir (its own and its data dir),
|
||||
// and one remainder read of `edge` once its 8-entry sample was full.
|
||||
@@ -25363,8 +25330,10 @@ mod test {
|
||||
vec!["logs/default/2026/08/28/23/".to_owned()]
|
||||
);
|
||||
assert_eq!(scan_names(&disk, bucket, "logs/").await, vec!["logs/default/".to_owned()]);
|
||||
assert_eq!(scan_names(&disk, bucket, "bulk/2026/08/").await, Vec::<String>::new());
|
||||
assert_eq!(scan_names(&disk, bucket, "bulk-live/").await, vec!["bulk-live/live.parquet".to_owned()]);
|
||||
assert_eq!(scan_names(&disk, bucket, "bulk/2026/08/").await, vec!["bulk/2026/08/28/".to_owned()]);
|
||||
// Listed directly, every object dir is probed on its own and hidden;
|
||||
// the budget only bites where the whole tree hangs off one entry, so
|
||||
// the root listing below shows `edge-show/` but not `edge-hide/`.
|
||||
assert_eq!(scan_names(&disk, bucket, "edge-hide/").await, Vec::<String>::new());
|
||||
assert_eq!(scan_names(&disk, bucket, "edge-show/").await, Vec::<String>::new());
|
||||
assert_eq!(scan_names(&disk, bucket, "stray/").await, vec!["stray/2026/".to_owned()]);
|
||||
@@ -25372,7 +25341,8 @@ mod test {
|
||||
assert_eq!(
|
||||
scan_names(&disk, bucket, "").await,
|
||||
vec![
|
||||
"bulk-live/".to_owned(),
|
||||
"bulk/".to_owned(),
|
||||
"edge-show/".to_owned(),
|
||||
"logs/".to_owned(),
|
||||
"odd/".to_owned(),
|
||||
"stray/".to_owned(),
|
||||
@@ -26534,71 +26504,6 @@ mod test {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_part_leaves_metadata_reads_on_foreground_lane() {
|
||||
let dir = tempfile::tempdir().expect("disk root");
|
||||
let endpoint = Endpoint::try_from(dir.path().to_string_lossy().as_ref()).expect("endpoint");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk");
|
||||
ensure_test_volume(&disk, "tmp").await;
|
||||
ensure_test_volume(&disk, "bucket").await;
|
||||
|
||||
disk.write_metadata(
|
||||
"",
|
||||
"bucket",
|
||||
"unrelated/object",
|
||||
test_file_info("unrelated/object", Uuid::new_v4(), Some(Uuid::new_v4()), None),
|
||||
)
|
||||
.await
|
||||
.expect("write unrelated object metadata");
|
||||
|
||||
let payload = Bytes::from_static(b"part payload");
|
||||
let meta = Bytes::from(
|
||||
ObjectPartInfo {
|
||||
number: 1,
|
||||
size: payload.len(),
|
||||
..Default::default()
|
||||
}
|
||||
.marshal_msg()
|
||||
.expect("part metadata"),
|
||||
);
|
||||
disk.write_all("tmp", "upload/part.1", payload.clone())
|
||||
.await
|
||||
.expect("stage part");
|
||||
disk.prepare_part_transaction("tmp", "upload/part.1", "bucket", "object/part.1", meta.clone())
|
||||
.await
|
||||
.expect("prepare part transaction");
|
||||
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let queue = Arc::clone(&disk.blocking_queue);
|
||||
let blocker = tokio::spawn(async move {
|
||||
queue
|
||||
.run_fsync_cancellable(move |_| {
|
||||
let _ = started_tx.send(());
|
||||
let _ = release_rx.blocking_recv();
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
});
|
||||
started_rx.await.expect("fsync lane blocked");
|
||||
|
||||
let mut rename = std::pin::pin!(disk.rename_part("tmp", "upload/part.1", "bucket", "object/part.1", meta.clone()));
|
||||
assert!(futures::FutureExt::now_or_never(&mut rename).is_none());
|
||||
|
||||
let read = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
disk.read_version("", "bucket", "unrelated/object", "", &ReadOptions::default()),
|
||||
)
|
||||
.await
|
||||
.expect("read_version on unrelated key must not be blocked by rename_part sync")
|
||||
.expect("unrelated object metadata should be readable");
|
||||
assert_eq!(read.name, "unrelated/object");
|
||||
|
||||
release_tx.send(()).expect("release fsync lane");
|
||||
blocker.await.expect("blocker joined").expect("blocker completed");
|
||||
rename.await.expect("rename_part completed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_read_job_retains_resource_unit_until_native_completion() {
|
||||
use crate::disk::with_background_resource_unit;
|
||||
|
||||
@@ -548,18 +548,11 @@ impl AbortDeletePermit {
|
||||
pub(super) struct MultipartDeleteOwner {
|
||||
owner: Option<Arc<dyn Send + Sync>>,
|
||||
source_key: Option<PathBuf>,
|
||||
admission_validated: bool,
|
||||
}
|
||||
impl MultipartDeleteOwner {
|
||||
pub(super) fn namespace_owner(&self) -> Option<Arc<dyn Send + Sync>> {
|
||||
self.owner.clone()
|
||||
}
|
||||
pub(super) fn mark_validated(&mut self) {
|
||||
self.admission_validated = true;
|
||||
}
|
||||
pub(super) fn is_validated(&self) -> bool {
|
||||
self.admission_validated
|
||||
}
|
||||
pub(super) fn chain(self, owner: Option<Arc<dyn Send + Sync>>) -> Self {
|
||||
let owner = match (self.owner, owner) {
|
||||
(None, owner) | (owner, None) => owner,
|
||||
@@ -569,7 +562,6 @@ impl MultipartDeleteOwner {
|
||||
Self {
|
||||
owner,
|
||||
source_key: self.source_key,
|
||||
admission_validated: self.admission_validated,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -590,11 +582,7 @@ impl LocalDisk {
|
||||
) -> Result<MultipartDeleteOwner> {
|
||||
let source_key = self.multipart_delete_owner_key(volume, path)?;
|
||||
let owner = self.multipart_source_owner(volume, path, true, true, owner).await?;
|
||||
Ok(MultipartDeleteOwner {
|
||||
owner,
|
||||
source_key,
|
||||
admission_validated: false,
|
||||
})
|
||||
Ok(MultipartDeleteOwner { owner, source_key })
|
||||
}
|
||||
pub(super) fn validate_multipart_delete_owner(
|
||||
&self,
|
||||
|
||||
@@ -552,33 +552,25 @@ impl LocalDisk {
|
||||
let target = self.get_object_path(&publication.lineage.bucket, &publication.lineage.object)?;
|
||||
let _target_owner = os::acquire_metadata_mutation_lease(&target, store.owner.clone()).await;
|
||||
if require_target {
|
||||
let expected_publication = publication.clone();
|
||||
let physical_slot = store.physical_slot;
|
||||
let disk_count = store.disk_ids.len();
|
||||
store
|
||||
.read_and_sync_target(publication, move |raw| {
|
||||
if !super::multipart_source_state::SourceStore::target_matches_for_slot(
|
||||
&expected_publication,
|
||||
raw,
|
||||
physical_slot,
|
||||
disk_count,
|
||||
)? {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
let raw = store
|
||||
.read_target(publication)
|
||||
.await?
|
||||
.ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
if !store.target_matches(publication, &raw)? {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
store.sync_target(publication).await?;
|
||||
}
|
||||
let snapshot = if !matches!(
|
||||
if !matches!(
|
||||
state.phase,
|
||||
MultipartSourcePhase::LocalTerminalSeal | MultipartSourcePhase::GlobalTerminalCertified
|
||||
) {
|
||||
state.capability_sha256 = None;
|
||||
state.advance(MultipartSourcePhase::LocalTerminalSeal)?;
|
||||
store.persist_with_source_sync(&state, false).await?
|
||||
} else {
|
||||
loaded.snapshot.ok_or(DiskError::ConditionalDeleteConflict)?
|
||||
};
|
||||
store.persist(&state).await?;
|
||||
}
|
||||
let current = store.load().await?;
|
||||
let snapshot = current.snapshot.ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
if snapshot.head_sha256.is_some()
|
||||
|| snapshot.capability_sha256.is_some()
|
||||
|| snapshot.publication.as_ref() != Some(publication)
|
||||
|
||||
@@ -335,48 +335,6 @@ fn target_backup_path(source: &Path, owner: Uuid) -> PathBuf {
|
||||
source.join(".mpu-completions").join(owner.to_string()).join("target.xl.meta")
|
||||
}
|
||||
|
||||
fn terminal_record_path(source: &Path, state: &SourceState) -> Option<PathBuf> {
|
||||
matches!(
|
||||
state.phase,
|
||||
MultipartSourcePhase::LocalTerminalSeal | MultipartSourcePhase::GlobalTerminalCertified
|
||||
)
|
||||
.then(|| {
|
||||
source
|
||||
.join(".mpu-completions")
|
||||
.join(state.backup_owner.to_string())
|
||||
.join(format!("terminal-{}.msgpack", state.revision))
|
||||
})
|
||||
}
|
||||
|
||||
fn snapshot_for_state(
|
||||
disk_id: Uuid,
|
||||
state: &SourceState,
|
||||
state_raw: &[u8],
|
||||
head_sha256: Option<[u8; 32]>,
|
||||
) -> Result<MultipartSourceSnapshot> {
|
||||
let state_sha256 = raw_sha(state_raw);
|
||||
let inventory = raw_sha(&encode(&(
|
||||
Some(state_sha256),
|
||||
head_sha256,
|
||||
state.original_sha256,
|
||||
state.negative_certificate_sha256,
|
||||
state.ready_certificate_sha256,
|
||||
))?);
|
||||
Ok(MultipartSourceSnapshot {
|
||||
disk_id,
|
||||
physical_slot: state.physical_slot,
|
||||
lineage: state.lineage.clone(),
|
||||
generation: state.generation,
|
||||
revision: state.revision,
|
||||
phase: state.phase,
|
||||
state_sha256,
|
||||
head_sha256,
|
||||
inventory_sha256: inventory,
|
||||
capability_sha256: state.capability_sha256,
|
||||
publication: state.publication.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
impl SourceStore {
|
||||
pub fn validate_certificate_topology(&self, certificate: &MultipartCertificate) -> Result<()> {
|
||||
if certificate.disk_ids != self.disk_ids || certificate.disk_ids.get(self.physical_slot) != Some(&self.disk_id) {
|
||||
@@ -411,67 +369,33 @@ impl SourceStore {
|
||||
.map_err(DiskError::from)?
|
||||
}
|
||||
pub async fn persist(&self, state: &SourceState) -> Result<()> {
|
||||
self.persist_with_source_sync(state, false).await.map(|_| ())
|
||||
self.persist_with_source_sync(state, false).await
|
||||
}
|
||||
pub(super) async fn persist_with_source_sync(
|
||||
&self,
|
||||
state: &SourceState,
|
||||
sync_source_before: bool,
|
||||
) -> Result<MultipartSourceSnapshot> {
|
||||
async fn persist_with_source_sync(&self, state: &SourceState, sync_source_before: bool) -> Result<()> {
|
||||
state.validate(self.disk_id, &self.path)?;
|
||||
let raw = encode(state)?;
|
||||
let snapshot = snapshot_for_state(self.disk_id, state, &raw, None)?;
|
||||
let source = self.source.clone();
|
||||
let base = self.base.clone();
|
||||
let root = self.publication_root.clone();
|
||||
let terminal = terminal_record_path(&source, state);
|
||||
os::run_blocking_namespace_operation_with_sync(self.lease.clone(), true, move || {
|
||||
Self::persist_state_std(&source, &base, &root, &raw, terminal.as_deref(), sync_source_before)
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
fn persist_state_std(
|
||||
source: &Path,
|
||||
base: &Path,
|
||||
root: &os::PublicationRoot,
|
||||
raw: &[u8],
|
||||
terminal: Option<&Path>,
|
||||
sync_source_before: bool,
|
||||
) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
let mut pinned = os::pin_multipart_source_directory(source, base, root)?;
|
||||
#[cfg(not(unix))]
|
||||
let _pinned = os::pin_multipart_source_directory(source, base, root)?;
|
||||
if sync_source_before {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let opened = std::fs::File::from(pinned.pop().ok_or_else(|| IoError::other("empty source directory guard"))?);
|
||||
os::fsync_directory_handle_std(source, Some(&opened))?;
|
||||
let terminal = matches!(
|
||||
state.phase,
|
||||
MultipartSourcePhase::LocalTerminalSeal | MultipartSourcePhase::GlobalTerminalCertified
|
||||
)
|
||||
.then(|| {
|
||||
source
|
||||
.join(".mpu-completions")
|
||||
.join(state.backup_owner.to_string())
|
||||
.join(format!("terminal-{}.msgpack", state.revision))
|
||||
});
|
||||
os::run_blocking_namespace_operation(self.lease.clone(), move || {
|
||||
let _pin = os::pin_multipart_source_directory(&source, &base, &root)?;
|
||||
if sync_source_before {
|
||||
os::fsync_dir_std(&source)?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
os::fsync_dir_std(source)?;
|
||||
}
|
||||
if let Some(path) = terminal {
|
||||
os::replace_multipart_source_metadata(path, raw, base, root)?;
|
||||
}
|
||||
os::replace_multipart_source_metadata(&source.join(STATE), raw, base, root)
|
||||
}
|
||||
async fn write_owned_backup_and_persist(&self, owner: Uuid, target: bool, bytes: &[u8], state: &SourceState) -> Result<()> {
|
||||
if owner.is_nil() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
state.validate(self.disk_id, &self.path)?;
|
||||
let raw = encode(state)?;
|
||||
let source = self.source.clone();
|
||||
let base = self.base.clone();
|
||||
let root = self.publication_root.clone();
|
||||
let terminal = terminal_record_path(&source, state);
|
||||
let bytes = bytes.to_vec();
|
||||
os::run_blocking_namespace_operation_with_sync(self.lease.clone(), true, move || {
|
||||
Self::write_owned_backup_std(&source, &base, &root, owner, target, &bytes)?;
|
||||
Self::persist_state_std(&source, &base, &root, &raw, terminal.as_deref(), false)
|
||||
if let Some(path) = terminal {
|
||||
os::replace_multipart_source_metadata(&path, &raw, &base, &root)?;
|
||||
}
|
||||
os::replace_multipart_source_metadata(&source.join(STATE), &raw, &base, &root)
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)
|
||||
@@ -525,65 +449,54 @@ impl SourceStore {
|
||||
restore_from: None,
|
||||
abort: None,
|
||||
};
|
||||
self.write_owned_backup_and_persist(state.backup_owner, false, original, &state)
|
||||
.await?;
|
||||
self.write_owned_backup(state.backup_owner, false, original).await?;
|
||||
self.persist(&state).await?;
|
||||
Ok(state)
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub async fn write_owned_backup(&self, owner: Uuid, target: bool, bytes: &[u8]) -> Result<()> {
|
||||
if owner.is_nil() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
let path = if target {
|
||||
target_backup_path(&self.source, owner)
|
||||
} else {
|
||||
original_path(&self.source, owner)
|
||||
};
|
||||
let source = self.source.clone();
|
||||
let base = self.base.clone();
|
||||
let root = self.publication_root.clone();
|
||||
let bytes = bytes.to_vec();
|
||||
os::run_blocking_namespace_operation(self.lease.clone(), move || {
|
||||
Self::write_owned_backup_std(&source, &base, &root, owner, target, &bytes)
|
||||
let _pin = os::pin_multipart_source_directory(&source, &base, &root)?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| std::io::Error::other("source backup has no parent"))?;
|
||||
let _parents = os::mkdir_all_below_existing_base_std(parent, &base, &root)?;
|
||||
let existed = if target {
|
||||
match os::observe_multipart_source_metadata(&path, &base, &root, bytes.len() as u64) {
|
||||
Ok((length, sha)) if length == bytes.len() as u64 && sha == raw_sha(&bytes) => true,
|
||||
Ok(_) => {
|
||||
return Err(IoError::new(std::io::ErrorKind::AlreadyExists, "immutable target backup changed"));
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
} else if let Some(previous) = read_optional(&path, &base, &root).map_err(IoError::other)? {
|
||||
if previous.as_ref() != bytes.as_slice() {
|
||||
return Err(IoError::new(std::io::ErrorKind::AlreadyExists, "immutable source backup changed"));
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !existed {
|
||||
os::replace_multipart_source_metadata(&path, &bytes, &base, &root)?;
|
||||
}
|
||||
os::sync_multipart_owned_record_parents(&source, parent, existed, &base, &root)
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)
|
||||
}
|
||||
fn write_owned_backup_std(
|
||||
source: &Path,
|
||||
base: &Path,
|
||||
root: &os::PublicationRoot,
|
||||
owner: Uuid,
|
||||
target: bool,
|
||||
bytes: &[u8],
|
||||
) -> std::io::Result<()> {
|
||||
let path = if target {
|
||||
target_backup_path(source, owner)
|
||||
} else {
|
||||
original_path(source, owner)
|
||||
};
|
||||
let _pin = os::pin_multipart_source_directory(source, base, root)?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| std::io::Error::other("source backup has no parent"))?;
|
||||
let _parents = os::mkdir_all_below_existing_base_std(parent, base, root)?;
|
||||
let existed = if target {
|
||||
match os::observe_multipart_source_metadata(&path, base, root, bytes.len() as u64) {
|
||||
Ok((length, sha)) if length == bytes.len() as u64 && sha == raw_sha(bytes) => true,
|
||||
Ok(_) => {
|
||||
return Err(IoError::new(std::io::ErrorKind::AlreadyExists, "immutable target backup changed"));
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
} else if let Some(previous) = read_optional(&path, base, root).map_err(IoError::other)? {
|
||||
if previous.as_ref() != bytes {
|
||||
return Err(IoError::new(std::io::ErrorKind::AlreadyExists, "immutable source backup changed"));
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !existed {
|
||||
os::replace_multipart_source_metadata(&path, bytes, base, root)?;
|
||||
}
|
||||
os::sync_multipart_owned_record_parents(source, parent, existed, base, root)
|
||||
}
|
||||
pub async fn replace_head(&self, bytes: &[u8]) -> Result<()> {
|
||||
let path = self.source.join(STORAGE_FORMAT_FILE);
|
||||
let base = self.base.clone();
|
||||
@@ -764,14 +677,6 @@ impl SourceStore {
|
||||
Ok(metadata.map(|_| bytes))
|
||||
}
|
||||
pub fn target_matches(&self, publication: &PublicationIdentity, raw: &[u8]) -> Result<bool> {
|
||||
Self::target_matches_for_slot(publication, raw, self.physical_slot, self.disk_ids.len())
|
||||
}
|
||||
pub(super) fn target_matches_for_slot(
|
||||
publication: &PublicationIdentity,
|
||||
raw: &[u8],
|
||||
physical_slot: usize,
|
||||
disk_count: usize,
|
||||
) -> Result<bool> {
|
||||
let info = file_info(raw, &publication.lineage.bucket, &publication.lineage.object, publication.version_id)?;
|
||||
let slot = publication
|
||||
.lineage
|
||||
@@ -793,8 +698,8 @@ impl SourceStore {
|
||||
&& info.erasure.parity_blocks == publication.lineage.geometry.parity_blocks
|
||||
&& info.erasure.block_size == publication.lineage.geometry.block_size
|
||||
&& info.erasure.distribution == publication.lineage.geometry.distribution
|
||||
&& slot == physical_slot
|
||||
&& publication.lineage.geometry.disks() == disk_count)
|
||||
&& slot == self.physical_slot
|
||||
&& publication.lineage.geometry.disks() == self.disk_ids.len())
|
||||
}
|
||||
pub async fn restore_exact_target(&self, state: &SourceState, owner: Option<Arc<dyn Send + Sync>>) -> Result<()> {
|
||||
let publication = state.publication.as_ref().ok_or(DiskError::FileCorrupt)?;
|
||||
@@ -1020,12 +925,26 @@ pub(super) fn load_source(
|
||||
}
|
||||
}
|
||||
let head_sha = head.as_ref().map(|head| head.sha256);
|
||||
snapshot = Some(snapshot_for_state(
|
||||
disk_id,
|
||||
state,
|
||||
state_raw.as_ref().ok_or(DiskError::FileCorrupt)?,
|
||||
let inventory = raw_sha(&encode(&(
|
||||
state_raw.as_ref().map(|bytes| raw_sha(bytes)),
|
||||
head_sha,
|
||||
)?);
|
||||
state.original_sha256,
|
||||
state.negative_certificate_sha256,
|
||||
state.ready_certificate_sha256,
|
||||
))?);
|
||||
snapshot = Some(MultipartSourceSnapshot {
|
||||
disk_id,
|
||||
physical_slot: state.physical_slot,
|
||||
lineage: state.lineage.clone(),
|
||||
generation: state.generation,
|
||||
revision: state.revision,
|
||||
phase: state.phase,
|
||||
state_sha256: raw_sha(state_raw.as_ref().ok_or(DiskError::FileCorrupt)?),
|
||||
head_sha256: head_sha,
|
||||
inventory_sha256: inventory,
|
||||
capability_sha256: state.capability_sha256,
|
||||
publication: state.publication.clone(),
|
||||
});
|
||||
} else if let Some(observed) = &head {
|
||||
let raw = observed.raw().ok_or(DiskError::FileCorrupt)?;
|
||||
let current = get_file_info(
|
||||
@@ -1332,7 +1251,11 @@ impl SourceStore {
|
||||
directory: &Path,
|
||||
lease: Arc<os::NamespaceMutationLease>,
|
||||
) -> Result<()> {
|
||||
if state.lineage != publication.lineage
|
||||
let loaded = self.load().await?;
|
||||
let current = loaded.state.as_ref().ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
if encode(current)? != encode(state)?
|
||||
|| !loaded.head_matches_state
|
||||
|| state.lineage != publication.lineage
|
||||
|| state.generation != publication.source_generation
|
||||
|| state.disk_id != self.disk_id
|
||||
|| state.physical_slot != self.physical_slot
|
||||
@@ -1569,7 +1492,8 @@ impl LocalDisk {
|
||||
};
|
||||
// Copy the small source FI to an independent immutable inode. Its
|
||||
// canonical inode is never truncated to build the destination proxy.
|
||||
store.write_owned_backup_and_persist(owner, false, original, &state).await?;
|
||||
store.write_owned_backup(owner, false, original).await?;
|
||||
store.persist(&state).await?;
|
||||
let prepared_path = store.source.join(format!(".mpu-prepared-{owner}"));
|
||||
let context = NativeMultipartPublication {
|
||||
store,
|
||||
@@ -1612,19 +1536,14 @@ impl NativeMultipartPublication {
|
||||
length: new_metadata.len() as u64,
|
||||
};
|
||||
if let Some(previous) = old_metadata {
|
||||
self.store.write_owned_backup(state.backup_owner, true, previous).await?;
|
||||
state.previous_target_sha256 = Some(raw_sha(previous));
|
||||
state.previous_target_length = Some(previous.len() as u64);
|
||||
}
|
||||
state.prepared_sha256 = Some(validated.sha256);
|
||||
state.prepared_length = Some(validated.length);
|
||||
state.advance(MultipartSourcePhase::Prepared)?;
|
||||
if let Some(previous) = old_metadata {
|
||||
self.store
|
||||
.write_owned_backup_and_persist(state.backup_owner, true, previous, &state)
|
||||
.await?;
|
||||
} else {
|
||||
self.store.persist(&state).await?;
|
||||
}
|
||||
self.store.persist(&state).await?;
|
||||
*self.state.lock() = state;
|
||||
*self.validated_prepared.lock() = Some(validated);
|
||||
Ok(())
|
||||
@@ -1706,28 +1625,29 @@ impl NativeMultipartPublication {
|
||||
if current.head.is_some() || encode(current_state)? != encode(&state)? {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
let (expected_sha256, expected_length) = {
|
||||
let target = self
|
||||
.store
|
||||
.read_target(&self.publication)
|
||||
.await?
|
||||
.ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
{
|
||||
let guard = self.validated_prepared.lock();
|
||||
let validated = guard.as_ref().ok_or(DiskError::ConditionalDeleteConflict)?;
|
||||
if validated.publication != self.publication
|
||||
|| state.prepared_sha256 != Some(validated.sha256)
|
||||
|| state.prepared_length != Some(validated.length)
|
||||
|| target.len() as u64 != validated.length
|
||||
|| raw_sha(&target) != validated.sha256
|
||||
{
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
(validated.sha256, validated.length)
|
||||
};
|
||||
self.store
|
||||
.read_and_sync_target_with_lease(&self.publication, target_lease, move |target| {
|
||||
if target.len() as u64 != expected_length || raw_sha(target) != expected_sha256 {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
self.store.sync_target_with_lease(&self.publication, target_lease).await?;
|
||||
state.advance(MultipartSourcePhase::ConsumedUnsealed)?;
|
||||
let snapshot = self.store.persist_with_source_sync(&state, true).await?;
|
||||
self.store.persist_with_source_sync(&state, true).await?;
|
||||
*self.state.lock() = state;
|
||||
let loaded = self.store.load().await?;
|
||||
let snapshot = loaded.snapshot.ok_or(DiskError::FileCorrupt)?;
|
||||
let proof = self
|
||||
.store
|
||||
.issue_proof(MultipartProofKind::Published, self.publication.attempt, None, snapshot)?;
|
||||
@@ -1766,46 +1686,6 @@ impl SourceStore {
|
||||
.await
|
||||
.map_err(DiskError::from)
|
||||
}
|
||||
pub async fn read_and_sync_target(
|
||||
&self,
|
||||
publication: &PublicationIdentity,
|
||||
validate: impl FnOnce(&[u8]) -> Result<()> + Send + 'static,
|
||||
) -> Result<()> {
|
||||
let io = self.target_path(publication)?;
|
||||
let logical = self
|
||||
.root
|
||||
.join(io.strip_prefix(&self.base).map_err(|_| DiskError::InvalidPath)?);
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&logical, Some(self.lease.clone())).await;
|
||||
self.read_and_sync_target_with_lease(publication, lease, validate).await
|
||||
}
|
||||
pub async fn read_and_sync_target_with_lease(
|
||||
&self,
|
||||
publication: &PublicationIdentity,
|
||||
lease: Arc<os::NamespaceMutationLease>,
|
||||
validate: impl FnOnce(&[u8]) -> Result<()> + Send + 'static,
|
||||
) -> Result<()> {
|
||||
let directory = self.target_path(publication)?;
|
||||
let base = self.base.clone();
|
||||
let root = self.publication_root.clone();
|
||||
os::run_blocking_namespace_operation_with_sync(lease, true, move || {
|
||||
os::read_and_sync_multipart_target_metadata(
|
||||
&directory,
|
||||
std::ffi::OsStr::new(STORAGE_FORMAT_FILE),
|
||||
&base,
|
||||
&root,
|
||||
|bytes| validate(bytes).map_err(IoError::other),
|
||||
)
|
||||
.map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
IoError::other(DiskError::ConditionalDeleteConflict)
|
||||
} else {
|
||||
error
|
||||
}
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)
|
||||
}
|
||||
async fn sync_source_absence(&self) -> Result<()> {
|
||||
let source = self.source.clone();
|
||||
let base = self.base.clone();
|
||||
|
||||
+67
-140
@@ -3047,6 +3047,17 @@ pub(crate) async fn rename_all_with_lease(
|
||||
|
||||
/// A source admission must run before destination preparation can mkdir and
|
||||
/// again before the consuming rename, inside the actual owned callback.
|
||||
pub(crate) async fn rename_all_with_precondition(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
base: &Path,
|
||||
publication_root: &PublicationRoot,
|
||||
owner: Option<Arc<dyn Send + Sync>>,
|
||||
precondition: Option<Arc<dyn Fn() -> io::Result<()> + Send + Sync>>,
|
||||
) -> Result<()> {
|
||||
rename_all_with_precondition_and_parent_sync(source, destination, base, publication_root, owner, precondition, false).await
|
||||
}
|
||||
|
||||
pub(crate) async fn rename_all_with_precondition_and_parent_sync(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
@@ -3064,63 +3075,45 @@ pub(crate) async fn rename_all_with_precondition_and_parent_sync(
|
||||
let base = base.to_path_buf();
|
||||
let root = publication_root.clone();
|
||||
let lease = acquire_namespace_mutation_lease_with_owner(&destination, owner).await;
|
||||
run_blocking_namespace_operation_with_sync(lease, sync_parent, move || {
|
||||
rename_all_with_precondition_and_parent_sync_std(
|
||||
&source,
|
||||
&destination,
|
||||
&base,
|
||||
&root,
|
||||
precondition.as_deref(),
|
||||
sync_parent,
|
||||
)
|
||||
run_blocking_namespace_operation(lease, move || {
|
||||
if let Some(check) = precondition.as_ref() {
|
||||
check()?;
|
||||
}
|
||||
let preparation = prepare_rename_with_retry(&source, &destination, &base, &root)?;
|
||||
#[cfg(all(test, not(windows)))]
|
||||
prepared_publication_test_hooks::run_rename_destination(&source, &destination);
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
{
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &source);
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &destination);
|
||||
}
|
||||
if let Some(check) = precondition.as_ref() {
|
||||
check()?;
|
||||
}
|
||||
rename_prepared(&source, &destination, &preparation)?;
|
||||
if sync_parent {
|
||||
let parent = destination
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::other("source metadata parent is missing"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pinned = preparation
|
||||
.parent_guard
|
||||
.as_ref()
|
||||
.and_then(|guard| guard.last())
|
||||
.ok_or_else(|| io::Error::other("source metadata parent was not pinned"))?;
|
||||
let opened = std::fs::File::from(pinned.try_clone()?);
|
||||
fsync_directory_handle_std(parent, Some(&opened))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fsync_dir_std(parent)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| to_file_error(error).into())
|
||||
}
|
||||
|
||||
pub(crate) fn rename_all_with_precondition_and_parent_sync_std(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
base: &Path,
|
||||
publication_root: &PublicationRoot,
|
||||
precondition: Option<&(dyn Fn() -> io::Result<()> + Send + Sync)>,
|
||||
sync_parent: bool,
|
||||
) -> io::Result<()> {
|
||||
if let Some(check) = precondition {
|
||||
check()?;
|
||||
}
|
||||
let preparation = prepare_rename_with_retry(source, destination, base, publication_root)?;
|
||||
#[cfg(all(test, not(windows)))]
|
||||
prepared_publication_test_hooks::run_rename_destination(source, destination);
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
{
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, source);
|
||||
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, destination);
|
||||
if let Some(check) = precondition {
|
||||
check()?;
|
||||
}
|
||||
}
|
||||
rename_prepared(source, destination, &preparation)?;
|
||||
if sync_parent {
|
||||
let parent = destination
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::other("source metadata parent is missing"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pinned = preparation
|
||||
.parent_guard
|
||||
.as_ref()
|
||||
.and_then(|guard| guard.last())
|
||||
.ok_or_else(|| io::Error::other("source metadata parent was not pinned"))?;
|
||||
let opened = std::fs::File::from(pinned.try_clone()?);
|
||||
fsync_directory_handle_std(parent, Some(&opened))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fsync_dir_std(parent)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish a conditional repair and sync its directory in one owned executor.
|
||||
/// Cancellation cannot release its metadata/fleet/tier leases between rename
|
||||
/// and fsync. The last authority check runs after destination preparation.
|
||||
@@ -3193,32 +3186,26 @@ pub(crate) struct DurableFileReceipt {
|
||||
|
||||
#[cfg(unix)]
|
||||
fn open_anchored_metadata(path: &Path, publication_root: &PublicationRoot) -> io::Result<std::fs::File> {
|
||||
use rustix::fs::{Mode, OFlags, openat};
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let parent = path.parent().ok_or_else(|| io::Error::other("metadata has no parent"))?;
|
||||
existing_base_directory_suffix(parent, publication_root.path())
|
||||
.or_else(|_| existing_base_directory_suffix(parent, publication_root.io_path()))?;
|
||||
publication_root.open_readonly(path)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
use rustix::fs::{Mode, OFlags, openat};
|
||||
let parents = open_existing_directory_below_base_std(
|
||||
path.parent().ok_or_else(|| io::Error::other("metadata has no parent"))?,
|
||||
publication_root.path(),
|
||||
publication_root,
|
||||
)?;
|
||||
let directory = parents
|
||||
.last()
|
||||
.ok_or_else(|| io::Error::other("missing metadata directory guard"))?;
|
||||
Ok(openat(
|
||||
directory,
|
||||
path.file_name().ok_or_else(|| io::Error::other("metadata has no name"))?,
|
||||
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?
|
||||
.into())
|
||||
}
|
||||
let logical_path = publication_root.path().join(publication_root.relative_path(path)?);
|
||||
#[cfg(target_os = "linux")]
|
||||
let path = logical_path.as_path();
|
||||
let parents = open_existing_directory_below_base_std(
|
||||
path.parent().ok_or_else(|| io::Error::other("metadata has no parent"))?,
|
||||
publication_root.path(),
|
||||
publication_root,
|
||||
)?;
|
||||
let directory = parents
|
||||
.last()
|
||||
.ok_or_else(|| io::Error::other("missing metadata directory guard"))?;
|
||||
Ok(openat(
|
||||
directory,
|
||||
path.file_name().ok_or_else(|| io::Error::other("metadata has no name"))?,
|
||||
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?
|
||||
.into())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -3694,10 +3681,8 @@ pub(crate) fn read_destination_file_with_commit_guard(
|
||||
};
|
||||
match open_linux_relative(&parent.file, Path::new(name), OFlags::RDONLY, Mode::empty()) {
|
||||
Ok(mut file) => {
|
||||
let len = file.metadata()?.len();
|
||||
let cap = usize::try_from(len).map_or(0, |len| len.saturating_add(1));
|
||||
let mut data = Vec::with_capacity(cap);
|
||||
std::io::Read::read_to_end(&mut std::io::Read::take(&mut file, len), &mut data)?;
|
||||
let mut data = Vec::new();
|
||||
std::io::Read::read_to_end(&mut file, &mut data)?;
|
||||
Ok(Some(data))
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
@@ -6274,7 +6259,6 @@ pub(crate) fn replace_multipart_source_metadata(
|
||||
publication_root: &PublicationRoot,
|
||||
) -> io::Result<()> {
|
||||
let parent = path.parent().ok_or_else(|| io::Error::other("MPU record has no parent"))?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _pinned = pin_multipart_source_directory(parent, base, publication_root)?;
|
||||
let guard = prepare_rename_commit_guard(parent, parent, base, publication_root)?;
|
||||
let staged = parent.join(format!(".mpu-record-{}", uuid::Uuid::new_v4()));
|
||||
@@ -6490,7 +6474,6 @@ pub(crate) fn install_multipart_prepared_head(
|
||||
if original_path.parent() != Some(parent) || original_path == canonical_head {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "MPU proxy must be a private sibling"));
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _pinned = pin_multipart_source_directory(parent, base, publication_root)?;
|
||||
let guard = prepare_rename_commit_guard(parent, parent, base, publication_root)?;
|
||||
rename_prepared_source_std(&prepared, &original_path, canonical_head, base, publication_root, &guard)?;
|
||||
@@ -6657,69 +6640,13 @@ fn read_multipart_metadata_in_pinned(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_and_sync_multipart_target_metadata(
|
||||
directory: &Path,
|
||||
name: &std::ffi::OsStr,
|
||||
base: &Path,
|
||||
publication_root: &PublicationRoot,
|
||||
validate: impl FnOnce(&[u8]) -> io::Result<()>,
|
||||
) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use rustix::fs::{Mode, OFlags, openat};
|
||||
let mut pinned = pin_multipart_source_directory(directory, base, publication_root)?;
|
||||
let dir_fd = pinned
|
||||
.last()
|
||||
.ok_or_else(|| io::Error::other("missing multipart target directory"))?;
|
||||
let mut file =
|
||||
std::fs::File::from(openat(dir_fd, name, OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty())?);
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "multipart metadata is not a regular file"));
|
||||
}
|
||||
let bytes = read_multipart_metadata_bytes(&mut file, Some(metadata.len()), None)?;
|
||||
validate(&bytes)?;
|
||||
file.sync_data()?;
|
||||
let opened = std::fs::File::from(pinned.pop().ok_or_else(|| io::Error::other("empty target directory guard"))?);
|
||||
fsync_directory_handle_std(directory, Some(&opened))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _pinned = pin_multipart_source_directory(directory, base, publication_root)?;
|
||||
let file = directory.join(name);
|
||||
let bytes = read_multipart_metadata(&file, base, publication_root, None)?;
|
||||
validate(&bytes)?;
|
||||
sync_file(&file)?;
|
||||
fsync_dir_std(directory)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_multipart_metadata(
|
||||
path: &Path,
|
||||
base: &Path,
|
||||
publication_root: &PublicationRoot,
|
||||
maximum: Option<u64>,
|
||||
) -> io::Result<Vec<u8>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::other("multipart metadata has no parent"))?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| io::Error::other("multipart metadata has no name"))?;
|
||||
if name.is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "multipart metadata has no name"));
|
||||
}
|
||||
existing_base_directory_suffix(parent, base)?;
|
||||
let mut file = publication_root.open_readonly(path)?;
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "multipart metadata is not a regular file"));
|
||||
}
|
||||
read_multipart_metadata_bytes(&mut file, Some(metadata.len()), maximum)
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let parent = path
|
||||
.parent()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::meta::{
|
||||
RebalanceMetaMergeOutcome, RebalanceMetadataLoadError, clone_first_arc, clone_rebalance_pool_stats,
|
||||
defer_bucket_in_rebalance_queue, ensure_valid_rebalance_pool_index, invalid_rebalance_pool_index_error,
|
||||
is_rebalance_actively_running, is_rebalance_conflicting_with_decommission, mark_rebalance_bucket_done, merge_rebalance_meta,
|
||||
percent_free_ratio, rebalance_metadata_not_initialized_error, record_rebalance_cleanup_warning_in_meta,
|
||||
RebalanceMetaMergeOutcome, clone_first_arc, clone_rebalance_pool_stats, defer_bucket_in_rebalance_queue,
|
||||
ensure_valid_rebalance_pool_index, invalid_rebalance_pool_index_error, is_rebalance_actively_running,
|
||||
is_rebalance_conflicting_with_decommission, mark_rebalance_bucket_done, merge_rebalance_meta, percent_free_ratio,
|
||||
rebalance_metadata_not_initialized_error, record_rebalance_cleanup_warning_in_meta,
|
||||
record_rebalance_stop_propagation_snapshot, resolve_next_rebalance_bucket, rollback_rebalance_start_meta_snapshot_for_id,
|
||||
should_accept_rebalance_stats_update, should_pool_participate, stop_rebalance_meta_snapshot_for_id,
|
||||
validate_init_rebalance_state,
|
||||
@@ -853,26 +853,6 @@ impl ECStore {
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Admin-only rebalance status snapshot. Diagnostic reads may use the last
|
||||
/// published state when the persisted record is temporarily unavailable;
|
||||
/// mutation paths continue to use the authoritative loaders above.
|
||||
pub async fn rebalance_admin_diagnostic_snapshot(&self) -> (Option<RebalanceMeta>, &'static str) {
|
||||
let fallback = self.rebalance_meta.read().await.clone();
|
||||
match self.rebalance_status_meta_snapshot().await {
|
||||
Ok(snapshot) => (snapshot, "fresh"),
|
||||
Err(err) => {
|
||||
let status = if error_chain_has_rebalance_metadata_load_error(&err) {
|
||||
"recovery_required"
|
||||
} else if fallback.is_some() {
|
||||
"stale"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
(fallback, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted rebalance metadata, or `None` when no rebalance was recorded.
|
||||
async fn load_rebalance_status_meta(&self) -> Result<Option<RebalanceMeta>> {
|
||||
let pool = clone_first_arc(&self.pools, "refresh_rebalance_status_meta: no pools available")?;
|
||||
@@ -880,7 +860,7 @@ impl ECStore {
|
||||
match persisted.load(pool).await {
|
||||
Ok(()) => Ok(Some(persisted)),
|
||||
Err(Error::ConfigNotFound) => Ok(None),
|
||||
Err(err) => Err(Error::other_with_context("rebalance metadata refresh failed during pool status", err)),
|
||||
Err(err) => Err(Error::other(format!("rebalance metadata refresh failed during pool status: {err}"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1467,21 +1447,6 @@ impl ECStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn error_chain_has_rebalance_metadata_load_error(err: &(dyn std::error::Error + 'static)) -> bool {
|
||||
let mut current = Some(err);
|
||||
while let Some(error) = current {
|
||||
if error.downcast_ref::<RebalanceMetadataLoadError>().is_some() {
|
||||
return true;
|
||||
}
|
||||
current = if let Some(io) = error.downcast_ref::<std::io::Error>() {
|
||||
io.get_ref().map(|source| source as &(dyn std::error::Error + 'static))
|
||||
} else {
|
||||
error.source()
|
||||
};
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -154,8 +154,7 @@ impl RebalanceMeta {
|
||||
ver => return Err(rebalance_meta_load_unknown_version_error(ver)),
|
||||
}
|
||||
|
||||
let meta: Self = rmp_serde::from_read(Cursor::new(&data[4..]))
|
||||
.map_err(|source| Error::other(RebalanceMetadataLoadError::Decode(source)))?;
|
||||
let meta: Self = rmp_serde::from_read(Cursor::new(&data[4..]))?;
|
||||
*self = meta;
|
||||
|
||||
self.last_refreshed_at = Some(OffsetDateTime::now_utc());
|
||||
@@ -260,28 +259,16 @@ pub(super) fn first_rebalance_bucket(pool_stat: &RebalanceStats) -> Option<Strin
|
||||
pool_stat.buckets.first().cloned()
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(super) enum RebalanceMetadataLoadError {
|
||||
#[error("rebalance metadata load failed: metadata payload is too short")]
|
||||
PayloadTooShort,
|
||||
#[error("rebalance metadata load failed: unknown format {0}")]
|
||||
UnknownFormat(u16),
|
||||
#[error("rebalance metadata load failed: unknown version {0}")]
|
||||
UnknownVersion(u16),
|
||||
#[error("rebalance metadata load failed: payload decode failed: {0}")]
|
||||
Decode(#[source] rmp_serde::decode::Error),
|
||||
}
|
||||
|
||||
pub(super) fn rebalance_meta_load_no_data_error() -> Error {
|
||||
Error::other(RebalanceMetadataLoadError::PayloadTooShort)
|
||||
Error::other("rebalance metadata load failed: metadata payload is too short")
|
||||
}
|
||||
|
||||
pub(super) fn rebalance_meta_load_unknown_format_error(fmt: u16) -> Error {
|
||||
Error::other(RebalanceMetadataLoadError::UnknownFormat(fmt))
|
||||
Error::other(format!("rebalance metadata load failed: unknown format {fmt}"))
|
||||
}
|
||||
|
||||
pub(super) fn rebalance_meta_load_unknown_version_error(ver: u16) -> Error {
|
||||
Error::other(RebalanceMetadataLoadError::UnknownVersion(ver))
|
||||
Error::other(format!("rebalance metadata load failed: unknown version {ver}"))
|
||||
}
|
||||
|
||||
pub fn encode_rebalance_stop_propagation_record(record: &RebalanceStopPropagationRecord) -> String {
|
||||
|
||||
@@ -5752,19 +5752,6 @@ impl SetDisks {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// A metadata update that misses write quorum is not rolled back, so
|
||||
// refuse it before writing when the target disks cannot reach quorum
|
||||
// even if every write succeeds; otherwise the change a caller reports
|
||||
// as failed would be served and spread by heal.
|
||||
let write_quorum = fi.write_quorum(self.default_write_quorum());
|
||||
let reachable = disks
|
||||
.iter()
|
||||
.map(|disk| disk.is_none().then_some(DiskError::DiskNotFound))
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(err) = reduce_write_quorum_errs(&reachable, OBJECT_OP_IGNORED_ERRS, write_quorum) {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
self.invalidate_get_object_metadata_cache(bucket, object).await;
|
||||
|
||||
let mut futures = Vec::with_capacity(disks.len());
|
||||
@@ -5794,7 +5781,7 @@ impl SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(err) = reduce_write_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, write_quorum) {
|
||||
if let Some(err) = reduce_write_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, fi.write_quorum(self.default_write_quorum())) {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
@@ -6351,6 +6338,40 @@ impl SetDisks {
|
||||
Ok(purged)
|
||||
}
|
||||
|
||||
/// Reclaim metadata-less directory trees discovered by a recursive empty
|
||||
/// bucket listing. The initial directory read fails closed if any slot is
|
||||
/// unavailable; per-prefix purges then reuse the full cross-disk scan.
|
||||
pub(crate) async fn purge_orphan_dir_objects_in_bucket(&self, bucket: &str) -> bool {
|
||||
let disks = self.get_disks_internal().await;
|
||||
if disks.is_empty() || disks.iter().any(Option::is_none) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut prefixes = HashSet::new();
|
||||
for disk in disks.iter().flatten() {
|
||||
let entries = match disk.list_dir("", bucket, "", 0).await {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return false,
|
||||
};
|
||||
prefixes.extend(
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| entry.ends_with(SLASH_SEPARATOR) && is_safe_orphan_dir_entry(entry)),
|
||||
);
|
||||
}
|
||||
|
||||
let mut prefixes = prefixes.into_iter().collect::<Vec<_>>();
|
||||
prefixes.sort_unstable();
|
||||
let mut purged = false;
|
||||
for prefix in prefixes {
|
||||
match self.purge_orphan_dir_object(bucket, &prefix).await {
|
||||
Ok(prefix_purged) => purged |= prefix_purged,
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
purged
|
||||
}
|
||||
|
||||
/// Orphan cleanup must not infer a complete scan from only the online
|
||||
/// subset of a set's disks.
|
||||
pub(crate) async fn orphan_purge_has_complete_disk_set(&self) -> bool {
|
||||
@@ -9453,7 +9474,7 @@ mod tests {
|
||||
"lifecycle-inline-data-get-fanout-bucket",
|
||||
"lifecycle-inline-data-get-object",
|
||||
GET_OBJECT_PATH_LEGACY_DUPLEX,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9464,7 +9485,7 @@ mod tests {
|
||||
"lifecycle-bounded-inline-data-get-fanout-bucket",
|
||||
"lifecycle-bounded-inline-data-get-object",
|
||||
GET_OBJECT_PATH_LEGACY_DUPLEX,
|
||||
true,
|
||||
Some("true"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9475,7 +9496,7 @@ mod tests {
|
||||
RUSTFS_META_BUCKET,
|
||||
"buckets/.usage-cache/lifecycle-inline-data-get-object",
|
||||
GET_OBJECT_PATH_INTERNAL_META,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9576,7 +9597,7 @@ mod tests {
|
||||
bucket: &'static str,
|
||||
object_prefix: &str,
|
||||
expected_path: &'static str,
|
||||
bounded_fanout: bool,
|
||||
bounded_fanout_env: Option<&'static str>,
|
||||
) {
|
||||
const DISKS: usize = 4;
|
||||
let object = object_with_initial_data_shards(bucket, object_prefix, 2, 3);
|
||||
@@ -9596,26 +9617,16 @@ mod tests {
|
||||
[
|
||||
("RUSTFS_GET_METADATA_EARLY_STOP_ENABLE", Some("true")),
|
||||
("RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE", Some("true")),
|
||||
(
|
||||
"RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT",
|
||||
Some(if bounded_fanout { "true" } else { "false" }),
|
||||
),
|
||||
("RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT", bounded_fanout_env),
|
||||
],
|
||||
async {
|
||||
let barrier_disk = bounded_spare_disk_index(bucket, &object, 2, 2);
|
||||
let barrier_disk = if bounded_fanout_env.is_some() {
|
||||
bounded_spare_disk_index(bucket, &object, 2, 2)
|
||||
} else {
|
||||
3
|
||||
};
|
||||
let barrier =
|
||||
rename_fanout_barrier::arm(&object, barrier_disk, rename_fanout_barrier::PHASE_READ_VERSION);
|
||||
// Keep one initial bounded read pending so its hedge is scheduled;
|
||||
// eager fanout needs all quorum reads parked until the spare starts.
|
||||
let quorum_disks = if bounded_fanout {
|
||||
vec![bounded_metadata_fanout_order(bucket, &object, DISKS, 2)[0]]
|
||||
} else {
|
||||
(0..DISKS).filter(|&index| index != barrier_disk).collect()
|
||||
};
|
||||
let quorum_barriers = quorum_disks
|
||||
.into_iter()
|
||||
.map(|index| rename_fanout_barrier::arm(&object, index, rename_fanout_barrier::PHASE_READ_VERSION))
|
||||
.collect::<Vec<_>>();
|
||||
let disks_for_read = disks.clone();
|
||||
let object_for_read = object.clone();
|
||||
let mut read = tokio::spawn(async move {
|
||||
@@ -9637,9 +9648,6 @@ mod tests {
|
||||
tokio::time::timeout(BARRIER_PAUSE_GUARD, barrier.wait_until_paused())
|
||||
.await
|
||||
.expect("metadata fanout should pause the spare metadata task");
|
||||
for quorum_barrier in &quorum_barriers {
|
||||
quorum_barrier.release();
|
||||
}
|
||||
let (parts_metadata, errs, diagnostics) = tokio::time::timeout(BARRIER_PAUSE_GUARD, &mut read)
|
||||
.await
|
||||
.expect("verified inline quorum should abort the paused eager fanout")
|
||||
|
||||
@@ -597,8 +597,10 @@ mod tests {
|
||||
.iter()
|
||||
.map(|slot| slot.file_info().expect("every disk must have arrived").clone())
|
||||
.collect::<Vec<_>>();
|
||||
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; 4], &completed_metadata, &vec![None; 4], 2)
|
||||
.expect("three matching B disks must select the current identity");
|
||||
let (_, selected, selection_quorum) =
|
||||
SetDisks::select_valid_fileinfo(&vec![None; 4], &completed_metadata, &vec![None; 4], "", 2, 3)
|
||||
.expect("three matching B disks must select the current identity");
|
||||
assert_eq!(selection_quorum, 3);
|
||||
assert_eq!(selected.version_id, expected_current.version_id);
|
||||
assert_eq!(selected.mod_time, expected_current.mod_time);
|
||||
assert_eq!(selected.data_dir, expected_current.data_dir);
|
||||
|
||||
@@ -665,20 +665,14 @@ impl SetDisks {
|
||||
read_quorum
|
||||
}
|
||||
|
||||
/// The commit-proof rule kept for internal metadata in the system bucket.
|
||||
/// Pool metadata's PREPARE/COMMIT recovery and identity epochs treat an
|
||||
/// ordinary read as proof that a record committed: when enough copies
|
||||
/// answer to prove write quorum, the latest version must hold it, and a
|
||||
/// degraded read never serves a newer version below write quorum. A split
|
||||
/// write therefore stays unreadable until native recovery resolves it.
|
||||
fn select_committed_fileinfo(
|
||||
pub(super) fn select_valid_fileinfo(
|
||||
disks: &[Option<DiskStore>],
|
||||
parts_metadata: &[FileInfo],
|
||||
errs: &[Option<DiskError>],
|
||||
version_id: &str,
|
||||
read_quorum: usize,
|
||||
write_quorum: usize,
|
||||
) -> disk::error::Result<(Vec<Option<DiskStore>>, FileInfo)> {
|
||||
) -> disk::error::Result<(Vec<Option<DiskStore>>, FileInfo, usize)> {
|
||||
let selection_quorum =
|
||||
Self::latest_fileinfo_selection_quorum(version_id, parts_metadata, errs, read_quorum, write_quorum);
|
||||
let (usable_metadata, has_read_error) = Self::usable_fileinfo_count(parts_metadata, errs);
|
||||
@@ -689,71 +683,14 @@ impl SetDisks {
|
||||
&& usable_metadata >= write_quorum
|
||||
&& selection_quorum == read_quorum
|
||||
{
|
||||
return Self::pick_degraded_latest_fileinfo(disks, parts_metadata, errs, read_quorum, write_quorum);
|
||||
let (online_disks, fi) = Self::pick_degraded_latest_fileinfo(disks, parts_metadata, errs, read_quorum, write_quorum)?;
|
||||
return Ok((online_disks, fi, read_quorum));
|
||||
}
|
||||
|
||||
let (online_disks, mod_time, etag) = Self::list_online_disks(disks, parts_metadata, errs, selection_quorum);
|
||||
let fi = Self::pick_valid_fileinfo(parts_metadata, mod_time, etag, selection_quorum)?;
|
||||
|
||||
Ok((online_disks, fi))
|
||||
}
|
||||
|
||||
/// Select the version a read of `bucket` serves: S3 buckets use MinIO's
|
||||
/// read-quorum rule (`select_valid_fileinfo`); the system bucket keeps the
|
||||
/// commit-proof rule (`select_committed_fileinfo`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn select_fileinfo_for_read(
|
||||
bucket: &str,
|
||||
disks: &[Option<DiskStore>],
|
||||
parts_metadata: &[FileInfo],
|
||||
errs: &[Option<DiskError>],
|
||||
version_id: &str,
|
||||
read_quorum: usize,
|
||||
write_quorum: usize,
|
||||
) -> disk::error::Result<(Vec<Option<DiskStore>>, FileInfo)> {
|
||||
if crate::bucket::utils::is_meta_bucketname(bucket) {
|
||||
Self::select_committed_fileinfo(disks, parts_metadata, errs, version_id, read_quorum, write_quorum)
|
||||
} else {
|
||||
Self::select_valid_fileinfo(disks, parts_metadata, errs, read_quorum)
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the version an S3 read serves with MinIO's rule (`getObjectFileInfo`
|
||||
/// -> `listOnlineDisks` -> `pickValidFileInfo`): the modification time
|
||||
/// shared by the most copies wins when it reaches `read_quorum`, a tie
|
||||
/// goes to the newer time, and one content identity at that time must
|
||||
/// itself reach `read_quorum`. Heal picks the latest version the same
|
||||
/// way, so a read returns the version heal converges the set to; a write
|
||||
/// that failed after reaching exactly `read_quorum` copies is readable
|
||||
/// instead of failing until heal runs, as listing already shows it.
|
||||
///
|
||||
/// `read_quorum` follows the parity most copies agree on, which delete
|
||||
/// markers and empty versions can lower below the selected version's own
|
||||
/// data blocks; like MinIO's `getLatestFileInfo`, a version with local
|
||||
/// shards also needs that many copies, or HEAD would report a version
|
||||
/// GET cannot decode.
|
||||
pub(super) fn select_valid_fileinfo(
|
||||
disks: &[Option<DiskStore>],
|
||||
parts_metadata: &[FileInfo],
|
||||
errs: &[Option<DiskError>],
|
||||
read_quorum: usize,
|
||||
) -> disk::error::Result<(Vec<Option<DiskStore>>, FileInfo)> {
|
||||
let (online_disks, mod_time, etag) = Self::list_online_disks(disks, parts_metadata, errs, read_quorum);
|
||||
let (fi, copies) = Self::find_file_info_in_quorum_counted(parts_metadata, &mod_time, &etag, read_quorum)?;
|
||||
if !fi.deleted && !fi.is_remote() && fi.size > 0 && copies < fi.erasure.data_blocks {
|
||||
// The shortcut counts byte-identical copies only; count every copy
|
||||
// of the content identity before refusing the version.
|
||||
let identity = Self::file_info_quorum_hash(&fi);
|
||||
let copies = parts_metadata
|
||||
.iter()
|
||||
.filter(|meta| file_info_is_valid_for_metadata(meta) && Self::file_info_quorum_hash(meta) == identity)
|
||||
.count();
|
||||
if copies < fi.erasure.data_blocks {
|
||||
return Err(DiskError::ErasureReadQuorum);
|
||||
}
|
||||
}
|
||||
|
||||
Ok((online_disks, fi))
|
||||
Ok((online_disks, fi, selection_quorum))
|
||||
}
|
||||
|
||||
pub(super) fn pick_valid_fileinfo(
|
||||
@@ -1088,23 +1025,6 @@ impl SetDisks {
|
||||
etag: &Option<String>,
|
||||
quorum: usize,
|
||||
) -> disk::error::Result<FileInfo> {
|
||||
Self::find_file_info_in_quorum_counted(metas, mod_time, etag, quorum).map(|(fi, _)| fi)
|
||||
}
|
||||
|
||||
/// `find_file_info_in_quorum`, also returning how many copies carry the
|
||||
/// selected content identity.
|
||||
///
|
||||
/// Two identities can share the top count only in a data = parity set
|
||||
/// split exactly in half at one modification time, such as a metadata
|
||||
/// update that failed after reaching half the drives. The identity held
|
||||
/// by the lowest-index disk wins, so every read and heal picks the same
|
||||
/// side and a retried metadata write can repair the split.
|
||||
fn find_file_info_in_quorum_counted(
|
||||
metas: &[FileInfo],
|
||||
mod_time: &Option<OffsetDateTime>,
|
||||
etag: &Option<String>,
|
||||
quorum: usize,
|
||||
) -> disk::error::Result<(FileInfo, usize)> {
|
||||
if quorum < 1 {
|
||||
warn!("find_file_info_in_quorum: quorum < 1");
|
||||
return Err(DiskError::ErasureReadQuorum);
|
||||
@@ -1133,7 +1053,7 @@ impl SetDisks {
|
||||
if same_count >= quorum && same_count > total_valid - same_count {
|
||||
let mut fi = first.clone();
|
||||
fi.is_latest = fi.successor_mod_time.is_none();
|
||||
return Ok((fi, same_count));
|
||||
return Ok(fi);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1186,12 +1106,15 @@ impl SetDisks {
|
||||
*count_map.entry(hash).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let max_count = count_map.values().copied().max().unwrap_or(0);
|
||||
let max_val = meta_hashes
|
||||
.iter()
|
||||
.flatten()
|
||||
.find(|hash| count_map.get(*hash) == Some(&max_count))
|
||||
.copied();
|
||||
let mut max_val = None;
|
||||
let mut max_count = 0;
|
||||
|
||||
for (&val, &count) in &count_map {
|
||||
if count > max_count {
|
||||
max_val = Some(val);
|
||||
max_count = count;
|
||||
}
|
||||
}
|
||||
|
||||
if max_count < quorum {
|
||||
warn!(
|
||||
@@ -1232,24 +1155,17 @@ impl SetDisks {
|
||||
if found {
|
||||
let mut fi = found_fi.expect("operation should succeed");
|
||||
|
||||
// Scan in disk order so that two version-stack shapes with equal
|
||||
// counts resolve the same way on every read.
|
||||
let quorum_props = metas
|
||||
.iter()
|
||||
.zip(meta_hashes.iter())
|
||||
.filter(|(meta, hash)| hash.is_some() && **hash == max_val && file_info_is_valid_for_metadata(meta))
|
||||
.map(|(meta, _)| ObjProps {
|
||||
successor_mod_time: meta.successor_mod_time,
|
||||
num_versions: meta.num_versions,
|
||||
})
|
||||
.find(|props| valid_obj_map.get(props).is_some_and(|&count| count >= quorum));
|
||||
if let Some(props) = quorum_props {
|
||||
fi.successor_mod_time = props.successor_mod_time;
|
||||
fi.num_versions = props.num_versions;
|
||||
fi.is_latest = props.successor_mod_time.is_none();
|
||||
for (val, &count) in &valid_obj_map {
|
||||
if count >= quorum {
|
||||
fi.successor_mod_time = val.successor_mod_time;
|
||||
fi.num_versions = val.num_versions;
|
||||
fi.is_latest = val.successor_mod_time.is_none();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok((fi, max_count));
|
||||
return Ok(fi);
|
||||
}
|
||||
|
||||
warn!("find_file_info_in_quorum: fileinfo not found");
|
||||
|
||||
+109
-291
@@ -1024,19 +1024,6 @@ impl GetObjectFileInfo {
|
||||
self.as_parts().0
|
||||
}
|
||||
|
||||
/// Marks an exact-version selection as the object's latest version after
|
||||
/// the versions above it were skipped as dangling.
|
||||
fn mark_selected_as_latest(&mut self) {
|
||||
if let Some(OwnedGetObjectFileInfo {
|
||||
payload: OwnedGetObjectFileInfoPayload::Direct { fi, .. },
|
||||
..
|
||||
}) = self.owned.as_mut()
|
||||
{
|
||||
fi.is_latest = true;
|
||||
fi.successor_mod_time = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn parts_metadata(&self) -> &[FileInfo] {
|
||||
self.as_parts().1
|
||||
}
|
||||
@@ -1803,7 +1790,7 @@ mod prepared_get_object_metadata_tests {
|
||||
assert!(original.is_complete());
|
||||
let (original_metadata, original_errors, _) = original.into_legacy();
|
||||
assert!(original_errors.iter().all(Option::is_none));
|
||||
let (_, generation_a) = SetDisks::select_valid_fileinfo(&disks, &original_metadata, &original_errors, 2)
|
||||
let (_, generation_a, _) = SetDisks::select_valid_fileinfo(&disks, &original_metadata, &original_errors, "", 2, 3)
|
||||
.expect("generation A must have real metadata quorum");
|
||||
assert!(!generation_a.inline_data(), "fixture must use non-inline shard files");
|
||||
let order = bounded_metadata_fanout_order(bucket, &object, 4, 2);
|
||||
@@ -1864,8 +1851,8 @@ mod prepared_get_object_metadata_tests {
|
||||
assert!(replacement.is_complete());
|
||||
let (replacement_metadata, replacement_errors, _) = replacement.into_legacy();
|
||||
assert!(replacement_errors.iter().all(Option::is_none));
|
||||
let (online, generation_b) =
|
||||
SetDisks::select_valid_fileinfo(&disks, &replacement_metadata, &replacement_errors, 2)
|
||||
let (online, generation_b, _) =
|
||||
SetDisks::select_valid_fileinfo(&disks, &replacement_metadata, &replacement_errors, "", 2, 3)
|
||||
.expect("replacement must have healthy real metadata quorum");
|
||||
assert_eq!(online.iter().flatten().count(), 4);
|
||||
assert_ne!(generation_a.data_dir, generation_b.data_dir);
|
||||
@@ -6842,7 +6829,7 @@ impl SetDisks {
|
||||
let (metadata, errors) = Self::read_all_fileinfo(&disks, "", bucket, object, version_id, false, false, false).await?;
|
||||
// Match delete_object_version_with_purge, independently of data parity or online inventory size.
|
||||
let quorum = self.set_drive_count / 2 + 1;
|
||||
let (_, marker) = Self::select_valid_fileinfo(&disks, &metadata, &errors, quorum)
|
||||
let (_, marker, _) = Self::select_valid_fileinfo(&disks, &metadata, &errors, version_id, quorum, quorum)
|
||||
.map_err(|error| to_object_err(error.into(), vec![bucket, object, version_id]))?;
|
||||
if !marker.deleted || marker.tier_free_version() || marker.version_id.unwrap_or_else(Uuid::nil) != requested_version {
|
||||
return Err(Error::DataMovementOverwriteErr(
|
||||
@@ -10488,6 +10475,13 @@ mod tests {
|
||||
|
||||
assert!(!purged, "an incomplete disk scan must not claim the tree is an orphan");
|
||||
assert!(prefix_dir.join("nested/leaf").exists(), "online disk contents must remain untouched");
|
||||
|
||||
let bucket_purged = set.purge_orphan_dir_objects_in_bucket("bucket").await;
|
||||
assert!(!bucket_purged, "bucket-wide cleanup must fail closed with an unavailable disk slot");
|
||||
assert!(
|
||||
prefix_dir.join("nested/leaf").exists(),
|
||||
"bucket-wide cleanup must preserve the online tree"
|
||||
);
|
||||
}
|
||||
|
||||
// Cross-disk safety: if any drive still holds object data under the prefix, refuse
|
||||
@@ -11982,121 +11976,41 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_latest_fileinfo_selection_breaks_same_time_tie_in_disk_order() {
|
||||
fn test_latest_fileinfo_selection_quorum_requires_write_quorum_when_full_metadata_is_available() {
|
||||
let mod_time = OffsetDateTime::now_utc();
|
||||
let data_dir = Uuid::new_v4();
|
||||
let tagged = |index, tag: &str| {
|
||||
let mut fi = quorum_test_fileinfo(mod_time, data_dir, "part-etag", index);
|
||||
fi.metadata.insert("x-amz-tagging".to_string(), tag.to_string());
|
||||
fi
|
||||
};
|
||||
let metas = vec![
|
||||
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 1),
|
||||
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 2),
|
||||
quorum_test_fileinfo(mod_time, data_dir, "part-etag-b", 3),
|
||||
quorum_test_fileinfo(mod_time, data_dir, "part-etag-b", 4),
|
||||
];
|
||||
let errs = vec![None, None, None, None];
|
||||
|
||||
// A tagging write that failed after reaching half the drives.
|
||||
for (metas, expected) in [
|
||||
(vec![tagged(1, "a=1"), tagged(2, "a=1"), tagged(3, "a=2"), tagged(4, "a=2")], "a=1"),
|
||||
(vec![tagged(1, "a=2"), tagged(2, "a=1"), tagged(3, "a=1"), tagged(4, "a=2")], "a=2"),
|
||||
] {
|
||||
// Each call hashes into fresh maps; a map-order choice would flip.
|
||||
for _ in 0..32 {
|
||||
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
|
||||
.expect("a same-time half split serves the side on the lowest-index disk");
|
||||
let healed =
|
||||
SetDisks::find_file_info_in_quorum(&metas, &Some(mod_time), &None, 2).expect("heal resolves the same split");
|
||||
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
|
||||
|
||||
assert_eq!(selected.metadata.get("x-amz-tagging").map(String::as_str), Some(expected));
|
||||
assert_eq!(healed.metadata.get("x-amz-tagging").map(String::as_str), Some(expected));
|
||||
}
|
||||
}
|
||||
assert_eq!(quorum, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_latest_fileinfo_selection_requires_the_versions_own_data_blocks() {
|
||||
let old_mod_time = OffsetDateTime::now_utc();
|
||||
let new_mod_time = old_mod_time + time::Duration::seconds(1);
|
||||
let old = |index| quorum_test_fileinfo(old_mod_time, Uuid::from_u128(1), "part-etag-old", index);
|
||||
let new = |index| {
|
||||
let mut fi = quorum_test_fileinfo(new_mod_time, Uuid::from_u128(2), "part-etag-new", index);
|
||||
fi.erasure.data_blocks = 3;
|
||||
fi.erasure.parity_blocks = 1;
|
||||
fi
|
||||
};
|
||||
let errs = vec![None, None, None, None];
|
||||
fn test_latest_fileinfo_selection_quorum_preserves_read_quorum_for_version_or_degraded_reads() {
|
||||
let mod_time = OffsetDateTime::now_utc();
|
||||
let data_dir = Uuid::new_v4();
|
||||
let metas = vec![
|
||||
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 1),
|
||||
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 2),
|
||||
FileInfo::default(),
|
||||
FileInfo::default(),
|
||||
];
|
||||
let degraded_errs = vec![None, None, Some(DiskError::DiskNotFound), Some(DiskError::DiskNotFound)];
|
||||
let clean_errs = vec![None, None, None, None];
|
||||
|
||||
// Read quorum two comes from the 2+2 copies; the newer 3+1 version
|
||||
// wins the time tie but cannot be decoded from two shards.
|
||||
let metas = vec![old(1), old(2), new(3), new(4)];
|
||||
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2);
|
||||
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
|
||||
|
||||
let metas = vec![old(1), new(2), new(3), new(4)];
|
||||
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
|
||||
.expect("three copies decode a 3+1 version");
|
||||
assert_eq!(selected.mod_time, Some(new_mod_time));
|
||||
|
||||
// Replication-status drift does not change the content identity, so
|
||||
// the drifted copy still counts toward the version's data blocks.
|
||||
let replication_status_key = format!(
|
||||
"{}{}",
|
||||
rustfs_utils::http::RUSTFS_INTERNAL_PREFIX,
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_STATUS
|
||||
);
|
||||
let mut drifted = new(4);
|
||||
drifted
|
||||
.metadata
|
||||
.insert(replication_status_key, "target-a=PENDING;".to_string());
|
||||
let metas = vec![old(1), new(2), new(3), drifted];
|
||||
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
|
||||
.expect("a replication-status drift still leaves three copies of the version");
|
||||
assert_eq!(selected.mod_time, Some(new_mod_time));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_object_meta_below_write_quorum_writes_nothing() {
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
use crate::storage_api_contracts::object::ObjectIO as _;
|
||||
|
||||
let (_dirs, set) = crate::ecstore_validation_blackbox::make_local_set_disks(4, 2).await;
|
||||
let (bucket, object) = ("metadata-write-quorum", "object");
|
||||
set.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created");
|
||||
set.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut crate::object_api::PutObjReader::from_vec(b"body".to_vec()),
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("object should commit on every drive");
|
||||
let disks = set.get_disks_internal().await;
|
||||
let (metadata, _) = SetDisks::read_all_fileinfo(&disks, "", bucket, object, "", false, false, false)
|
||||
.await
|
||||
.expect("committed metadata should be readable");
|
||||
let mut update = metadata[0].clone();
|
||||
update.metadata.insert("x-amz-meta-probe".to_string(), "written".to_string());
|
||||
|
||||
// A read that selected one side of a 2+2 split returns two disks.
|
||||
let mut targets = disks.clone();
|
||||
targets[2] = None;
|
||||
targets[3] = None;
|
||||
let err = set
|
||||
.update_object_meta(bucket, object, update, &targets)
|
||||
.await
|
||||
.expect_err("two of four disks cannot reach write quorum");
|
||||
|
||||
assert_eq!(err, DiskError::ErasureWriteQuorum);
|
||||
let (after, _) = SetDisks::read_all_fileinfo(&disks, "", bucket, object, "", false, false, false)
|
||||
.await
|
||||
.expect("metadata should stay readable");
|
||||
assert!(
|
||||
after.iter().all(|fi| !fi.metadata.contains_key("x-amz-meta-probe")),
|
||||
"a refused update must not reach any disk"
|
||||
);
|
||||
assert_eq!(SetDisks::latest_fileinfo_selection_quorum("", &metas, °raded_errs, 2, 3), 2);
|
||||
assert_eq!(SetDisks::latest_fileinfo_selection_quorum("version-id", &metas, &clean_errs, 2, 3), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_latest_fileinfo_selection_keeps_older_majority_over_partial_overwrite() {
|
||||
fn test_latest_fileinfo_selection_quorum_keeps_read_quorum_for_partial_overwrite_with_read_error() {
|
||||
let old_mod_time = OffsetDateTime::now_utc();
|
||||
let new_mod_time = old_mod_time + time::Duration::seconds(1);
|
||||
let old_data_dir = Uuid::new_v4();
|
||||
@@ -12109,15 +12023,26 @@ mod tests {
|
||||
];
|
||||
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
|
||||
|
||||
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
|
||||
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
|
||||
let (online_disks, mod_time, etag) = SetDisks::list_online_disks(&vec![None; metas.len()], &metas, &errs, quorum);
|
||||
let fi = SetDisks::pick_valid_fileinfo(&metas, mod_time, etag, quorum)
|
||||
.expect("old metadata should remain readable with read quorum");
|
||||
|
||||
assert_eq!(quorum, 2);
|
||||
assert_eq!(online_disks.len(), metas.len());
|
||||
assert_eq!(fi.data_dir, Some(old_data_dir));
|
||||
assert_eq!(fi.parts[0].etag, "part-etag-old");
|
||||
|
||||
let (_, selected, selected_quorum) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
|
||||
.expect("old metadata should remain selectable with read quorum");
|
||||
assert_eq!(selected_quorum, 2);
|
||||
assert_eq!(selected.data_dir, Some(old_data_dir));
|
||||
assert_eq!(selected.parts[0].etag, "part-etag-old");
|
||||
assert!(selected.is_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_latest_fileinfo_selection_serves_newer_read_quorum_with_read_error() {
|
||||
fn test_latest_fileinfo_selection_rejects_partial_latest_read_quorum_with_read_error() {
|
||||
let old_mod_time = OffsetDateTime::now_utc();
|
||||
let new_mod_time = old_mod_time + time::Duration::seconds(1);
|
||||
let old_data_dir = Uuid::new_v4();
|
||||
@@ -12130,120 +12055,9 @@ mod tests {
|
||||
];
|
||||
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
|
||||
|
||||
// The new version may hold write quorum through the missing disk; the
|
||||
// old one cannot, and heal converges the set to the new version.
|
||||
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
|
||||
.expect("the newer version reaches read quorum");
|
||||
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
|
||||
|
||||
assert_eq!(selected.data_dir, Some(new_data_dir));
|
||||
assert_eq!(selected.mod_time, Some(new_mod_time));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_latest_fileinfo_selection_serves_newer_side_of_complete_split() {
|
||||
let old_mod_time = OffsetDateTime::now_utc();
|
||||
let new_mod_time = old_mod_time + time::Duration::seconds(1);
|
||||
let old_data_dir = Uuid::new_v4();
|
||||
let new_data_dir = Uuid::new_v4();
|
||||
let old = |index| quorum_test_fileinfo(old_mod_time, old_data_dir, "part-etag-old", index);
|
||||
let new = |index| quorum_test_fileinfo(new_mod_time, new_data_dir, "part-etag-new", index);
|
||||
let errs = vec![None, None, None, None];
|
||||
|
||||
for metas in [vec![old(1), old(2), new(3), new(4)], vec![new(1), old(2), new(3), old(4)]] {
|
||||
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
|
||||
.expect("a 2+2 split with every disk answering serves the newer version");
|
||||
|
||||
assert_eq!(selected.data_dir, Some(new_data_dir));
|
||||
assert_eq!(selected.parts[0].etag, "part-etag-new");
|
||||
assert!(selected.is_latest);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_bucket_reads_keep_the_commit_proof_rule() {
|
||||
let old_mod_time = OffsetDateTime::now_utc();
|
||||
let new_mod_time = old_mod_time + time::Duration::seconds(1);
|
||||
let old_data_dir = Uuid::new_v4();
|
||||
let new_data_dir = Uuid::new_v4();
|
||||
let old = |index| quorum_test_fileinfo(old_mod_time, old_data_dir, "part-etag-old", index);
|
||||
let new = |index| quorum_test_fileinfo(new_mod_time, new_data_dir, "part-etag-new", index);
|
||||
|
||||
// A PREPARE/COMMIT split of pool metadata must stay unreadable so that
|
||||
// native recovery, not an ordinary read, decides whether it committed.
|
||||
for (metas, errs) in [
|
||||
(vec![old(1), old(2), new(3), new(4)], vec![None, None, None, None]),
|
||||
(
|
||||
vec![new(1), new(2), old(3), FileInfo::default()],
|
||||
vec![None, None, None, Some(DiskError::DiskNotFound)],
|
||||
),
|
||||
] {
|
||||
let disks = vec![None; metas.len()];
|
||||
let (_, served) = SetDisks::select_fileinfo_for_read("bucket", &disks, &metas, &errs, "", 2, 3)
|
||||
.expect("an S3 read serves the newer side at read quorum");
|
||||
assert_eq!(served.data_dir, Some(new_data_dir));
|
||||
|
||||
let internal = SetDisks::select_fileinfo_for_read(crate::disk::RUSTFS_META_BUCKET, &disks, &metas, &errs, "", 2, 3);
|
||||
assert!(matches!(internal, Err(DiskError::ErasureReadQuorum)));
|
||||
}
|
||||
|
||||
let committed = vec![new(1), new(2), new(3), old(4)];
|
||||
let (_, internal) = SetDisks::select_fileinfo_for_read(
|
||||
crate::disk::RUSTFS_META_BUCKET,
|
||||
&vec![None; committed.len()],
|
||||
&committed,
|
||||
&[None, None, None, None],
|
||||
"",
|
||||
2,
|
||||
3,
|
||||
)
|
||||
.expect("a write-quorum version stays readable in the system bucket");
|
||||
assert_eq!(internal.data_dir, Some(new_data_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_latest_fileinfo_selection_picks_version_stack_shape_in_disk_order() {
|
||||
let mod_time = OffsetDateTime::now_utc();
|
||||
let data_dir = Uuid::new_v4();
|
||||
let successor = mod_time + time::Duration::seconds(1);
|
||||
let shape = |index, successor_mod_time: Option<OffsetDateTime>, num_versions| {
|
||||
let mut fi = quorum_test_fileinfo(mod_time, data_dir, "part-etag", index);
|
||||
fi.successor_mod_time = successor_mod_time;
|
||||
fi.is_latest = successor_mod_time.is_none();
|
||||
fi.num_versions = num_versions;
|
||||
fi
|
||||
};
|
||||
let errs = vec![None, None, None, None];
|
||||
|
||||
for (metas, expected) in [
|
||||
(
|
||||
vec![
|
||||
shape(1, Some(successor), 2),
|
||||
shape(2, None, 1),
|
||||
shape(3, Some(successor), 2),
|
||||
shape(4, None, 1),
|
||||
],
|
||||
Some(successor),
|
||||
),
|
||||
(
|
||||
vec![
|
||||
shape(1, None, 1),
|
||||
shape(2, Some(successor), 2),
|
||||
shape(3, None, 1),
|
||||
shape(4, Some(successor), 2),
|
||||
],
|
||||
None,
|
||||
),
|
||||
] {
|
||||
// Each call hashes into fresh maps; a map-order choice would flip.
|
||||
for _ in 0..32 {
|
||||
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
|
||||
.expect("one content identity reaches read quorum");
|
||||
|
||||
assert_eq!(selected.successor_mod_time, expected);
|
||||
assert_eq!(selected.is_latest, expected.is_none());
|
||||
assert_eq!(selected.num_versions, if expected.is_none() { 1 } else { 2 });
|
||||
}
|
||||
}
|
||||
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -12261,9 +12075,11 @@ mod tests {
|
||||
let metas = vec![first, second, FileInfo::default(), FileInfo::default()];
|
||||
let errs = vec![None, None, Some(DiskError::DiskNotFound), Some(DiskError::DiskNotFound)];
|
||||
|
||||
let (_, mut selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
|
||||
.expect("read quorum should remain enough when no competing latest is visible");
|
||||
let (_, mut selected, selected_quorum) =
|
||||
SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
|
||||
.expect("read quorum should remain enough when no competing latest is visible");
|
||||
|
||||
assert_eq!(selected_quorum, 2);
|
||||
assert_eq!(selected.data_dir, Some(data_dir));
|
||||
assert_eq!(selected.parts[0].etag, "part-etag-old");
|
||||
assert!(selected.parts[0].checksums.is_none());
|
||||
@@ -12290,7 +12106,7 @@ mod tests {
|
||||
let metas = vec![first, second, FileInfo::default(), FileInfo::default()];
|
||||
let errs = vec![None, None, Some(DiskError::DiskNotFound), Some(DiskError::DiskNotFound)];
|
||||
|
||||
let (_, mut selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
|
||||
let (_, mut selected, _) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
|
||||
.expect("winner selection should defer sidecar decoding");
|
||||
let err = SetDisks::hydrate_selected_fileinfo_part_checksums(&mut selected)
|
||||
.expect_err("a malformed degraded winner must fail closed when checksums are requested");
|
||||
@@ -12357,9 +12173,10 @@ mod tests {
|
||||
let metas = vec![latest_meta, stale_stack_meta, newer_stack_meta, FileInfo::default()];
|
||||
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
|
||||
|
||||
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
|
||||
let (_, selected, selected_quorum) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
|
||||
.expect("same object version should stay readable despite derived version stack drift");
|
||||
|
||||
assert_eq!(selected_quorum, 3);
|
||||
assert_eq!(selected.data_dir, Some(data_dir));
|
||||
assert_eq!(selected.parts[0].etag, "part-etag");
|
||||
assert_eq!(selected.mod_time, Some(mod_time));
|
||||
@@ -12452,77 +12269,81 @@ mod tests {
|
||||
let metas = vec![meta_a, meta_b, meta_c, FileInfo::default()];
|
||||
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
|
||||
|
||||
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
|
||||
let (_, selected, selected_quorum) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
|
||||
.expect("replication status drift should not split readable object identity");
|
||||
|
||||
assert_eq!(selected_quorum, 3);
|
||||
assert_eq!(selected.data_dir, Some(data_dir));
|
||||
assert_eq!(selected.parts[0].etag, "part-etag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_latest_fileinfo_selection_serves_same_time_metadata_majority() {
|
||||
fn test_latest_fileinfo_selection_rejects_same_modtime_metadata_split_without_write_quorum() {
|
||||
let mod_time = OffsetDateTime::now_utc();
|
||||
let data_dir = Uuid::new_v4();
|
||||
let colored = |index, color: &str| {
|
||||
let mut fi = quorum_test_fileinfo(mod_time, data_dir, "part-etag", index);
|
||||
fi.metadata.insert("x-amz-meta-color".to_string(), color.to_string());
|
||||
fi
|
||||
};
|
||||
let mut old_meta_a = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 1);
|
||||
let mut old_meta_b = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 2);
|
||||
let mut partial_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 3);
|
||||
old_meta_a.metadata.insert("x-amz-meta-color".to_string(), "blue".to_string());
|
||||
old_meta_b.metadata.insert("x-amz-meta-color".to_string(), "blue".to_string());
|
||||
partial_meta
|
||||
.metadata
|
||||
.insert("x-amz-meta-color".to_string(), "red".to_string());
|
||||
let metas = vec![old_meta_a, old_meta_b, partial_meta, FileInfo::default()];
|
||||
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
|
||||
|
||||
// With one disk missing, only the two-copy side can hold write quorum,
|
||||
// whichever disk order the copies arrive in.
|
||||
for (metas, expected) in [
|
||||
(
|
||||
vec![colored(1, "blue"), colored(2, "blue"), colored(3, "red"), FileInfo::default()],
|
||||
"blue",
|
||||
),
|
||||
(vec![colored(1, "blue"), colored(2, "red"), colored(3, "red"), FileInfo::default()], "red"),
|
||||
] {
|
||||
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
|
||||
.expect("the read-quorum side of a same-time metadata split is served");
|
||||
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
|
||||
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
|
||||
|
||||
assert_eq!(selected.metadata.get("x-amz-meta-color").map(String::as_str), Some(expected));
|
||||
}
|
||||
assert_eq!(quorum, 2);
|
||||
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_latest_fileinfo_selection_serves_same_time_transition_majority() {
|
||||
fn test_latest_fileinfo_selection_rejects_same_modtime_partial_metadata_read_quorum() {
|
||||
let mod_time = OffsetDateTime::now_utc();
|
||||
let data_dir = Uuid::new_v4();
|
||||
let local = |index| quorum_test_fileinfo(mod_time, data_dir, "part-etag", index);
|
||||
let remote = |index| {
|
||||
let mut fi = quorum_test_fileinfo(mod_time, data_dir, "part-etag", index);
|
||||
fi.transition_status = TRANSITION_COMPLETE.to_string();
|
||||
fi.transition_tier = "WARM".to_string();
|
||||
fi.transitioned_objname = "remote/object".to_string();
|
||||
fi.transition_version_id = Some(Uuid::from_u128(7));
|
||||
fi
|
||||
};
|
||||
let mut old_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 1);
|
||||
let mut partial_meta_a = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 2);
|
||||
let mut partial_meta_b = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 3);
|
||||
old_meta.metadata.insert("x-amz-meta-color".to_string(), "blue".to_string());
|
||||
partial_meta_a
|
||||
.metadata
|
||||
.insert("x-amz-meta-color".to_string(), "red".to_string());
|
||||
partial_meta_b
|
||||
.metadata
|
||||
.insert("x-amz-meta-color".to_string(), "red".to_string());
|
||||
let metas = vec![old_meta, partial_meta_a, partial_meta_b, FileInfo::default()];
|
||||
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
|
||||
|
||||
for (metas, expect_remote) in [
|
||||
(vec![local(1), local(2), remote(3), FileInfo::default()], false),
|
||||
(vec![local(1), remote(2), remote(3), FileInfo::default()], true),
|
||||
] {
|
||||
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
|
||||
.expect("the read-quorum side of a same-time transition split is served");
|
||||
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
|
||||
|
||||
assert_eq!(selected.is_remote(), expect_remote);
|
||||
}
|
||||
|
||||
for (tied, expect_remote) in [
|
||||
(vec![local(1), local(2), remote(3), remote(4)], false),
|
||||
(vec![remote(1), local(2), local(3), remote(4)], true),
|
||||
] {
|
||||
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; tied.len()], &tied, &[None, None, None, None], 2)
|
||||
.expect("a same-time half split serves the side on the lowest-index disk");
|
||||
assert_eq!(selected.is_remote(), expect_remote);
|
||||
}
|
||||
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_latest_fileinfo_selection_serves_degraded_committed_identity() {
|
||||
fn test_latest_fileinfo_selection_rejects_same_modtime_transition_split_without_write_quorum() {
|
||||
let mod_time = OffsetDateTime::now_utc();
|
||||
let data_dir = Uuid::new_v4();
|
||||
let old_meta_a = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 1);
|
||||
let old_meta_b = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 2);
|
||||
let mut partial_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 3);
|
||||
partial_meta.transition_status = TRANSITION_COMPLETE.to_string();
|
||||
partial_meta.transition_tier = "WARM".to_string();
|
||||
partial_meta.transitioned_objname = "remote/object".to_string();
|
||||
partial_meta.transition_version_id = Some(Uuid::new_v4());
|
||||
let metas = vec![old_meta_a, old_meta_b, partial_meta, FileInfo::default()];
|
||||
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
|
||||
|
||||
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
|
||||
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
|
||||
|
||||
assert_eq!(quorum, 2);
|
||||
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_latest_fileinfo_selection_quorum_uses_write_quorum_for_degraded_committed_identity() {
|
||||
let mod_time = OffsetDateTime::now_utc();
|
||||
let data_dir = Uuid::new_v4();
|
||||
let metas = vec![
|
||||
@@ -12533,10 +12354,7 @@ mod tests {
|
||||
];
|
||||
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
|
||||
|
||||
let (_, selected) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, 2)
|
||||
.expect("a committed identity stays readable with one disk missing");
|
||||
|
||||
assert_eq!(selected.parts[0].etag, "part-etag-a");
|
||||
assert_eq!(SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9320,8 +9320,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
};
|
||||
|
||||
let src_version_id = src_opts.version_id.as_deref().unwrap_or_default();
|
||||
let (online_disks, mut fi) =
|
||||
Self::select_fileinfo_for_read(src_bucket, &disks, &metas, &errs, src_version_id, read_quorum, write_quorum)
|
||||
let (online_disks, mut fi, _) =
|
||||
Self::select_valid_fileinfo(&disks, &metas, &errs, src_version_id, read_quorum, write_quorum)
|
||||
.map_err(|e| to_object_err(e.into(), vec![src_bucket, src_object]))?;
|
||||
|
||||
if fi.deleted {
|
||||
@@ -11182,9 +11182,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
.map_err(|_| to_object_err(DiskError::ErasureWriteQuorum.into(), vec![bucket, object]))?;
|
||||
|
||||
let version_id = opts.version_id.as_deref().unwrap_or_default();
|
||||
let (online_disks, mut fi) =
|
||||
Self::select_fileinfo_for_read(bucket, &disks, &metas, &errs, version_id, read_quorum, write_quorum)
|
||||
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
|
||||
let (online_disks, mut fi, _) = Self::select_valid_fileinfo(&disks, &metas, &errs, version_id, read_quorum, write_quorum)
|
||||
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
|
||||
|
||||
if fi.deleted {
|
||||
return Err(to_object_err(Error::MethodNotAllowed, vec![bucket, object]));
|
||||
|
||||
@@ -264,11 +264,6 @@ enum MetadataReadPurpose {
|
||||
GetObjectReader,
|
||||
}
|
||||
|
||||
/// Upper bound on exact-version reads a latest read spends looking past
|
||||
/// dangling versions before it keeps the read-quorum error; each read is one
|
||||
/// metadata fan-out.
|
||||
const MAX_DANGLING_LATEST_VERSIONS: usize = 8;
|
||||
|
||||
/// How a metadata read-quorum failure is reported when the replies prove the
|
||||
/// object or version dangling. Only the object read entry points
|
||||
/// (`get_object_info`, `get_object_reader` and prepared GET metadata) report
|
||||
@@ -971,23 +966,10 @@ impl SetDisks {
|
||||
return Err(to_object_err(err.into(), vec![bucket, object, &vid]));
|
||||
}
|
||||
|
||||
let (op_online_disks, mut fi) =
|
||||
match Self::select_fileinfo_for_read(bucket, &disks, &parts_metadata, &errs, vid.as_str(), read_quorum, write_quorum)
|
||||
{
|
||||
let (op_online_disks, mut fi, fileinfo_selection_quorum) =
|
||||
match Self::select_valid_fileinfo(&disks, &parts_metadata, &errs, vid.as_str(), read_quorum, write_quorum) {
|
||||
Ok(selection) => selection,
|
||||
Err(err) => {
|
||||
if err == DiskError::ErasureReadQuorum
|
||||
&& vid.is_empty()
|
||||
&& dangling_read == DanglingRead::ReportNotFound
|
||||
&& metadata_fanout_complete
|
||||
&& !crate::bucket::utils::is_meta_bucketname(bucket)
|
||||
&& let Some(snapshot) =
|
||||
Box::pin(self.read_latest_past_dangling_versions(bucket, object, opts, read_data, purpose, &disks))
|
||||
.await
|
||||
{
|
||||
rustfs_io_metrics::record_get_object_metadata_quorum_result(metadata_metrics_path, "reached");
|
||||
return Ok(snapshot);
|
||||
}
|
||||
let err = StorageError::from(err);
|
||||
rustfs_io_metrics::record_get_object_metadata_quorum_result(
|
||||
metadata_metrics_path,
|
||||
@@ -1005,7 +987,7 @@ impl SetDisks {
|
||||
rustfs_utils::http::remove_str(&mut metadata.metadata, rustfs_utils::http::SUFFIX_PART_CHECKSUMS);
|
||||
}
|
||||
}
|
||||
metadata_fanout_diagnostics.record_quorum_candidate_latency(metadata_metrics_path, read_quorum);
|
||||
metadata_fanout_diagnostics.record_quorum_candidate_latency(metadata_metrics_path, fileinfo_selection_quorum);
|
||||
let needs_late_metadata_fanout = !metadata_fanout_complete
|
||||
&& allow_early_stop
|
||||
&& non_inline_data_read_early_stop_allowed(read_data, bucket, object)
|
||||
@@ -1032,6 +1014,7 @@ impl SetDisks {
|
||||
&& allow_early_stop
|
||||
&& metadata_fanout_diagnostics.valid_responses() >= read_quorum
|
||||
&& errs.iter().all(Option::is_none)
|
||||
&& fileinfo_selection_quorum >= read_quorum
|
||||
&& op_online_disks.iter().filter(|disk| disk.is_some()).count() >= read_quorum))
|
||||
{
|
||||
#[cfg(test)]
|
||||
@@ -1068,168 +1051,6 @@ impl SetDisks {
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// A latest read whose split cannot reach read quorum may still have a
|
||||
/// readable older version under newer versions that never committed;
|
||||
/// MinIO resolves this in `pickLatestQuorumFilesInfo`. With every drive
|
||||
/// answering, try versions newest first through exact-version reads and
|
||||
/// serve the readable one with the newest selected modification time. A
|
||||
/// version whose exact read proves it dangling (the NoSuchVersion an
|
||||
/// explicit GET reports) is skipped and queued for heal, which owns its
|
||||
/// cleanup; any other outcome keeps the read-quorum error.
|
||||
async fn read_latest_past_dangling_versions(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
read_data: bool,
|
||||
purpose: MetadataReadPurpose,
|
||||
disks: &[Option<DiskStore>],
|
||||
) -> Option<GetObjectFileInfo> {
|
||||
let disk_object = rustfs_utils::path::encode_dir_object(object);
|
||||
let (raw, errs) = Self::read_all_raw_file_info(disks, bucket, &disk_object, false).await;
|
||||
if errs
|
||||
.iter()
|
||||
.any(|err| err.as_ref().is_some_and(|err| !matches!(err, DiskError::FileNotFound)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Stacks are newest first, so only each drive's first few headers can
|
||||
// matter. Keep a version's newest modification time across drives: a
|
||||
// rewritten null version can carry different times on different drives.
|
||||
let mut newest = std::collections::HashMap::<Option<uuid::Uuid>, Option<time::OffsetDateTime>>::new();
|
||||
let mut truncated = false;
|
||||
let mut metadata = Vec::with_capacity(raw.len());
|
||||
for raw in raw.into_iter().flatten() {
|
||||
let mut meta = rustfs_filemeta::FileMeta::load(&raw.buf).ok()?;
|
||||
let mut versions = meta
|
||||
.versions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, version)| !version.header.free_version());
|
||||
let mut retained_end = 0;
|
||||
for (index, version) in versions.by_ref().take(MAX_DANGLING_LATEST_VERSIONS) {
|
||||
let mod_time = newest.entry(version.header.version_id).or_insert(version.header.mod_time);
|
||||
*mod_time = (*mod_time).max(version.header.mod_time);
|
||||
retained_end = index + 1;
|
||||
}
|
||||
truncated |= versions.next().is_some();
|
||||
meta.versions.truncate(retained_end);
|
||||
metadata.push(meta);
|
||||
}
|
||||
let mut candidates = newest.into_iter().collect::<Vec<_>>();
|
||||
candidates.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
|
||||
|
||||
// Use the stored header for equal-time ordering: re-encoding a selected
|
||||
// FileInfo can change legacy signatures or omit part-checksum metadata.
|
||||
let selected_header = |snapshot: &GetObjectFileInfo| {
|
||||
let mut selected = snapshot.fi().clone();
|
||||
selected.data = None;
|
||||
let version_id = selected.version_id.unwrap_or_else(uuid::Uuid::nil).to_string();
|
||||
let include_part_checksums =
|
||||
opts.include_part_checksums || opts.part_number.is_some() || opts.data_movement || opts.raw_data_movement_read;
|
||||
metadata.iter().find_map(|meta| {
|
||||
let version = meta
|
||||
.versions
|
||||
.iter()
|
||||
.filter(|version| !version.header.free_version())
|
||||
.take(MAX_DANGLING_LATEST_VERSIONS)
|
||||
.find(|version| {
|
||||
version.header.version_id.unwrap_or_else(uuid::Uuid::nil)
|
||||
== selected.version_id.unwrap_or_else(uuid::Uuid::nil)
|
||||
&& version.header.mod_time == selected.mod_time
|
||||
})?;
|
||||
let mut original = if include_part_checksums {
|
||||
meta.into_fileinfo(&selected.volume, &selected.name, &version_id, false, opts.incl_free_versions, true)
|
||||
} else {
|
||||
meta.into_fileinfo_without_part_checksums(
|
||||
&selected.volume,
|
||||
&selected.name,
|
||||
&version_id,
|
||||
false,
|
||||
opts.incl_free_versions,
|
||||
)
|
||||
}
|
||||
.ok()?;
|
||||
if !include_part_checksums {
|
||||
rustfs_utils::http::remove_str(&mut original.metadata, rustfs_utils::http::SUFFIX_PART_CHECKSUMS);
|
||||
}
|
||||
original.data = None;
|
||||
original.is_latest = selected.is_latest;
|
||||
original.successor_mod_time = selected.successor_mod_time;
|
||||
original.num_versions = selected.num_versions;
|
||||
(original == selected).then_some(version.header.clone())
|
||||
})
|
||||
};
|
||||
|
||||
let mut best: Option<(Option<time::OffsetDateTime>, GetObjectFileInfo)> = None;
|
||||
let mut skipped = Vec::new();
|
||||
let mut settled = false;
|
||||
for (reads, (version_id, newest_mod_time)) in candidates.into_iter().enumerate() {
|
||||
if best.as_ref().is_some_and(|(selected, _)| newest_mod_time < *selected) {
|
||||
settled = true;
|
||||
break;
|
||||
}
|
||||
if reads == MAX_DANGLING_LATEST_VERSIONS {
|
||||
return None;
|
||||
}
|
||||
// The nil UUID addresses the null version slot at this layer.
|
||||
let version_id = version_id.unwrap_or_else(uuid::Uuid::nil).to_string();
|
||||
let exact = ObjectOptions {
|
||||
version_id: Some(version_id.clone()),
|
||||
..opts.clone()
|
||||
};
|
||||
match Box::pin(self.get_object_fileinfo_gated_inner(
|
||||
bucket,
|
||||
object,
|
||||
&exact,
|
||||
read_data,
|
||||
false,
|
||||
purpose,
|
||||
DanglingRead::ReportNotFound,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(snapshot) => {
|
||||
let selected = snapshot.fi().mod_time;
|
||||
let replace = match best.as_ref() {
|
||||
Some((current, previous)) if selected == *current => {
|
||||
selected_header(&snapshot)?.sorts_before(&selected_header(previous)?)
|
||||
}
|
||||
Some((current, _)) => selected > *current,
|
||||
None => true,
|
||||
};
|
||||
if replace {
|
||||
best = Some((selected, snapshot));
|
||||
}
|
||||
}
|
||||
Err(StorageError::VersionNotFound(..)) => skipped.push(version_id),
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
// Headers past each drive's first few were not compared; only a walk
|
||||
// that proved no remaining candidate can be newer may stop early.
|
||||
if !settled && truncated {
|
||||
return None;
|
||||
}
|
||||
let (_, mut snapshot) = best?;
|
||||
snapshot.mark_selected_as_latest();
|
||||
if !opts.suppress_read_repair {
|
||||
for version_id in &skipped {
|
||||
submit_read_repair_heal(
|
||||
bucket,
|
||||
object,
|
||||
Some(version_id),
|
||||
self.pool_index,
|
||||
self.set_index,
|
||||
None,
|
||||
"dangling_latest_version",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
#[hotpath::measure(impl_type = "SetDisks")]
|
||||
pub(super) async fn get_object_info_fileinfo_and_quorum(
|
||||
&self,
|
||||
@@ -2644,8 +2465,8 @@ impl SetDisks {
|
||||
return Err(to_object_err(err.into(), vec![bucket, object]));
|
||||
}
|
||||
|
||||
let (mut online_disks, full_fi) =
|
||||
SetDisks::select_fileinfo_for_read(bucket, fallback_disks, &parts_metadata, &errs, "", read_quorum, write_quorum)?;
|
||||
let (mut online_disks, full_fi, _) =
|
||||
SetDisks::select_valid_fileinfo(fallback_disks, &parts_metadata, &errs, "", read_quorum, write_quorum)?;
|
||||
if !late_metadata_read_identity_matches(expected, &full_fi) {
|
||||
return Err(to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]));
|
||||
}
|
||||
@@ -4231,7 +4052,8 @@ mod metadata_cache_tests {
|
||||
assert!(original.is_complete());
|
||||
let (baseline, errors, _) = original.into_legacy();
|
||||
assert!(errors.iter().all(Option::is_none), "original real shards must all be readable");
|
||||
let (_, selected) = SetDisks::select_valid_fileinfo(&disks, &baseline, &errors, 2).expect("original metadata quorum");
|
||||
let (_, selected, _) =
|
||||
SetDisks::select_valid_fileinfo(&disks, &baseline, &errors, "", 2, 3).expect("original metadata quorum");
|
||||
assert!(!selected.inline_data(), "late fixture must retain real non-inline shard files");
|
||||
let expected = LateMetadataIdentity::from_file_info(&selected);
|
||||
let (_, _, original_online) =
|
||||
@@ -4264,7 +4086,7 @@ mod metadata_cache_tests {
|
||||
.expect("read changed real disk metadata");
|
||||
assert!(changed.is_complete(), "late fixture must reach complete metadata observation");
|
||||
let (metadata, errors, _) = changed.into_legacy();
|
||||
let (online, selected) = SetDisks::select_valid_fileinfo(&disks, &metadata, &errors, 2)
|
||||
let (online, selected, _) = SetDisks::select_valid_fileinfo(&disks, &metadata, &errors, "", 2, 3)
|
||||
.expect("changed metadata still reaches selection quorum");
|
||||
assert_eq!(online.iter().flatten().count(), 4, "the late identity guard must decide this failure");
|
||||
if changed_generation {
|
||||
@@ -5313,7 +5135,7 @@ mod metadata_cache_tests {
|
||||
// This seam prepares a real, independently validated D1/P1/P2 quorum.
|
||||
// The hedge test separately exercises its production publication timing.
|
||||
let errs: Vec<Option<DiskError>> = (0..4).map(|_| None).collect();
|
||||
let (online_disks, fi) = SetDisks::select_valid_fileinfo(&disks, &metadata, &errs, 2)
|
||||
let (online_disks, fi, _) = SetDisks::select_valid_fileinfo(&disks, &metadata, &errs, "", 2, 3)
|
||||
.expect("three real members satisfy latest-generation selection");
|
||||
assert_eq!(online_disks.iter().flatten().count(), 3);
|
||||
assert_eq!(fi.metadata.get("etag"), committed.etag.as_ref());
|
||||
@@ -6418,17 +6240,16 @@ mod tests {
|
||||
let parts_metadata = vec![first.clone(), second, third, FileInfo::default()];
|
||||
let errs = vec![None, None, None, None];
|
||||
|
||||
let (read_quorum, _) = SetDisks::object_quorum_from_meta(&parts_metadata, &errs, 2)
|
||||
let (read_quorum, write_quorum) = SetDisks::object_quorum_from_meta(&parts_metadata, &errs, 2)
|
||||
.expect("partial early-stop metadata should preserve read quorum");
|
||||
let read_quorum = usize::try_from(read_quorum).expect("read quorum should be non-negative");
|
||||
let (_, latest) = SetDisks::select_valid_fileinfo(&vec![None; parts_metadata.len()], &parts_metadata, &errs, read_quorum)
|
||||
.expect("partial early-stop metadata should select the candidate");
|
||||
let write_quorum = usize::try_from(write_quorum).expect("write quorum should be non-negative");
|
||||
let selection_quorum = SetDisks::latest_fileinfo_selection_quorum("", &parts_metadata, &errs, read_quorum, write_quorum);
|
||||
let selected = SetDisks::pick_valid_fileinfo(&parts_metadata, None, Some("etag-1".to_string()), read_quorum)
|
||||
.expect("partial early-stop metadata should preserve selected FileInfo");
|
||||
|
||||
assert_eq!(read_quorum, 2);
|
||||
assert_eq!(latest.name, first.name);
|
||||
assert_eq!(latest.get_etag(), first.get_etag());
|
||||
assert_eq!(selection_quorum, 3);
|
||||
assert_eq!(selected.name, first.name);
|
||||
assert_eq!(selected.get_etag(), first.get_etag());
|
||||
}
|
||||
@@ -9940,13 +9761,9 @@ mod dangling_read_tests {
|
||||
const PARITY: usize = 2;
|
||||
|
||||
async fn dangling_test_set(bucket: &str) -> (Vec<TempDir>, Arc<SetDisks>) {
|
||||
dangling_test_set_with(bucket, DRIVES, PARITY).await
|
||||
}
|
||||
|
||||
async fn dangling_test_set_with(bucket: &str, drives: usize, parity: usize) -> (Vec<TempDir>, Arc<SetDisks>) {
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
ctx.update_erasure_type(crate::layout::endpoints::SetupType::Erasure).await;
|
||||
let (dirs, set) = crate::ecstore_validation_blackbox::make_local_set_disks_with_ctx(drives, parity, ctx).await;
|
||||
let (dirs, set) = crate::ecstore_validation_blackbox::make_local_set_disks_with_ctx(DRIVES, PARITY, ctx).await;
|
||||
set.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("test bucket should be created on every drive");
|
||||
@@ -9960,9 +9777,6 @@ mod dangling_read_tests {
|
||||
&mut PutObjReader::from_vec(body.to_vec()),
|
||||
&ObjectOptions {
|
||||
versioned,
|
||||
// Wait for every drive so tests that edit one drive's metadata
|
||||
// do not race the PUT's background tail writes.
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -9982,25 +9796,6 @@ mod dangling_read_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops `version_id` from `disks`, modelling drives a failed PUT never
|
||||
/// reached while the older versions below it stay intact.
|
||||
async fn drop_version_from(set: &SetDisks, bucket: &str, object: &str, version_id: Uuid, disks: std::ops::Range<usize>) {
|
||||
let all = set.disks.read().await.clone();
|
||||
for disk in &all[disks] {
|
||||
let disk = disk.as_ref().expect("every drive should be online");
|
||||
let mut remove = FileInfo {
|
||||
volume: bucket.to_string(),
|
||||
name: object.to_string(),
|
||||
version_id: Some(version_id),
|
||||
..Default::default()
|
||||
};
|
||||
remove.set_skip_tier_free_version();
|
||||
disk.delete_version(bucket, object, remove, false, DeleteOptions::default())
|
||||
.await
|
||||
.expect("dropping the version should model a drive that never received it");
|
||||
}
|
||||
}
|
||||
|
||||
fn read_opts(version_id: Option<&str>) -> ObjectOptions {
|
||||
ObjectOptions {
|
||||
version_id: version_id.map(str::to_owned),
|
||||
@@ -10160,295 +9955,12 @@ mod dangling_read_tests {
|
||||
head(&set, bucket, object, Some(&v1))
|
||||
.await
|
||||
.expect("HEAD of the committed version must succeed");
|
||||
// The latest read sees version two on three drives and version one on
|
||||
// the rest. Version two's exact read proves it dangling, so the latest
|
||||
// read serves version one, the newest version an exact read can serve.
|
||||
let latest = head(&set, bucket, object, None)
|
||||
.await
|
||||
.expect("the latest read must serve the newest readable version");
|
||||
assert_eq!(latest.version_id.map(|id| id.to_string()).as_deref(), Some(v1.as_str()));
|
||||
assert_eq!(get(&set, bucket, object, None).await.expect("latest GET"), b"version one");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_read_skips_dangling_versions_above_a_committed_version() {
|
||||
// EC 12+4: a failed PUT that left version two on 5..=11 drives keeps
|
||||
// version one, present in every stack, below its read quorum of twelve
|
||||
// when only the top of each stack is read. Up to seven copies the
|
||||
// listing quorum already shows version one; from eight it lists the
|
||||
// dangling version two.
|
||||
let bucket = "dangling-latest-12-4";
|
||||
let (_dirs, set) = dangling_test_set_with(bucket, 16, 4).await;
|
||||
for copies in 5..=11 {
|
||||
let object = format!("torn/latest-{copies:02}");
|
||||
let v1 = put(&set, bucket, &object, b"committed version", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
let v2 = put(&set, bucket, &object, b"failed overwrite", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
drop_version_from(&set, bucket, &object, v2, copies..16).await;
|
||||
|
||||
let context = format!("version two on {copies} of 16 drives");
|
||||
let latest = head(&set, bucket, &object, None)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("{context}: HEAD must serve version one: {err:?}"));
|
||||
assert_eq!(latest.version_id, Some(v1), "{context}");
|
||||
assert!(latest.is_latest, "{context}: the served version is the latest one");
|
||||
assert_eq!(
|
||||
get(&set, bucket, &object, None)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("{context}: GET must serve version one: {err:?}")),
|
||||
b"committed version",
|
||||
"{context}"
|
||||
);
|
||||
let Err(err) = head(&set, bucket, &object, Some(&v2.to_string())).await else {
|
||||
panic!("{context}: the dangling version must not be served");
|
||||
};
|
||||
assert!(matches!(err, StorageError::VersionNotFound(..)), "{context}: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_read_keeps_quorum_error_when_the_newest_version_is_uncertain() {
|
||||
// Version two is on every drive, but a torn metadata update left two
|
||||
// identities at one modification time, three copies each. Its exact
|
||||
// read fails with a quorum error rather than NoSuchVersion, so the
|
||||
// walk must not fall through to version one.
|
||||
let bucket = "dangling-latest-uncertain";
|
||||
let object = "torn/latest";
|
||||
let (_dirs, set) = dangling_test_set(bucket).await;
|
||||
put(&set, bucket, object, b"version one", true).await;
|
||||
let v2 = put(&set, bucket, object, b"version two", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id")
|
||||
.to_string();
|
||||
let disks = set.disks.read().await.clone();
|
||||
for disk in disks.iter().take(3) {
|
||||
let disk = disk.as_ref().expect("every drive should be online");
|
||||
let mut fi = disk
|
||||
.read_version("", bucket, object, &v2, &ReadOptions::default())
|
||||
.await
|
||||
.expect("version two should be on every drive");
|
||||
fi.metadata.insert("x-amz-meta-torn".to_string(), "half-applied".to_string());
|
||||
disk.update_metadata(bucket, object, fi, &crate::disk::UpdateMetadataOpts::default())
|
||||
.await
|
||||
.expect("a half-applied metadata update should be stored");
|
||||
}
|
||||
|
||||
assert_get_and_head_keep_read_quorum_error(&set, bucket, object, "torn metadata update on version two").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_read_serves_a_committed_null_version_below_a_dangling_version() {
|
||||
// A null version written while versioning was suspended sits between
|
||||
// version one and a dangling version three. The walk reaches the null
|
||||
// slot through the nil UUID and serves it, not version one.
|
||||
let bucket = "dangling-latest-null";
|
||||
let object = "torn/latest";
|
||||
let (_dirs, set) = dangling_test_set(bucket).await;
|
||||
let v1 = put(&set, bucket, object, b"version one", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
set.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(b"null version".to_vec()),
|
||||
&ObjectOptions {
|
||||
version_suspended: true,
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a suspended-versioning PUT should commit the null version");
|
||||
let v3 = put(&set, bucket, object, b"version three", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
drop_version_from(&set, bucket, object, v3, 3..DRIVES).await;
|
||||
|
||||
let latest = head(&set, bucket, object, None)
|
||||
.await
|
||||
.expect("the committed null version must be served");
|
||||
assert_ne!(latest.version_id, Some(v1));
|
||||
assert_ne!(latest.version_id, Some(v3));
|
||||
assert!(latest.is_latest);
|
||||
assert_eq!(get(&set, bucket, object, None).await.expect("latest GET"), b"null version");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_read_orders_versions_by_their_newest_modification_time() {
|
||||
// Drive 0 missed the suspended-versioning rewrite of the null version,
|
||||
// so its null copy predates version one while every other drive holds
|
||||
// the committed rewrite. Ordering by the first drive's time would try
|
||||
// version one before the null version and serve it.
|
||||
let bucket = "dangling-latest-order";
|
||||
let object = "torn/latest";
|
||||
let (dirs, set) = dangling_test_set(bucket).await;
|
||||
let suspended = ObjectOptions {
|
||||
version_suspended: true,
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
// The latest read sees two generations (version two on three drives,
|
||||
// version one on the rest), which is not proof of absence.
|
||||
let Err(err) = head(&set, bucket, object, None).await else {
|
||||
panic!("a split latest version must not resolve without quorum");
|
||||
};
|
||||
set.put_object(bucket, object, &mut PutObjReader::from_vec(b"stale null".to_vec()), &suspended)
|
||||
.await
|
||||
.expect("the first null version should commit");
|
||||
let v1 = put(&set, bucket, object, b"version one", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
let drive_zero = object_dir(&dirs, 0, bucket, object).join(STORAGE_FORMAT_FILE);
|
||||
let stale = tokio::fs::read(&drive_zero)
|
||||
.await
|
||||
.expect("drive zero metadata should be readable");
|
||||
set.put_object(bucket, object, &mut PutObjReader::from_vec(b"committed null".to_vec()), &suspended)
|
||||
.await
|
||||
.expect("the null rewrite should commit");
|
||||
tokio::fs::write(&drive_zero, stale)
|
||||
.await
|
||||
.expect("drive zero should keep the pre-rewrite stack");
|
||||
let v5 = put(&set, bucket, object, b"version five", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
drop_version_from(&set, bucket, object, v5, 3..DRIVES).await;
|
||||
|
||||
let latest = head(&set, bucket, object, None)
|
||||
.await
|
||||
.expect("the committed null rewrite must be served");
|
||||
assert_ne!(latest.version_id, Some(v1));
|
||||
assert_eq!(get(&set, bucket, object, None).await.expect("latest GET"), b"committed null");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_read_preserves_object_before_equal_time_marker() {
|
||||
let bucket = "dangling-latest-equal-time-marker";
|
||||
let (_dirs, set) = dangling_test_set(bucket).await;
|
||||
let same_time = time::OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixed source mtime");
|
||||
for suspended in [false, true] {
|
||||
let object = if suspended {
|
||||
"torn/equal-time-null"
|
||||
} else {
|
||||
"torn/equal-time-versioned"
|
||||
};
|
||||
let object_id = if suspended { Uuid::nil() } else { Uuid::from_u128(2) };
|
||||
set.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(b"committed object".to_vec()),
|
||||
&ObjectOptions {
|
||||
versioned: !suspended,
|
||||
version_suspended: suspended,
|
||||
replication_request: true,
|
||||
version_id: Some(object_id.to_string()),
|
||||
mod_time: Some(same_time),
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("commit object version");
|
||||
set.delete_object(
|
||||
bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(Uuid::from_u128(1).to_string()),
|
||||
mod_time: Some(same_time),
|
||||
data_movement: true,
|
||||
delete_marker: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("recreate source delete marker during data movement");
|
||||
let expected = head(&set, bucket, object, None)
|
||||
.await
|
||||
.expect("canonical latest before dangling write");
|
||||
assert_eq!(expected.version_id, (!suspended).then_some(object_id));
|
||||
assert!(!expected.delete_marker, "canonical header order prefers object to equal-time marker");
|
||||
assert_eq!(get(&set, bucket, object, None).await.expect("canonical GET"), b"committed object");
|
||||
let dangling_id = Uuid::from_u128(3);
|
||||
set.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(b"failed newer version".to_vec()),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
replication_request: true,
|
||||
version_id: Some(dangling_id.to_string()),
|
||||
mod_time: Some(same_time + time::Duration::seconds(1)),
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("commit newer version before modeling failed rollback");
|
||||
drop_version_from(&set, bucket, object, dangling_id, 3..DRIVES).await;
|
||||
let actual = head(&set, bucket, object, None)
|
||||
.await
|
||||
.expect("latest HEAD past dangling version");
|
||||
let actual_get = get(&set, bucket, object, None).await;
|
||||
assert_eq!(
|
||||
actual.version_id, expected.version_id,
|
||||
"{object}: unreadable newer version must preserve canonical equal-time latest; GET result: {actual_get:?}"
|
||||
);
|
||||
assert!(
|
||||
!actual.delete_marker,
|
||||
"{object}: fallback must preserve the object before its equal-time marker"
|
||||
);
|
||||
assert_eq!(actual_get.expect("latest GET"), b"committed object");
|
||||
|
||||
let checksum_opts = ObjectOptions {
|
||||
include_part_checksums: true,
|
||||
..Default::default()
|
||||
};
|
||||
let checksum_head = set
|
||||
.get_object_info(bucket, object, &checksum_opts)
|
||||
.await
|
||||
.expect("latest HEAD with part checksums");
|
||||
assert_eq!(checksum_head.version_id, expected.version_id, "{object}: checksum HEAD version");
|
||||
assert!(!checksum_head.delete_marker, "{object}: checksum HEAD must return the object");
|
||||
let mut checksum_reader = set
|
||||
.get_object_reader(bucket, object, None, HeaderMap::new(), &checksum_opts)
|
||||
.await
|
||||
.expect("latest GET with part checksums");
|
||||
let mut checksum_body = Vec::new();
|
||||
checksum_reader
|
||||
.stream
|
||||
.read_to_end(&mut checksum_body)
|
||||
.await
|
||||
.expect("checksum GET should stream the committed bytes");
|
||||
assert_eq!(checksum_body, b"committed object", "{object}: checksum GET bytes");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_read_past_dangling_versions_requires_every_drive_to_answer() {
|
||||
let bucket = "dangling-latest-offline";
|
||||
let object = "torn/latest";
|
||||
let (_dirs, set) = dangling_test_set(bucket).await;
|
||||
put(&set, bucket, object, b"version one", true).await;
|
||||
let v2 = put(&set, bucket, object, b"version two", true)
|
||||
.await
|
||||
.version_id
|
||||
.expect("versioned PUT should assign a version id");
|
||||
drop_version_from(&set, bucket, object, v2, 3..DRIVES).await;
|
||||
// The offline drive may hold the copy that makes version two readable.
|
||||
let offline = set.disks.write().await[5].take().expect("drive five should start online");
|
||||
|
||||
assert_get_and_head_keep_read_quorum_error(&set, bucket, object, "one drive offline").await;
|
||||
|
||||
set.disks.write().await[5] = Some(offline);
|
||||
let latest = head(&set, bucket, object, None)
|
||||
.await
|
||||
.expect("with every drive answering the dangling version is skipped");
|
||||
assert_ne!(latest.version_id, Some(v2));
|
||||
assert!(is_read_quorum_error(&err), "a split latest version keeps the quorum error: {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -22,7 +22,9 @@ fn to_filemeta_err(err: Error) -> rustfs_filemeta::Error {
|
||||
}
|
||||
|
||||
use crate::bucket::lifecycle::bucket_lifecycle_ops::free_version_remote_tuple_matches;
|
||||
use crate::bucket::metadata_sys::{get_versioning_config, has_authoritative_never_versioned_state};
|
||||
use crate::bucket::metadata_sys::{
|
||||
get_versioning_config, has_authoritative_never_versioned_state, has_authoritative_never_versioned_state_in,
|
||||
};
|
||||
use crate::bucket::utils::{check_list_objs_args, has_bad_path_component};
|
||||
use crate::bucket::versioning::VersioningApi;
|
||||
use crate::cache_value::metacache_set::{
|
||||
@@ -371,6 +373,49 @@ async fn can_skip_hidden_prefix_check(options: &ListPathOptions) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether an empty listing of `prefix` is the proof that lets the caller
|
||||
/// reclaim delete residue under it. Any first page that scanned the whole
|
||||
/// prefix without finding an object or a sub-prefix qualifies, with or without
|
||||
/// a delimiter: that is the shape clients issue when they stat, browse, or
|
||||
/// recursively remove a phantom folder. The purge itself re-verifies every
|
||||
/// directory on every disk before deleting anything.
|
||||
fn should_purge_empty_directory_listing(
|
||||
prefix: &str,
|
||||
marker: Option<&str>,
|
||||
max_keys: i32,
|
||||
incl_deleted: bool,
|
||||
result: &ListObjectsInfo,
|
||||
) -> bool {
|
||||
// list_path rejects leading slashes, so an empty result cannot justify cleanup.
|
||||
!prefix.is_empty()
|
||||
&& !prefix.starts_with(SLASH_SEPARATOR)
|
||||
&& prefix.ends_with(SLASH_SEPARATOR)
|
||||
&& marker.is_none()
|
||||
&& max_keys > 0
|
||||
&& !incl_deleted
|
||||
&& !result.is_truncated
|
||||
&& result.objects.is_empty()
|
||||
&& result.prefixes.is_empty()
|
||||
}
|
||||
|
||||
fn should_purge_empty_recursive_bucket_listing(
|
||||
prefix: &str,
|
||||
delimiter: Option<&str>,
|
||||
marker: Option<&str>,
|
||||
max_keys: i32,
|
||||
incl_deleted: bool,
|
||||
result: &ListObjectsInfo,
|
||||
) -> bool {
|
||||
prefix.is_empty()
|
||||
&& delimiter.is_none()
|
||||
&& marker.is_none()
|
||||
&& max_keys > 0
|
||||
&& !incl_deleted
|
||||
&& !result.is_truncated
|
||||
&& result.objects.is_empty()
|
||||
&& result.prefixes.is_empty()
|
||||
}
|
||||
|
||||
const MARKER_TAG_VERSION: &str = "v2";
|
||||
const LEGACY_MARKER_TAG_VERSIONS: &[&str] = &["v1", MARKER_TAG_VERSION];
|
||||
const LIST_CACHE_MARKER_PREFIX: &str = "[rustfs_cache:";
|
||||
@@ -507,6 +552,18 @@ impl ListContinuationV2 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the key part of a marker that may still carry the legacy cursor tag.
|
||||
/// The data-usage scanner compares successive version-listing markers through
|
||||
/// it so two differently tagged markers for the same key never count as progress.
|
||||
// RUSTFS_COMPAT_TODO(listing-cursor-legacy-tag): markers tagged by 1.0.x servers. Remove after every supported
|
||||
// direct-upgrade release issues plain key markers; callers can then compare markers directly.
|
||||
pub(crate) fn list_marker_key(marker: &str) -> &str {
|
||||
marker
|
||||
.rfind(LIST_CACHE_MARKER_PREFIX)
|
||||
.filter(|start_idx| marker[*start_idx..].ends_with(']'))
|
||||
.map_or(marker, |start_idx| &marker[..start_idx])
|
||||
}
|
||||
|
||||
fn normalize_list_quorum(value: &str) -> &'static str {
|
||||
let value = value.trim();
|
||||
if value.eq_ignore_ascii_case("disk") {
|
||||
@@ -574,7 +631,8 @@ fn versioned_prefix_page_boundary_key<'a>(
|
||||
/// old-readable escape until legacy tag parsing can be removed.
|
||||
// RUSTFS_COMPAT_TODO(listing-cursor-legacy-tag): Remove after the minimum upgrade source stops issuing tagged cursors.
|
||||
fn escape_list_marker(marker: String) -> String {
|
||||
// One old-readable outer tag protects a literal cache-shaped key.
|
||||
// Both the legacy request parser and scanner cursor comparison recognize
|
||||
// cache-shaped suffixes. One old-readable outer tag protects the literal key.
|
||||
if marker
|
||||
.rfind(LIST_CACHE_MARKER_PREFIX)
|
||||
.is_some_and(|start| marker[start..].contains(']'))
|
||||
@@ -611,9 +669,9 @@ fn build_list_versions_next_marker(objects: &[ObjectInfo], prefixes: &[String])
|
||||
.unwrap_or_else(|| "null".to_string()),
|
||||
)
|
||||
};
|
||||
(Some(last.name.clone()), version_marker)
|
||||
(Some(escape_list_marker(last.name.clone())), version_marker)
|
||||
} else if let Some(last_prefix) = prefixes.last() {
|
||||
(Some(last_prefix.clone()), None)
|
||||
(Some(escape_list_marker(last_prefix.clone())), None)
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
@@ -676,7 +734,7 @@ fn list_objects_paginate(
|
||||
&& let Some(last_scanned) = last_scanned_key
|
||||
{
|
||||
is_truncated = true;
|
||||
next_marker = Some(last_scanned.to_owned());
|
||||
next_marker = Some(escape_list_marker(last_scanned.to_owned()));
|
||||
} else if should_truncate {
|
||||
is_truncated = true;
|
||||
if include_version_id {
|
||||
@@ -744,7 +802,7 @@ where
|
||||
pagination.last_scanned_key,
|
||||
);
|
||||
if is_truncated && let Some(raw_key) = prefix_at_page_boundary {
|
||||
next_marker = Some(raw_key);
|
||||
next_marker = Some(escape_list_marker(raw_key));
|
||||
next_version_idmarker = None;
|
||||
}
|
||||
|
||||
@@ -836,8 +894,10 @@ impl ListPathOptions {
|
||||
/// Releases up to 1.0.x appended `[rustfs_cache:v2,id:...,src:walker,gen:live]`
|
||||
/// to every internal continuation marker and exposed it through the V2
|
||||
/// continuation token and the ListObjectVersions `NextKeyMarker`. Current
|
||||
/// servers still accept the tagged form in V2 continuation tokens and
|
||||
/// legacy object-list markers. Version-listing key markers are literal keys.
|
||||
/// servers emit plain keys, but a client that paginates across the upgrade
|
||||
/// still sends the tagged form back as `continuation-token`, `key-marker`
|
||||
/// or `marker`, and the opt-in index provider still carries its `src`/`gen`
|
||||
/// cursor fields in the same tag.
|
||||
///
|
||||
/// Newly issued cursors protect cache-shaped literal keys with one legacy
|
||||
/// outer tag. Parse only at the listing entry point; parsing the resulting
|
||||
@@ -893,9 +953,7 @@ impl ECStore {
|
||||
) -> Result<ListObjectsV2Info> {
|
||||
let marker = {
|
||||
if continuation_token.is_none() {
|
||||
// StartAfter is a literal object key, even if it resembles a
|
||||
// legacy cursor. Protect it from the compatibility parser.
|
||||
start_after.map(escape_list_marker)
|
||||
start_after
|
||||
} else {
|
||||
continuation_token.clone()
|
||||
}
|
||||
@@ -964,15 +1022,39 @@ impl ECStore {
|
||||
let get_objects =
|
||||
ObjectInfo::from_meta_cache_entries_sorted_infos(&page.entries, bucket, prefix, delimiter.clone()).await;
|
||||
|
||||
let (objects, prefixes, is_truncated, next_marker, _) =
|
||||
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) =
|
||||
list_objects_paginate(get_objects, &delimiter, max_keys, disk_has_more, false, last_scanned_key.as_deref());
|
||||
let _ = next_version_idmarker;
|
||||
|
||||
Ok(ListObjectsInfo {
|
||||
let result = ListObjectsInfo {
|
||||
is_truncated,
|
||||
next_marker,
|
||||
objects,
|
||||
prefixes,
|
||||
})
|
||||
};
|
||||
|
||||
let purge_exact_prefix =
|
||||
should_purge_empty_directory_listing(prefix, opts.marker.as_deref(), max_keys, incl_deleted, &result);
|
||||
let purge_empty_bucket = should_purge_empty_recursive_bucket_listing(
|
||||
prefix,
|
||||
delimiter.as_deref(),
|
||||
opts.marker.as_deref(),
|
||||
max_keys,
|
||||
incl_deleted,
|
||||
&result,
|
||||
);
|
||||
if (purge_exact_prefix || purge_empty_bucket)
|
||||
&& has_authoritative_never_versioned_state_in(&self.ctx, bucket)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if purge_exact_prefix {
|
||||
self.purge_orphan_dir_object(bucket, prefix).await;
|
||||
} else {
|
||||
self.purge_orphan_dir_objects_in_bucket(bucket).await;
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn inner_list_object_versions(
|
||||
@@ -1116,7 +1198,7 @@ impl ECStore {
|
||||
};
|
||||
|
||||
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
|
||||
let opts = ListPathOptions {
|
||||
let mut opts = ListPathOptions {
|
||||
bucket: bucket.to_owned(),
|
||||
caller: if include_version_purge {
|
||||
ListObjectsGatherCaller::Lifecycle
|
||||
@@ -1133,6 +1215,10 @@ impl ECStore {
|
||||
include_marker: has_version_marker,
|
||||
..Default::default()
|
||||
};
|
||||
// Strip a legacy cursor tag before any name comparison (notably
|
||||
// `forward_past`), see backlog#1047.
|
||||
opts.parse_marker();
|
||||
|
||||
let mut page = self
|
||||
.list_path(&opts)
|
||||
.await
|
||||
@@ -2330,7 +2416,7 @@ impl SetDisks {
|
||||
let has_version_marker = version_marker.is_some();
|
||||
let version_marker = version_marker.map(parse_version_marker).transpose()?;
|
||||
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
|
||||
let opts = ListPathOptions {
|
||||
let mut opts = ListPathOptions {
|
||||
bucket: bucket.to_owned(),
|
||||
prefix: prefix.to_owned(),
|
||||
limit: effective_max_keys,
|
||||
@@ -2341,6 +2427,8 @@ impl SetDisks {
|
||||
include_marker: has_version_marker,
|
||||
..Default::default()
|
||||
};
|
||||
opts.parse_marker();
|
||||
|
||||
let mut page = self
|
||||
.list_path_result(&opts)
|
||||
.await
|
||||
@@ -2812,12 +2900,13 @@ mod lifecycle_exact_tests;
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::{
|
||||
ENV_API_LIST_OBJECTS_QUORUM, GatherResultsState, ListPathOptions, ListSourceMode, MAX_OBJECT_LIST, RUSTFS_META_BUCKET,
|
||||
VersionMarker, gather_results, list_merged_entry_channel, list_metadata_resolution_params, list_objects_paginate,
|
||||
list_objects_quorum_from_env, max_keys_plus_one, merge_entry_channels, normalize_list_quorum,
|
||||
observe_scanner_namespace_mutations, parse_version_marker, recursive_walk_list_path_options,
|
||||
reset_scanner_namespace_mutation_generation_for_test, scanner_namespace_mutation_generation, send_or_cancel,
|
||||
version_marker_for_entries, walk_result_from_set_errors,
|
||||
ENV_API_LIST_OBJECTS_QUORUM, GatherResultsState, ListObjectsInfo, ListPathOptions, ListSourceMode, MAX_OBJECT_LIST,
|
||||
RUSTFS_META_BUCKET, VersionMarker, gather_results, list_marker_key, list_merged_entry_channel,
|
||||
list_metadata_resolution_params, list_objects_paginate, list_objects_quorum_from_env, max_keys_plus_one,
|
||||
merge_entry_channels, normalize_list_quorum, observe_scanner_namespace_mutations, parse_version_marker,
|
||||
recursive_walk_list_path_options, reset_scanner_namespace_mutation_generation_for_test,
|
||||
scanner_namespace_mutation_generation, send_or_cancel, should_purge_empty_directory_listing, version_marker_for_entries,
|
||||
walk_result_from_set_errors,
|
||||
};
|
||||
use crate::bucket::replication::ReplicationState;
|
||||
use crate::cache_value::metacache_set::ListPathRawOutcome;
|
||||
@@ -4322,10 +4411,7 @@ mod test {
|
||||
list_objects_paginate(vec![plain_object(key), plain_object("zz")], &None, 1, false, include_version_id, None);
|
||||
assert!(truncated);
|
||||
let marker = marker.expect("truncated page needs a cursor");
|
||||
if include_version_id {
|
||||
assert_eq!(marker, key, "version markers must be literal keys");
|
||||
continue;
|
||||
}
|
||||
assert_eq!(list_marker_key(&marker), key, "scanner must compare the complete literal key");
|
||||
let mut opts = ListPathOptions {
|
||||
marker: Some(marker),
|
||||
..Default::default()
|
||||
@@ -4383,7 +4469,9 @@ mod test {
|
||||
assert_eq!(page.is_truncated, index + 1 < keys.len());
|
||||
marker = page.next_continuation_token;
|
||||
|
||||
// V1 sends the literal NextMarker back as StartAfter.
|
||||
// ListObjects (V1) returns the same cursor as NextMarker and the
|
||||
// client sends it back as `marker`, which reaches this layer as
|
||||
// StartAfter.
|
||||
let v1_page = store
|
||||
.clone()
|
||||
.inner_list_objects_v2(bucket, "", None, None, 1, false, v1_marker, false)
|
||||
@@ -4391,7 +4479,7 @@ mod test {
|
||||
.expect("list next object through the V1 marker path");
|
||||
assert_eq!(v1_page.objects.iter().map(|object| object.name.as_str()).collect::<Vec<_>>(), [key]);
|
||||
assert_eq!(v1_page.is_truncated, index + 1 < keys.len());
|
||||
v1_marker = v1_page.is_truncated.then(|| key.to_owned());
|
||||
v1_marker = v1_page.next_continuation_token;
|
||||
|
||||
let versions = store
|
||||
.clone()
|
||||
@@ -4400,44 +4488,12 @@ mod test {
|
||||
.expect("list next version");
|
||||
assert_eq!(versions.objects.iter().map(|object| object.name.as_str()).collect::<Vec<_>>(), [key]);
|
||||
assert_eq!(versions.is_truncated, index + 1 < keys.len());
|
||||
if versions.is_truncated {
|
||||
assert_eq!(versions.next_marker.as_deref(), Some(key), "NextKeyMarker must be the literal key");
|
||||
}
|
||||
key_marker = versions.next_marker;
|
||||
version_marker = versions.next_version_idmarker;
|
||||
}
|
||||
assert!(marker.is_none());
|
||||
assert!(v1_marker.is_none());
|
||||
assert!(key_marker.is_none());
|
||||
|
||||
let literal = "b[rustfs_cache:v1,return:]";
|
||||
let after_literal = store
|
||||
.clone()
|
||||
.inner_list_objects_v2(bucket, "", None, None, 1, false, Some(literal.to_owned()), false)
|
||||
.await
|
||||
.expect("start after a caller-supplied literal key");
|
||||
assert_eq!(after_literal.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
|
||||
|
||||
let resumed = store
|
||||
.clone()
|
||||
.inner_list_object_versions(bucket, "", Some(literal.to_owned()), None, None, 1)
|
||||
.await
|
||||
.expect("resume from a caller-supplied literal key marker");
|
||||
assert_eq!(resumed.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
|
||||
|
||||
let resumed_version = store
|
||||
.clone()
|
||||
.inner_list_object_versions(bucket, "", Some(literal.to_owned()), Some("null".to_owned()), None, 1)
|
||||
.await
|
||||
.expect("resume after the literal key's null version");
|
||||
assert_eq!(resumed_version.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
|
||||
|
||||
let recursive_delete_page = store.pools[0].disk_set[0]
|
||||
.clone()
|
||||
.inner_list_object_versions_for_recursive_delete(bucket, "", Some(literal.to_owned()), None, 1)
|
||||
.await
|
||||
.expect("resume recursive-delete validation from the literal key");
|
||||
assert_eq!(recursive_delete_page.objects[0].name, "c[rustfs_cache:v2,id:x]suffix");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4603,32 +4659,6 @@ mod test {
|
||||
assert!(next_version_idmarker.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_delimiter_boundaries_preserve_cache_shaped_raw_keys() {
|
||||
let delimiter = Some("-".to_owned());
|
||||
let raw_key = "data-0002[rustfs_cache:v2,return:]";
|
||||
let (_, _, truncated, marker, version_marker) =
|
||||
list_objects_paginate(vec![folded_prefix_object("data-")], &delimiter, 2, true, true, Some(raw_key));
|
||||
assert!(truncated);
|
||||
assert_eq!(marker.as_deref(), Some(raw_key));
|
||||
assert!(version_marker.is_none());
|
||||
|
||||
let (_, _, truncated, marker, version_marker) = super::list_objects_paginate_versions(
|
||||
vec![folded_prefix_object("data-")],
|
||||
super::VersionedListPagination {
|
||||
delimiter: &delimiter,
|
||||
max_keys: 1,
|
||||
disk_has_more: true,
|
||||
marker: None,
|
||||
last_scanned_key: Some(raw_key),
|
||||
raw_keys: std::iter::once(raw_key),
|
||||
},
|
||||
);
|
||||
assert!(truncated);
|
||||
assert_eq!(marker.as_deref(), Some(raw_key));
|
||||
assert!(version_marker.is_none());
|
||||
}
|
||||
|
||||
// End-to-end simulation of versioned pagination with `max_keys=1`, where the
|
||||
// next page resumes via a bare key marker (`forward_past` keeps `name > marker`
|
||||
// only, matching `inner_list_object_versions_with_projection` without a version
|
||||
@@ -4919,114 +4949,77 @@ mod test {
|
||||
assert_eq!(scanner_namespace_mutation_generation(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_preserves_valid_legacy_residue_until_explicit_delete() {
|
||||
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
|
||||
#[test]
|
||||
fn empty_directory_listing_purge_requires_complete_first_page_of_prefix() {
|
||||
let empty = ListObjectsInfo::default();
|
||||
assert!(should_purge_empty_directory_listing("ghost/", None, 1, false, &empty));
|
||||
assert!(should_purge_empty_directory_listing("ghost/", None, 1000, false, &empty));
|
||||
assert!(!should_purge_empty_directory_listing("ghost", None, 1, false, &empty));
|
||||
assert!(!should_purge_empty_directory_listing("ghost/", Some("marker"), 1, false, &empty));
|
||||
assert!(!should_purge_empty_directory_listing("ghost/", None, 0, false, &empty));
|
||||
assert!(!should_purge_empty_directory_listing("ghost/", None, 1, true, &empty));
|
||||
|
||||
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
|
||||
let mut live = ListObjectsInfo::default();
|
||||
live.objects.push(ObjectInfo::default());
|
||||
assert!(!should_purge_empty_directory_listing("ghost/", None, 1, false, &live));
|
||||
|
||||
let (dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let bucket = "stage-e-exact-delete-legacy";
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created with authoritative metadata");
|
||||
let transaction = uuid::Uuid::new_v4();
|
||||
let mut backups = Vec::new();
|
||||
for object in ["repro/obj-00001.bin", "repro/obj-00002.bin"] {
|
||||
let mut reader = crate::object_api::PutObjReader::from_vec(vec![0x62; 1024 * 1024]);
|
||||
store
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&crate::object_api::ObjectOptions {
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create genuine per-drive metadata and shards");
|
||||
for dir in &dirs {
|
||||
let object_dir = dir.path().join(bucket).join(object);
|
||||
let mut entries = tokio::fs::read_dir(&object_dir).await.expect("read actual object root");
|
||||
let mut marked = 0;
|
||||
while let Some(entry) = entries.next_entry().await.expect("read object entry") {
|
||||
if uuid::Uuid::parse_str(&entry.file_name().to_string_lossy()).is_ok() {
|
||||
tokio::fs::write(
|
||||
entry
|
||||
.path()
|
||||
.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction)),
|
||||
[],
|
||||
)
|
||||
.await
|
||||
.expect("mark actual data dir as historical delete residue");
|
||||
marked += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(marked, 1, "the real shard must have exactly one data directory");
|
||||
let backup = object_dir.join(transaction.to_string());
|
||||
tokio::fs::create_dir_all(&backup).await.expect("create legacy rollback root");
|
||||
tokio::fs::rename(
|
||||
object_dir.join(crate::disk::STORAGE_FORMAT_FILE),
|
||||
backup.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP),
|
||||
)
|
||||
.await
|
||||
.expect("retain the actual valid metadata as a historical backup");
|
||||
let backup = backup.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP);
|
||||
let bytes = tokio::fs::read(&backup).await.expect("read the historical backup");
|
||||
backups.push((backup, bytes));
|
||||
}
|
||||
}
|
||||
let mut prefixed = ListObjectsInfo::default();
|
||||
prefixed.prefixes.push("ghost/child/".to_owned());
|
||||
assert!(!should_purge_empty_directory_listing("ghost/", None, 1, false, &prefixed));
|
||||
|
||||
let delimited = store
|
||||
.clone()
|
||||
.list_objects_generic(bucket, "", None, Some("/".to_owned()), 1000, false)
|
||||
.await
|
||||
.expect("delimiter listing should succeed");
|
||||
assert!(delimited.objects.is_empty());
|
||||
assert!(delimited.prefixes.is_empty(), "residue must not appear as a common prefix");
|
||||
let truncated = ListObjectsInfo {
|
||||
is_truncated: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!should_purge_empty_directory_listing("ghost/", None, 1, false, &truncated));
|
||||
}
|
||||
|
||||
let result = store
|
||||
.clone()
|
||||
.list_objects_generic(bucket, "", None, None, 1000, false)
|
||||
.await
|
||||
.expect("recursive bucket listing should succeed");
|
||||
assert!(result.objects.is_empty());
|
||||
let exact_prefix = store
|
||||
.clone()
|
||||
.list_objects_generic(bucket, "repro/", None, None, 1000, false)
|
||||
.await
|
||||
.expect("recursive prefix listing should succeed");
|
||||
assert!(exact_prefix.objects.is_empty());
|
||||
for (path, bytes) in backups {
|
||||
assert_eq!(tokio::fs::read(path).await.expect("LIST must preserve backup"), bytes);
|
||||
}
|
||||
for dir in &dirs {
|
||||
#[test]
|
||||
fn empty_directory_listing_does_not_purge_rejected_leading_slash_prefixes() {
|
||||
let empty = ListObjectsInfo::default();
|
||||
for prefix in ["/", "//", "/ghost/"] {
|
||||
assert!(
|
||||
dir.path().join(bucket).join("repro").exists(),
|
||||
"LIST must preserve the historical residue"
|
||||
!should_purge_empty_directory_listing(prefix, None, 1000, false, &empty),
|
||||
"rejected prefix {prefix:?} must not initiate orphan cleanup"
|
||||
);
|
||||
}
|
||||
for object in ["repro/obj-00001.bin", "repro/obj-00002.bin"] {
|
||||
store
|
||||
.delete_object(bucket, object, crate::object_api::ObjectOptions::default())
|
||||
.await
|
||||
.expect("fresh exact-key DELETE should reclaim eligible legacy residue");
|
||||
for dir in &dirs {
|
||||
assert!(!dir.path().join(bucket).join(object).exists(), "exact DELETE must reclaim the root");
|
||||
}
|
||||
}
|
||||
store
|
||||
.delete_bucket(bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("the bucket is empty once the residue is reclaimed");
|
||||
assert!(should_purge_empty_directory_listing("ghost/", None, 1000, false, &empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_bucket_orphan_purge_requires_an_empty_complete_root_scan() {
|
||||
let empty = ListObjectsInfo::default();
|
||||
assert!(super::should_purge_empty_recursive_bucket_listing("", None, None, 1, false, &empty));
|
||||
assert!(!super::should_purge_empty_recursive_bucket_listing(
|
||||
"ghost/", None, None, 1, false, &empty
|
||||
));
|
||||
assert!(!super::should_purge_empty_recursive_bucket_listing("", Some("/"), None, 1, false, &empty));
|
||||
assert!(!super::should_purge_empty_recursive_bucket_listing(
|
||||
"",
|
||||
None,
|
||||
Some("marker"),
|
||||
1,
|
||||
false,
|
||||
&empty
|
||||
));
|
||||
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 0, false, &empty));
|
||||
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 1, true, &empty));
|
||||
|
||||
let live = ListObjectsInfo {
|
||||
objects: vec![ObjectInfo::default()],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 1, false, &live));
|
||||
|
||||
let incomplete = ListObjectsInfo {
|
||||
is_truncated: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!super::should_purge_empty_recursive_bucket_listing("", None, None, 1, false, &incomplete));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_recursive_listing_preserves_committed_delete_residue() {
|
||||
async fn empty_recursive_listing_purges_committed_delete_residue() {
|
||||
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
|
||||
@@ -5069,73 +5062,20 @@ mod test {
|
||||
assert!(result.objects.is_empty());
|
||||
assert!(result.prefixes.is_empty());
|
||||
for dir in &dirs {
|
||||
let residue = dir.path().join(bucket).join("ghost/nested/object").join(data_dir.to_string());
|
||||
assert_eq!(tokio::fs::read(residue.join("part.1")).await.expect("LIST preserves part"), b"stale");
|
||||
assert!(
|
||||
residue
|
||||
.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction))
|
||||
.exists()
|
||||
!dir.path().join(bucket).join("ghost").exists(),
|
||||
"the empty listing should reclaim its committed delete residue"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// #6898: what an abandoned DeleteObjects left on every disk (rollback
|
||||
// backup plus marked data dir) no longer blocks DeleteBucket after an empty
|
||||
// recursive listing of the bucket.
|
||||
#[tokio::test]
|
||||
async fn large_preserved_residue_does_not_create_a_common_prefix() {
|
||||
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
|
||||
let (dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let bucket = "large-listing-residue";
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
for object in 0..20 {
|
||||
let data_dir = uuid::Uuid::new_v4();
|
||||
let transaction = uuid::Uuid::new_v4();
|
||||
for dir in &dirs {
|
||||
let residue = dir
|
||||
.path()
|
||||
.join(bucket)
|
||||
.join(format!("ghost/{object:02}"))
|
||||
.join(data_dir.to_string());
|
||||
tokio::fs::create_dir_all(&residue).await.expect("create residue");
|
||||
tokio::fs::write(residue.join("part.1"), b"stale")
|
||||
.await
|
||||
.expect("write residue part");
|
||||
tokio::fs::write(
|
||||
residue.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction)),
|
||||
[],
|
||||
)
|
||||
.await
|
||||
.expect("write residue marker");
|
||||
}
|
||||
}
|
||||
|
||||
let recursive = store
|
||||
.clone()
|
||||
.list_objects_generic(bucket, "", None, None, 1000, false)
|
||||
.await
|
||||
.expect("list recursively");
|
||||
assert!(recursive.objects.is_empty());
|
||||
let delimited = store
|
||||
.list_objects_generic(bucket, "", None, Some("/".to_owned()), 1000, false)
|
||||
.await
|
||||
.expect("list with delimiter");
|
||||
assert!(delimited.objects.is_empty());
|
||||
assert!(delimited.prefixes.is_empty(), "residue must not produce a common prefix");
|
||||
for dir in &dirs {
|
||||
assert!(dir.path().join(bucket).join("ghost/00").exists(), "listing must not remove residue");
|
||||
}
|
||||
}
|
||||
|
||||
// A corrupt historical backup must survive LIST and a failed exact DELETE.
|
||||
#[tokio::test]
|
||||
async fn empty_recursive_listing_preserves_corrupt_delete_rollback_backups() {
|
||||
async fn empty_recursive_listing_purges_stranded_delete_rollback_backups() {
|
||||
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
|
||||
use crate::storage_api_contracts::object::ObjectOperations as _;
|
||||
|
||||
let (dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let bucket = "listing-purge-rollback-backup";
|
||||
@@ -5179,38 +5119,21 @@ mod test {
|
||||
.expect("recursive bucket listing should succeed");
|
||||
assert!(result.objects.is_empty());
|
||||
for dir in &dirs {
|
||||
let backup = dir
|
||||
.path()
|
||||
.join(bucket)
|
||||
.join("repro/obj-00001.bin")
|
||||
.join(transaction.to_string())
|
||||
.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP);
|
||||
assert_eq!(tokio::fs::read(backup).await.expect("LIST preserves backup"), b"rollback metadata");
|
||||
}
|
||||
let err = store
|
||||
.delete_object(bucket, "repro/obj-00001.bin", crate::object_api::ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("corrupt metadata must not be reclaimed");
|
||||
assert!(matches!(err, StorageError::FileCorrupt));
|
||||
assert!(store.delete_bucket(bucket, &DeleteBucketOptions::default()).await.is_err());
|
||||
for dir in &dirs {
|
||||
let backup = dir
|
||||
.path()
|
||||
.join(bucket)
|
||||
.join("repro/obj-00001.bin")
|
||||
.join(transaction.to_string())
|
||||
.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP);
|
||||
assert_eq!(
|
||||
tokio::fs::read(backup).await.expect("failed cleanup preserves backup"),
|
||||
b"rollback metadata"
|
||||
assert!(
|
||||
!dir.path().join(bucket).join("repro").exists(),
|
||||
"the empty listing should reclaim the stranded delete residue"
|
||||
);
|
||||
}
|
||||
store
|
||||
.delete_bucket(bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("the bucket is empty once the residue is reclaimed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_recursive_bucket_listing_preserves_empty_directory_prefixes() {
|
||||
async fn empty_recursive_bucket_listing_purges_orphan_directory_prefixes() {
|
||||
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
|
||||
let (dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let bucket = "recursive-bucket-orphan-purge";
|
||||
@@ -5236,14 +5159,10 @@ mod test {
|
||||
assert!(result.prefixes.is_empty(), "a delimiter-less listing has no CommonPrefixes");
|
||||
for dir in &dirs {
|
||||
assert!(
|
||||
dir.path().join(bucket).join("ghost/nested/leaf").exists(),
|
||||
"LIST must preserve empty directories"
|
||||
!dir.path().join(bucket).join("ghost").exists(),
|
||||
"an empty recursive bucket scan should reclaim its metadata-less orphan prefix"
|
||||
);
|
||||
}
|
||||
store
|
||||
.delete_bucket(bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket deletion removes empty directories");
|
||||
|
||||
let versioned_bucket = "recursive-bucket-versioned-orphan";
|
||||
store
|
||||
@@ -5395,7 +5314,7 @@ mod test {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_delimiter_listing_hides_and_preserves_committed_delete_residue() {
|
||||
async fn empty_delimiter_listing_hides_and_purges_committed_delete_residue() {
|
||||
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
|
||||
@@ -5440,22 +5359,15 @@ mod test {
|
||||
assert!(result.objects.is_empty());
|
||||
assert!(result.prefixes.is_empty(), "delete residue must not surface as a prefix");
|
||||
for dir in &dirs {
|
||||
let residue = dir
|
||||
.path()
|
||||
.join(bucket)
|
||||
.join("metrics/2026/object.parquet")
|
||||
.join(data_dir.to_string());
|
||||
assert_eq!(tokio::fs::read(residue.join("part.1")).await.expect("LIST preserves part"), b"stale");
|
||||
assert!(
|
||||
residue
|
||||
.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction))
|
||||
.exists()
|
||||
!dir.path().join(bucket).join("metrics").join("2026").exists(),
|
||||
"the empty delimiter listing should reclaim the committed delete residue under it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_delimiter_listing_of_residue_ancestor_hides_and_preserves_whole_tree() {
|
||||
async fn empty_delimiter_listing_of_residue_ancestor_hides_and_purges_whole_tree() {
|
||||
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
|
||||
@@ -5488,7 +5400,9 @@ mod test {
|
||||
.expect("committed delete marker should be written");
|
||||
}
|
||||
|
||||
// Browsing an ancestor must not expose the deleted key's date folders.
|
||||
// Browsing an ancestor of the deleted key must not show the empty
|
||||
// date folders, and the empty result reclaims the whole residue tree
|
||||
// in one pass instead of one level per listing.
|
||||
let result = store
|
||||
.clone()
|
||||
.list_objects_generic(bucket, "metrics/", None, Some("/".to_owned()), 1000, false)
|
||||
@@ -5497,22 +5411,15 @@ mod test {
|
||||
assert!(result.objects.is_empty());
|
||||
assert!(result.prefixes.is_empty(), "ancestors of delete residue must not surface as prefixes");
|
||||
for dir in &dirs {
|
||||
let residue = dir
|
||||
.path()
|
||||
.join(bucket)
|
||||
.join("metrics/kubelet/2026/08/28/23/74992556388248657933757.parquet")
|
||||
.join(data_dir.to_string());
|
||||
assert_eq!(tokio::fs::read(residue.join("part.1")).await.expect("LIST preserves part"), b"stale");
|
||||
assert!(
|
||||
residue
|
||||
.join(format!("{}{}", crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX, transaction))
|
||||
.exists()
|
||||
!dir.path().join(bucket).join("metrics").exists(),
|
||||
"the empty delimiter listing should reclaim the committed delete residue tree under it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_delimiter_listing_of_mixed_residue_ancestor_preserves_every_subtree() {
|
||||
async fn empty_delimiter_listing_of_mixed_residue_ancestor_reclaims_only_committed_subtree() {
|
||||
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
|
||||
@@ -5585,18 +5492,9 @@ mod test {
|
||||
assert!(result.prefixes.is_empty(), "residue subtrees may not surface as prefixes");
|
||||
for dir in &dirs {
|
||||
let metrics = dir.path().join(bucket).join("metrics");
|
||||
let committed = metrics.join("cpu/2026/08/28/22/a.parquet").join(committed_dir.to_string());
|
||||
assert_eq!(
|
||||
tokio::fs::read(committed.join("part.1"))
|
||||
.await
|
||||
.expect("LIST preserves committed part"),
|
||||
b"stale"
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(committed.join(crate::disk::STORAGE_FORMAT_FILE_BACKUP))
|
||||
.await
|
||||
.expect("LIST preserves backup"),
|
||||
b"rollback metadata"
|
||||
assert!(
|
||||
!metrics.join("cpu").exists(),
|
||||
"the committed subtree must be reclaimed even though a sibling subtree is blocked"
|
||||
);
|
||||
assert!(
|
||||
metrics
|
||||
@@ -5604,7 +5502,7 @@ mod test {
|
||||
.join(unmarked_dir.to_string())
|
||||
.join("part.1")
|
||||
.exists(),
|
||||
"residue without a committed marker must survive the listing"
|
||||
"residue without a committed marker must survive the purge"
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(
|
||||
@@ -6521,147 +6419,6 @@ mod test {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_get_follows_read_quorum_on_torn_writes() {
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
use crate::storage_api_contracts::object::ObjectOperations as _;
|
||||
|
||||
// HEAD and GET serve the version with the most copies once it reaches
|
||||
// the object's data blocks, and a tie goes to the newer version, as in
|
||||
// MinIO's getObjectFileInfo and as heal converges the set. GET used to
|
||||
// demand write quorum whenever enough metadata answered, so a 2+2 or
|
||||
// 8+8 split, or a newer side that could still hold write quorum
|
||||
// through an offline drive, listed but answered 503.
|
||||
let opts = ObjectOptions {
|
||||
suppress_read_repair: true,
|
||||
..Default::default()
|
||||
};
|
||||
for (drives, parity) in [(4_usize, 2_usize), (16, 8), (16, 4)] {
|
||||
let data = drives - parity;
|
||||
let bucket = format!("torn-write-get-{drives}-{parity}");
|
||||
let (dirs, set) = crate::ecstore_validation_blackbox::make_local_set_disks(drives, parity).await;
|
||||
set.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created");
|
||||
// Two committed generations of one key give every drive its own
|
||||
// inline shard of each generation; the cases copy them per drive.
|
||||
let mut generations = Vec::new();
|
||||
for body in [b"old generation".as_slice(), b"new generation".as_slice()] {
|
||||
let info = set
|
||||
.put_object(
|
||||
&bucket,
|
||||
"template",
|
||||
&mut PutObjReader::from_vec(body.to_vec()),
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("generation PUT should commit on every drive");
|
||||
let mut metadata = Vec::with_capacity(drives);
|
||||
for dir in &dirs {
|
||||
metadata.push(
|
||||
tokio::fs::read(dir.path().join(&bucket).join("template").join(STORAGE_FORMAT_FILE))
|
||||
.await
|
||||
.expect("generation metadata should carry the inline shard"),
|
||||
);
|
||||
}
|
||||
generations.push((body, info.etag.expect("PUT should report an etag"), metadata));
|
||||
}
|
||||
let (new_body, new_etag, new) = generations.pop().expect("new generation");
|
||||
let (old_body, old_etag, old) = generations.pop().expect("old generation");
|
||||
|
||||
// New copies occupy the first drives, so the offline drive always
|
||||
// removes one new copy. Each phase reads its own keys so the GET
|
||||
// metadata cache never answers for the other phase.
|
||||
let mut cases = Vec::new();
|
||||
for offline in [false, true] {
|
||||
for copies in 1..drives {
|
||||
for overwrite in [false, true] {
|
||||
let name = format!(
|
||||
"{}-{}-{copies:02}",
|
||||
if offline { "offline" } else { "online" },
|
||||
if overwrite { "overwrite" } else { "new" }
|
||||
);
|
||||
for slot in 0..drives {
|
||||
let bytes = if slot < copies {
|
||||
&new[slot]
|
||||
} else if overwrite {
|
||||
&old[slot]
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let path = dirs[slot].path().join(&bucket).join(&name);
|
||||
tokio::fs::create_dir_all(&path).await.expect("create object directory");
|
||||
tokio::fs::write(path.join(STORAGE_FORMAT_FILE), bytes)
|
||||
.await
|
||||
.expect("write generation metadata");
|
||||
}
|
||||
cases.push((offline, name, copies, overwrite));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for offline in [false, true] {
|
||||
let parked = if offline { set.disks.write().await[0].take() } else { None };
|
||||
for (_, name, copies, overwrite) in cases.iter().filter(|case| case.0 == offline) {
|
||||
let new_copies = copies - usize::from(offline);
|
||||
let old_copies = if *overwrite { drives - copies } else { 0 };
|
||||
let expected = if new_copies >= data && new_copies >= old_copies {
|
||||
Some((&new_etag, new_body))
|
||||
} else if old_copies >= data {
|
||||
Some((&old_etag, old_body))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let context = format!("{drives} drives EC {data}+{parity}, {name}: {new_copies} new, {old_copies} old");
|
||||
let head = set.get_object_info(&bucket, name, &opts).await;
|
||||
let get = set.get_object_reader(&bucket, name, None, HeaderMap::new(), &opts).await;
|
||||
match (expected, head, get) {
|
||||
(Some((etag, body)), Ok(info), Ok(mut reader)) => {
|
||||
assert_eq!(info.etag.as_ref(), Some(etag), "{context}: HEAD");
|
||||
assert_eq!(reader.object_info.etag.as_ref(), Some(etag), "{context}: GET");
|
||||
let mut streamed = Vec::new();
|
||||
reader
|
||||
.stream
|
||||
.read_to_end(&mut streamed)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("{context}: GET body: {err:?}"));
|
||||
assert_eq!(streamed, body, "{context}: GET body");
|
||||
}
|
||||
(Some((etag, _)), head, get) => panic!(
|
||||
"{context}: expected {etag}, HEAD {:?}, GET {:?}",
|
||||
head.map(|info| info.etag),
|
||||
get.map(|reader| reader.object_info.etag)
|
||||
),
|
||||
(None, head, get) => {
|
||||
for (method, result) in [
|
||||
("HEAD", head.map(|info| info.etag)),
|
||||
("GET", get.map(|reader| reader.object_info.etag)),
|
||||
] {
|
||||
let err = result.expect_err(&format!("{context}: {method} expected 404 or unavailable"));
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
StorageError::ObjectNotFound(..)
|
||||
| StorageError::FileNotFound
|
||||
| StorageError::ErasureReadQuorum
|
||||
| StorageError::InsufficientReadQuorum(..)
|
||||
),
|
||||
"{context}: {method} expected 404 or unavailable, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if offline {
|
||||
set.disks.write().await[0] = parked;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_listing_and_get_agree_on_hermetic_set_states() {
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -6734,10 +6491,6 @@ mod test {
|
||||
("d-torn-two", [Some(Generation::New), Some(Generation::New), None, None]),
|
||||
("e-torn-one", [Some(Generation::New), None, None, None]),
|
||||
("f-stale-one", [None, None, None, Some(Generation::Old)]),
|
||||
(
|
||||
"fa-overwrite-two-of-three",
|
||||
[Some(Generation::New), Some(Generation::New), Some(Generation::Old), None],
|
||||
),
|
||||
(
|
||||
"g-overwrite-torn-one",
|
||||
[
|
||||
@@ -6782,7 +6535,6 @@ mod test {
|
||||
("b-copied", &new_etag),
|
||||
("c-split", &new_etag),
|
||||
("d-torn-two", &new_etag),
|
||||
("fa-overwrite-two-of-three", &new_etag),
|
||||
("g-overwrite-torn-one", &old_etag),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -6809,6 +6561,12 @@ mod test {
|
||||
"{name}: GET must serve the version LIST selected"
|
||||
);
|
||||
}
|
||||
// Known divergence: when every drive answers, GET of the latest
|
||||
// version demands write quorum (three of four), so a 2+2 split
|
||||
// answers 503 although LIST shows the newer generation, as
|
||||
// MinIO's listing does. F.0's dangling classification does not
|
||||
// cover a split; a GET change is a separate decision.
|
||||
(Some(_), Err(StorageError::InsufficientReadQuorum(..))) if name == "c-split" => {}
|
||||
(Some(_), Err(err)) => assert!(
|
||||
matches!(
|
||||
err,
|
||||
@@ -7011,6 +6769,22 @@ mod test {
|
||||
assert_eq!(parsed.set_idx, Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marker_key_strips_only_trailing_cache_tag() {
|
||||
assert_eq!(
|
||||
list_marker_key("photos/2026/image.jpg[rustfs_cache:v2,id:list-cache-id,src:walker,gen:live]"),
|
||||
"photos/2026/image.jpg"
|
||||
);
|
||||
assert_eq!(
|
||||
list_marker_key("photos/[rustfs_cache:v2]/image.jpg"),
|
||||
"photos/[rustfs_cache:v2]/image.jpg"
|
||||
);
|
||||
assert_eq!(
|
||||
list_marker_key("photos/image.jpg[rustfs_cache:v2,id:list-cache-id"),
|
||||
"photos/image.jpg[rustfs_cache:v2,id:list-cache-id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_path_marker_parser_return_tag_forces_cache_refresh() {
|
||||
let mut parsed = ListPathOptions {
|
||||
|
||||
@@ -755,7 +755,7 @@ async fn list_objects_shared_corpus_versions_delete_markers_and_free_versions()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_objects_shared_corpus_orphan_listing_preserves_inputs() {
|
||||
async fn list_objects_shared_corpus_orphan_cleanup_requires_authoritative_evidence() {
|
||||
for authoritative in [false, true] {
|
||||
let fixture = Fixture::new(
|
||||
&format!("list-corpus-orphan-{authoritative}"),
|
||||
@@ -774,9 +774,10 @@ async fn list_objects_shared_corpus_orphan_listing_preserves_inputs() {
|
||||
let result = fixture.page("ghost/", None, None, 1).await;
|
||||
assert!(result.objects.is_empty() && result.prefixes.is_empty() && !result.is_truncated);
|
||||
for dir in &fixture.dirs {
|
||||
assert!(
|
||||
dir.path().join(&fixture.bucket).join("ghost/nested/leaf").exists(),
|
||||
"LIST must preserve orphan input"
|
||||
assert_eq!(
|
||||
dir.path().join(&fixture.bucket).join("ghost").exists(),
|
||||
!authoritative,
|
||||
"unknown metadata must fail closed"
|
||||
);
|
||||
assert!(
|
||||
dir.path()
|
||||
@@ -784,7 +785,7 @@ async fn list_objects_shared_corpus_orphan_listing_preserves_inputs() {
|
||||
.join("legal/child")
|
||||
.join(STORAGE_FORMAT_FILE)
|
||||
.exists(),
|
||||
"LIST must preserve legal siblings"
|
||||
"purge must preserve legal siblings"
|
||||
);
|
||||
}
|
||||
fixture.assert_walk("legal/", None, None, 1).await;
|
||||
@@ -795,7 +796,7 @@ async fn list_objects_shared_corpus_orphan_listing_preserves_inputs() {
|
||||
.join("legal/child")
|
||||
.join(STORAGE_FORMAT_FILE)
|
||||
.exists(),
|
||||
"an orphan in the same subtree must not affect a legitimate child"
|
||||
"an orphan in the same subtree must not cause the legitimate child to be purged"
|
||||
);
|
||||
}
|
||||
if authoritative {
|
||||
@@ -810,15 +811,17 @@ async fn list_objects_shared_corpus_orphan_listing_preserves_inputs() {
|
||||
.bucket_metadata_sys()
|
||||
.expect("authoritative fixture has a metadata system");
|
||||
let guard = metadata_sys.write().await;
|
||||
let result = timeout(Duration::from_secs(2), fixture.page("timed-out/", None, None, 1))
|
||||
.await
|
||||
.expect("LIST must not wait for cleanup authorization");
|
||||
assert!(result.objects.is_empty() && result.prefixes.is_empty() && !result.is_truncated);
|
||||
assert!(
|
||||
timeout(Duration::from_millis(100), fixture.page("timed-out/", None, None, 1))
|
||||
.await
|
||||
.is_err(),
|
||||
"blocked authoritative evidence must time out before cleanup can classify the prefix as empty"
|
||||
);
|
||||
drop(guard);
|
||||
for dir in &fixture.dirs {
|
||||
assert!(
|
||||
dir.path().join(&fixture.bucket).join("timed-out").exists(),
|
||||
"LIST must preserve its orphan input while metadata is locked"
|
||||
"a timed-out evidence check must preserve its orphan input"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5139,6 +5139,27 @@ impl ECStore {
|
||||
purged
|
||||
}
|
||||
|
||||
pub(super) async fn purge_orphan_dir_objects_in_bucket(&self, bucket: &str) -> bool {
|
||||
if is_meta_bucketname(bucket) {
|
||||
return false;
|
||||
}
|
||||
for pool in self.pools.iter() {
|
||||
for set in pool.disk_set.iter() {
|
||||
if !set.orphan_purge_has_complete_disk_set().await {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut purged = false;
|
||||
for pool in self.pools.iter() {
|
||||
for set in pool.disk_set.iter() {
|
||||
purged |= set.purge_orphan_dir_objects_in_bucket(bucket).await;
|
||||
}
|
||||
}
|
||||
purged
|
||||
}
|
||||
|
||||
/// Delete one object (or a prefix) on behalf of a caller that may be
|
||||
/// dropped mid-way, such as an S3 request whose client disconnects.
|
||||
///
|
||||
|
||||
@@ -19,7 +19,6 @@ use thiserror::Error;
|
||||
use super::heal::{DiskError, EcstoreError};
|
||||
|
||||
const HEAL_DANGLING_DELETE_GRACE_MESSAGE: &str = "dangling object deletion deferred by heal grace window";
|
||||
pub(crate) const MRF_MISSING_PROOF_PREFIX: &str = "Missing exact storage proof for durable MRF repair ";
|
||||
|
||||
/// Custom error type for heal operations
|
||||
/// This enum defines various error variants that can occur during
|
||||
@@ -222,7 +221,6 @@ pub(crate) fn classify_heal_error(error: &Error) -> HealErrorClass {
|
||||
Error::Storage(error) => classify_storage_error(error),
|
||||
Error::Disk(error) => classify_disk_error(error),
|
||||
Error::Io(error) => classify_io_error(error),
|
||||
Error::TaskExecutionFailed { message } if message.starts_with(MRF_MISSING_PROOF_PREFIX) => HealErrorClass::Permanent,
|
||||
Error::TaskExecutionFailed { message } | Error::Other(message) => {
|
||||
classify_error_display(message).unwrap_or(HealErrorClass::Permanent)
|
||||
}
|
||||
|
||||
@@ -58,8 +58,6 @@ fn result_object_size_u64(result: &HealResultItem) -> u64 {
|
||||
|
||||
const NEW_VERSION_SKIP_GRACE_SECS: u64 = 60;
|
||||
const NANOS_PER_SECOND: i128 = 1_000_000_000;
|
||||
const DEFAULT_REPLACEMENT_PAGE_OBJECT_CONCURRENCY: usize = 2;
|
||||
const REPLACEMENT_OBJECT_MAX_RETRIES: u32 = 2;
|
||||
|
||||
fn should_skip_new_version(mod_time_unix_nanos: Option<i128>, started_at_secs: u64) -> bool {
|
||||
let Some(mod_time_unix_nanos) = mod_time_unix_nanos else {
|
||||
@@ -179,9 +177,7 @@ mod mainline_pacing_tests {
|
||||
.await
|
||||
.expect("existing object owns capacity");
|
||||
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 0));
|
||||
let pacer = Arc::new(
|
||||
MainlinePacer::new(provider.clone(), 80, 80, Duration::from_millis(250), HealRequestSource::Admin).expect("pacer"),
|
||||
);
|
||||
let pacer = Arc::new(MainlinePacer::new(provider.clone(), 80, 80, Duration::from_millis(250)).expect("pacer"));
|
||||
let cancel = CancellationToken::new();
|
||||
let waiting = tokio::spawn({
|
||||
let semaphore = semaphore.clone();
|
||||
@@ -220,7 +216,7 @@ mod mainline_pacing_tests {
|
||||
let occupied = semaphore.clone().acquire_owned().await.expect("queue both waiters");
|
||||
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 0));
|
||||
let pause = Duration::from_millis(250);
|
||||
let pacer = MainlinePacer::new(provider.clone(), 80, 80, pause, HealRequestSource::Admin).expect("pacer");
|
||||
let pacer = MainlinePacer::new(provider.clone(), 80, 80, pause).expect("pacer");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut first = Box::pin(acquire_page_permit(semaphore.clone(), Some(&pacer), &cancel));
|
||||
let mut second = Box::pin(acquire_page_permit(semaphore.clone(), Some(&pacer), &cancel));
|
||||
@@ -308,21 +304,9 @@ impl ErasureSetHealer {
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_heal_page_object_concurrency_for_source(
|
||||
source: HealRequestSource,
|
||||
scan_mode: HealScanMode,
|
||||
replacement: bool,
|
||||
) -> usize {
|
||||
fn effective_heal_page_object_concurrency_for_source(source: HealRequestSource, scan_mode: HealScanMode) -> usize {
|
||||
if matches!(source, HealRequestSource::AutoHeal) {
|
||||
if !replacement || matches!(scan_mode, HealScanMode::Deep) || !Self::page_parallel_enabled() {
|
||||
1
|
||||
} else {
|
||||
rustfs_utils::get_env_usize(
|
||||
rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY,
|
||||
DEFAULT_REPLACEMENT_PAGE_OBJECT_CONCURRENCY,
|
||||
)
|
||||
.clamp(1, Semaphore::MAX_PERMITS)
|
||||
}
|
||||
1
|
||||
} else {
|
||||
Self::effective_heal_page_object_concurrency_for_scan_mode(scan_mode)
|
||||
}
|
||||
@@ -1483,16 +1467,7 @@ impl ErasureSetHealer {
|
||||
// transient cache id that changes between identical pages.
|
||||
let mut previous_page_last: Option<(String, Option<String>)> = None;
|
||||
let page_concurrency_limit =
|
||||
if self.source == HealRequestSource::AutoHeal && self.replacement_task_id.is_some() && self.heal_opts.no_lock {
|
||||
// Versions of the same object must not race without its namespace lock.
|
||||
1
|
||||
} else {
|
||||
Self::effective_heal_page_object_concurrency_for_source(
|
||||
self.source,
|
||||
self.heal_opts.scan_mode,
|
||||
self.replacement_task_id.is_some(),
|
||||
)
|
||||
};
|
||||
Self::effective_heal_page_object_concurrency_for_source(self.source, self.heal_opts.scan_mode);
|
||||
let in_flight = Arc::new(AtomicUsize::new(0));
|
||||
// Per-bucket sample caps for per-object warn! lines: a flapping rebuild
|
||||
// disk can fail/skip hundreds of thousands of versions in one sweep, so
|
||||
@@ -1714,7 +1689,6 @@ impl ErasureSetHealer {
|
||||
let semaphore = semaphore.clone();
|
||||
let target_endpoints = self.target_endpoints.clone();
|
||||
let replacement_commit_evidence_required = self.replacement_task_id.is_some();
|
||||
let retry_replacement = replacement_commit_evidence_required && self.source == HealRequestSource::AutoHeal;
|
||||
let mainline_pacer = self.mainline_pacer.clone();
|
||||
|
||||
let admin_identity = self.admin_identity(bucket, &object_name, version_id.as_deref(), incarnation);
|
||||
@@ -1724,125 +1698,107 @@ impl ErasureSetHealer {
|
||||
.as_ref()
|
||||
.map(|_| (dedup_key.clone(), object_name.clone(), version_id.clone()));
|
||||
let work = async move {
|
||||
let mut retries = 0;
|
||||
let mut admin_identity = admin_identity;
|
||||
let mut admin_task_id = admin_task_id;
|
||||
loop {
|
||||
let permit = acquire_page_permit(semaphore.clone(), mainline_pacer.as_deref(), &cancel_token).await;
|
||||
let permit = acquire_page_permit(semaphore, mainline_pacer.as_deref(), &cancel_token).await;
|
||||
|
||||
let permit = match permit {
|
||||
Ok(permit) => permit,
|
||||
Err(err) => return (dedup_key, object_name, version_id, (0, Err(err)), None),
|
||||
};
|
||||
let _permit = match permit {
|
||||
Ok(permit) => permit,
|
||||
Err(err) => return (dedup_key, object_name, version_id, (0, Err(err)), None),
|
||||
};
|
||||
|
||||
let in_flight_guard = PageConcurrencyGuard::new(in_flight.clone(), set_label.clone());
|
||||
let _in_flight_guard = PageConcurrencyGuard::new(in_flight, set_label);
|
||||
|
||||
if let (Some(identity), Some(task_id)) = (admin_identity.take(), admin_task_id.take()) {
|
||||
let (result, canonical) =
|
||||
admin::heal_object(storage.as_ref(), &heal_opts, identity, &task_id, &cancel_token).await;
|
||||
return (dedup_key, object_name, version_id, result, canonical);
|
||||
}
|
||||
if let (Some(identity), Some(task_id)) = (admin_identity, admin_task_id) {
|
||||
let (result, canonical) =
|
||||
admin::heal_object(storage.as_ref(), &heal_opts, identity, &task_id, &cancel_token).await;
|
||||
return (dedup_key, object_name, version_id, result, canonical);
|
||||
}
|
||||
|
||||
// Always go through heal_object. Genuine absence flows through
|
||||
// heal_object -> FileVersionNotFound/FileNotFound ->
|
||||
// classify_heal_object_error -> Absent, so gone versions are
|
||||
// recorded as skipped-ok rather than failed. The delete-marker
|
||||
// vs data path is chosen internally in ops/heal.rs.
|
||||
let result = if cancel_token.is_cancelled() {
|
||||
(0, Err(Error::TaskCancelled))
|
||||
} else {
|
||||
match storage
|
||||
.heal_object(&bucket_name, &object_name, version_id.as_deref(), &heal_opts)
|
||||
.await
|
||||
{
|
||||
Ok((result, None)) if target_outcomes_complete(&result, &target_endpoints) => {
|
||||
let object_size = result_object_size_u64(&result);
|
||||
if !replacement_commit_evidence_required {
|
||||
(object_size, Ok(true))
|
||||
} else {
|
||||
match storage
|
||||
.replacement_targets_have_version(
|
||||
&bucket_name,
|
||||
&object_name,
|
||||
version_id.as_deref(),
|
||||
&heal_opts,
|
||||
&target_endpoints,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => (object_size, Ok(true)),
|
||||
Ok(false) => (
|
||||
object_size,
|
||||
Err(Error::transient_skip(format!(
|
||||
"Skipped heal for {bucket_name}/{object_name} because replacement target readback did not confirm the committed version"
|
||||
))),
|
||||
),
|
||||
Err(err) => (
|
||||
object_size,
|
||||
Err(Error::transient_skip(format!(
|
||||
"Skipped heal for {bucket_name}/{object_name} because replacement target readback failed: {err}"
|
||||
))),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((result, None)) if !target_endpoints.is_empty() => (
|
||||
result_object_size_u64(&result),
|
||||
Err(Error::transient_skip(format!(
|
||||
"Skipped heal for {bucket_name}/{object_name} because a replacement target was not committed"
|
||||
))),
|
||||
),
|
||||
Ok((result, None)) => (result_object_size_u64(&result), Ok(true)),
|
||||
Ok((result, Some(err))) if is_missing_object_dir_heal_result(&object_name, &err) => {
|
||||
(result_object_size_u64(&result), Ok(false))
|
||||
}
|
||||
Ok((result, Some(err))) => {
|
||||
let object_size = result_object_size_u64(&result);
|
||||
match Self::classify_heal_object_error(&err) {
|
||||
HealObjectOutcome::Absent => (object_size, Ok(false)),
|
||||
HealObjectOutcome::DanglingDeleteDeferred { retry_not_before } => {
|
||||
(object_size, Err(Error::DanglingDeleteDeferred { retry_not_before }))
|
||||
}
|
||||
HealObjectOutcome::Transient => (
|
||||
// Always go through heal_object. Genuine absence flows through
|
||||
// heal_object -> FileVersionNotFound/FileNotFound ->
|
||||
// classify_heal_object_error -> Absent, so gone versions are
|
||||
// recorded as skipped-ok rather than failed. The delete-marker
|
||||
// vs data path is chosen internally in ops/heal.rs.
|
||||
let result = if cancel_token.is_cancelled() {
|
||||
(0, Err(Error::TaskCancelled))
|
||||
} else {
|
||||
match storage
|
||||
.heal_object(&bucket_name, &object_name, version_id.as_deref(), &heal_opts)
|
||||
.await
|
||||
{
|
||||
Ok((result, None)) if target_outcomes_complete(&result, &target_endpoints) => {
|
||||
let object_size = result_object_size_u64(&result);
|
||||
if !replacement_commit_evidence_required {
|
||||
(object_size, Ok(true))
|
||||
} else {
|
||||
match storage
|
||||
.replacement_targets_have_version(
|
||||
&bucket_name,
|
||||
&object_name,
|
||||
version_id.as_deref(),
|
||||
&heal_opts,
|
||||
&target_endpoints,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => (object_size, Ok(true)),
|
||||
Ok(false) => (
|
||||
object_size,
|
||||
Err(Error::transient_skip(format!(
|
||||
"Skipped heal for {bucket_name}/{object_name} due to transient error: {err}"
|
||||
"Skipped heal for {bucket_name}/{object_name} because replacement target readback did not confirm the committed version"
|
||||
))),
|
||||
),
|
||||
Err(err) => (
|
||||
object_size,
|
||||
Err(Error::transient_skip(format!(
|
||||
"Skipped heal for {bucket_name}/{object_name} because replacement target readback failed: {err}"
|
||||
))),
|
||||
),
|
||||
HealObjectOutcome::Failed => (object_size, Err(err)),
|
||||
}
|
||||
}
|
||||
Err(err) => match Self::classify_heal_object_error(&err) {
|
||||
HealObjectOutcome::Absent => (0, Ok(false)),
|
||||
}
|
||||
Ok((result, None)) if !target_endpoints.is_empty() => (
|
||||
result_object_size_u64(&result),
|
||||
Err(Error::transient_skip(format!(
|
||||
"Skipped heal for {bucket_name}/{object_name} because a replacement target was not committed"
|
||||
))),
|
||||
),
|
||||
Ok((result, None)) => (result_object_size_u64(&result), Ok(true)),
|
||||
Ok((result, Some(err))) if is_missing_object_dir_heal_result(&object_name, &err) => {
|
||||
(result_object_size_u64(&result), Ok(false))
|
||||
}
|
||||
Ok((result, Some(err))) => {
|
||||
let object_size = result_object_size_u64(&result);
|
||||
match Self::classify_heal_object_error(&err) {
|
||||
HealObjectOutcome::Absent => (object_size, Ok(false)),
|
||||
HealObjectOutcome::DanglingDeleteDeferred { retry_not_before } => {
|
||||
(0, Err(Error::DanglingDeleteDeferred { retry_not_before }))
|
||||
(object_size, Err(Error::DanglingDeleteDeferred { retry_not_before }))
|
||||
}
|
||||
HealObjectOutcome::Transient => (
|
||||
0,
|
||||
object_size,
|
||||
Err(Error::transient_skip(format!(
|
||||
"Skipped heal for {bucket_name}/{object_name} due to transient error: {err}"
|
||||
))),
|
||||
),
|
||||
HealObjectOutcome::Failed => (0, Err(err)),
|
||||
},
|
||||
HealObjectOutcome::Failed => (object_size, Err(err)),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
drop(in_flight_guard);
|
||||
drop(permit);
|
||||
if retry_replacement
|
||||
&& retries < REPLACEMENT_OBJECT_MAX_RETRIES
|
||||
&& matches!(&result.1, Err(Error::TransientSkip { .. }))
|
||||
{
|
||||
retries += 1;
|
||||
tokio::select! {
|
||||
_ = cancel_token.cancelled() => return (dedup_key, object_name, version_id, (0, Err(Error::TaskCancelled)), None),
|
||||
_ = tokio::time::sleep(Duration::from_millis(250 * u64::from(retries))) => {}
|
||||
}
|
||||
continue;
|
||||
Err(err) => match Self::classify_heal_object_error(&err) {
|
||||
HealObjectOutcome::Absent => (0, Ok(false)),
|
||||
HealObjectOutcome::DanglingDeleteDeferred { retry_not_before } => {
|
||||
(0, Err(Error::DanglingDeleteDeferred { retry_not_before }))
|
||||
}
|
||||
HealObjectOutcome::Transient => (
|
||||
0,
|
||||
Err(Error::transient_skip(format!(
|
||||
"Skipped heal for {bucket_name}/{object_name} due to transient error: {err}"
|
||||
))),
|
||||
),
|
||||
HealObjectOutcome::Failed => (0, Err(err)),
|
||||
},
|
||||
}
|
||||
return (dedup_key, object_name, version_id, result, None);
|
||||
}
|
||||
};
|
||||
|
||||
(dedup_key, object_name, version_id, result, None)
|
||||
};
|
||||
page_tasks.push(async move {
|
||||
if let (Some(execution), Some((key, object, version))) = (execution, failure_identity) {
|
||||
@@ -2173,7 +2129,6 @@ mod tests {
|
||||
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
|
||||
HealRequestSource::AutoHeal,
|
||||
HealScanMode::Normal,
|
||||
false,
|
||||
),
|
||||
1
|
||||
);
|
||||
@@ -2181,73 +2136,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_replacement_uses_a_conservative_default_and_respects_serial_controls() {
|
||||
temp_env::with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
|
||||
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, None),
|
||||
],
|
||||
|| {
|
||||
assert_eq!(
|
||||
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
|
||||
HealRequestSource::AutoHeal,
|
||||
HealScanMode::Normal,
|
||||
true,
|
||||
),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
|
||||
HealRequestSource::AutoHeal,
|
||||
HealScanMode::Deep,
|
||||
true,
|
||||
),
|
||||
1
|
||||
);
|
||||
},
|
||||
);
|
||||
temp_env::with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("false")),
|
||||
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("8")),
|
||||
],
|
||||
|| {
|
||||
assert_eq!(
|
||||
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
|
||||
HealRequestSource::AutoHeal,
|
||||
HealScanMode::Normal,
|
||||
true,
|
||||
),
|
||||
1
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_replacement_concurrency_fits_the_semaphore() {
|
||||
let maximum = usize::MAX.to_string();
|
||||
for (value, expected) in [("0", 1), (maximum.as_str(), tokio::sync::Semaphore::MAX_PERMITS)] {
|
||||
temp_env::with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
|
||||
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some(value)),
|
||||
],
|
||||
|| {
|
||||
assert_eq!(
|
||||
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
|
||||
HealRequestSource::AutoHeal,
|
||||
HealScanMode::Normal,
|
||||
true,
|
||||
),
|
||||
expected
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_auto_normal_scan_page_object_concurrency_uses_effective_limit() {
|
||||
temp_env::with_vars(
|
||||
@@ -2260,7 +2148,6 @@ mod tests {
|
||||
ErasureSetHealer::effective_heal_page_object_concurrency_for_source(
|
||||
HealRequestSource::Admin,
|
||||
HealScanMode::Normal,
|
||||
false,
|
||||
),
|
||||
11
|
||||
);
|
||||
@@ -2633,8 +2520,6 @@ mod resume_loop_tests {
|
||||
lifecycle_expired: Mutex<HashSet<String>>,
|
||||
/// every heal_object call recorded as (name, version_id)
|
||||
heal_calls: Mutex<Vec<(String, Option<String>)>>,
|
||||
heal_gate: Mutex<Option<Arc<tokio::sync::Semaphore>>>,
|
||||
heal_started: tokio::sync::Notify,
|
||||
list_include_lifecycle_object_info: Mutex<Vec<bool>>,
|
||||
replacement_target_identity_sequences: Mutex<VecDeque<Vec<ReplacementTargetIdentity>>>,
|
||||
replacement_targets_unready: AtomicBool,
|
||||
@@ -2819,11 +2704,6 @@ mod resume_loop_tests {
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((object.to_string(), version_id.map(str::to_string)));
|
||||
self.heal_started.notify_one();
|
||||
let heal_gate = self.heal_gate.lock().unwrap().clone();
|
||||
if let Some(gate) = heal_gate {
|
||||
gate.acquire().await.unwrap().forget();
|
||||
}
|
||||
let key = compose_key(object, version_id);
|
||||
let outcome = {
|
||||
let mut outcomes = self.outcomes.lock().unwrap();
|
||||
@@ -4018,287 +3898,6 @@ mod resume_loop_tests {
|
||||
assert_eq!(unique.len(), 50, "no version healed twice");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_replacement_runs_two_object_repairs_within_the_configured_limit() {
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
|
||||
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("2")),
|
||||
],
|
||||
async {
|
||||
let mut env = make_env().await;
|
||||
env.healer.source = HealRequestSource::AutoHeal;
|
||||
env.healer = env
|
||||
.healer
|
||||
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()));
|
||||
let gate = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
*env.storage.heal_gate.lock().unwrap() = Some(gate.clone());
|
||||
let objects = ["a", "b", "c"];
|
||||
for name in objects {
|
||||
env.storage
|
||||
.set_result(name, None, replacement_target_ok_result("replacement-a", name));
|
||||
}
|
||||
env.storage.set_page(
|
||||
None,
|
||||
Page {
|
||||
items: objects.into_iter().map(|name| item(name, None, false)).collect(),
|
||||
next: None,
|
||||
truncated: false,
|
||||
},
|
||||
);
|
||||
|
||||
let observe = async {
|
||||
let two_started = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
loop {
|
||||
let notified = env.storage.heal_started.notified();
|
||||
if env.storage.calls().len() >= 2 {
|
||||
break;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
let started = env.storage.calls().len();
|
||||
gate.add_permits(objects.len());
|
||||
assert!(two_started, "automatic replacement should start two repairs before either finishes");
|
||||
assert_eq!(started, 2, "configured concurrency must bound in-flight repairs");
|
||||
};
|
||||
let ((processed, successful, failed, skipped, result), ()) = tokio::join!(run(&env), observe);
|
||||
result.expect("all replacement objects must be verified");
|
||||
assert_eq!((processed, successful, failed, skipped), (3, 3, 0, 0));
|
||||
assert_eq!(env.storage.calls().len(), 3);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_replacement_without_object_locks_stays_serial() {
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
|
||||
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("2")),
|
||||
],
|
||||
async {
|
||||
let mut env = make_env().await;
|
||||
env.healer.source = HealRequestSource::AutoHeal;
|
||||
env.healer.heal_opts.no_lock = true;
|
||||
env.healer = env
|
||||
.healer
|
||||
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()));
|
||||
let gate = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
*env.storage.heal_gate.lock().unwrap() = Some(gate.clone());
|
||||
for version in ["v1", "v2"] {
|
||||
env.storage
|
||||
.set_result("object", Some(version), replacement_target_ok_result("replacement-a", "object"));
|
||||
}
|
||||
env.storage.set_page(
|
||||
None,
|
||||
Page {
|
||||
items: vec![item("object", Some("v1"), false), item("object", Some("v2"), false)],
|
||||
next: None,
|
||||
truncated: false,
|
||||
},
|
||||
);
|
||||
let observe = async {
|
||||
let first_started = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
let notified = env.storage.heal_started.notified();
|
||||
if !env.storage.calls().is_empty() {
|
||||
break;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
let two_started = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
loop {
|
||||
let notified = env.storage.heal_started.notified();
|
||||
if env.storage.calls().len() >= 2 {
|
||||
break;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
gate.add_permits(2);
|
||||
assert!(first_started, "the first object repair must start");
|
||||
assert!(!two_started, "unlocked object repairs must not overlap");
|
||||
};
|
||||
let ((processed, successful, failed, skipped, result), ()) = tokio::join!(run(&env), observe);
|
||||
result.expect("the serial replacement must finish");
|
||||
assert_eq!((processed, successful, failed, skipped), (2, 2, 0, 0));
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_replacement_retries_a_transient_object_before_rewinding_the_scan() {
|
||||
let mut env = make_env().await;
|
||||
env.healer.source = HealRequestSource::AutoHeal;
|
||||
env.healer = env
|
||||
.healer
|
||||
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()));
|
||||
env.storage.set_page(
|
||||
None,
|
||||
Page {
|
||||
items: vec![item("object", Some("version"), false)],
|
||||
next: None,
|
||||
truncated: false,
|
||||
},
|
||||
);
|
||||
env.storage
|
||||
.set_outcome("object", Some("version"), HealOutcome::RpcCancelled(1));
|
||||
env.storage
|
||||
.set_result("object", Some("version"), replacement_target_ok_result("replacement-a", "object"));
|
||||
|
||||
let (processed, successful, failed, skipped, result) = run(&env).await;
|
||||
result.expect("the transient repair should succeed during the current pass");
|
||||
assert_eq!((processed, successful, failed, skipped), (1, 1, 0, 0));
|
||||
assert_eq!(env.storage.calls().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_replacement_releases_capacity_during_a_bounded_retry() {
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
|
||||
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("1")),
|
||||
],
|
||||
async {
|
||||
let mut env = make_env().await;
|
||||
env.healer.source = HealRequestSource::AutoHeal;
|
||||
env.healer = env
|
||||
.healer
|
||||
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()));
|
||||
env.storage.set_page(
|
||||
None,
|
||||
Page {
|
||||
items: vec![item("retry", None, false), item("ready", None, false)],
|
||||
next: None,
|
||||
truncated: false,
|
||||
},
|
||||
);
|
||||
env.storage.set_outcome("retry", None, HealOutcome::Transient);
|
||||
env.storage
|
||||
.set_result("ready", None, replacement_target_ok_result("replacement-a", "ready"));
|
||||
|
||||
let (processed, successful, failed, skipped, result) = run(&env).await;
|
||||
result.expect("a failed object is retained as a transient skip");
|
||||
assert_eq!((processed, successful, failed, skipped), (2, 1, 0, 1));
|
||||
let calls = env.storage.calls();
|
||||
let retries: Vec<_> = calls.iter().enumerate().filter(|(_, call)| call.0 == "retry").collect();
|
||||
let ready = calls.iter().position(|call| call.0 == "ready").expect("ready object ran");
|
||||
assert_eq!(retries.len(), 3, "only two retries are allowed");
|
||||
assert!(ready < retries[1].0, "the backoff must release the only page permit");
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_replacement_cancellation_interrupts_object_retry() {
|
||||
let mut env = make_env().await;
|
||||
env.healer.source = HealRequestSource::AutoHeal;
|
||||
env.healer = env
|
||||
.healer
|
||||
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()));
|
||||
env.storage.set_page(
|
||||
None,
|
||||
Page {
|
||||
items: vec![item("retry", None, false)],
|
||||
next: None,
|
||||
truncated: false,
|
||||
},
|
||||
);
|
||||
env.storage.set_outcome("retry", None, HealOutcome::Transient);
|
||||
let cancel = env.healer.cancel_token.clone();
|
||||
let observe = async {
|
||||
loop {
|
||||
let notified = env.storage.heal_started.notified();
|
||||
if !env.storage.calls().is_empty() {
|
||||
break;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
cancel.cancel();
|
||||
};
|
||||
|
||||
let ((_, successful, _, _, result), ()) = tokio::join!(run(&env), observe);
|
||||
assert!(matches!(result, Err(Error::TaskCancelled)));
|
||||
assert_eq!(successful, 0);
|
||||
assert_eq!(env.storage.calls().len(), 1);
|
||||
assert!(env.checkpoint.get_checkpoint().await.processed_objects.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_replacement_cancel_retains_workers_until_inflight_repairs_settle() {
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_HEAL_PAGE_PARALLEL_ENABLE, Some("true")),
|
||||
(rustfs_config::ENV_HEAL_PAGE_OBJECT_CONCURRENCY, Some("2")),
|
||||
],
|
||||
async {
|
||||
let mut env = make_env().await;
|
||||
env.healer.source = HealRequestSource::AutoHeal;
|
||||
let execution = crate::heal::storage::ReplacementExecution::for_test(Vec::new(), Vec::new());
|
||||
env.healer = env
|
||||
.healer
|
||||
.with_replacement_targets(vec!["replacement-a".to_string()], Some(env.task_id.clone()))
|
||||
.with_replacement_execution(Some(execution.clone()));
|
||||
let gate = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
*env.storage.heal_gate.lock().unwrap() = Some(gate.clone());
|
||||
for name in ["a", "b", "c"] {
|
||||
env.storage
|
||||
.set_result(name, None, replacement_target_ok_result("replacement-a", name));
|
||||
}
|
||||
env.storage.set_page(
|
||||
None,
|
||||
Page {
|
||||
items: ["a", "b", "c"].into_iter().map(|name| item(name, None, false)).collect(),
|
||||
next: None,
|
||||
truncated: false,
|
||||
},
|
||||
);
|
||||
let cancel = env.healer.cancel_token.clone();
|
||||
let observe = async {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
let notified = env.storage.heal_started.notified();
|
||||
if env.storage.calls().len() >= 2 {
|
||||
break;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("two repairs must start");
|
||||
cancel.cancel();
|
||||
};
|
||||
|
||||
let ((_, successful, _, _, result), ()) = tokio::join!(run(&env), observe);
|
||||
assert!(matches!(result, Err(Error::TaskCancelled)));
|
||||
assert_eq!(successful, 0);
|
||||
assert_eq!(env.storage.calls().len(), 2, "queued work must not start after cancellation");
|
||||
assert!(env.checkpoint.get_checkpoint().await.processed_objects.is_empty());
|
||||
assert!(Arc::strong_count(&execution) >= 4, "in-flight workers must retain execution ownership");
|
||||
gate.add_permits(2);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while Arc::strong_count(&execution) > 2 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("workers must release execution ownership after their storage work settles");
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resume_after_version_deleted_midheal_no_skip() {
|
||||
let env = make_env().await;
|
||||
@@ -4713,11 +4312,7 @@ mod resume_loop_tests {
|
||||
assert!(!state.completed, "readback errors must leave the replacement task incomplete");
|
||||
assert_eq!(state.retry_count, 1, "readback errors must arm the bounded retry path");
|
||||
assert!(env.checkpoint.get_checkpoint().await.processed_objects.is_empty());
|
||||
assert_eq!(
|
||||
env.storage.calls(),
|
||||
vec![("object".to_string(), Some("dm-v1".to_string())); 3],
|
||||
"unverified readback must exhaust only the bounded object retries"
|
||||
);
|
||||
assert_eq!(env.storage.calls(), vec![("object".to_string(), Some("dm-v1".to_string()))]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -30,30 +30,6 @@ pub(super) fn retry_admission_delay(
|
||||
Some(delay)
|
||||
}
|
||||
|
||||
pub(super) fn running_mainline_pacer(
|
||||
request: &HealRequest,
|
||||
config: &HealConfig,
|
||||
provider: &Option<WorkloadSnapshotProviderRef>,
|
||||
) -> Option<Arc<crate::heal::pacing::MainlinePacer>> {
|
||||
let pace_running = request.source == HealRequestSource::Admin
|
||||
|| (request.source == HealRequestSource::AutoHeal
|
||||
&& matches!(&request.heal_type, HealType::ErasureSet { .. })
|
||||
&& !request.heal_endpoints.is_empty());
|
||||
if !pace_running || !config.mainline_throttle_enable {
|
||||
return None;
|
||||
}
|
||||
provider.as_ref().and_then(|provider| {
|
||||
crate::heal::pacing::MainlinePacer::new(
|
||||
provider.clone(),
|
||||
config.mainline_read_utilization_high_percent,
|
||||
config.mainline_write_utilization_high_percent,
|
||||
config.mainline_max_sleep,
|
||||
request.source,
|
||||
)
|
||||
.map(Arc::new)
|
||||
})
|
||||
}
|
||||
|
||||
impl HealManager {
|
||||
/// Start scheduler
|
||||
pub(super) async fn start_scheduler(&self) -> Result<()> {
|
||||
@@ -226,7 +202,19 @@ impl HealManager {
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.get(&request.id)
|
||||
.cloned();
|
||||
let mainline_pacer = running_mainline_pacer(&request, &config, workload_provider);
|
||||
let mainline_pacer = if request.source == HealRequestSource::Admin && config.mainline_throttle_enable {
|
||||
workload_provider.as_ref().and_then(|provider| {
|
||||
crate::heal::pacing::MainlinePacer::new(
|
||||
provider.clone(),
|
||||
config.mainline_read_utilization_high_percent,
|
||||
config.mainline_write_utilization_high_percent,
|
||||
config.mainline_max_sleep,
|
||||
)
|
||||
.map(Arc::new)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let task = Arc::new(
|
||||
HealTask::from_replacement_recovery_request(request, storage.clone(), replacement_resume_endpoint)
|
||||
.with_mainline_pacer(mainline_pacer)
|
||||
|
||||
@@ -6,41 +6,6 @@ use crate::heal::storage::HealListItem;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn automatic_replacement_uses_running_foreground_pacing() {
|
||||
let provider = Arc::new(super::super::super::pacing::TestPressure::new(WorkloadClass::ForegroundRead, 100));
|
||||
let provider: Option<WorkloadSnapshotProviderRef> = Some(provider);
|
||||
let mut config = HealConfig {
|
||||
mainline_throttle_enable: true,
|
||||
mainline_read_utilization_high_percent: 80,
|
||||
mainline_max_sleep: Duration::from_millis(250),
|
||||
..HealConfig::default()
|
||||
};
|
||||
let mut request = HealRequest::new(
|
||||
HealType::ErasureSet {
|
||||
buckets: Vec::new(),
|
||||
set_disk_id: "pool_0_set_0".to_owned(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::Low,
|
||||
);
|
||||
request.source = HealRequestSource::AutoHeal;
|
||||
request.heal_endpoints = vec!["replacement-a".to_owned()];
|
||||
|
||||
let pacer = super::super::scheduler::running_mainline_pacer(&request, &config, &provider)
|
||||
.expect("automatic replacement must pace under foreground pressure");
|
||||
let started = tokio::time::Instant::now();
|
||||
pacer.wait(&CancellationToken::new()).await.expect("bounded progress");
|
||||
assert_eq!(started.elapsed(), Duration::from_millis(250));
|
||||
|
||||
assert!(super::super::scheduler::running_mainline_pacer(&request, &config, &None).is_none());
|
||||
config.mainline_throttle_enable = false;
|
||||
assert!(super::super::scheduler::running_mainline_pacer(&request, &config, &provider).is_none());
|
||||
config.mainline_throttle_enable = true;
|
||||
request.heal_endpoints.clear();
|
||||
assert!(super::super::scheduler::running_mainline_pacer(&request, &config, &provider).is_none());
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PressureProbe {
|
||||
active: AtomicUsize,
|
||||
|
||||
@@ -6,7 +6,6 @@ use rustfs_concurrency::{
|
||||
WorkloadAdmissionSnapshotProvider,
|
||||
workload::{ForegroundPressure, foreground_pressure},
|
||||
};
|
||||
use rustfs_heal_contracts::heal_channel::HealRequestSource;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::{sync::Mutex, time::Instant};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -22,13 +21,12 @@ pub(crate) enum PacingDecision {
|
||||
Wait(Option<ForegroundPressure>),
|
||||
}
|
||||
|
||||
/// Cooperative pacing for one heal execution, not a storage admission permit.
|
||||
/// Cooperative pacing for one admin execution, not a storage admission permit.
|
||||
pub(crate) struct MainlinePacer {
|
||||
provider: Arc<dyn WorkloadAdmissionSnapshotProvider + Send + Sync>,
|
||||
read_high: usize,
|
||||
write_high: usize,
|
||||
pause: Duration,
|
||||
source: HealRequestSource,
|
||||
state: Mutex<PacingState>,
|
||||
}
|
||||
|
||||
@@ -38,7 +36,6 @@ impl MainlinePacer {
|
||||
read_high: usize,
|
||||
write_high: usize,
|
||||
pause: Duration,
|
||||
source: HealRequestSource,
|
||||
) -> Option<Self> {
|
||||
if (read_high == 0 && write_high == 0) || pause.is_zero() {
|
||||
return None;
|
||||
@@ -48,7 +45,6 @@ impl MainlinePacer {
|
||||
read_high: read_high.min(100),
|
||||
write_high: write_high.min(100),
|
||||
pause: pause.min(Duration::from_secs(1)),
|
||||
source,
|
||||
state: Mutex::new(PacingState::default()),
|
||||
})
|
||||
}
|
||||
@@ -112,7 +108,7 @@ impl MainlinePacer {
|
||||
}
|
||||
metrics::counter!(
|
||||
"rustfs_heal_mainline_throttle_total",
|
||||
"source" => self.source.as_str(),
|
||||
"source" => "admin",
|
||||
"result" => "delayed",
|
||||
"reason" => pressure.or(observed).map_or("recovery_window", |pressure| pressure.reason())
|
||||
)
|
||||
@@ -174,7 +170,6 @@ mod tests {
|
||||
if class == WorkloadClass::ForegroundRead { 40 } else { 0 },
|
||||
if class == WorkloadClass::ForegroundWrite { 40 } else { 0 },
|
||||
pause,
|
||||
HealRequestSource::Admin,
|
||||
)
|
||||
.expect("enabled pacer");
|
||||
let cancel = CancellationToken::new();
|
||||
@@ -197,9 +192,9 @@ mod tests {
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn running_mainline_huge_pause_is_capped_and_disabled_classes_do_not_sleep() {
|
||||
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 100));
|
||||
assert!(MainlinePacer::new(provider.clone(), 0, 0, Duration::from_secs(1), HealRequestSource::Admin).is_none());
|
||||
assert!(MainlinePacer::new(provider.clone(), 80, 80, Duration::ZERO, HealRequestSource::Admin).is_none());
|
||||
let pacer = MainlinePacer::new(provider, 80, 80, Duration::from_secs(3600), HealRequestSource::Admin).expect("pacer");
|
||||
assert!(MainlinePacer::new(provider.clone(), 0, 0, Duration::from_secs(1)).is_none());
|
||||
assert!(MainlinePacer::new(provider.clone(), 80, 80, Duration::ZERO).is_none());
|
||||
let pacer = MainlinePacer::new(provider, 80, 80, Duration::from_secs(3600)).expect("pacer");
|
||||
let before = Instant::now();
|
||||
pacer.wait(&CancellationToken::new()).await.expect("hard-capped pause");
|
||||
assert_eq!(Instant::now() - before, Duration::from_secs(1));
|
||||
@@ -208,9 +203,7 @@ mod tests {
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn running_mainline_waiters_cancel_and_task_latches_are_isolated() {
|
||||
let provider = Arc::new(TestPressure::new(WorkloadClass::ForegroundRead, 100));
|
||||
let paced = Arc::new(
|
||||
MainlinePacer::new(provider.clone(), 80, 80, Duration::from_secs(1), HealRequestSource::Admin).expect("pacer"),
|
||||
);
|
||||
let paced = Arc::new(MainlinePacer::new(provider.clone(), 80, 80, Duration::from_secs(1)).expect("pacer"));
|
||||
let cancel_first = CancellationToken::new();
|
||||
let first = tokio::spawn({
|
||||
let paced = paced.clone();
|
||||
@@ -228,8 +221,7 @@ mod tests {
|
||||
cancel_second.cancel();
|
||||
assert!(matches!(second.await.expect("queued waiter"), Err(Error::TaskCancelled)));
|
||||
provider.active.store(0, Ordering::SeqCst);
|
||||
let other_task = MainlinePacer::new(provider.clone(), 80, 80, Duration::from_secs(1), HealRequestSource::Admin)
|
||||
.expect("independent task");
|
||||
let other_task = MainlinePacer::new(provider.clone(), 80, 80, Duration::from_secs(1)).expect("independent task");
|
||||
let before = Instant::now();
|
||||
other_task
|
||||
.wait(&CancellationToken::new())
|
||||
|
||||
@@ -489,13 +489,9 @@ impl HealTask {
|
||||
))
|
||||
.await?;
|
||||
if let Some(error) = storage_result.error {
|
||||
if error.is_dangling_delete_grace() {
|
||||
// Preserve the existing MRF grace retry ownership.
|
||||
return Err(Error::TaskExecutionFailed {
|
||||
message: format!("Failed to recreate missing object {bucket}/{object}: {error}"),
|
||||
});
|
||||
}
|
||||
return Err(error);
|
||||
return Err(Error::TaskExecutionFailed {
|
||||
message: format!("Failed to recreate missing object {bucket}/{object}: {error}"),
|
||||
});
|
||||
}
|
||||
|
||||
let object_size = storage_result.item.object_size as u64;
|
||||
@@ -530,7 +526,7 @@ impl HealTask {
|
||||
}
|
||||
} else {
|
||||
Error::TaskExecutionFailed {
|
||||
message: format!("{}{bucket}/{object}", crate::error::MRF_MISSING_PROOF_PREFIX),
|
||||
message: format!("Missing exact storage proof for durable MRF repair {bucket}/{object}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2912,44 +2912,6 @@ fn mrf_task_for_item(object: &str, item: HealResultItem) -> (HealTask, Arc<MockS
|
||||
(HealTask::from_request(request, storage.clone()), storage)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn durable_mrf_slowdown_remains_retryable_without_a_storage_receipt() {
|
||||
let (task, storage) = mrf_task_for_item("object", HealResultItem::default());
|
||||
*storage.heal_object_outcome.lock().unwrap() = Some(MockHealObjectOutcome::RetryableSlowDown);
|
||||
|
||||
let error = task
|
||||
.execute()
|
||||
.await
|
||||
.expect_err("a failed repair cannot discharge the responsibility");
|
||||
assert!(matches!(error, Error::Storage(EcstoreError::SlowDown)), "{error:?}");
|
||||
assert!(error.is_recoverable_heal());
|
||||
assert_eq!(task.get_outcome().await.counters.healed, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn durable_mrf_grace_remains_owned_by_the_responsibility() {
|
||||
let (task, storage) = mrf_task_for_item("object", HealResultItem::default());
|
||||
*storage.heal_object_outcome.lock().unwrap() = Some(MockHealObjectOutcome::DanglingGraceDeferred);
|
||||
|
||||
let error = task.execute().await.expect_err("grace must retain the responsibility");
|
||||
assert!(error.is_dangling_delete_grace());
|
||||
assert!(!error.is_recoverable_heal());
|
||||
assert_eq!(task.get_outcome().await.counters.healed, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn durable_mrf_unverified_object_names_cannot_change_retry_classification() {
|
||||
for object in ["ordinary", "timed out", "network error"] {
|
||||
let (task, _) = mrf_task_for_item(object, HealResultItem::default());
|
||||
let error = task
|
||||
.execute()
|
||||
.await
|
||||
.expect_err("missing proof must retain the responsibility");
|
||||
assert!(!error.is_recoverable_heal(), "object name must not change retry classification: {object}");
|
||||
assert_eq!(task.get_outcome().await.counters.healed, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mrf_legacy_object_names_cannot_change_retry_classification() {
|
||||
for object in [
|
||||
|
||||
@@ -39,37 +39,7 @@ struct GuardHolderKey {
|
||||
owner: Arc<str>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
enum TestPausePoint {
|
||||
FastExisting,
|
||||
BeforeFastWrite,
|
||||
FastWriteExisting,
|
||||
SlowLookup,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct TestPause {
|
||||
entered: std::sync::mpsc::Sender<()>,
|
||||
resume: std::sync::mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static TEST_PAUSES: std::sync::LazyLock<parking_lot::Mutex<HashMap<(TestPausePoint, ObjectKey), TestPause>>> =
|
||||
std::sync::LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
|
||||
|
||||
#[cfg(test)]
|
||||
fn test_pause(point: TestPausePoint, key: &ObjectKey) {
|
||||
let pause = TEST_PAUSES.lock().remove(&(point, key.clone()));
|
||||
if let Some(pause) = pause {
|
||||
pause.entered.send(()).expect("pause receiver");
|
||||
pause.resume.recv_timeout(Duration::from_secs(5)).expect("resume signal");
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock shard to reduce global contention.
|
||||
///
|
||||
/// When both are needed, lock `objects` before a state's owner metadata.
|
||||
/// Lock shard to reduce global contention
|
||||
#[derive(Debug)]
|
||||
pub struct LockShard {
|
||||
/// Object lock states - using parking_lot for better performance
|
||||
@@ -163,16 +133,13 @@ impl LockShard {
|
||||
let objects = self.objects.read();
|
||||
if let Some(state) = objects.get(&request.key) {
|
||||
let state = state.clone();
|
||||
|
||||
#[cfg(test)]
|
||||
test_pause(TestPausePoint::FastExisting, &request.key);
|
||||
drop(objects);
|
||||
|
||||
// Try atomic acquisition
|
||||
let success = match request.mode {
|
||||
LockMode::Shared => state.try_acquire_shared_fast(&request.owner, request.lock_timeout),
|
||||
LockMode::Exclusive => state.try_acquire_exclusive_fast(&request.owner, request.lock_timeout),
|
||||
};
|
||||
drop(objects);
|
||||
|
||||
if success {
|
||||
return Some(state);
|
||||
@@ -183,21 +150,14 @@ impl LockShard {
|
||||
// If object doesn't exist and we're requesting exclusive lock,
|
||||
// try to create and acquire atomically
|
||||
if request.mode == LockMode::Exclusive {
|
||||
#[cfg(test)]
|
||||
test_pause(TestPausePoint::BeforeFastWrite, &request.key);
|
||||
let mut objects = self.objects.write();
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if let Some(state) = objects.get(&request.key) {
|
||||
let state = state.clone();
|
||||
let objects = parking_lot::RwLockWriteGuard::downgrade(objects);
|
||||
|
||||
#[cfg(test)]
|
||||
test_pause(TestPausePoint::FastWriteExisting, &request.key);
|
||||
|
||||
let acquired = state.try_acquire_exclusive_fast(&request.owner, request.lock_timeout);
|
||||
drop(objects);
|
||||
if acquired {
|
||||
|
||||
if state.try_acquire_exclusive_fast(&request.owner, request.lock_timeout) {
|
||||
return Some(state);
|
||||
}
|
||||
} else {
|
||||
@@ -233,10 +193,10 @@ impl LockShard {
|
||||
const NOTIFY_WAIT_CAP: Duration = Duration::from_millis(50);
|
||||
|
||||
loop {
|
||||
// Keep the state in the map until its acquisition attempt completes.
|
||||
let (state, success) = {
|
||||
// Get or create object state
|
||||
let state = {
|
||||
let mut objects = self.objects.write();
|
||||
let state = match objects.get(&request.key) {
|
||||
match objects.get(&request.key) {
|
||||
Some(state) => state.clone(),
|
||||
None => {
|
||||
let state_box = self.object_pool.acquire();
|
||||
@@ -244,17 +204,13 @@ impl LockShard {
|
||||
objects.insert(request.key.clone(), state.clone());
|
||||
state
|
||||
}
|
||||
};
|
||||
let objects = parking_lot::RwLockWriteGuard::downgrade(objects);
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
test_pause(TestPausePoint::SlowLookup, &request.key);
|
||||
let success = match request.mode {
|
||||
LockMode::Shared => state.try_acquire_shared_fast(&request.owner, request.lock_timeout),
|
||||
LockMode::Exclusive => state.try_acquire_exclusive_fast(&request.owner, request.lock_timeout),
|
||||
};
|
||||
drop(objects);
|
||||
(state, success)
|
||||
// Try acquisition again
|
||||
let success = match request.mode {
|
||||
LockMode::Shared => state.try_acquire_shared_fast(&request.owner, request.lock_timeout),
|
||||
LockMode::Exclusive => state.try_acquire_exclusive_fast(&request.owner, request.lock_timeout),
|
||||
};
|
||||
|
||||
if success {
|
||||
@@ -949,184 +905,6 @@ impl LockShard {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fast_lock::types::{LockPriority, ObjectKey};
|
||||
use crate::fast_lock::{FastLockGuard, FastObjectLockManager};
|
||||
|
||||
fn install_pause(point: TestPausePoint, key: &ObjectKey) -> (std::sync::mpsc::Receiver<()>, std::sync::mpsc::Sender<()>) {
|
||||
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
|
||||
let (resume_tx, resume_rx) = std::sync::mpsc::channel();
|
||||
let previous = TEST_PAUSES.lock().insert(
|
||||
(point, key.clone()),
|
||||
TestPause {
|
||||
entered: entered_tx,
|
||||
resume: resume_rx,
|
||||
},
|
||||
);
|
||||
assert!(previous.is_none());
|
||||
(entered_rx, resume_tx)
|
||||
}
|
||||
|
||||
fn test_manager() -> Arc<FastObjectLockManager> {
|
||||
// Construct outside a runtime so no background cleanup races the test.
|
||||
Arc::new(
|
||||
std::thread::spawn(|| {
|
||||
FastObjectLockManager::with_config(crate::fast_lock::types::LockConfig {
|
||||
shard_count: 4,
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.join()
|
||||
.expect("manager construction"),
|
||||
)
|
||||
}
|
||||
|
||||
fn start_acquisition(
|
||||
manager: &Arc<FastObjectLockManager>,
|
||||
request: ObjectLockRequest,
|
||||
) -> tokio::task::JoinHandle<Result<FastLockGuard, LockResult>> {
|
||||
let manager = manager.clone();
|
||||
tokio::spawn(async move { manager.acquire_lock(request).await })
|
||||
}
|
||||
|
||||
async fn assert_cleanup_cannot_split_state(
|
||||
manager: Arc<FastObjectLockManager>,
|
||||
key: ObjectKey,
|
||||
entered: std::sync::mpsc::Receiver<()>,
|
||||
resume: std::sync::mpsc::Sender<()>,
|
||||
pending: tokio::task::JoinHandle<Result<FastLockGuard, LockResult>>,
|
||||
seed: Option<FastLockGuard>,
|
||||
) {
|
||||
entered.recv_timeout(Duration::from_secs(5)).expect("state lookup");
|
||||
drop(seed);
|
||||
|
||||
// Idle timestamps have one-second precision, while cleanup compares milliseconds.
|
||||
let initial_millis = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis();
|
||||
while SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis() == initial_millis {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
|
||||
let can_write_map = manager.get_shard(&key).objects.try_write().is_some();
|
||||
let (started_tx, started_rx) = std::sync::mpsc::channel();
|
||||
let mut cleanup = {
|
||||
let manager = manager.clone();
|
||||
let key = key.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
started_tx.send(()).expect("cleanup start receiver");
|
||||
manager.get_shard(&key).cleanup_expired_millis(0)
|
||||
})
|
||||
};
|
||||
started_rx.recv_timeout(Duration::from_secs(5)).expect("cleanup started");
|
||||
if can_write_map {
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(5), &mut cleanup)
|
||||
.await
|
||||
.expect("cleanup completed")
|
||||
.expect("cleanup task"),
|
||||
1
|
||||
);
|
||||
let successor = manager
|
||||
.acquire_write_lock(key.clone(), "second")
|
||||
.await
|
||||
.expect("successor should acquire after cleanup");
|
||||
resume.send(()).expect("resume acquisition");
|
||||
let result = tokio::time::timeout(Duration::from_secs(5), pending)
|
||||
.await
|
||||
.expect("acquisition completed")
|
||||
.expect("acquisition task");
|
||||
assert!(result.is_err(), "a removed state must not grant a conflicting lock");
|
||||
drop(successor);
|
||||
} else {
|
||||
resume.send(()).expect("resume acquisition");
|
||||
let first = tokio::time::timeout(Duration::from_secs(5), pending)
|
||||
.await
|
||||
.expect("acquisition completed")
|
||||
.expect("acquisition task")
|
||||
.expect("first acquisition");
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(5), cleanup)
|
||||
.await
|
||||
.expect("cleanup completed")
|
||||
.expect("cleanup task"),
|
||||
0
|
||||
);
|
||||
let second = ObjectLockRequest::new_write(key.clone(), "second").with_acquire_timeout(Duration::from_millis(100));
|
||||
assert!(
|
||||
manager.acquire_lock(second).await.is_err(),
|
||||
"conflicting locks must remain mutually exclusive"
|
||||
);
|
||||
drop(first);
|
||||
let successor = manager
|
||||
.acquire_write_lock(key, "second")
|
||||
.await
|
||||
.expect("successor after release");
|
||||
drop(successor);
|
||||
}
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_cleanup_during_fast_existing_state_acquisition() {
|
||||
let manager = test_manager();
|
||||
let key = ObjectKey::new("bucket", "fast-existing-cleanup");
|
||||
drop(manager.acquire_write_lock(key.clone(), "seed").await.expect("seed"));
|
||||
let (entered, resume) = install_pause(TestPausePoint::FastExisting, &key);
|
||||
let pending = start_acquisition(&manager, ObjectLockRequest::new_write(key.clone(), "first"));
|
||||
assert_cleanup_cannot_split_state(manager, key, entered, resume, pending, None).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_cleanup_during_fast_write_recheck_acquisition() {
|
||||
let manager = test_manager();
|
||||
let key = ObjectKey::new("bucket", "fast-recheck-cleanup");
|
||||
let (first_entered, first_resume) = install_pause(TestPausePoint::BeforeFastWrite, &key);
|
||||
let pending = start_acquisition(&manager, ObjectLockRequest::new_write(key.clone(), "first"));
|
||||
first_entered
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.expect("before write recheck");
|
||||
drop(manager.acquire_write_lock(key.clone(), "seed").await.expect("seed"));
|
||||
let (entered, resume) = install_pause(TestPausePoint::FastWriteExisting, &key);
|
||||
first_resume.send(()).expect("resume before write recheck");
|
||||
assert_cleanup_cannot_split_state(manager, key, entered, resume, pending, None).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_cleanup_during_slow_existing_state_acquisition() {
|
||||
let manager = test_manager();
|
||||
let key = ObjectKey::new("bucket", "slow-existing-cleanup");
|
||||
let seed = manager.acquire_write_lock(key.clone(), "seed").await.expect("seed");
|
||||
let (entered, resume) = install_pause(TestPausePoint::SlowLookup, &key);
|
||||
let pending = start_acquisition(&manager, ObjectLockRequest::new_write(key.clone(), "first"));
|
||||
assert_cleanup_cannot_split_state(manager, key, entered, resume, pending, Some(seed)).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_cleanup_during_slow_new_state_acquisition() {
|
||||
let manager = test_manager();
|
||||
let key = ObjectKey::new("bucket", "slow-new-cleanup");
|
||||
let (entered, resume) = install_pause(TestPausePoint::SlowLookup, &key);
|
||||
let pending = start_acquisition(&manager, ObjectLockRequest::new_read(key.clone(), "first"));
|
||||
assert_cleanup_cannot_split_state(manager, key, entered, resume, pending, None).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cleanup_keeps_new_fast_state_while_held() {
|
||||
let manager = test_manager();
|
||||
let key = ObjectKey::new("bucket", "fast-new-cleanup");
|
||||
let first = manager
|
||||
.acquire_write_lock(key.clone(), "first")
|
||||
.await
|
||||
.expect("first acquisition");
|
||||
assert_eq!(manager.get_shard(&key).cleanup_expired_millis(0), 0);
|
||||
let second = ObjectLockRequest::new_write(key.clone(), "second").with_acquire_timeout(Duration::from_millis(100));
|
||||
assert!(manager.acquire_lock(second).await.is_err());
|
||||
drop(first);
|
||||
drop(
|
||||
manager
|
||||
.acquire_write_lock(key, "second")
|
||||
.await
|
||||
.expect("successor after release"),
|
||||
);
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_shard_fast_path() {
|
||||
|
||||
@@ -111,7 +111,7 @@ pub trait StorageBackend: Send + Sync {
|
||||
///
|
||||
/// Backends that implement this must apply per-bucket authorization. The default denies the
|
||||
/// request so existing backends cannot expose unfiltered bucket names.
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
#[cfg(feature = "webdav")]
|
||||
async fn list_buckets_for_session(
|
||||
&self,
|
||||
_session_context: &SessionContext,
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
#[cfg(feature = "webdav")]
|
||||
use crate::common::client::s3::SessionCapacityView;
|
||||
use crate::common::client::s3::StorageBackend;
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
#[cfg(feature = "webdav")]
|
||||
use crate::common::session::SessionContext;
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
@@ -254,9 +254,6 @@ impl Inner {
|
||||
pub struct DummyBackend {
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
deny_authorization: bool,
|
||||
stall_authorization: bool,
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
stall_session_listing: bool,
|
||||
}
|
||||
|
||||
// Drivers whose trait bounds require `Debug` (for example FtpsDriver) cannot be
|
||||
@@ -281,9 +278,6 @@ impl DummyBackend {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(Inner::new())),
|
||||
deny_authorization: false,
|
||||
stall_authorization: false,
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
stall_session_listing: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,19 +294,6 @@ impl DummyBackend {
|
||||
self
|
||||
}
|
||||
|
||||
/// Make every authorize_operation await std::future::pending, so a
|
||||
/// caller's authorization deadline elapses instead of an allow or deny.
|
||||
pub fn stall_authorization(mut self) -> Self {
|
||||
self.stall_authorization = true;
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
pub fn stall_session_listing(mut self) -> Self {
|
||||
self.stall_session_listing = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Opt in to consuming upload bodies for byte-for-byte protocol tests.
|
||||
pub fn capture_upload_bodies(&self) {
|
||||
self.inner.lock().expect("lock").capture_upload_bodies = true;
|
||||
@@ -714,9 +695,6 @@ impl StorageBackend for DummyBackend {
|
||||
if self.deny_authorization {
|
||||
return Err(crate::common::gateway::AuthorizationError::AccessDenied);
|
||||
}
|
||||
if self.stall_authorization {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
crate::common::gateway::authorize_operation(session, action, bucket, object).await
|
||||
}
|
||||
|
||||
@@ -870,7 +848,7 @@ impl StorageBackend for DummyBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
#[cfg(feature = "webdav")]
|
||||
async fn list_buckets_for_session(
|
||||
&self,
|
||||
session_context: &SessionContext,
|
||||
@@ -878,19 +856,12 @@ impl StorageBackend for DummyBackend {
|
||||
secure_transport: bool,
|
||||
) -> s3s::S3Result<ListBucketsOutput> {
|
||||
let _ = session_context;
|
||||
if self.stall_session_listing {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
let mut inner = self.inner.lock().expect("lock");
|
||||
inner.last_session_list_context = Some((request_headers.clone(), secure_transport));
|
||||
// Fail closed when nothing is queued, like the trait default for
|
||||
// backends without session-aware listing.
|
||||
inner.session_list_buckets.pop_front().unwrap_or_else(|| {
|
||||
Err(s3s::S3Error::with_message(
|
||||
s3s::S3ErrorCode::AccessDenied,
|
||||
"Session-aware bucket listing is not supported",
|
||||
))
|
||||
})
|
||||
inner
|
||||
.session_list_buckets
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| Ok(ListBucketsOutput::default()))
|
||||
}
|
||||
|
||||
/// Capacity reporting is opt-in; an empty queue models a backend without
|
||||
|
||||
@@ -225,32 +225,16 @@ impl<S: StorageBackend + Send + Sync + 'static> SftpDriver<S> {
|
||||
/// OPENDIR so a client without ListAllMyBuckets can still open the
|
||||
/// root directory handle.
|
||||
///
|
||||
/// A principal without ListAllMyBuckets (a bucket-scoped user) gets
|
||||
/// the session-aware ListBuckets instead, which IAM filters to the
|
||||
/// buckets the principal may ListBucket or GetBucketLocation, the
|
||||
/// same as S3 ListBuckets and the WebDAV root. It carries the
|
||||
/// session's request headers and transport security, like every
|
||||
/// other SFTP authorization.
|
||||
///
|
||||
/// ListBuckets is not batched in the S3 API. A single response
|
||||
/// carries the full set. Truncate at ROOT_LISTING_MAX_ENTRIES so a
|
||||
/// principal with many visible buckets produces a bounded Vec and
|
||||
/// does not exceed the SSH channel window with a single response.
|
||||
pub(super) async fn fetch_bucket_list(&self) -> Result<Vec<File>, SftpError> {
|
||||
let out = if self.is_authorized(&S3Action::ListBuckets, "", None).await? {
|
||||
self.run_backend("list_buckets", self.storage.list_buckets(self.credentials()))
|
||||
.await?
|
||||
} else {
|
||||
self.run_backend(
|
||||
"list_buckets",
|
||||
self.storage.list_buckets_for_session(
|
||||
&self.session_context,
|
||||
&self.session_context.request_headers,
|
||||
self.session_context.secure_transport,
|
||||
),
|
||||
)
|
||||
.await?
|
||||
};
|
||||
self.authorize(&S3Action::ListBuckets, "", None).await?;
|
||||
|
||||
let out = self
|
||||
.run_backend("list_buckets", self.storage.list_buckets(self.credentials()))
|
||||
.await?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let mut truncated_at: Option<usize> = None;
|
||||
@@ -460,174 +444,15 @@ impl<S: StorageBackend + Send + Sync + 'static> SftpDriver<S> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::state::{DirCursor, HandleState, ListingContinuation};
|
||||
use super::super::test_support::{TEST_PART_SIZE, build_driver, build_driver_with_timeout, capture_tracing_at};
|
||||
use super::super::test_support::{TEST_PART_SIZE, build_driver, capture_tracing_at};
|
||||
use crate::common::dummy_storage::{DummyBackend, DummyError};
|
||||
use crate::common::gateway::{with_test_auth_override, with_test_iam_unavailable};
|
||||
use crate::common::gateway::with_test_auth_override;
|
||||
use russh_sftp::protocol::StatusCode;
|
||||
use russh_sftp::server::Handler;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Notify;
|
||||
use tracing::Level;
|
||||
|
||||
fn buckets(names: &[&str]) -> s3s::dto::ListBucketsOutput {
|
||||
s3s::dto::ListBucketsOutput {
|
||||
buckets: Some(
|
||||
names
|
||||
.iter()
|
||||
.map(|n| s3s::dto::Bucket {
|
||||
name: Some((*n).to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn names(files: &[russh_sftp::protocol::File]) -> Vec<&str> {
|
||||
files.iter().map(|f| f.filename.as_str()).collect()
|
||||
}
|
||||
|
||||
fn deny_list_all_my_buckets(action: &crate::common::gateway::S3Action, _: &str, _: Option<&str>) -> bool {
|
||||
!matches!(action, crate::common::gateway::S3Action::ListBuckets)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_listing_with_list_all_my_buckets_lists_every_bucket() {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
backend.queue_list_buckets_ok(buckets(&["alpha", "beta"]));
|
||||
backend.queue_session_list_buckets_ok(buckets(&["beta"]));
|
||||
let driver = build_driver(backend.clone(), TEST_PART_SIZE);
|
||||
|
||||
let files = with_test_auth_override(|_, _, _| true, driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect("global listing");
|
||||
|
||||
assert_eq!(names(&files), ["alpha", "beta"]);
|
||||
assert!(backend.last_session_list_context().is_none(), "session listing must not run");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_listing_without_list_all_my_buckets_lists_only_visible_buckets() {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
backend.queue_list_buckets_ok(buckets(&["alpha", "beta"]));
|
||||
backend.queue_session_list_buckets_ok(buckets(&["beta"]));
|
||||
let mut driver = build_driver(backend.clone(), TEST_PART_SIZE);
|
||||
driver
|
||||
.session_context
|
||||
.request_headers
|
||||
.insert("user-agent", http::HeaderValue::from_static("sftp-listing-test"));
|
||||
driver.session_context.secure_transport = false;
|
||||
|
||||
let files = with_test_auth_override(deny_list_all_my_buckets, driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect("bucket-scoped listing");
|
||||
|
||||
assert_eq!(names(&files), ["beta"]);
|
||||
let (headers, secure) = backend.last_session_list_context().expect("session listing ran");
|
||||
assert_eq!(headers, driver.session_context.request_headers, "same headers as every other SFTP check");
|
||||
assert_eq!(
|
||||
secure, driver.session_context.secure_transport,
|
||||
"same transport as every other SFTP check"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_listing_without_any_visible_bucket_is_permission_denied() {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
backend.queue_session_list_buckets_err(s3s::S3Error::new(s3s::S3ErrorCode::AccessDenied));
|
||||
let driver = build_driver(backend.clone(), TEST_PART_SIZE);
|
||||
|
||||
let err = with_test_auth_override(deny_list_all_my_buckets, driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect_err("no visible bucket");
|
||||
|
||||
assert_eq!(err.0, StatusCode::PermissionDenied);
|
||||
assert!(backend.last_session_list_context().is_some(), "the session listing ran");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_listing_fails_without_fallback_when_iam_is_unavailable() {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
backend.queue_session_list_buckets_ok(buckets(&["beta"]));
|
||||
let driver = build_driver(backend.clone(), TEST_PART_SIZE);
|
||||
|
||||
let err = with_test_iam_unavailable(driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect_err("IAM outage");
|
||||
|
||||
assert_eq!(err.0, StatusCode::Failure);
|
||||
assert!(backend.last_session_list_context().is_none(), "an IAM outage is not a policy deny");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn root_listing_fails_without_fallback_when_the_global_check_times_out() {
|
||||
let backend = Arc::new(DummyBackend::new().stall_authorization());
|
||||
backend.queue_session_list_buckets_ok(buckets(&["beta"]));
|
||||
let driver = build_driver_with_timeout(backend.clone(), TEST_PART_SIZE, 1);
|
||||
|
||||
let err = driver.fetch_bucket_list().await.expect_err("authorization deadline");
|
||||
|
||||
assert_eq!(err.0, StatusCode::Failure);
|
||||
assert!(backend.last_session_list_context().is_none(), "a timeout is not a policy deny");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn root_listing_fallback_respects_the_backend_deadline() {
|
||||
let backend = Arc::new(DummyBackend::new().stall_session_listing());
|
||||
let driver = build_driver_with_timeout(backend, TEST_PART_SIZE, 1);
|
||||
|
||||
let err = with_test_auth_override(deny_list_all_my_buckets, driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect_err("session listing deadline");
|
||||
|
||||
assert_eq!(err.0, StatusCode::Failure);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_listing_session_error_other_than_access_denied_is_failure() {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
backend.queue_session_list_buckets_err(s3s::S3Error::with_message(
|
||||
s3s::S3ErrorCode::InternalError,
|
||||
"AccessDenied in the message is not an access denial",
|
||||
));
|
||||
let driver = build_driver(backend.clone(), TEST_PART_SIZE);
|
||||
|
||||
let err = with_test_auth_override(deny_list_all_my_buckets, driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect_err("backend failure");
|
||||
|
||||
assert_eq!(err.0, StatusCode::Failure);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_listing_fallback_is_truncated_at_the_root_listing_limit() {
|
||||
for count in [super::ROOT_LISTING_MAX_ENTRIES, super::ROOT_LISTING_MAX_ENTRIES + 1] {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
let many: Vec<String> = (0..count).map(|i| format!("bucket-{i:05}")).collect();
|
||||
backend.queue_session_list_buckets_ok(buckets(&many.iter().map(String::as_str).collect::<Vec<_>>()));
|
||||
let driver = build_driver(backend, TEST_PART_SIZE);
|
||||
|
||||
let files = with_test_auth_override(deny_list_all_my_buckets, driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect("bucket-scoped listing");
|
||||
|
||||
assert_eq!(files.len(), super::ROOT_LISTING_MAX_ENTRIES, "bucket count {count}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_listing_fails_closed_when_the_backend_has_no_session_listing() {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
let driver = build_driver(backend.clone(), TEST_PART_SIZE);
|
||||
|
||||
let err = with_test_auth_override(deny_list_all_my_buckets, driver.fetch_bucket_list())
|
||||
.await
|
||||
.expect_err("no session-aware listing");
|
||||
|
||||
assert_eq!(err.0, StatusCode::PermissionDenied);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn opendir_and_readdir_authorize_the_same_listing_parameters() {
|
||||
let backend = Arc::new(DummyBackend::new());
|
||||
|
||||
@@ -301,28 +301,6 @@ impl<S: StorageBackend + Send + Sync + 'static> SftpDriver<S> {
|
||||
self.await_authorization(action, bucket, key, auth_fut).await
|
||||
}
|
||||
|
||||
/// Like authorize, but a policy deny returns Ok(false) instead of
|
||||
/// PermissionDenied, without the SFTP driver's access_denied warn,
|
||||
/// for callers that fall back to a narrower operation when the
|
||||
/// action is denied. The storage layer may still log the denial.
|
||||
/// IamUnavailable and the deadline are handled exactly as in
|
||||
/// authorize.
|
||||
pub(super) async fn is_authorized(&self, action: &S3Action, bucket: &str, key: Option<&str>) -> Result<bool, SftpError> {
|
||||
let auth_fut = self.storage.authorize_operation(&self.session_context, action, bucket, key);
|
||||
let mut denied = false;
|
||||
let deny_as_ok = async {
|
||||
match auth_fut.await {
|
||||
Err(AuthorizationError::AccessDenied) => {
|
||||
denied = true;
|
||||
Ok(())
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
};
|
||||
self.await_authorization(action, bucket, key, deny_as_ok).await?;
|
||||
Ok(!denied)
|
||||
}
|
||||
|
||||
pub(super) async fn authorize_list_objects(&self, input: &ListObjectsV2Input) -> Result<(), SftpError> {
|
||||
let auth_fut = self.storage.authorize_list_objects(&self.session_context, input);
|
||||
self.await_authorization(&S3Action::ListBucket, &input.bucket, input.prefix.as_deref(), auth_fut)
|
||||
|
||||
@@ -1098,24 +1098,6 @@ pub(super) async fn send_cache_root_entry(
|
||||
.map_err(|err| ScannerError::Other(format!("scanner cache root channel closed: {err}")))
|
||||
}
|
||||
|
||||
fn log_missing_set_snapshot_metadata(store: &SetDisks, info: &DataUsageCacheInfo, reason: &'static str) {
|
||||
warn!(
|
||||
target: "rustfs::scanner::io",
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "snapshot_metadata_missing",
|
||||
reason,
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = info.source.map(|source| source.pool_index),
|
||||
snapshot_source_set = info.source.map(|source| source.set_index),
|
||||
snapshot_cycle = info.next_cycle,
|
||||
snapshot_leader_epoch = info.leader_epoch,
|
||||
"Scanner set snapshot is missing required metadata"
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
store: Arc<SetDisks>,
|
||||
updates: &mpsc::Sender<DataUsageCache>,
|
||||
@@ -1124,20 +1106,9 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
cache_cycle_floor: &AtomicU64,
|
||||
expected_publication_epoch: u64,
|
||||
) -> Option<SystemTime> {
|
||||
let snapshot_cycle = cache_snapshot.info.next_cycle;
|
||||
let snapshot_leader_epoch = cache_snapshot.info.leader_epoch;
|
||||
let Some(source) = cache_snapshot.info.source else {
|
||||
log_missing_set_snapshot_metadata(&store, &cache_snapshot.info, "source");
|
||||
return None;
|
||||
};
|
||||
let Some(coverage_digest) = cache_snapshot.info.scan_coverage_digest else {
|
||||
log_missing_set_snapshot_metadata(&store, &cache_snapshot.info, "coverage_digest");
|
||||
return None;
|
||||
};
|
||||
let Some(execution_digest) = cache_snapshot.info.scan_execution_digest else {
|
||||
log_missing_set_snapshot_metadata(&store, &cache_snapshot.info, "execution_digest");
|
||||
return None;
|
||||
};
|
||||
let source = cache_snapshot.info.source?;
|
||||
let coverage_digest = cache_snapshot.info.scan_coverage_digest?;
|
||||
let execution_digest = cache_snapshot.info.scan_execution_digest?;
|
||||
let guard = match acquire_scanner_cache_locks(store.as_ref(), DATA_USAGE_CACHE_NAME, source).await {
|
||||
Ok(guard) => guard,
|
||||
Err(err) => {
|
||||
@@ -1146,14 +1117,8 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = err.state(),
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = err.state(),
|
||||
error = %err,
|
||||
"Scanner cache snapshot lock acquisition failed"
|
||||
);
|
||||
@@ -1170,24 +1135,15 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "load_or_revision_lookup_failed",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = "load_or_revision_lookup_failed",
|
||||
error = %err,
|
||||
"Scanner cache snapshot load or revision lookup failed"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let Some(scan_plan_digest) = cache_snapshot.info.scan_plan_digest else {
|
||||
log_missing_set_snapshot_metadata(&store, &cache_snapshot.info, "plan_digest");
|
||||
return None;
|
||||
};
|
||||
let scan_plan_digest = cache_snapshot.info.scan_plan_digest?;
|
||||
if persisted.info.next_cycle > cache_snapshot.info.next_cycle {
|
||||
cache_cycle_floor.fetch_max(persisted.info.next_cycle, Ordering::AcqRel);
|
||||
warn!(
|
||||
@@ -1195,16 +1151,10 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "stale_cycle_rejected",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
requested_cycle = cache_snapshot.info.next_cycle,
|
||||
persisted_cycle = persisted.info.next_cycle,
|
||||
state = "stale_cycle_rejected",
|
||||
"Scanner rejected a set cache cycle regression"
|
||||
);
|
||||
return None;
|
||||
@@ -1215,16 +1165,10 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "stale_leader_rejected",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
requested_epoch = cache_snapshot.info.leader_epoch,
|
||||
persisted_epoch = persisted.info.leader_epoch,
|
||||
state = "stale_leader_rejected",
|
||||
"Scanner rejected a set cache snapshot from an older leader epoch"
|
||||
);
|
||||
return None;
|
||||
@@ -1255,12 +1199,6 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "scan_baseline_revision_changed",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
"Scanner skipped set snapshot without an unchanged baseline revision"
|
||||
);
|
||||
@@ -1272,14 +1210,8 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "lock_lost",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = "lock_lost",
|
||||
"Scanner cache snapshot save skipped after lock loss"
|
||||
);
|
||||
return None;
|
||||
@@ -1295,14 +1227,8 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "save_failed",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = "save_failed",
|
||||
error = %e,
|
||||
"Scanner cache snapshot persistence failed"
|
||||
);
|
||||
@@ -1317,14 +1243,8 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "lock_lost_after_save",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = "lock_lost_after_save",
|
||||
"Scanner cache snapshot publish skipped after lock loss"
|
||||
);
|
||||
return None;
|
||||
@@ -1341,14 +1261,8 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "publication_epoch_changed_before_publish",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = "publication_epoch_changed_before_publish",
|
||||
"Scanner cache root publish skipped after movement epoch change"
|
||||
);
|
||||
return None;
|
||||
@@ -1362,14 +1276,8 @@ pub(super) async fn persist_and_publish_cache_snapshot(
|
||||
event = EVENT_SCANNER_CACHE_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "publish_failed",
|
||||
store_pool = store.pool_index,
|
||||
store_set = store.set_index,
|
||||
snapshot_source_pool = source.pool_index,
|
||||
snapshot_source_set = source.set_index,
|
||||
snapshot_cycle,
|
||||
snapshot_leader_epoch,
|
||||
cache_name = DATA_USAGE_CACHE_NAME,
|
||||
state = "publish_failed",
|
||||
error = %e,
|
||||
"Scanner cache snapshot publish failed"
|
||||
);
|
||||
|
||||
@@ -21,41 +21,15 @@ pub(super) async fn collect_completed_bucket_results(
|
||||
) -> (DataUsageCache, HashSet<String>) {
|
||||
let mut cancelled = false;
|
||||
let mut completed = HashSet::new();
|
||||
let mut received_roots = 0usize;
|
||||
let mut duplicate_applied_roots = 0usize;
|
||||
let mut rejected_roots = 0usize;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = ctx.cancelled(), if !cancelled => cancelled = true,
|
||||
result = results.recv() => {
|
||||
let Some(result) = result else {
|
||||
debug!(
|
||||
target: "rustfs::scanner::io",
|
||||
event = EVENT_SCANNER_SET_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_IO,
|
||||
state = "bucket_root_collector_drained",
|
||||
collector_cache_pool = cache.info.source.map(|source| source.pool_index),
|
||||
collector_cache_set = cache.info.source.map(|source| source.set_index),
|
||||
collector_cache_cycle = cache.info.next_cycle,
|
||||
collector_cache_leader_epoch = cache.info.leader_epoch,
|
||||
received_roots,
|
||||
distinct_applied_roots = completed.len(),
|
||||
duplicate_applied_roots,
|
||||
rejected_roots,
|
||||
cancellation_observed = cancelled,
|
||||
cancelled_at_drain = ctx.is_cancelled(),
|
||||
"Scanner bucket root collector drained"
|
||||
);
|
||||
return (cache, completed);
|
||||
};
|
||||
received_roots = received_roots.saturating_add(1);
|
||||
if let Some(bucket) = apply_bucket_result_to_cache(&mut cache, result, SystemTime::now()) {
|
||||
if !completed.insert(bucket) {
|
||||
duplicate_applied_roots = duplicate_applied_roots.saturating_add(1);
|
||||
}
|
||||
} else {
|
||||
rejected_roots = rejected_roots.saturating_add(1);
|
||||
completed.insert(bucket);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1833,7 +1807,6 @@ impl ScannerIOCache for SetDisks {
|
||||
pool = source.pool_index,
|
||||
set = source.set_index,
|
||||
cycle = want_cycle,
|
||||
leader_epoch,
|
||||
completed_buckets = completed_count,
|
||||
carried_buckets = carried_buckets.len(),
|
||||
total_buckets = buckets.len(),
|
||||
|
||||
@@ -2211,147 +2211,6 @@ fn test_bucket_incarnations(buckets: &[&str]) -> HashMap<String, Uuid> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SetCacheLogWriter(Arc<StdMutex<Vec<u8>>>);
|
||||
|
||||
impl std::io::Write for SetCacheLogWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().expect("set cache log buffer").extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn capture_set_cache_logs<F: std::future::Future>(future: F) -> (F::Output, Vec<serde_json::Value>) {
|
||||
use tracing::instrument::WithSubscriber as _;
|
||||
|
||||
let buffer = Arc::new(StdMutex::new(Vec::new()));
|
||||
let writer = Arc::clone(&buffer);
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.json()
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.with_max_level(tracing::Level::WARN)
|
||||
.with_writer(move || SetCacheLogWriter(Arc::clone(&writer)))
|
||||
.finish();
|
||||
// Avoid a cached Never interest when other tests use an unsubscribed dispatcher.
|
||||
let _interest_guard = tracing::Dispatch::new(tracing::subscriber::NoSubscriber::default());
|
||||
let result = future.with_subscriber(subscriber).await;
|
||||
let logs = buffer.lock().expect("set cache log buffer");
|
||||
let events = std::str::from_utf8(&logs)
|
||||
.expect("set cache logs should be UTF-8")
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).expect("set cache log should be JSON"))
|
||||
.collect();
|
||||
(result, events)
|
||||
}
|
||||
|
||||
fn set_cache_event<'a>(events: &'a [serde_json::Value], state: &str) -> &'a serde_json::Value {
|
||||
let matching = events
|
||||
.iter()
|
||||
.filter(|event| event["fields"]["state"] == state)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(matching.len(), 1, "expected one {state} event: {events:?}");
|
||||
&matching[0]["fields"]
|
||||
}
|
||||
|
||||
fn assert_set_cache_context(fields: &serde_json::Value, source: Option<(usize, usize)>) {
|
||||
assert_eq!(fields["store_pool"], 0);
|
||||
assert_eq!(fields["store_set"], 0);
|
||||
assert_eq!(fields["snapshot_cycle"], 7);
|
||||
assert_eq!(fields["snapshot_leader_epoch"], 11);
|
||||
if let Some((pool, set)) = source {
|
||||
assert_eq!(fields["snapshot_source_pool"], pool);
|
||||
assert_eq!(fields["snapshot_source_set"], set);
|
||||
} else {
|
||||
assert!(fields.get("snapshot_source_pool").is_none());
|
||||
assert!(fields.get("snapshot_source_set").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_cache_log_fixture() -> (tempfile::TempDir, Arc<ECStore>, DataUsageCache, u64) {
|
||||
let (temp_dir, store) = setup_two_pool_scanner_store().await;
|
||||
let set = Arc::clone(&store.pools[0].disk_set[0]);
|
||||
let epoch = scanner_publication_epoch(Arc::clone(&set)).await.expect("idle set admission");
|
||||
let mut cache = complete_set_usage_cache(&[("photos", 5)], DataUsageScanPlanDigest([1; 32]));
|
||||
cache.info.source = Some(DataUsageCacheSource::new(0, 0));
|
||||
cache.info.scan_execution_digest = Some(DataUsageScanPlanDigest([2; 32]));
|
||||
cache.save(set, DATA_USAGE_CACHE_NAME).await.expect("seed set cache");
|
||||
(temp_dir, store, cache, epoch)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn set_cache_missing_metadata_logs_receiver_and_snapshot() {
|
||||
let (_temp_dir, store, cache, epoch) = set_cache_log_fixture().await;
|
||||
let set = Arc::clone(&store.pools[0].disk_set[0]);
|
||||
let cycle_floor = AtomicU64::new(cache.info.next_cycle);
|
||||
let (tx, _rx) = mpsc::channel(1);
|
||||
|
||||
for missing in ["source", "coverage_digest", "execution_digest", "plan_digest"] {
|
||||
let mut candidate = cache.clone();
|
||||
match missing {
|
||||
"source" => candidate.info.source = None,
|
||||
"coverage_digest" => candidate.info.scan_coverage_digest = None,
|
||||
"execution_digest" => {
|
||||
candidate.info.source = Some(DataUsageCacheSource::new(1, 2));
|
||||
candidate.info.scan_execution_digest = None;
|
||||
}
|
||||
"plan_digest" => candidate.info.scan_plan_digest = None,
|
||||
_ => unreachable!("fixture only uses the four checked metadata fields"),
|
||||
}
|
||||
let source = candidate.info.source.map(|source| (source.pool_index, source.set_index));
|
||||
let (result, events) = capture_set_cache_logs(persist_and_publish_cache_snapshot(
|
||||
Arc::clone(&set),
|
||||
&tx,
|
||||
candidate,
|
||||
None,
|
||||
&cycle_floor,
|
||||
epoch,
|
||||
))
|
||||
.await;
|
||||
assert!(result.is_none(), "missing {missing} should retain the existing rejection");
|
||||
let fields = set_cache_event(&events, "snapshot_metadata_missing");
|
||||
assert_eq!(fields["reason"], missing);
|
||||
assert_set_cache_context(fields, source);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn set_cache_revision_rejection_logs_receiver_and_snapshot() {
|
||||
let (_temp_dir, store, mut cache, epoch) = set_cache_log_fixture().await;
|
||||
let set = Arc::clone(&store.pools[0].disk_set[0]);
|
||||
cache.info.scan_execution_digest = Some(DataUsageScanPlanDigest([3; 32]));
|
||||
let cycle_floor = AtomicU64::new(cache.info.next_cycle);
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
let (result, events) =
|
||||
capture_set_cache_logs(persist_and_publish_cache_snapshot(set, &tx, cache, None, &cycle_floor, epoch)).await;
|
||||
assert!(result.is_none());
|
||||
assert!(matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty)));
|
||||
assert_set_cache_context(set_cache_event(&events, "scan_baseline_revision_changed"), Some((0, 0)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn set_cache_send_failure_logs_receiver_and_snapshot_without_changing_return() {
|
||||
let (_temp_dir, store, cache, epoch) = set_cache_log_fixture().await;
|
||||
let set = Arc::clone(&store.pools[0].disk_set[0]);
|
||||
let cycle_floor = AtomicU64::new(cache.info.next_cycle);
|
||||
let (tx, rx) = mpsc::channel(1);
|
||||
drop(rx);
|
||||
let (result, events) =
|
||||
capture_set_cache_logs(persist_and_publish_cache_snapshot(set, &tx, cache, None, &cycle_floor, epoch)).await;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"the existing return value must not be treated as receiver acknowledgement"
|
||||
);
|
||||
assert_set_cache_context(set_cache_event(&events, "publish_failed"), Some((0, 0)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers() {
|
||||
|
||||
@@ -354,202 +354,3 @@ async fn collector_worker_panic_waits_for_admitted_resume_and_preserves_checkpoi
|
||||
async fn collector_parent_cancel_drains_accepted_root_and_preserves_checkpoint() {
|
||||
run_collector_lifecycle(Stop::ParentCancellation).await;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CollectorLogWriter(Arc<StdMutex<Vec<u8>>>);
|
||||
|
||||
impl std::io::Write for CollectorLogWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().expect("collector log buffer").extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn capture_collector_logs<F: std::future::Future>(future: F) -> (F::Output, Vec<serde_json::Value>) {
|
||||
use tracing::instrument::WithSubscriber as _;
|
||||
|
||||
let buffer = Arc::new(StdMutex::new(Vec::new()));
|
||||
let writer = Arc::clone(&buffer);
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.json()
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.with_writer(move || CollectorLogWriter(Arc::clone(&writer)))
|
||||
.finish();
|
||||
let _interest_guard = tracing::Dispatch::new(tracing::subscriber::NoSubscriber::default());
|
||||
let result = future.with_subscriber(subscriber).await;
|
||||
let logs = buffer.lock().expect("collector log buffer");
|
||||
let events = std::str::from_utf8(&logs)
|
||||
.expect("collector logs should be UTF-8")
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).expect("collector event should be JSON"))
|
||||
.collect();
|
||||
(result, events)
|
||||
}
|
||||
|
||||
fn bucket_root(name: &str, generation: u64, size: usize) -> DataUsageEntryInfo {
|
||||
DataUsageEntryInfo {
|
||||
name: name.to_string(),
|
||||
parent: DATA_USAGE_ROOT.to_string(),
|
||||
entry: DataUsageEntry {
|
||||
size,
|
||||
..Default::default()
|
||||
},
|
||||
bucket_incarnation: None,
|
||||
tier_registry_generation: Some(generation),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn collector_reports_distinct_duplicate_and_rejected_roots_after_drain() {
|
||||
for (cancel, recover_missing) in [(false, false), (false, true), (true, false), (true, true)] {
|
||||
let ctx = CancellationToken::new();
|
||||
let mut cache = DataUsageCache {
|
||||
info: DataUsageCacheInfo {
|
||||
name: DATA_USAGE_ROOT.to_string(),
|
||||
source: Some(DataUsageCacheSource::new(2, 3)),
|
||||
next_cycle: 17,
|
||||
leader_epoch: 23,
|
||||
tier_registry_generation: Some(7),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
cache.replace(
|
||||
"bucket-b",
|
||||
DATA_USAGE_ROOT,
|
||||
DataUsageEntry {
|
||||
size: 19,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let (tx, rx) = mpsc::channel(4);
|
||||
if cancel {
|
||||
ctx.cancel();
|
||||
}
|
||||
let run = async {
|
||||
let collector = super::super::io_cache::collect_completed_bucket_results(ctx.clone(), cache, rx);
|
||||
tokio::pin!(collector);
|
||||
if cancel {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = &mut collector => panic!("collector cannot close while the sender is open"),
|
||||
_ = tokio::task::yield_now() => {}
|
||||
}
|
||||
}
|
||||
tx.send(bucket_root("bucket-a", 7, 1)).await.expect("first root send");
|
||||
tx.send(bucket_root("bucket-a", 7, 2)).await.expect("duplicate root send");
|
||||
tx.send(bucket_root("bucket-b", 8, 3)).await.expect("wrong-tier root send");
|
||||
if recover_missing {
|
||||
tx.send(bucket_root("bucket-b", 7, 4)).await.expect("current root send");
|
||||
}
|
||||
drop(tx);
|
||||
collector.await
|
||||
};
|
||||
let ((cache, completed), events) = capture_collector_logs(run).await;
|
||||
assert_eq!(completed.len(), if recover_missing { 2 } else { 1 });
|
||||
assert_eq!(cache.find("bucket-a").expect("applied duplicate root").size, 2);
|
||||
assert_eq!(
|
||||
cache.find("bucket-b").expect("retained or recovered root").size,
|
||||
if recover_missing { 4 } else { 19 }
|
||||
);
|
||||
assert_eq!(should_publish_completed_snapshot(completed.len(), 2, false, false), recover_missing);
|
||||
assert!(!should_publish_completed_snapshot(completed.len(), 2, false, true));
|
||||
|
||||
let events = events
|
||||
.iter()
|
||||
.filter(|event| event["fields"]["state"] == "bucket_root_collector_drained")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(events.len(), 1, "each collector drain should report one bounded summary");
|
||||
let fields = &events[0]["fields"];
|
||||
assert_eq!(fields["collector_cache_pool"], 2);
|
||||
assert_eq!(fields["collector_cache_set"], 3);
|
||||
assert_eq!(fields["collector_cache_cycle"], 17);
|
||||
assert_eq!(fields["collector_cache_leader_epoch"], 23);
|
||||
assert_eq!(fields["received_roots"], if recover_missing { 4 } else { 3 });
|
||||
assert_eq!(fields["distinct_applied_roots"], if recover_missing { 2 } else { 1 });
|
||||
assert_eq!(fields["duplicate_applied_roots"], 1);
|
||||
assert_eq!(fields["rejected_roots"], 1);
|
||||
assert_eq!(fields["cancellation_observed"], cancel);
|
||||
assert_eq!(fields["cancelled_at_drain"], cancel);
|
||||
assert!(fields.get("bucket").is_none());
|
||||
assert!(fields.get("object").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn collector_reports_cancellation_when_close_and_cancel_are_both_ready() {
|
||||
let ctx = CancellationToken::new();
|
||||
ctx.cancel();
|
||||
let (tx, rx) = mpsc::channel(1);
|
||||
drop(tx);
|
||||
let ((_, completed), events) = capture_collector_logs(super::super::io_cache::collect_completed_bucket_results(
|
||||
ctx,
|
||||
DataUsageCache::default(),
|
||||
rx,
|
||||
))
|
||||
.await;
|
||||
assert!(completed.is_empty());
|
||||
let events = events
|
||||
.iter()
|
||||
.filter(|event| event["fields"]["state"] == "bucket_root_collector_drained")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(events.len(), 1);
|
||||
let fields = &events[0]["fields"];
|
||||
assert_eq!(fields["cancelled_at_drain"], true);
|
||||
assert!(fields["cancellation_observed"].is_boolean(), "either ready branch may be selected first");
|
||||
assert_eq!(fields["received_roots"], 0);
|
||||
assert_eq!(fields["distinct_applied_roots"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn incomplete_set_event_reports_requested_cycle_and_epoch() {
|
||||
let ((), events) = capture_collector_logs(async {
|
||||
let (_dir, store) = setup_local_scanner_store(1, 1).await;
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
create_bucket(&store, PARTIAL_BUCKET, 4).await;
|
||||
let set = Arc::clone(&store.pools[0].disk_set[0]);
|
||||
let parent = CancellationToken::new();
|
||||
let budget = ScannerCycleBudget::new_with_progress_tracking(
|
||||
&parent,
|
||||
ScannerCycleBudgetConfig {
|
||||
max_objects: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let (updates, mut results) = mpsc::channel(1);
|
||||
set.clone()
|
||||
.nsscanner_cache(
|
||||
budget.token(),
|
||||
Arc::clone(&budget),
|
||||
scan_plan(&set, &[PARTIAL_BUCKET], 1).await,
|
||||
updates,
|
||||
1,
|
||||
HealScanMode::Normal,
|
||||
)
|
||||
.await
|
||||
.expect("budgeted fixture should return partial progress");
|
||||
assert!(budget.budget_elapsed());
|
||||
assert!(!results.recv().await.expect("incomplete set result").info.snapshot_complete);
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
})
|
||||
.await;
|
||||
let events = events
|
||||
.iter()
|
||||
.filter(|event| event["fields"]["state"] == "set_cache_publish_skipped")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(events.len(), 1);
|
||||
let fields = &events[0]["fields"];
|
||||
assert_eq!(fields["pool"], 0);
|
||||
assert_eq!(fields["set"], 0);
|
||||
assert_eq!(fields["cycle"], 1);
|
||||
assert_eq!(fields["leader_epoch"], 11);
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ collection:
|
||||
## Operations
|
||||
|
||||
- [Retained MRF responsibilities](operations/mrf-responsibilities.md) — unknown bucket identities, bounded probes, and unreadable checkpoint recovery.
|
||||
- [Read-only BTF declaration inspection](operations/btf-prototype-inspection.md) — bounded prototypes and scalar member layouts, explicit void handling, and independent native references.
|
||||
- [Read-only BTF prototype inspection](operations/btf-prototype-inspection.md) — bounded declarations, explicit void handling, and independent native reference tests.
|
||||
|
||||
- [Internode HTTP streaming transport](operations/internode-http-transport.md) — protocol selection, pooled connection isolation, and distributed read validation.
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
- `backlog-2097-tier-mutation-v4-error-text` tier-mutation Prepare rejection classification: a v3 server rejects a v4 request before store/runtime dispatch with the FailedPrecondition status and an authenticated, byte-exact unsupported-version message. A v4 coordinator recognizes only that exact code/message/requested-version tuple as definitely not persisted and fails the mutation without sending that peer an incompatible Abort; Unimplemented, near-text, missing/unknown failure classes, timeouts, and every other transport outcome remain ambiguous and stay in identity-bound Abort fanout. There is no automatic v3 retry. New servers retain v3 request/proof decoding for older coordinators, while operators must pause tier edit/remove/clear during a mixed v3/v4 rollout. Remove the text classifier after the minimum supported RustFS peer version returns the signed v4 PreDispatchRejected failure class.
|
||||
- `backlog-2097-tier-delete-journal-v6` tier-delete sole-owner recovery: v6 distinguishes transactions that may replace the xl.meta free-version owner and therefore require the all-pool live-source proof. v5-and-older readers reject and retain v6 records during rolling upgrades instead of performing an unsafe remote delete. Keep v1-v5 readers for upgrade recovery and keep the downgrade prohibition while any v6 record exists; retire the fence only after every supported rollback release understands and enforces v6 proof semantics.
|
||||
- `multipart-compression-default-off-window` staged multipart disk-compression rollout: releases before the resumable legacy decompressor fail transient reads of compressed objects under mid-payload suspension, so multipart uploads advertise the compression marker only when RUSTFS_COMPRESSION_MULTIPART_ENABLED is set in addition to RUSTFS_COMPRESSION_ENABLED, keeping rolling upgrades from creating new compressed multipart objects while pre-fix nodes may still serve reads. Flip the default to enabled (and retire the extra switch) after the minimum supported direct-upgrade release ships the resumable decompressor.
|
||||
- `listing-cursor-legacy-tag` ListObjects cursor compatibility: releases up to 1.0.x appended a metacache tag ([rustfs_cache:v2,id:...,src:walker,gen:live]) to continuation markers. V2 tokens continue to accept that legacy form, and cache-shaped literal keys retain an outer tag in newly issued V2 cursors. V1 markers, V2 StartAfter, and ListObjectVersions markers are literal keys. Clients must restart old version-listing chains and V1 chains holding escaped markers after upgrading. Remove the V2 tag stripping after every supported direct-upgrade release has issued plain key markers for a full release, so no client can still hold a tagged cursor.
|
||||
- `listing-cursor-legacy-tag` ListObjects cursor compatibility: releases up to 1.0.x appended a metacache tag ([rustfs_cache:v2,id:...,src:walker,gen:live]) to every internal continuation marker and exposed it through the base64 V2 continuation token and the ListObjectVersions NextKeyMarker; no server component consumed the tag, and it caused keys sorting between the marker and the tag to be skipped (backlog#1047). Current servers emit plain markers for ordinary keys; cache-shaped literal keys retain one legacy outer tag so both old and new readers resume after the complete key. V2 tokens remain base64 of the cursor. ListPathOptions::parse_marker decodes the cursor once at the listing entry point, and list_marker_key removes the outer tag for scanner comparisons. V1 responses remove only the conditional outer escape; the pre-existing ambiguity of incoming raw V1 markers remains during the compatibility window. Remove the tag stripping after every supported direct-upgrade release has issued plain key markers for a full release, so no client can still hold a tagged cursor.
|
||||
- `s3gate-trailer-adapter` aws-chunked trailer handle bridge: s3s decodes aws-chunked request bodies and publishes their x-amz-checksum-* trailers through its own handle, while rio consumes trailers only through its framework-neutral TrailerSource trait. The RustFS application crate adapts the s3s handle at the object write path so rio carries no s3s dependency. Remove the adapter after the gateway stack replaces s3s as the request body decoder and publishes trailers through its own TrailerSource implementation.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
@@ -313,7 +313,7 @@ Small objects store their payload inline after the container CRC ([filemeta_inli
|
||||
## 8. Read path and read quorum
|
||||
|
||||
- **INVARIANT — read quorum = `data_blocks`.** `object_quorum_from_meta` returns `(read_quorum = data_blocks, write_quorum)` ([set_disk/metadata.rs](../../crates/ecstore/src/set_disk/metadata.rs)); `parity_blocks = common_parity(...)` is the parity value held by the most disks that still reaches its own read quorum. When `default_parity_count == 0`, read = write = all shards.
|
||||
- Authoritative FileInfo selection — `find_file_info_in_quorum` ([set_disk/metadata.rs](../../crates/ecstore/src/set_disk/metadata.rs)) groups valid metas by a content-identity SHA-256 (`file_info_quorum_hash`) that hashes size/flags/mod_time/transition/version_id/data_dir/parts and, for real objects, data/parity/distribution — **excluding replication-status keys** so replication noise never splits quorum. A meta counts only if its mod_time equals the common mod_time (or etag matches when mod_time is absent). The winning hash must reach quorum, else `ErasureReadQuorum`. S3 reads select at read quorum through `select_valid_fileinfo`, the same rule heal uses and MinIO's `getObjectFileInfo`: the most common mod_time wins and a tie goes to the newer one, so a write that failed after reaching exactly `data_blocks` copies is served as listing shows it, instead of `ErasureReadQuorum` until heal runs. Two identities tied at the selected mod_time (a data = parity set split in half, such as a metadata update that failed after reaching half the drives) resolve to the one on the lowest-index disk, in reads and heal alike, so a retried metadata write can repair the split. A selected version with local shards also needs as many copies as its own `data_blocks`, since delete markers and empty versions can lower the common parity's read quorum. A metadata update whose target disks cannot reach write quorum is refused before any write. A version replaced by a committed write keeps fewer than `data_blocks` copies and can never be selected again; the newer side of a failed write can be served and then lose to the older side if one of its disks fails before heal converges the set. Reads in the system bucket (`is_meta_bucketname`) keep the commit-proof rule in `select_committed_fileinfo`: when enough copies answer to prove write quorum, the latest version must hold it, and a degraded read never serves a newer version below write quorum. Pool metadata's PREPARE/COMMIT recovery and identity epochs rely on an ordinary read implying a committed record, so a split stays unreadable until native recovery resolves it. When every drive answered and an S3 latest read still cannot select a version, `read_latest_past_dangling_versions` walks the version stack newest first through exact-version reads, as MinIO's `pickLatestQuorumFilesInfo` resolves stacks: it serves the readable version with the newest selected mod_time, skips versions whose exact read proves them dangling (the NoSuchVersion an explicit GET reports) and queues heal for them, and keeps the read-quorum error on any other outcome. A failed versioned PUT that left the new version on fewer drives than its data blocks therefore no longer hides the committed version below it; ListObjectVersions can still show the dangling version as latest until heal removes it.
|
||||
- Authoritative FileInfo selection — `find_file_info_in_quorum` ([set_disk/metadata.rs](../../crates/ecstore/src/set_disk/metadata.rs)) groups valid metas by a content-identity SHA-256 (`file_info_quorum_hash`) that hashes size/flags/mod_time/transition/version_id/data_dir/parts and, for real objects, data/parity/distribution — **excluding replication-status keys** so replication noise never splits quorum. A meta counts only if its mod_time equals the common mod_time (or etag matches when mod_time is absent). The winning hash must reach quorum, else `ErasureReadQuorum`. Latest-version reads may escalate to write_quorum to avoid resurrecting a partially-overwritten version.
|
||||
- **INVARIANT — decode needs ≥ `data_blocks` shards.** The stripe reader requires `available_shards ≥ data_shards`; below that the read fails closed with a read-quorum error (never silent truncation) ([set_disk/read.rs](../../crates/ecstore/src/set_disk/read.rs), [set_disk/shard_source.rs](../../crates/ecstore/src/set_disk/shard_source.rs)). Codec geometry is validated at construction: `Erasure::try_new` / `try_new_with_options` ([erasure.rs](../../crates/ecstore/src/erasure/coding/erasure.rs)) return `ErasureConstructionError` for `data_shards == 0`, `block_size == 0`, shard-count overflow, or an unsupported shard configuration, so a read never reaches a `block_size` / `data_shards` division with invalid geometry (§13).
|
||||
- If `available ≥ data_blocks` but some shards are missing, the read is served **and** a background read-repair heal is enqueued.
|
||||
- **INVARIANT — cross-stripe read verification.** When a data shard is missing and `available > data_blocks`, reconstruction regenerates parity and compares it to the surviving parity; a mismatch is `InvalidData "inconsistent read source shards"` (backlog#832), catching corruption that passed per-shard bitrot but disagrees across the stripe ([erasure.rs](../../crates/ecstore/src/erasure/coding/erasure.rs)).
|
||||
|
||||
@@ -325,45 +325,9 @@ worker finishing does not certify its namespace coverage.
|
||||
| `bucket_result_rejected` | A bucket result did not match the cycle's frozen tier-registry generation and was not applied. |
|
||||
| `set_cache_publish_skipped` | `completed_buckets` counts distinct bucket roots successfully applied to this set cache and `carried_buckets` the partial walks covered by a certified value. Use `first_incomplete_bucket` with pool/set/cycle to locate one actual gap in this set. Inspect `unprocessed_buckets` and budget/cancellation state, then correlate that bucket's worker events. |
|
||||
| `set_scan_completed` | Set workers have finished. `coverage_complete` reports the structural coverage gate, not durable publication. This event alone does not prove a complete set snapshot. |
|
||||
| `incomplete_coverage` | A Partial cycle did not match either of the more specific partial-cause labels. It does not identify which completeness or publication check failed, or prove that a bucket or drive was missed. |
|
||||
| `incomplete_coverage` | The cycle did not obtain complete structural coverage. This does not identify a hot bucket or prove that ordinary writes caused the gap. |
|
||||
| `publication_proof_pending` | Publication is waiting for a retryable proof. Use cycle/stage, `pending_duration`, the preceding `publication_proof_started` kind, and the peer/purpose in the error. This event does not assert that the walk was Complete. |
|
||||
|
||||
`bucket_sweep_incomplete`, `partial_saved_not_published`,
|
||||
`set_cache_publish_skipped`, and `set_scan_completed` are debug events. Their
|
||||
absence cannot exclude those paths when debug logging was not collected.
|
||||
|
||||
`metrics.last_cycle_bucket_drive_scans` is the local process's change in
|
||||
`scan_bucket_drive` timed completions during the cycle. It includes successful,
|
||||
partial, namespace-not-found, and non-cancelled error returns; a cancelled
|
||||
scan can emit a partial result without incrementing this count. The leader's
|
||||
remote progress adds object and directory counts, but not remote bucket-drive
|
||||
completions. The count can also include concurrent local scan work. It is not a
|
||||
cluster-wide success count or a coverage denominator, so comparing it with
|
||||
bucket count multiplied by set or drive count cannot identify missing drives.
|
||||
See `Metrics::scan_cycle_work_since` in [scanner metrics](../../crates/scanner-metrics/src/metrics.rs)
|
||||
and `ScannerIODisk::nsscanner_disk` in [the disk scanner](../../crates/scanner/src/scanner_io/io_disk.rs).
|
||||
|
||||
For a Partial cycle, compare the same leader process's `metrics.usage_candidates`
|
||||
in `/rustfs/admin/v3/scanner/status` before and after the cycle, when available.
|
||||
The corresponding `rustfs_scanner_usage_candidates_total` counters require an
|
||||
enabled exporter; other scanner metrics endpoints can expose a different schema.
|
||||
For `kind=completed`, the `outcome` names the first candidate-construction check
|
||||
that returned no candidate, or `accepted`; acceptance does not prove durable
|
||||
publication. Counters are process-lifetime, not per-cycle or per-bucket, and
|
||||
reset on restart. Correlate their deltas with the same cycle and leader epoch in
|
||||
logs; concurrent candidate construction and non-atomic sampling can make
|
||||
attribution uncertain. An unavailable field or counter is not zero.
|
||||
|
||||
The scanner metrics `rustfs_scanner_bucket_drive_result_total` and
|
||||
`rustfs_scanner_cycle_bucket_drive_result` expose local results by server, bucket, drive and
|
||||
`result` (`success`, `partial`, or `error`); the latter also has `cycle_scope`
|
||||
(`current` or `last`). `rustfs_scanner_active_bucket_drive_scans` shows work
|
||||
active at the sample time. These are observations, not per-drive terminal acknowledgements:
|
||||
the local result table is capped and evicts old keys, the cycle labels do not
|
||||
carry a cycle or epoch, and retries can repeat work. A missing row does not
|
||||
prove that a terminal was never received. Match observations to the relevant
|
||||
node and cycle before comparing them with set-cache and publication logs.
|
||||
|
||||
For a report such as 52 completed buckets out of 53, first identify whether
|
||||
the missing bucket was Partial, failed, never admitted, or rejected at merge.
|
||||
Only then inspect its worker evidence. Mixed coverage is a resumable sweep
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Read-only BTF declaration inspection
|
||||
# Read-only BTF prototype inspection
|
||||
|
||||
Use `scripts/inspect_btf_prototypes.py` to inspect declared function types or selected
|
||||
integer member layouts in a
|
||||
Use `scripts/inspect_btf_prototypes.py` to inspect declared function types in a
|
||||
raw, little-endian BTF v1 regular file. Symlinks, devices and pipes are rejected.
|
||||
The command reads the named file and writes one
|
||||
JSON document to stdout. It does not load BPF, attach probes, read kernel memory,
|
||||
@@ -19,37 +18,14 @@ parameter type IDs, qualifier chains, and resolved declarations. A missing or
|
||||
ambiguous function is an error, rather than a substitute selected by proximity.
|
||||
The schema version is `1`.
|
||||
|
||||
Prototype records expose common BTF headers. A reported prototype is not a
|
||||
complete C type, recursive layout proof or runtime calling convention.
|
||||
|
||||
For integer counters, select one exact struct and one or more named member paths:
|
||||
|
||||
```bash
|
||||
python3 scripts/inspect_btf_prototypes.py \
|
||||
--btf /sys/kernel/btf/vmlinux \
|
||||
--struct bpf_ringbuf \
|
||||
--field mask --field consumer_pos --field producer_pos --field pending_pos
|
||||
```
|
||||
|
||||
This mode validates the selected struct member records, qualifier chains and
|
||||
integer payloads. `scalar_fields` preserves request order and reports each
|
||||
declared byte offset, width, integer encoding flags and member path. Nested
|
||||
structs are supported; every selected span must fit its declared container.
|
||||
Only complete 1, 2, 4 or 8-byte integers are supported. Bitfields (including
|
||||
legacy integer-offset encodings), pointer traversal, pointer/array/union leaves,
|
||||
ambiguous members, missing types and by-value cycles are errors. No pointer
|
||||
width is inferred. Byte alignment does not guarantee natural machine alignment.
|
||||
|
||||
`--struct` requires `--field` and cannot be combined with `--function`. Up to
|
||||
16 unique paths, each containing at most 32 named members, are accepted. A
|
||||
failure in any selected path rejects the whole report before emitting JSON.
|
||||
The mode sets `runtime_ABI_verified` to `false`: it does not bind BTF to executing
|
||||
machine code, pointer values, loaded images or an observer's admission contract.
|
||||
Only requested paths are validated, not the complete BTF graph or all C layouts.
|
||||
Selected type records expose their common BTF headers. Kind-specific payloads
|
||||
and recursive layouts are not decoded by this command; use an independent BTF
|
||||
dump when those records are required. A reported declaration is not a complete
|
||||
C type, layout proof or runtime calling convention.
|
||||
|
||||
The reader supports BTF kinds 1 through 19, indexes at most 500,000 records,
|
||||
reads at most 32 MiB plus one overflow-detection byte, limits names to 4,096
|
||||
bytes, follows at most 32 type records per qualifier chain, and accepts at most 16 parameters
|
||||
bytes, follows at most 32 qualifier records, and accepts at most 16 parameters
|
||||
per selected prototype. Reports above 1 MiB are rejected before any JSON prefix
|
||||
is emitted. Invalid/truncated/unsupported source, missing resolved type IDs,
|
||||
malformed pointer/qualifier headers, and ambiguous selectors exit nonzero with
|
||||
@@ -71,11 +47,8 @@ python3 -B scripts/test_inspect_btf_prototypes.py
|
||||
|
||||
The `BTF prototype reference` workflow additionally compiles a small C fixture,
|
||||
extracts its raw `.BTF` section, and independently compares both selected
|
||||
prototypes, scalar layouts and resolved types against `bpftool` output. A separate
|
||||
ordinary C executable uses compiler `offsetof`/`sizeof` to check layout offsets
|
||||
and widths, including nested and packed members; it does not parse BTF or load
|
||||
probes. The BPF object is never loaded. The workflow selects a concrete
|
||||
`bpftool` executable by availability and
|
||||
declarations and resolved types against `bpftool` output. The object is never
|
||||
loaded. The workflow selects a concrete `bpftool` executable by availability and
|
||||
version before comparing data; it does not depend on the hosted kernel's tools
|
||||
wrapper or switch tools after a comparison failure. The native
|
||||
[file-dump mode](https://github.com/libbpf/bpftool/blob/main/docs/bpftool-btf.rst)
|
||||
|
||||
@@ -64,14 +64,12 @@ A single local path (`rustfs D:\rustfs\data`) is a single-node single-drive depl
|
||||
|
||||
An empty `RUSTFS_STORAGE_CLASS_STANDARD` or `RUSTFS_STORAGE_CLASS_RRS` restores the automatic policy and overrides a persisted value (`standard_policy` / `rrs_policy`). `MINIO_`-prefixed spellings of these keys, of `ERASURE_SET_DRIVE_COUNT`, and of `VOLUMES` are mapped onto the `RUSTFS_` names when the `RUSTFS_` name is unset (`apply_external_env_compat` in `crates/utils/src/envs.rs`); when both are set the `RUSTFS_` value wins and the conflict is reported at startup.
|
||||
|
||||
Heal knobs (`RUSTFS_HEAL_*`) and their defaults are documented once, in [scanner-runtime-controls.md](scanner-runtime-controls.md#heal-runtime-controls); the deep bitrot cycle is the admin config key `heal.bitrot_cycle` (environment `RUSTFS_SCANNER_BITROT_CYCLE_SECS`), not a `RUSTFS_HEAL_*` variable. Data-movement knobs: `RUSTFS_REBALANCE_MAX_ATTEMPTS` (default `DEFAULT_REBALANCE_MAX_ATTEMPTS` = 3, `crates/ecstore/src/services/rebalance/mod.rs`), `RUSTFS_DECOMMISSION_BUCKET_CONCURRENCY` (default cap 4) and `RUSTFS_DECOMMISSION_ENTRY_CONCURRENCY` (default cap 8, hard cap 64) in `crates/ecstore/src/core/pools.rs`, and `RUSTFS_LIST_OBJECTS_QUORUM` (default `strict`, which reads every online drive of the set; `disk`, `reduced`, `optimal` and `auto` read a sample, and anything else means `strict`, per `normalize_list_quorum` in `crates/ecstore/src/store/list_objects.rs`). `strict` reads all online drives, but resolves entries using a quorum of those drives. After a successful delete, a change in which drives are offline can leave enough stale copies online for the deleted key to reappear in LIST; GET or HEAD can return 404 or 503. Restore the drives and allow healing to complete before relying on degraded listings for backup, sync, replication, or lifecycle work. `optimal` samples about half the drives and can also omit a committed object whose copies mostly sit outside the sample: an `EC:8` object committed on nine of sixteen drives can miss a four-copy sample quorum. `disk` and `reduced` sample fewer drives and encounter these limits more often. Keep the default `strict` for work that needs a complete listing, while accounting for the degraded-drive limitation above. None of these settings changes object read or write quorum.
|
||||
Heal knobs (`RUSTFS_HEAL_*`) and their defaults are documented once, in [scanner-runtime-controls.md](scanner-runtime-controls.md#heal-runtime-controls); the deep bitrot cycle is the admin config key `heal.bitrot_cycle` (environment `RUSTFS_SCANNER_BITROT_CYCLE_SECS`), not a `RUSTFS_HEAL_*` variable. Data-movement knobs: `RUSTFS_REBALANCE_MAX_ATTEMPTS` (default `DEFAULT_REBALANCE_MAX_ATTEMPTS` = 3, `crates/ecstore/src/services/rebalance/mod.rs`), `RUSTFS_DECOMMISSION_BUCKET_CONCURRENCY` (default cap 4) and `RUSTFS_DECOMMISSION_ENTRY_CONCURRENCY` (default cap 8, hard cap 64) in `crates/ecstore/src/core/pools.rs`, and `RUSTFS_LIST_OBJECTS_QUORUM` (default `strict`, which reads every online drive of the set; `disk`, `reduced`, `optimal` and `auto` read a sample, and anything else means `strict`, per `normalize_list_quorum` in `crates/ecstore/src/store/list_objects.rs`). `optimal` reads about half of the drives, so a deleted object can briefly reappear in a listing while the sampled drives still hold a stale copy; a GET or HEAD of that key still returns 404. A sampled listing can also omit a committed object whose copies mostly sit outside the sample: an object written with more parity than the set default, for example `EC:8` on a 16-drive set whose default is `EC:4`, can hold copies on only nine drives, and a random eight of the sixteen then holds fewer than the four copies a listing needs in roughly one listing in six. `disk` and `reduced` sample fewer drives and have both limits more often. Keep `strict` wherever every committed object must appear, such as backup, sync, replication and lifecycle tooling. None of them relaxes object quorum, object locks, or version checks.
|
||||
|
||||
### 3.1 Retired listing index: upgrade and rollback
|
||||
|
||||
ListObjects uses the disk walker; the experimental `RUSTFS_LIST_OBJECTS_INDEX_*` provider is retired. New writers no longer update its namespace mutation journal. An older node can therefore mistake a persistent index snapshot for a current one and omit newly written keys. Deployments that never used persistent index snapshots need no additional rollout step.
|
||||
|
||||
After all nodes have upgraded to literal listing markers, restart any unfinished version-listing pagination from the beginning, including manual transition jobs resumed from older checkpoints. Restart V1 pagination chains holding escaped markers as well. During a mixed-version rollout, old and new nodes interpret cache-shaped key markers differently, so do not continue those pagination chains until the upgrade is complete.
|
||||
|
||||
For deployments that used persistent snapshots:
|
||||
|
||||
1. Before replacing any binary, first restart every old node with `RUSTFS_LIST_OBJECTS_INDEX_MODE` unset. Verify the running configuration on every node and finish this configuration rollout while all nodes still use the old binary.
|
||||
|
||||
@@ -105,12 +105,6 @@ locking, and movement guards still apply. Prefix recovery settles only matching
|
||||
keys, including transactions hidden from listings. Dry-run inspects and reports without
|
||||
settling transactions.
|
||||
|
||||
LIST does not remove historical residue on updated nodes. During a rolling
|
||||
upgrade, older nodes can still remove it while serving LIST. A bucket with
|
||||
hidden residue can still fail DeleteBucket; recover known keys with an
|
||||
unconditional exact-key DELETE, or inspect and remove eligible residue through
|
||||
administrator heal.
|
||||
|
||||
An unconditional DELETE of a missing exact key, or an administrator heal with
|
||||
`remove` enabled, can authorize historical residue removal only when the
|
||||
bucket is authoritatively never-versioned and has no Object Lock. Every
|
||||
|
||||
@@ -561,11 +561,11 @@ Heal knobs are environment-only and read by `HealConfig::default` (`crates/heal/
|
||||
| `RUSTFS_HEAL_EVENT_DRIVEN_SCHEDULER_ENABLE` | `true` (`DEFAULT_HEAL_EVENT_DRIVEN_SCHEDULER_ENABLE`) | Notify-driven scheduler wakeups. |
|
||||
| `RUSTFS_HEAL_SET_BULKHEAD_ENABLE` | `true` (`DEFAULT_HEAL_SET_BULKHEAD_ENABLE`) | Per-set bulkhead scheduling. |
|
||||
| `RUSTFS_HEAL_PAGE_PARALLEL_ENABLE` | `true` (`DEFAULT_HEAL_PAGE_PARALLEL_ENABLE`) | Page-level parallel object healing during erasure-set repair. |
|
||||
| `RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY` | `8` for ordinary heals; `2` for automatic replacement | Concurrent object heals within one erasure-set page. An explicit value applies to both; automatic replacement clamps it to the semaphore's supported range. Forced to `1` when page parallelism is off, for `Deep` scans, and for automatic heals without a replacement generation or object locks. |
|
||||
| `RUSTFS_HEAL_MAINLINE_THROTTLE_ENABLE` | `true` (`DEFAULT_HEAL_MAINLINE_THROTTLE_ENABLE`) | Defer best-effort starts and cooperatively pace running admin and automatic replacement heals at safe work boundaries. |
|
||||
| `RUSTFS_HEAL_MAINLINE_READ_UTILIZATION_HIGH_PERCENT` | `80` (`DEFAULT_HEAL_MAINLINE_READ_UTILIZATION_HIGH_PERCENT`, capped at 100) | Read-utilization high watermark for start admission and running heal pacing; zero disables this class. |
|
||||
| `RUSTFS_HEAL_MAINLINE_WRITE_UTILIZATION_HIGH_PERCENT` | `80` (`DEFAULT_HEAL_MAINLINE_WRITE_UTILIZATION_HIGH_PERCENT`, capped at 100) | Write-utilization high watermark for start admission and running heal pacing; zero disables this class. |
|
||||
| `RUSTFS_HEAL_MAINLINE_MAX_SLEEP_MS` | `250` (`DEFAULT_HEAL_MAINLINE_MAX_SLEEP_MS`) | Start recheck interval; running waits cap each pacing-gate holder at 1000 ms. Zero disables running pacing. |
|
||||
| `RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY` | `8` (`DEFAULT_HEAL_PAGE_OBJECT_CONCURRENCY`) | Concurrent object heals within one erasure-set page. Forced to `1` when page parallelism is off, for `Deep` scan mode, and for `AutoHeal`-sourced requests (`ErasureSetHealer::effective_heal_page_object_concurrency_for_source`). |
|
||||
| `RUSTFS_HEAL_MAINLINE_THROTTLE_ENABLE` | `true` (`DEFAULT_HEAL_MAINLINE_THROTTLE_ENABLE`) | Defer best-effort starts and cooperatively pace running admin heal at safe work boundaries. |
|
||||
| `RUSTFS_HEAL_MAINLINE_READ_UTILIZATION_HIGH_PERCENT` | `80` (`DEFAULT_HEAL_MAINLINE_READ_UTILIZATION_HIGH_PERCENT`, capped at 100) | Read-utilization high watermark for start admission and running admin pacing; zero disables this class. |
|
||||
| `RUSTFS_HEAL_MAINLINE_WRITE_UTILIZATION_HIGH_PERCENT` | `80` (`DEFAULT_HEAL_MAINLINE_WRITE_UTILIZATION_HIGH_PERCENT`, capped at 100) | Write-utilization high watermark for start admission and running admin pacing; zero disables this class. |
|
||||
| `RUSTFS_HEAL_MAINLINE_MAX_SLEEP_MS` | `250` (`DEFAULT_HEAL_MAINLINE_MAX_SLEEP_MS`) | Start recheck interval; running admin waits cap each pacing-gate holder at 1000 ms. Zero disables running pacing. |
|
||||
| `RUSTFS_HEAL_OVERLAP_POLICY` | `merge` (`DEFAULT_HEAL_OVERLAP_POLICY`) | `merge` reuses the token of an equivalent active, queued, or retrying admin heal; incompatible same-target starts and intersecting admin scopes return `already_running` / `overlapping_paths`. Durable-only owners reject until recovered or replaced with `forceStart`. `minio_error` also rejects equivalent starts and preserves overlap rejection against background tasks. |
|
||||
| `RUSTFS_HEAL_MRF_ENABLE` | `true` (`DEFAULT_HEAL_MRF_ENABLE`) | MRF intent pipeline: error paths deliver repair intents to the heal runtime and unconsumed intents replay from the durable journal after restart. |
|
||||
| `RUSTFS_HEAL_MRF_QUEUE_SIZE` | `100000` (`DEFAULT_HEAL_MRF_QUEUE_SIZE`) | MRF in-memory queue capacity. |
|
||||
@@ -595,8 +595,6 @@ Admin heal has three separate budgets. Increasing one does not extend the others
|
||||
| Task execution | `RUSTFS_HEAL_TASK_TIMEOUT_SECS` supplies the default of 300 seconds when the execution has no explicit timeout. Elapsed execution time is deducted before a recoverable scheduler retry, which keeps the request identity and remaining budget. Object/listing backoff and pressure pacing inside an execution consume that budget; time queued or in the scheduler's between-attempt backoff is not a new absolute wall-clock deadline. Zero is not an unlimited execution budget. |
|
||||
| Object/listing retry | A recursive bucket/prefix traversal retries recoverable failures at most three times after the initial attempt, with 2/4/8-second delays plus the existing task-derived jitter. Object retries also obey the bounded delayed window and its 30-second age, checked at safe boundaries; listing retries keep their cursor. These limits do not extend the task execution budget or force an in-flight storage operation to finish within the retry age. |
|
||||
|
||||
Automatic replacement retries a transient object repair twice within its current page, waiting 250 ms and then 500 ms without holding page capacity. An exhausted retry remains unverified and still triggers the existing generation retry; target readback and checkpoint requirements are unchanged.
|
||||
|
||||
For a large admin heal, select a finite configured execution budget appropriate to the expected work and contention, and inspect progress while it runs. Investigate stalled work and object failures before increasing this budget; a larger timeout must not hide lock or quorum problems. A longer HTTP or internode timeout does not keep a repair task alive past its execution budget. A successful start response returns the canonical token for accepted or merged work; it does not prove that repair has completed. Query that token without `forceStart`; terminal timeout/cancellation reports retain completed progress while the task report remains available.
|
||||
|
||||
Capability preflight runs before constructing and admitting a start request. However, the public `cluster heal coordination unavailable` error can also follow a transport failure or an invalid coordinator response after a request was sent. An unknown or lost HTTP response is therefore not proof that no task was admitted.
|
||||
@@ -607,13 +605,13 @@ Implementation references: `rustfs/src/admin/handlers/heal.rs` (`submit_cluster_
|
||||
|
||||
### Running admin heal pacing
|
||||
|
||||
The manager passes its existing workload provider and a configuration snapshot into each admin execution and automatic replacement generation. Bucket/prefix listing and object boundaries resample foreground pressure; erasure-set page workers also resample after earlier work releases page capacity. `High`, `Urgent`, and `force_start` do not exempt ordinary admin execution from this runtime pacing. The existing start-time bypass and overlap-control meanings are unchanged.
|
||||
The manager passes its existing workload provider and a configuration snapshot into each admin execution. Bucket/prefix listing and object boundaries resample foreground pressure; erasure-set page workers also resample after earlier work releases page capacity. `High`, `Urgent`, and `force_start` do not exempt ordinary admin execution from this runtime pacing. The existing start-time bypass and overlap-control meanings are unchanged.
|
||||
|
||||
Each execution has its own pacing latch, with no new global manager or cross-set pacing lock. The low watermark for each enabled class is `max(1, floor(high * 3 / 4))`: the default high watermark 80 therefore recovers below 60. High pressure latches pacing, and intermediate pressure resets the recovery window. Unpaced starts resume after sampled pressure remains below the low watermarks for four pause intervals, normally one second. While pressure persists, a pacing-gate holder waits only one interval, at most one second, then permits maintenance to continue. Concurrent page waiters serialize through this task-local gate; queue waiting still counts against the existing task execution timeout.
|
||||
|
||||
The pacing gate holds neither namespace locks nor I/O/page permits while sleeping. At final page admission, each real permit acquisition gets a fresh, nonblocking pressure decision. Low-pressure work keeps that permit; only a unit that needs a pause releases capacity to wait. A unit that has completed one bounded pause may proceed despite persistent pressure, which supplies minimum maintenance progress without an endless acquire/pause loop. Existing object operations and commit tails are not interrupted because pressure rose. Cancellation interrupts the pacing wait; replacement operations retain their execution lease until storage work settles. Disabling pacing cannot bypass the global, per-set or page-concurrency hard caps. The existing `RUSTFS_HEAL_MAINLINE_THROTTLE_ENABLE=false` setting is the operational opt-out for newly created executions; no additional request override is introduced.
|
||||
The pacing gate holds neither namespace locks nor I/O/page permits while sleeping. At final page admission, each real permit acquisition gets a fresh, nonblocking pressure decision. Low-pressure work keeps that permit; only a unit that needs a pause releases capacity to wait. A unit that has completed one bounded pause may proceed despite persistent pressure, which supplies minimum maintenance progress without an endless acquire/pause loop. Existing object operations and commit tails are not interrupted because pressure rose. Cancellation and deadlines remain interruptible, and disabling pacing cannot bypass the global, per-set or page-concurrency hard caps. The existing `RUSTFS_HEAL_MAINLINE_THROTTLE_ENABLE=false` setting is the operational opt-out for newly created executions; no additional request override is introduced.
|
||||
|
||||
A missing provider, zero pause, or both class thresholds set to zero preserves unpaced execution. Missing counts follow the existing shared pressure interpreter; they are observations, not health, quorum or resource-ownership proof. The current provider exposes node-level workload classes, so this does not claim independent per-set foreground measurements or a hard global resource budget. Runtime waits increment `rustfs_heal_mainline_throttle_total` with `source=admin` or `source=auto_heal`, `result=delayed`, and a foreground-pressure or `recovery_window` reason. Real p99/throughput protection requires the separate W20 fixed-load ABBA measurements.
|
||||
A missing provider, zero pause, or both class thresholds set to zero preserves unpaced execution. Missing counts follow the existing shared pressure interpreter; they are observations, not health, quorum or resource-ownership proof. The current provider exposes node-level workload classes, so this does not claim independent per-set foreground measurements or a hard global resource budget. Runtime waits increment `rustfs_heal_mainline_throttle_total` with `source=admin`, `result=delayed`, and a foreground-pressure or `recovery_window` reason. Real p99/throughput protection requires the separate W20 fixed-load ABBA measurements.
|
||||
|
||||
## Live Scanner Heal Completions
|
||||
|
||||
|
||||
@@ -1503,11 +1503,6 @@ mod pools_handler_tests {
|
||||
decommission_status: "none".to_string(),
|
||||
rebalance_status: "none".to_string(),
|
||||
decommission: None,
|
||||
availability: None,
|
||||
pool_metadata_status: None,
|
||||
rebalance_metadata_status: None,
|
||||
capacity_status: None,
|
||||
observed_at: None,
|
||||
},
|
||||
admin_discovery: super::PoolAdminDiscovery {
|
||||
runtime_capabilities: "/rustfs/admin/v4/runtime/capabilities".to_string(),
|
||||
|
||||
@@ -33,15 +33,12 @@ impl Operation for EnableTableBucketHandler {
|
||||
let warehouse = warehouse_from_params(¶ms)?;
|
||||
let resource = TableCatalogResource::warehouse(&warehouse);
|
||||
authorize_table_catalog_resource_request(&req, &resource, AdminAction::SetTableBucketAction).await?;
|
||||
let app_context = runtime_sources::app_context_from_extensions(&req.extensions);
|
||||
let backend = table_catalog_backend_from_extensions(&req.extensions)?;
|
||||
let object_store = runtime_sources::object_store_from_req(&req)
|
||||
.ok_or_else(|| table_catalog_internal_error("request object store is not initialized"))?;
|
||||
let store = table_catalog_store_from_backend(backend.clone())?;
|
||||
let publication = TableCommitObjectBackend::preauthorized(backend);
|
||||
let response = enable_table_bucket_response(&store, &publication, object_store.as_ref(), &warehouse).await?;
|
||||
crate::app::bucket_usecase::notify_table_bucket_metadata_reload(warehouse.clone(), "enable table bucket", app_context)
|
||||
.await;
|
||||
build_json_response(StatusCode::OK, &response)
|
||||
}
|
||||
}
|
||||
@@ -54,15 +51,12 @@ impl Operation for DisableTableBucketHandler {
|
||||
let warehouse = warehouse_from_params(¶ms)?;
|
||||
let resource = TableCatalogResource::warehouse(&warehouse);
|
||||
authorize_table_catalog_resource_request(&req, &resource, AdminAction::SetTableBucketAction).await?;
|
||||
let app_context = runtime_sources::app_context_from_extensions(&req.extensions);
|
||||
let backend = table_catalog_backend_from_extensions(&req.extensions)?;
|
||||
let object_store = runtime_sources::object_store_from_req(&req)
|
||||
.ok_or_else(|| table_catalog_internal_error("request object store is not initialized"))?;
|
||||
let store = table_catalog_store_from_backend(backend.clone())?;
|
||||
let publication = TableCommitObjectBackend::preauthorized(backend);
|
||||
let response = disable_table_bucket_response(&store, &publication, object_store.as_ref(), &warehouse).await?;
|
||||
crate::app::bucket_usecase::notify_table_bucket_metadata_reload(warehouse.clone(), "disable table bucket", app_context)
|
||||
.await;
|
||||
build_json_response(StatusCode::OK, &response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1679,26 +1679,6 @@ fn enable_table_bucket_response_fences_before_marker_and_catalog_entry() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_bucket_toggle_handlers_reload_peer_metadata_after_publication() {
|
||||
let src = table_catalog_handler_source();
|
||||
let enable = operation_block(&src, "EnableTableBucketHandler");
|
||||
let disable = operation_block(&src, "DisableTableBucketHandler");
|
||||
|
||||
for (block, response, operation) in [
|
||||
(enable, "enable_table_bucket_response", "enable table bucket"),
|
||||
(disable, "disable_table_bucket_response", "disable table bucket"),
|
||||
] {
|
||||
let response_offset = block
|
||||
.find(&format!("let response = {response}"))
|
||||
.expect("table bucket toggle should publish local state");
|
||||
let reload = block
|
||||
.find(&format!("notify_table_bucket_metadata_reload(warehouse.clone(), \"{operation}\""))
|
||||
.expect("table bucket toggle should reload peer metadata");
|
||||
assert!(response_offset < reload, "peer reload must follow local publication");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_commit_publication_acquires_migration_permits_before_publication_lock() {
|
||||
let src = table_catalog_handler_source();
|
||||
|
||||
+12
-159
@@ -16,7 +16,7 @@
|
||||
|
||||
use super::storage_api::admin_usecase::admin::get_server_info;
|
||||
use super::storage_api::admin_usecase::capacity::{
|
||||
DecommissionUnresolvedEntry, PoolAdminDiagnostic, PoolDecommissionInfo, PoolMeta, PoolStatus, RebalStatus, RebalanceMeta,
|
||||
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolMeta, PoolStatus, RebalStatus, RebalanceMeta,
|
||||
get_total_usable_capacity, get_total_usable_capacity_free,
|
||||
};
|
||||
use super::storage_api::admin_usecase::contract::StorageAdminApi;
|
||||
@@ -143,20 +143,6 @@ pub struct AdminPoolStatus {
|
||||
pub rebalance_status: String,
|
||||
#[serde(rename = "decommissionInfo")]
|
||||
pub decommission: Option<AdminPoolDecommissionInfo>,
|
||||
#[serde(rename = "availability", skip_serializing_if = "Option::is_none")]
|
||||
pub availability: Option<String>,
|
||||
#[serde(rename = "poolMetadataStatus", skip_serializing_if = "Option::is_none")]
|
||||
pub pool_metadata_status: Option<String>,
|
||||
#[serde(rename = "rebalanceMetadataStatus", skip_serializing_if = "Option::is_none")]
|
||||
pub rebalance_metadata_status: Option<String>,
|
||||
#[serde(rename = "capacityStatus", skip_serializing_if = "Option::is_none")]
|
||||
pub capacity_status: Option<String>,
|
||||
#[serde(
|
||||
rename = "observedAt",
|
||||
with = "time::serde::rfc3339::option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub observed_at: Option<time::OffsetDateTime>,
|
||||
}
|
||||
|
||||
pub type AdminPoolListItem = AdminPoolStatus;
|
||||
@@ -430,19 +416,12 @@ impl DefaultAdminUsecase {
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(Self::app_error(S3ErrorCode::InternalError, "Not init"));
|
||||
};
|
||||
let (pool_diagnostics, pool_metadata_status) = store.admin_pool_diagnostic_snapshot(None).await;
|
||||
let (rebalance_meta, rebalance_metadata_status) = store.rebalance_admin_diagnostic_snapshot().await;
|
||||
let observed_at = time::OffsetDateTime::now_utc();
|
||||
let mut items = Vec::with_capacity(pool_diagnostics.len());
|
||||
for diagnostic in pool_diagnostics {
|
||||
let rebalance_status = ECStore::pool_rebalance_status(rebalance_meta.as_ref(), diagnostic.id);
|
||||
items.push(Self::pool_list_item_from_diagnostic(
|
||||
diagnostic,
|
||||
rebalance_status,
|
||||
pool_metadata_status,
|
||||
rebalance_metadata_status,
|
||||
observed_at,
|
||||
));
|
||||
let pool_statuses = self.execute_list_pool_statuses().await?;
|
||||
let rebalance_meta = Self::rebalance_status_snapshot(store.as_ref()).await?;
|
||||
let mut items = Vec::with_capacity(pool_statuses.len());
|
||||
for status in pool_statuses {
|
||||
let rebalance_status = ECStore::pool_rebalance_status(rebalance_meta.as_ref(), status.id);
|
||||
items.push(Self::pool_list_item_from_status(status, rebalance_status));
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
@@ -477,19 +456,11 @@ impl DefaultAdminUsecase {
|
||||
return Err(Self::app_error(S3ErrorCode::InternalError, "Not init"));
|
||||
};
|
||||
|
||||
let (mut diagnostics, pool_metadata_status) = store.admin_pool_diagnostic_snapshot(Some(idx)).await;
|
||||
let diagnostic = diagnostics
|
||||
.pop()
|
||||
.ok_or_else(|| Self::app_error_default(S3ErrorCode::InvalidArgument))?;
|
||||
let (rebalance_meta, rebalance_metadata_status) = store.rebalance_admin_diagnostic_snapshot().await;
|
||||
let pool_meta = Self::pool_status_snapshot(store.as_ref()).await?;
|
||||
let status = store.status(&pool_meta, idx).await.map_err(ApiError::from)?;
|
||||
let rebalance_meta = Self::rebalance_status_snapshot(store.as_ref()).await?;
|
||||
let rebalance_status = ECStore::pool_rebalance_status(rebalance_meta.as_ref(), idx);
|
||||
Ok(Self::pool_list_item_from_diagnostic(
|
||||
diagnostic,
|
||||
rebalance_status,
|
||||
pool_metadata_status,
|
||||
rebalance_metadata_status,
|
||||
time::OffsetDateTime::now_utc(),
|
||||
))
|
||||
Ok(Self::pool_list_item_from_status(status, rebalance_status))
|
||||
}
|
||||
|
||||
pub async fn execute_list_decommission_status(&self) -> AdminUsecaseResult<AdminDecommissionStatus> {
|
||||
@@ -564,63 +535,9 @@ impl DefaultAdminUsecase {
|
||||
decommission_status: decommission_status.to_string(),
|
||||
rebalance_status: rebalance_status.to_string(),
|
||||
decommission: decommission.map(Self::admin_decommission_info_from_pool),
|
||||
availability: None,
|
||||
pool_metadata_status: None,
|
||||
rebalance_metadata_status: None,
|
||||
capacity_status: None,
|
||||
observed_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pool_list_item_from_diagnostic(
|
||||
diagnostic: PoolAdminDiagnostic,
|
||||
rebalance_status: (RebalStatus, bool),
|
||||
pool_metadata_status: &'static str,
|
||||
rebalance_metadata_status: &'static str,
|
||||
observed_at: time::OffsetDateTime,
|
||||
) -> AdminPoolListItem {
|
||||
let availability = diagnostic.diagnostic.availability.as_str().to_string();
|
||||
let capacity_status = diagnostic.diagnostic.capacity_status.as_str().to_string();
|
||||
let mut item = match diagnostic.pool {
|
||||
Some(status) => Self::pool_list_item_from_status(status, rebalance_status),
|
||||
None => AdminPoolStatus {
|
||||
id: diagnostic.id,
|
||||
cmd_line: diagnostic.cmd_line,
|
||||
last_update: time::OffsetDateTime::UNIX_EPOCH,
|
||||
total_size: 0,
|
||||
current_size: 0,
|
||||
used_size: 0,
|
||||
used: 0.0,
|
||||
status: Self::POOL_STATUS_UNKNOWN.to_string(),
|
||||
decommission_status: Self::POOL_STATUS_UNKNOWN.to_string(),
|
||||
rebalance_status: Self::POOL_STATUS_UNKNOWN.to_string(),
|
||||
decommission: None,
|
||||
availability: None,
|
||||
pool_metadata_status: None,
|
||||
rebalance_metadata_status: None,
|
||||
capacity_status: None,
|
||||
observed_at: None,
|
||||
},
|
||||
};
|
||||
item.availability = Some(availability);
|
||||
item.pool_metadata_status = Some(pool_metadata_status.to_string());
|
||||
item.rebalance_metadata_status = Some(rebalance_metadata_status.to_string());
|
||||
item.capacity_status = Some(capacity_status);
|
||||
item.observed_at = Some(observed_at);
|
||||
if pool_metadata_status == "unknown" {
|
||||
item.status = Self::POOL_STATUS_UNKNOWN.to_string();
|
||||
item.decommission_status = Self::POOL_STATUS_UNKNOWN.to_string();
|
||||
item.decommission = None;
|
||||
}
|
||||
if rebalance_metadata_status != "fresh" {
|
||||
item.rebalance_status = Self::POOL_STATUS_UNKNOWN.to_string();
|
||||
if item.decommission.is_none() {
|
||||
item.status = Self::POOL_STATUS_UNKNOWN.to_string();
|
||||
}
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
fn pool_lifecycle_state(decommission: Option<&PoolDecommissionInfo>, rebalance_status: &'static str) -> &'static str {
|
||||
match decommission {
|
||||
Some(info) if info.complete => Self::POOL_STATE_DECOMMISSIONED,
|
||||
@@ -784,73 +701,9 @@ impl DefaultAdminUsecase {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::storage_api::admin_usecase::capacity::{
|
||||
DecommissionUnresolvedEntry, PoolAvailability, PoolCapacityStatus, PoolDecommissionInfo, PoolDiagnosticStatus, PoolStatus,
|
||||
};
|
||||
use super::super::storage_api::admin_usecase::capacity::{DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolStatus};
|
||||
use super::*;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
fn pool_diagnostic(
|
||||
pool: Option<PoolStatus>,
|
||||
availability: PoolAvailability,
|
||||
capacity_status: PoolCapacityStatus,
|
||||
) -> PoolAdminDiagnostic {
|
||||
PoolAdminDiagnostic {
|
||||
id: pool.as_ref().map_or(1, |pool| pool.id),
|
||||
cmd_line: pool
|
||||
.as_ref()
|
||||
.map_or_else(|| "pool-1".to_string(), |pool| pool.cmd_line.clone()),
|
||||
pool,
|
||||
diagnostic: PoolDiagnosticStatus {
|
||||
availability,
|
||||
capacity_status,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_pool_diagnostic_serializes_availability_and_freshness() {
|
||||
let item = DefaultAdminUsecase::pool_list_item_from_diagnostic(
|
||||
pool_diagnostic(
|
||||
Some(PoolStatus {
|
||||
id: 1,
|
||||
cmd_line: "pool-1".to_string(),
|
||||
last_update: OffsetDateTime::UNIX_EPOCH,
|
||||
decommission: None,
|
||||
}),
|
||||
PoolAvailability::Offline,
|
||||
PoolCapacityStatus::Cached,
|
||||
),
|
||||
(RebalStatus::Started, false),
|
||||
"stale",
|
||||
"unknown",
|
||||
OffsetDateTime::UNIX_EPOCH,
|
||||
);
|
||||
|
||||
assert_eq!(item.availability.as_deref(), Some("offline"));
|
||||
assert_eq!(item.pool_metadata_status.as_deref(), Some("stale"));
|
||||
assert_eq!(item.rebalance_metadata_status.as_deref(), Some("unknown"));
|
||||
assert_eq!(item.capacity_status.as_deref(), Some("cached"));
|
||||
assert_eq!(item.status, "unknown", "unverified rebalance state must not define lifecycle");
|
||||
assert_eq!(item.rebalance_status, "unknown");
|
||||
assert_eq!(item.observed_at, Some(OffsetDateTime::UNIX_EPOCH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_pool_diagnostic_without_metadata_never_fabricates_active_state() {
|
||||
let item = DefaultAdminUsecase::pool_list_item_from_diagnostic(
|
||||
pool_diagnostic(None, PoolAvailability::Unknown, PoolCapacityStatus::Unknown),
|
||||
(RebalStatus::None, false),
|
||||
"unknown",
|
||||
"unknown",
|
||||
OffsetDateTime::UNIX_EPOCH,
|
||||
);
|
||||
|
||||
assert_eq!(item.status, "unknown");
|
||||
assert_eq!(item.decommission_status, "unknown");
|
||||
assert_eq!(item.rebalance_status, "unknown");
|
||||
assert_eq!(item.total_size, 0);
|
||||
}
|
||||
use tracing_subscriber::{Layer, Registry, layer::Context, prelude::*};
|
||||
|
||||
struct RejectEvents;
|
||||
|
||||
@@ -631,14 +631,6 @@ async fn notify_bucket_metadata_reload(
|
||||
notify_bucket_metadata_reload_with_context(bucket, operation, request_context, scanner_maintenance_change, None).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn notify_table_bucket_metadata_reload(
|
||||
bucket: String,
|
||||
operation: &'static str,
|
||||
app_context: Option<Arc<AppContext>>,
|
||||
) {
|
||||
notify_bucket_metadata_reload_with_context(bucket, operation, None, false, app_context).await;
|
||||
}
|
||||
|
||||
async fn notify_bucket_metadata_reload_with_context(
|
||||
bucket: String,
|
||||
operation: &'static str,
|
||||
|
||||
@@ -73,6 +73,7 @@ use super::trailer_adapter::trailer_source;
|
||||
use crate::app::object::{
|
||||
ConcurrencyManager, ForegroundWriteAdmission, get_concurrency_manager, guard_put_object_body_read_timeout,
|
||||
put_object_body_read_timeout, reject_non_wildcard_write_if_none_match, reject_oversize_single_upload,
|
||||
reject_veeam_sosapi_mutation,
|
||||
};
|
||||
use crate::app::object_data_cache::{
|
||||
ObjectDataCacheAdapter, invalidate_object_data_cache_after_complete_multipart_success,
|
||||
@@ -542,6 +543,8 @@ impl DefaultMultipartUsecase {
|
||||
bucket, key, upload_id, ..
|
||||
} = req.input;
|
||||
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &key).await?;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
@@ -610,6 +613,7 @@ impl DefaultMultipartUsecase {
|
||||
..
|
||||
} = input;
|
||||
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &key).await?;
|
||||
reject_non_wildcard_write_if_none_match(if_none_match.as_ref(), &req.headers)?;
|
||||
validate_table_catalog_object_mutation(&bucket, &key).await?;
|
||||
|
||||
@@ -1020,6 +1024,7 @@ impl DefaultMultipartUsecase {
|
||||
return Err(s3_error!(InvalidStorageClass));
|
||||
}
|
||||
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &key).await?;
|
||||
validate_table_catalog_object_mutation(&bucket, &key).await?;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
@@ -1243,6 +1248,7 @@ impl DefaultMultipartUsecase {
|
||||
|
||||
let part_id = parse_upload_part_number(part_number)?;
|
||||
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &key).await?;
|
||||
validate_table_catalog_object_mutation(&bucket, &key).await?;
|
||||
|
||||
let size = resolve_upload_part_size(content_length, body.as_ref())?;
|
||||
@@ -1641,6 +1647,7 @@ impl DefaultMultipartUsecase {
|
||||
..
|
||||
} = req.input;
|
||||
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &key).await?;
|
||||
let params = parse_list_parts_params(part_number_marker, max_parts)?;
|
||||
|
||||
let Some(store) = self.object_store() else {
|
||||
@@ -1702,6 +1709,7 @@ impl DefaultMultipartUsecase {
|
||||
..
|
||||
} = req.input;
|
||||
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &key).await?;
|
||||
let (src_bucket, src_key, raw_src_version_id) = match copy_source {
|
||||
CopySource::AccessPoint { .. } => return Err(s3_error!(NotImplemented)),
|
||||
CopySource::Outpost { .. } => return Err(s3_error!(NotImplemented)),
|
||||
|
||||
@@ -281,6 +281,7 @@ impl DefaultObjectUsecase {
|
||||
// Validate both source and destination keys
|
||||
validate_object_key(&src_key, "COPY (source)")?;
|
||||
validate_object_key(&key, "COPY (dest)")?;
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &key).await?;
|
||||
validate_table_catalog_object_mutation(&bucket, &key).await?;
|
||||
let replaces_metadata = match metadata_directive.as_ref().map(|directive| directive.as_str()) {
|
||||
None | Some(MetadataDirective::COPY) => false,
|
||||
|
||||
@@ -1038,6 +1038,7 @@ impl DefaultObjectUsecase {
|
||||
|
||||
// Validate object key
|
||||
validate_object_key(&key, "DELETE")?;
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &key).await?;
|
||||
|
||||
let replica = req
|
||||
.headers
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
//! GetObject / GetObjectAttributes read path: cold fill, resume, stream tuning.
|
||||
|
||||
use super::veeam_sosapi;
|
||||
use super::*;
|
||||
use crate::app::storage_api::object_usecase::object_cache::{
|
||||
GetObjectReadCondition, GetObjectReadMetadata, evaluate_read_preconditions, has_read_preconditions,
|
||||
@@ -4145,6 +4146,46 @@ impl DefaultObjectUsecase {
|
||||
result
|
||||
}
|
||||
|
||||
async fn serve_veeam_sosapi_get(
|
||||
&self,
|
||||
req: &S3Request<GetObjectInput>,
|
||||
store: &Arc<ECStore>,
|
||||
kind: veeam_sosapi::SosapiKind,
|
||||
part_number: Option<usize>,
|
||||
range: Option<&HTTPRangeSpec>,
|
||||
) -> S3Result<S3Response<GetObjectOutput>> {
|
||||
let (full, etag_hex, last_modified) = veeam_sosapi::generated(self, store, &req.input.bucket, kind).await;
|
||||
check_generated_read_preconditions(&req.headers, &etag_hex, last_modified)?;
|
||||
let representation =
|
||||
veeam_sosapi::representation_from(full, etag_hex, last_modified, part_number, range).map_err(|err| match err {
|
||||
veeam_sosapi::SosapiReadError::InvalidRange => {
|
||||
S3Error::with_message(S3ErrorCode::InvalidRange, "The requested range is not satisfiable")
|
||||
}
|
||||
veeam_sosapi::SosapiReadError::PartNumber => {
|
||||
S3Error::with_message(S3ErrorCode::InvalidArgument, "partNumber is not supported for Veeam SOSAPI objects")
|
||||
}
|
||||
})?;
|
||||
let content_type = ContentType::from_str(veeam_sosapi::CONTENT_TYPE)
|
||||
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "invalid SOSAPI content type"))?;
|
||||
let ranged = representation.content_range.is_some();
|
||||
let output = GetObjectOutput {
|
||||
body: Some(StreamingBlob::from(s3s::Body::from(representation.body))),
|
||||
content_length: Some(representation.content_length),
|
||||
content_type: Some(content_type),
|
||||
content_range: representation.content_range,
|
||||
e_tag: Some(to_s3s_etag(&representation.etag_hex)),
|
||||
last_modified: Some(Timestamp::from(representation.last_modified)),
|
||||
accept_ranges: Some(ACCEPT_RANGES_BYTES.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut response = wrap_response_with_cors(&req.input.bucket, &req.method, &req.headers, output).await;
|
||||
inject_accept_ranges_header(&mut response.headers);
|
||||
if ranged {
|
||||
response.status = Some(StatusCode::PARTIAL_CONTENT);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn execute_get_object_inner(&self, mut req: S3Request<GetObjectInput>) -> S3Result<S3Response<GetObjectOutput>> {
|
||||
let helper = OperationHelper::new(&req, EventName::ObjectAccessedGet, S3Operation::GetObject).suppress_event();
|
||||
|
||||
@@ -4223,6 +4264,24 @@ impl DefaultObjectUsecase {
|
||||
}
|
||||
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BUCKET_VALIDATION, bucket_validation_start);
|
||||
|
||||
if let Some(kind) = veeam_sosapi::handled_kind(&validated.key) {
|
||||
let served = self
|
||||
.serve_veeam_sosapi_get(&req, &store, kind, validated.part_number, validated.rs.as_ref())
|
||||
.await;
|
||||
match served {
|
||||
Ok(response) => {
|
||||
lifecycle.finish_ok();
|
||||
let result = Ok(response);
|
||||
let _ = helper.complete(&result);
|
||||
return result;
|
||||
}
|
||||
Err(err) => {
|
||||
lifecycle.finish_err();
|
||||
return Self::complete_get_object_error(helper, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let request_context_start = stage_metrics_enabled.then(std::time::Instant::now);
|
||||
let request_context = match Self::prepare_get_object_request_context(validated, &req.headers).await {
|
||||
Ok(request_context) => request_context,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
//! HeadObject path.
|
||||
|
||||
use super::veeam_sosapi;
|
||||
use super::*;
|
||||
use crate::on_demand_migration::{
|
||||
BucketOdmState, HeadPolicy, OdmLookup, OdmOp, OdmOutcome, OnDemandMigrationSys, SourceClient, SourceError, SourceHead,
|
||||
@@ -298,6 +299,49 @@ impl DefaultObjectUsecase {
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_veeam_sosapi_head(
|
||||
&self,
|
||||
req: &S3Request<HeadObjectInput>,
|
||||
store: &Arc<ECStore>,
|
||||
kind: veeam_sosapi::SosapiKind,
|
||||
part_number: Option<usize>,
|
||||
range: Option<&HTTPRangeSpec>,
|
||||
) -> S3Result<S3Response<HeadObjectOutput>> {
|
||||
let (full, etag_hex, last_modified) = veeam_sosapi::generated(self, store, &req.input.bucket, kind).await;
|
||||
check_generated_read_preconditions(&req.headers, &etag_hex, last_modified)?;
|
||||
let representation =
|
||||
veeam_sosapi::representation_from(full, etag_hex, last_modified, part_number, range).map_err(|err| match err {
|
||||
veeam_sosapi::SosapiReadError::InvalidRange => {
|
||||
S3Error::with_message(S3ErrorCode::InvalidRange, "The requested range is not satisfiable")
|
||||
}
|
||||
veeam_sosapi::SosapiReadError::PartNumber => {
|
||||
S3Error::with_message(S3ErrorCode::InvalidArgument, "partNumber is not supported for Veeam SOSAPI objects")
|
||||
}
|
||||
})?;
|
||||
let content_type = ContentType::from_str(veeam_sosapi::CONTENT_TYPE)
|
||||
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "invalid SOSAPI content type"))?;
|
||||
let content_range = representation.content_range;
|
||||
let output = HeadObjectOutput {
|
||||
content_length: Some(representation.content_length),
|
||||
content_type: Some(content_type),
|
||||
accept_ranges: Some(ACCEPT_RANGES_BYTES.to_string()),
|
||||
last_modified: Some(Timestamp::from(representation.last_modified)),
|
||||
e_tag: Some(to_s3s_etag(&representation.etag_hex)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut response = wrap_response_with_cors(&req.input.bucket, &req.method, &req.headers, output).await;
|
||||
inject_accept_ranges_header(&mut response.headers);
|
||||
if let Some(content_range) = content_range {
|
||||
response.headers.insert(
|
||||
http::header::CONTENT_RANGE,
|
||||
HeaderValue::from_str(&content_range)
|
||||
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "invalid content range"))?,
|
||||
);
|
||||
response.status = Some(StatusCode::PARTIAL_CONTENT);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
pub async fn execute_head_object(&self, mut req: S3Request<HeadObjectInput>) -> S3Result<S3Response<HeadObjectOutput>> {
|
||||
if let Some(context) = &self.context {
|
||||
@@ -336,6 +380,15 @@ impl DefaultObjectUsecase {
|
||||
};
|
||||
validate_bucket_exists(&store, &bucket).await?;
|
||||
|
||||
if let Some(kind) = veeam_sosapi::handled_kind(&key) {
|
||||
let response = self
|
||||
.serve_veeam_sosapi_head(&req, &store, kind, part_number, rs.as_ref())
|
||||
.await?;
|
||||
let result = Ok(response);
|
||||
let _ = helper.complete(&result);
|
||||
return result;
|
||||
}
|
||||
|
||||
let opts: ObjectOptions = get_opts(&bucket, &key, version_id, part_number, &req.headers)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
|
||||
@@ -220,6 +220,7 @@ mod restore;
|
||||
pub(crate) mod shared;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
mod veeam_sosapi;
|
||||
|
||||
pub(crate) use self::copy::*;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1272,6 +1272,7 @@ impl DefaultObjectUsecase {
|
||||
format!("{RUSTFS_MAX_CONTENT_LENGTH_QUERY} is not supported for archive extraction"),
|
||||
));
|
||||
}
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), req.input.bucket.as_str(), req.input.key.as_str()).await?;
|
||||
if is_put_object_extract_requested(&req.headers) && !inbound_replication_put {
|
||||
return Box::pin(self.execute_put_object_extract(req)).await;
|
||||
}
|
||||
@@ -1505,6 +1506,8 @@ impl DefaultObjectUsecase {
|
||||
sse_customer_key_md5,
|
||||
} = sse;
|
||||
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), bucket.as_str(), key.as_str()).await?;
|
||||
|
||||
// The app check preserves the existing S3 error contract; the storage
|
||||
// commit path reserves the exact net logical growth under its locks.
|
||||
let quota_stage_start = put_stage_metrics_enabled.then(Instant::now);
|
||||
|
||||
@@ -164,6 +164,7 @@ impl DefaultObjectUsecase {
|
||||
..
|
||||
} = req.input.clone();
|
||||
|
||||
reject_veeam_sosapi_mutation(|| self.object_store(), &bucket, &object).await?;
|
||||
validate_table_catalog_object_mutation(&bucket, &object).await?;
|
||||
|
||||
// Typed S3 errors on every RestoreObject failure (backlog#2205): a
|
||||
|
||||
@@ -562,6 +562,29 @@ pub(super) fn inject_accept_ranges_header(headers: &mut HeaderMap) {
|
||||
headers.insert(http::header::ACCEPT_RANGES, HeaderValue::from_static(ACCEPT_RANGES_BYTES));
|
||||
}
|
||||
|
||||
/// Reject a mutation of a Veeam SOSAPI key while the feature is enabled.
|
||||
///
|
||||
/// The store closure runs only after the key matches, so ordinary object
|
||||
/// writes pay two string compares. Bucket existence is checked first so a
|
||||
/// missing bucket stays `NoSuchBucket`.
|
||||
pub(crate) async fn reject_veeam_sosapi_mutation(
|
||||
store: impl FnOnce() -> Option<Arc<ECStore>>,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
) -> S3Result<()> {
|
||||
if !super::veeam_sosapi::mutation_blocked(key) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(store) = store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
validate_bucket_exists(&store, bucket).await?;
|
||||
Err(S3Error::with_message(
|
||||
S3ErrorCode::MethodNotAllowed,
|
||||
"Veeam SOSAPI system objects are read-only",
|
||||
))
|
||||
}
|
||||
|
||||
/// Derive the response-header echo pairs for an additional-checksum algorithm
|
||||
/// (XXHash3/64/128, SHA-512) from the server-computed content checksum, for
|
||||
/// PutObject to echo back (#1256). Returns empty for the five s3s-typed algorithms
|
||||
@@ -1107,6 +1130,23 @@ pub(crate) fn odm_check_source_preconditions(headers: &HeaderMap, head: &SourceH
|
||||
check_preconditions(headers, &info)
|
||||
}
|
||||
|
||||
/// Read preconditions for a generated object. `etag_hex` and `last_modified`
|
||||
/// are the virtual response values; this does not load a stored object.
|
||||
pub(crate) fn check_generated_read_preconditions(
|
||||
headers: &HeaderMap,
|
||||
etag_hex: &str,
|
||||
last_modified: OffsetDateTime,
|
||||
) -> S3Result<()> {
|
||||
check_preconditions(
|
||||
headers,
|
||||
&ObjectInfo {
|
||||
etag: Some(etag_hex.to_owned()),
|
||||
mod_time: Some(last_modified),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// S3 documents write-side `If-None-Match` only as `*` (create-only).
|
||||
/// Reject unsupported ETag comparisons with 501 NotImplemented.
|
||||
/// Treating it as an ETag comparison would let a write overwrite an existing key
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,13 +70,6 @@ pub(crate) mod admin {
|
||||
|
||||
pub(crate) mod capacity {
|
||||
pub(crate) type DecommissionUnresolvedEntry = crate::storage::storage_api::ecstore_capacity::DecommissionUnresolvedEntry;
|
||||
pub(crate) type PoolAdminDiagnostic = crate::storage::storage_api::ecstore_capacity::PoolAdminDiagnostic;
|
||||
#[cfg(test)]
|
||||
pub(crate) type PoolAvailability = crate::storage::storage_api::ecstore_capacity::PoolAvailability;
|
||||
#[cfg(test)]
|
||||
pub(crate) type PoolCapacityStatus = crate::storage::storage_api::ecstore_capacity::PoolCapacityStatus;
|
||||
#[cfg(test)]
|
||||
pub(crate) type PoolDiagnosticStatus = crate::storage::storage_api::ecstore_capacity::PoolDiagnosticStatus;
|
||||
pub(crate) type PoolDecommissionInfo = crate::storage::storage_api::ecstore_capacity::PoolDecommissionInfo;
|
||||
pub(crate) type PoolMeta = crate::storage::storage_api::ecstore_capacity::PoolMeta;
|
||||
pub(crate) type PoolStatus = crate::storage::storage_api::ecstore_capacity::PoolStatus;
|
||||
@@ -107,7 +100,6 @@ pub(crate) mod data_usage {
|
||||
crate::storage::storage_api::ecstore_data_usage::load_admin_data_usage_from_backend_cached(store).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn load_data_usage_from_backend_cached(
|
||||
store: Arc<crate::storage::storage_api::ECStore>,
|
||||
) -> Result<rustfs_data_usage::DataUsageInfo, crate::storage::storage_api::StorageError> {
|
||||
@@ -679,8 +671,8 @@ pub(crate) mod bucket {
|
||||
pub(crate) type QuotaChecker = crate::storage::storage_api::ecstore_bucket::quota::checker::QuotaChecker;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) type BucketQuota = crate::storage::storage_api::ecstore_bucket::quota::BucketQuota;
|
||||
pub(crate) type QuotaType = crate::storage::storage_api::ecstore_bucket::quota::QuotaType;
|
||||
pub(crate) type QuotaOperation = crate::storage::storage_api::ecstore_bucket::quota::QuotaOperation;
|
||||
pub(crate) type QuotaCheckResult = crate::storage::storage_api::ecstore_bucket::quota::QuotaCheckResult;
|
||||
pub(crate) type QuotaError = crate::storage::storage_api::ecstore_bucket::quota::QuotaError;
|
||||
@@ -1239,7 +1231,7 @@ pub(crate) mod object_usecase {
|
||||
}
|
||||
|
||||
pub(crate) use super::{
|
||||
access, bucket, compression, concurrency, data_usage, deadlock_detector, ecfs, error, head_prefix, helper, io,
|
||||
access, bucket, capacity, compression, concurrency, data_usage, deadlock_detector, ecfs, error, head_prefix, helper, io,
|
||||
object_utils, options, request_context, s3_api, set_disk, sse, storage_class, timeout_wrapper,
|
||||
};
|
||||
pub(crate) use crate::storage::storage_api::{
|
||||
|
||||
@@ -185,17 +185,16 @@ pub(crate) async fn list_objects_v2(
|
||||
}
|
||||
|
||||
for page_index in 0..TABLE_LIST_MAX_RAW_PAGES {
|
||||
// Table cursors store literal object names, not ecstore continuation tokens.
|
||||
let raw = store
|
||||
.clone()
|
||||
.list_objects_v2(
|
||||
bucket,
|
||||
prefix,
|
||||
None,
|
||||
marker.clone(),
|
||||
None,
|
||||
TABLE_LIST_RAW_PAGE_SIZE,
|
||||
false,
|
||||
marker.clone().or_else(|| raw_start_after.take()),
|
||||
raw_start_after.take(),
|
||||
incl_deleted,
|
||||
)
|
||||
.await
|
||||
@@ -635,59 +634,6 @@ mod tests {
|
||||
use crate::app::storage_api::bucket_usecase::StorageObjectInfo as ObjectInfo;
|
||||
use crate::app::storage_api::multipart_usecase::contract::multipart::MultipartInfo;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn protected_table_list_resumes_after_a_literal_cache_shaped_key() {
|
||||
use super::{ListObjectsV2Request, list_objects_v2};
|
||||
use crate::app::storage_api::bucket_usecase::access::TableDataPlaneListAccess;
|
||||
use crate::app::storage_api::test::StoragePutObjReader;
|
||||
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
use crate::app::storage_api::test::contract::object::ObjectIO as _;
|
||||
|
||||
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
let bucket = format!("table-marker-{}", uuid::Uuid::new_v4().simple());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create test bucket");
|
||||
let keys = [
|
||||
"tables/table-id/a0",
|
||||
"tables/table-id/a1",
|
||||
"tables/table-id/a[rustfs_cache:v2,return:]",
|
||||
"tables/table-id/z",
|
||||
];
|
||||
for key in keys.into_iter().chain(["tables/zz-denied/hidden"]) {
|
||||
store
|
||||
.put_object(&bucket, key, &mut StoragePutObjReader::from_vec(vec![1]), &Default::default())
|
||||
.await
|
||||
.expect("seed object");
|
||||
}
|
||||
let access = TableDataPlaneListAccess::test_listing_fixture(&bucket);
|
||||
let mut token = None;
|
||||
for (index, key) in keys.into_iter().enumerate() {
|
||||
let page = list_objects_v2(
|
||||
store.clone(),
|
||||
&access,
|
||||
ListObjectsV2Request {
|
||||
bucket: &bucket,
|
||||
prefix: "",
|
||||
continuation_token: token.as_deref(),
|
||||
delimiter: None,
|
||||
max_keys: 1,
|
||||
start_after: None,
|
||||
incl_deleted: false,
|
||||
opaque_cursor_supported: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("list next protected page");
|
||||
assert_eq!(page.objects.iter().map(|object| object.name.as_str()).collect::<Vec<_>>(), [key]);
|
||||
assert_eq!(page.is_truncated, index + 1 < keys.len());
|
||||
token = page.next_continuation_token;
|
||||
}
|
||||
assert!(token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_list_entry_keeps_an_object_without_a_delimiter() {
|
||||
let object = ObjectInfo {
|
||||
|
||||
@@ -528,6 +528,7 @@ struct ConfigInfoJson {
|
||||
kms_backend: String,
|
||||
buffer_profile: String,
|
||||
workload_profile: Option<WorkloadProfileJson>,
|
||||
veeam_sosapi_enable: bool,
|
||||
}
|
||||
|
||||
/// Workload profile information for JSON output
|
||||
@@ -572,6 +573,10 @@ fn collect_config_info_json() -> ConfigInfoJson {
|
||||
kms_backend: snapshot.kms_backend.clone(),
|
||||
buffer_profile: snapshot.buffer_profile.clone(),
|
||||
workload_profile,
|
||||
veeam_sosapi_enable: rustfs_utils::get_env_bool(
|
||||
rustfs_config::ENV_VEEAM_SOSAPI_ENABLE,
|
||||
rustfs_config::DEFAULT_VEEAM_SOSAPI_ENABLE,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,6 +894,8 @@ fn format_protocol_config_info() -> String {
|
||||
rustfs_utils::get_env_u64(rustfs_config::ENV_WEBDAV_REQUEST_TIMEOUT, DEFAULT_WEBDAV_REQUEST_TIMEOUT_SECS);
|
||||
let webdav_max_connections =
|
||||
rustfs_utils::get_env_usize(rustfs_config::ENV_WEBDAV_MAX_CONNECTIONS, DEFAULT_WEBDAV_MAX_CONNECTIONS);
|
||||
let veeam_sosapi_enable =
|
||||
rustfs_utils::get_env_bool(rustfs_config::ENV_VEEAM_SOSAPI_ENABLE, rustfs_config::DEFAULT_VEEAM_SOSAPI_ENABLE);
|
||||
|
||||
format!(
|
||||
"| FTPS | --- |\n\
|
||||
@@ -909,7 +916,9 @@ fn format_protocol_config_info() -> String {
|
||||
| WebDAV > CA File (`{}`) | {} |\n\
|
||||
| WebDAV > Max Body Size (`{}`) | {} bytes |\n\
|
||||
| WebDAV > Request Timeout (`{}`) | {} seconds |\n\
|
||||
| WebDAV > Max Connections (`{}`) | {} |",
|
||||
| WebDAV > Max Connections (`{}`) | {} |\n\
|
||||
| Veeam SOSAPI | --- |\n\
|
||||
| Veeam SOSAPI > Enabled (`{}`) | {} |",
|
||||
if cfg!(feature = "ftps") { "enabled" } else { "disabled" },
|
||||
rustfs_config::ENV_FTPS_ENABLE,
|
||||
ftps_enable,
|
||||
@@ -941,7 +950,9 @@ fn format_protocol_config_info() -> String {
|
||||
rustfs_config::ENV_WEBDAV_REQUEST_TIMEOUT,
|
||||
webdav_request_timeout,
|
||||
rustfs_config::ENV_WEBDAV_MAX_CONNECTIONS,
|
||||
webdav_max_connections
|
||||
webdav_max_connections,
|
||||
rustfs_config::ENV_VEEAM_SOSAPI_ENABLE,
|
||||
veeam_sosapi_enable
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1137,5 +1148,6 @@ mod tests {
|
||||
assert!(output.contains("| WebDAV > Enabled (`RUSTFS_WEBDAV_ENABLE`) |"));
|
||||
assert!(output.contains("| WebDAV > Address (`RUSTFS_WEBDAV_ADDRESS`) |"));
|
||||
assert!(output.contains("| WebDAV > Max Body Size (`RUSTFS_WEBDAV_MAX_BODY_SIZE`) |"));
|
||||
assert!(output.contains("| Veeam SOSAPI > Enabled (`RUSTFS_VEEAM_SOSAPI_ENABLE`) |"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,12 +42,6 @@ use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
|
||||
|
||||
use crate::connect::DeviceIdentity;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "perf_drive/test_diagnostics.rs"]
|
||||
mod test_diagnostics;
|
||||
#[cfg(test)]
|
||||
use self::test_diagnostics::IoDiagnostic;
|
||||
|
||||
pub const DRIVE_SCHEMA_VERSION: u16 = 1;
|
||||
pub const DRIVE_TOOL_ID: &str = "performance.drive";
|
||||
pub const DRIVE_CAPABILITY: &str = "performance.drive@1";
|
||||
@@ -395,36 +389,13 @@ pub async fn measure_drive(
|
||||
let _lease = CollectorLease::acquire()?;
|
||||
let started = Instant::now();
|
||||
let deadline = tokio::time::Instant::now() + request.duration;
|
||||
#[cfg(test)]
|
||||
let scratch_diagnostic = IoDiagnostic::new("scratch_create", 0, 0, 0);
|
||||
#[cfg(test)]
|
||||
scratch_diagnostic.emit("start");
|
||||
let mut scratch = match ScratchGuard::create(request) {
|
||||
Ok(scratch) => scratch,
|
||||
Err(error) => {
|
||||
#[cfg(test)]
|
||||
scratch_diagnostic.emit("io_error");
|
||||
return Ok(failed_measurement(request, started.elapsed(), classify_io(&error), 0, 0, 0));
|
||||
}
|
||||
Err(error) => return Ok(failed_measurement(request, started.elapsed(), classify_io(&error), 0, 0, 0)),
|
||||
};
|
||||
#[cfg(test)]
|
||||
scratch_diagnostic.emit("complete");
|
||||
|
||||
let measured = run_benchmark(request, &scratch, cancel, deadline).await;
|
||||
#[cfg(test)]
|
||||
let cleanup_diagnostic = IoDiagnostic::new("scratch_cleanup", 0, 0, 0);
|
||||
#[cfg(test)]
|
||||
cleanup_diagnostic.emit("start");
|
||||
let cleanup = scratch.cleanup();
|
||||
#[cfg(test)]
|
||||
{
|
||||
cleanup_diagnostic.emit(if cleanup.is_ok() { "complete" } else { "io_error" });
|
||||
eprintln!(
|
||||
"connect_drive_total elapsed_us={} cleanup_succeeded={}",
|
||||
started.elapsed().as_micros(),
|
||||
cleanup.is_ok()
|
||||
);
|
||||
}
|
||||
let elapsed = started.elapsed().min(request.duration);
|
||||
if cleanup.is_err() {
|
||||
let (read_bytes, write_bytes, io_count) = match &measured {
|
||||
@@ -690,13 +661,7 @@ async fn run_benchmark(
|
||||
cancel: &CancellationToken,
|
||||
deadline: tokio::time::Instant,
|
||||
) -> Result<BenchmarkSample, BenchmarkFailure> {
|
||||
#[cfg(test)]
|
||||
let open_diagnostic = IoDiagnostic::new("file_open", 0, 0, 0);
|
||||
#[cfg(test)]
|
||||
open_diagnostic.emit("start");
|
||||
let std_file = scratch.open_file().map_err(BenchmarkFailure::Io)?;
|
||||
#[cfg(test)]
|
||||
open_diagnostic.emit("complete");
|
||||
let mut file = tokio::fs::File::from_std(std_file);
|
||||
let block_size = usize::try_from(request.block_bytes).map_err(|_| BenchmarkFailure::Verification)?;
|
||||
let write_buffer = vec![0xa5; block_size];
|
||||
@@ -706,36 +671,15 @@ async fn run_benchmark(
|
||||
let write_started = Instant::now();
|
||||
while remaining > 0 {
|
||||
let length = usize::try_from(remaining.min(request.block_bytes)).map_err(|_| BenchmarkFailure::Verification)?;
|
||||
cancellable(
|
||||
file.write_all(&write_buffer[..length]),
|
||||
cancel,
|
||||
deadline,
|
||||
#[cfg(test)]
|
||||
IoDiagnostic::new("write", 0, write_bytes, io_count),
|
||||
)
|
||||
.await?;
|
||||
cancellable(file.write_all(&write_buffer[..length]), cancel, deadline).await?;
|
||||
write_bytes = write_bytes.checked_add(length as u64).ok_or(BenchmarkFailure::Verification)?;
|
||||
io_count = io_count.checked_add(1).ok_or(BenchmarkFailure::Verification)?;
|
||||
remaining -= length as u64;
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
cancellable(
|
||||
file.sync_all(),
|
||||
cancel,
|
||||
deadline,
|
||||
#[cfg(test)]
|
||||
IoDiagnostic::new("sync_all", 0, write_bytes, io_count),
|
||||
)
|
||||
.await?;
|
||||
cancellable(file.sync_all(), cancel, deadline).await?;
|
||||
let write_latency = write_started.elapsed();
|
||||
cancellable(
|
||||
file.seek(std::io::SeekFrom::Start(0)),
|
||||
cancel,
|
||||
deadline,
|
||||
#[cfg(test)]
|
||||
IoDiagnostic::new("seek", 0, write_bytes, io_count),
|
||||
)
|
||||
.await?;
|
||||
cancellable(file.seek(std::io::SeekFrom::Start(0)), cancel, deadline).await?;
|
||||
|
||||
let mut read_buffer = vec![0_u8; block_size];
|
||||
remaining = request.scratch_bytes;
|
||||
@@ -743,14 +687,7 @@ async fn run_benchmark(
|
||||
let read_started = Instant::now();
|
||||
while remaining > 0 {
|
||||
let length = usize::try_from(remaining.min(request.block_bytes)).map_err(|_| BenchmarkFailure::Verification)?;
|
||||
cancellable(
|
||||
file.read_exact(&mut read_buffer[..length]),
|
||||
cancel,
|
||||
deadline,
|
||||
#[cfg(test)]
|
||||
IoDiagnostic::new("read", read_bytes, write_bytes, io_count),
|
||||
)
|
||||
.await?;
|
||||
cancellable(file.read_exact(&mut read_buffer[..length]), cancel, deadline).await?;
|
||||
if read_buffer[..length] != write_buffer[..length] {
|
||||
return Err(BenchmarkFailure::Verification);
|
||||
}
|
||||
@@ -777,26 +714,15 @@ async fn cancellable<T, F>(
|
||||
operation: F,
|
||||
cancel: &CancellationToken,
|
||||
deadline: tokio::time::Instant,
|
||||
#[cfg(test)] diagnostic: IoDiagnostic,
|
||||
) -> Result<T, BenchmarkFailure>
|
||||
where
|
||||
F: std::future::Future<Output = std::io::Result<T>>,
|
||||
{
|
||||
let result = tokio::select! {
|
||||
tokio::select! {
|
||||
() = cancel.cancelled() => Err(BenchmarkFailure::Cancelled),
|
||||
() = tokio::time::sleep_until(deadline) => Err(BenchmarkFailure::TimedOut),
|
||||
result = operation => result.map_err(BenchmarkFailure::Io),
|
||||
};
|
||||
#[cfg(test)]
|
||||
if let Err(error) = &result {
|
||||
diagnostic.emit(match error {
|
||||
BenchmarkFailure::Cancelled => "cancelled",
|
||||
BenchmarkFailure::TimedOut => "timed_out",
|
||||
BenchmarkFailure::Verification => "verification_failed",
|
||||
BenchmarkFailure::Io(_) => "io_error",
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
struct ScratchGuard {
|
||||
|
||||
@@ -1,84 +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.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
pub(super) struct IoDiagnostic {
|
||||
phase: &'static str,
|
||||
started: Instant,
|
||||
read_bytes: u64,
|
||||
write_bytes: u64,
|
||||
io_count: u64,
|
||||
}
|
||||
|
||||
impl IoDiagnostic {
|
||||
pub(super) fn new(phase: &'static str, read_bytes: u64, write_bytes: u64, io_count: u64) -> Self {
|
||||
Self {
|
||||
phase,
|
||||
started: Instant::now(),
|
||||
read_bytes,
|
||||
write_bytes,
|
||||
io_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn line(&self, outcome: &'static str) -> String {
|
||||
format!(
|
||||
"connect_drive_phase phase={} outcome={} elapsed_us={} completed_read_bytes={} completed_write_bytes={} completed_io_count={}",
|
||||
self.phase,
|
||||
outcome,
|
||||
self.started.elapsed().as_micros(),
|
||||
self.read_bytes,
|
||||
self.write_bytes,
|
||||
self.io_count,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn emit(&self, outcome: &'static str) {
|
||||
eprintln!("{}", self.line(outcome));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::IoDiagnostic;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn timeout_retains_completed_io_progress() {
|
||||
let diagnostic = IoDiagnostic::new("read", 4_096, 32_768, 9);
|
||||
let line = diagnostic.line("timed_out");
|
||||
let fields: std::collections::BTreeMap<_, _> =
|
||||
line.split_whitespace().skip(1).map(|f| f.split_once('=').unwrap()).collect();
|
||||
assert_eq!(fields["phase"], "read");
|
||||
assert_eq!(fields["outcome"], "timed_out");
|
||||
assert_eq!(fields["completed_read_bytes"], "4096");
|
||||
assert_eq!(fields["completed_write_bytes"], "32768");
|
||||
assert_eq!(fields["completed_io_count"], "9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_elapsed_is_not_clamped_to_request_budget() {
|
||||
let mut diagnostic = IoDiagnostic::new("sync_all", 0, 32_768, 8);
|
||||
diagnostic.started -= Duration::from_secs(2);
|
||||
let line = diagnostic.line("timed_out");
|
||||
let micros = line
|
||||
.split_whitespace()
|
||||
.find_map(|f| f.strip_prefix("elapsed_us="))
|
||||
.unwrap()
|
||||
.parse::<u128>()
|
||||
.unwrap();
|
||||
assert!(micros >= 2_000_000);
|
||||
}
|
||||
}
|
||||
@@ -100,19 +100,14 @@ fn trace_protocol_request(operation: &str, bucket: Option<&str>, object: Option<
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
#[cfg(feature = "webdav")]
|
||||
fn session_list_buckets_request(
|
||||
input: ListBucketsInput,
|
||||
session_context: &SessionContext,
|
||||
request_headers: &HeaderMap,
|
||||
secure_transport: bool,
|
||||
credentials: rustfs_credentials::Credentials,
|
||||
is_owner: bool,
|
||||
) -> S3Request<ListBucketsInput> {
|
||||
let request_credentials = s3s::auth::Credentials {
|
||||
access_key: credentials.access_key.clone(),
|
||||
secret_key: credentials.secret_key.clone().into(),
|
||||
};
|
||||
let credentials = &session_context.principal.user_identity.credentials;
|
||||
let mut extensions = http::Extensions::default();
|
||||
let remote_addr = std::net::SocketAddr::new(session_context.source_ip, 0);
|
||||
extensions.insert(Some(RemoteAddr(remote_addr)));
|
||||
@@ -120,8 +115,9 @@ fn session_list_buckets_request(
|
||||
client_info.forwarded_proto = Some(if secure_transport { "https" } else { "http" }.to_string());
|
||||
extensions.insert(client_info);
|
||||
|
||||
let is_owner = current_action_credentials().is_some_and(|global_cred| credentials.access_key == global_cred.access_key);
|
||||
extensions.insert(ReqInfo {
|
||||
cred: Some(credentials),
|
||||
cred: Some(credentials.clone()),
|
||||
is_owner,
|
||||
bucket: None,
|
||||
object: None,
|
||||
@@ -138,7 +134,10 @@ fn session_list_buckets_request(
|
||||
uri: http::Uri::from_static("/"),
|
||||
headers: request_headers.clone(),
|
||||
extensions,
|
||||
credentials: Some(request_credentials),
|
||||
credentials: Some(s3s::auth::Credentials {
|
||||
access_key: credentials.access_key.clone(),
|
||||
secret_key: credentials.secret_key.clone().into(),
|
||||
}),
|
||||
region: None,
|
||||
service: None,
|
||||
trailing_headers: None,
|
||||
@@ -634,7 +633,7 @@ impl rustfs_protocols::common::client::s3::StorageBackend for ProtocolStorageCli
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
#[cfg(feature = "webdav")]
|
||||
async fn list_buckets_for_session(
|
||||
&self,
|
||||
session_context: &SessionContext,
|
||||
@@ -653,23 +652,7 @@ impl rustfs_protocols::common::client::s3::StorageBackend for ProtocolStorageCli
|
||||
let input = ListBucketsInput::builder().build().map_err(|e| {
|
||||
s3s::S3Error::with_message(s3s::S3ErrorCode::InvalidRequest, format!("Failed to build ListBucketsInput: {}", e))
|
||||
})?;
|
||||
let context = self.fs.server_ctx().installed_app_context().ok_or_else(|| {
|
||||
s3s::S3Error::with_message(s3s::S3ErrorCode::InternalError, "protocol server application context is not initialized")
|
||||
})?;
|
||||
// The session identity is captured at login. Re-check the key as
|
||||
// authorize_protocol_request does for every other operation, so a key
|
||||
// disabled or expired since then stops listing buckets. An invalid key
|
||||
// is an access denial, as in authorize_operation.
|
||||
let (credentials, is_owner) =
|
||||
crate::auth::check_key_valid_with_context("", session_context.access_key(), Some(context.as_ref()))
|
||||
.await
|
||||
.map_err(|e| match e.code() {
|
||||
s3s::S3ErrorCode::InvalidAccessKeyId => s3s::S3Error::new(s3s::S3ErrorCode::AccessDenied),
|
||||
_ => e,
|
||||
})?;
|
||||
let mut request =
|
||||
session_list_buckets_request(input, session_context, request_headers, secure_transport, credentials, is_owner);
|
||||
request.extensions.insert(self.fs.server_ctx().clone());
|
||||
let request = session_list_buckets_request(input, session_context, request_headers, secure_transport);
|
||||
self.fs.list_buckets(request).await.map(|response| response.output)
|
||||
}
|
||||
|
||||
@@ -684,9 +667,11 @@ impl rustfs_protocols::common::client::s3::StorageBackend for ProtocolStorageCli
|
||||
) -> Result<Option<SessionCapacityView>, Self::Error> {
|
||||
// List buckets under the session's own authorization so the view
|
||||
// never covers buckets the caller cannot see.
|
||||
let listing = self
|
||||
.list_buckets_for_session(session_context, request_headers, secure_transport)
|
||||
.await?;
|
||||
let input = ListBucketsInput::builder().build().map_err(|e| {
|
||||
s3s::S3Error::with_message(s3s::S3ErrorCode::InvalidRequest, format!("Failed to build ListBucketsInput: {}", e))
|
||||
})?;
|
||||
let request = session_list_buckets_request(input, session_context, request_headers, secure_transport);
|
||||
let listing = self.fs.list_buckets(request).await?.output;
|
||||
|
||||
let store = current_object_store_handle();
|
||||
let listed = listing.buckets.unwrap_or_default();
|
||||
@@ -1145,7 +1130,7 @@ mod tests {
|
||||
assert!(copied.is_service_account());
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
#[cfg(feature = "webdav")]
|
||||
#[test]
|
||||
fn request_extensions_preserve_authenticated_identity_and_source_ip() {
|
||||
use std::collections::HashMap;
|
||||
@@ -1164,26 +1149,14 @@ mod tests {
|
||||
let source_ip = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10));
|
||||
|
||||
let identity = rustfs_policy::auth::UserIdentity {
|
||||
credentials: rustfs_credentials::Credentials {
|
||||
secret_key: "stale-secret".to_string(),
|
||||
groups: None,
|
||||
claims: None,
|
||||
..credentials.clone()
|
||||
},
|
||||
credentials: credentials.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let principal = rustfs_protocols::common::ProtocolPrincipal::new(std::sync::Arc::new(identity));
|
||||
let session_context = SessionContext::new(principal, rustfs_protocols::Protocol::WebDav, source_ip);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("user-agent", http::HeaderValue::from_static("webdav-client"));
|
||||
let request = session_list_buckets_request(
|
||||
ListBucketsInput::default(),
|
||||
&session_context,
|
||||
&headers,
|
||||
true,
|
||||
credentials.clone(),
|
||||
true,
|
||||
);
|
||||
let request = session_list_buckets_request(ListBucketsInput::default(), &session_context, &headers, true);
|
||||
let request_info = request.extensions.get::<ReqInfo>().expect("request info should be present");
|
||||
let copied = request_info.cred.as_ref().expect("credentials should be present");
|
||||
let remote_addr = request
|
||||
@@ -1199,18 +1172,12 @@ mod tests {
|
||||
assert_eq!(copied.parent_user, credentials.parent_user);
|
||||
assert_eq!(copied.groups, credentials.groups);
|
||||
assert_eq!(copied.claims, Some(claims));
|
||||
assert!(request_info.is_owner, "ownership comes from the server's credential validation");
|
||||
let request_credentials = request.credentials.as_ref().expect("request credentials");
|
||||
assert_eq!(request_credentials.access_key, credentials.access_key);
|
||||
assert_eq!(request_credentials.secret_key.expose(), credentials.secret_key);
|
||||
assert_eq!(remote_addr.0.ip(), source_ip);
|
||||
assert_eq!(client_info.real_ip, source_ip);
|
||||
assert_eq!(client_info.forwarded_proto.as_deref(), Some("https"));
|
||||
assert_eq!(request.headers.get("user-agent").expect("user agent"), "webdav-client");
|
||||
|
||||
let insecure_request =
|
||||
session_list_buckets_request(ListBucketsInput::default(), &session_context, &headers, false, credentials, false);
|
||||
assert!(!insecure_request.extensions.get::<ReqInfo>().expect("request info").is_owner);
|
||||
let insecure_request = session_list_buckets_request(ListBucketsInput::default(), &session_context, &headers, false);
|
||||
let insecure_client_info = insecure_request
|
||||
.extensions
|
||||
.get::<ClientInfo>()
|
||||
|
||||
@@ -679,221 +679,3 @@ fn protocol_object_creation_operation_preserves_multipart_staging() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "webdav", feature = "sftp"))]
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn protocol_session_bucket_listing_revalidates_the_session_key() {
|
||||
use crate::storage_api::protocols::client::S3ErrorCode;
|
||||
|
||||
crate::app::gating_test_env::run_large_stack_test("protocol-session-bucket-listing", || async {
|
||||
let root = Credentials {
|
||||
access_key: "listing-policy-root".into(),
|
||||
secret_key: "listing-policy-root-secret".into(),
|
||||
status: "on".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let (_temp, context, iam) = protocol_test_context(root).await;
|
||||
let store = context.object_store();
|
||||
let slot = ServerContextSlot::new();
|
||||
assert!(slot.install(context));
|
||||
let backend = crate::init::protocol_storage_client_for_server(slot);
|
||||
for bucket in ["listing-visible", "listing-hidden"] {
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
}
|
||||
iam.create_user(
|
||||
"listing-parent",
|
||||
&rustfs_madmin::AddOrUpdateUserReq {
|
||||
secret_key: "listing-parent-secret".into(),
|
||||
policy: None,
|
||||
status: rustfs_madmin::AccountStatus::Enabled,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create parent user");
|
||||
let mut allow = json!({
|
||||
"Effect":"Allow", "Action":["s3:ListBucket","s3:GetBucketLocation"],
|
||||
"Resource":"arn:aws:s3:::listing-visible",
|
||||
"Condition":{
|
||||
"IpAddress":{"aws:SourceIp":"127.0.0.0/8"},
|
||||
"Bool":{"aws:SecureTransport":"true"},
|
||||
"StringEquals":{"aws:UserAgent":"sftp-listing-test"}
|
||||
}
|
||||
});
|
||||
iam.set_policy(
|
||||
"listing-policy",
|
||||
decode_policy(json!({"Version":"2012-10-17","Statement":[allow.clone()]})).expect("parse listing policy"),
|
||||
)
|
||||
.await
|
||||
.expect("save listing policy");
|
||||
iam.policy_db_set("listing-parent", UserType::Reg, false, "listing-policy")
|
||||
.await
|
||||
.expect("attach listing policy");
|
||||
iam.new_service_account(
|
||||
"listing-parent",
|
||||
None,
|
||||
rustfs_iam::sys::NewServiceAccountOpts {
|
||||
access_key: "listing-service-account".into(),
|
||||
secret_key: "listing-service-account-secret".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create service account");
|
||||
let identity = iam
|
||||
.check_key("listing-service-account")
|
||||
.await
|
||||
.expect("load service account")
|
||||
.0
|
||||
.expect("service account exists");
|
||||
// The identity is captured once, at login, like a protocol session does.
|
||||
let mut session = SessionContext::new(
|
||||
ProtocolPrincipal::new(Arc::new(identity)),
|
||||
Protocol::Sftp,
|
||||
"127.0.0.1".parse().expect("loopback"),
|
||||
);
|
||||
session
|
||||
.request_headers
|
||||
.insert("user-agent", "sftp-listing-test".parse().expect("user agent"));
|
||||
let list = || backend.list_buckets_for_session(&session, &session.request_headers, session.secure_transport);
|
||||
|
||||
let visible: Vec<_> = list()
|
||||
.await
|
||||
.expect("bucket-scoped listing")
|
||||
.buckets
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|b| b.name)
|
||||
.collect();
|
||||
assert_eq!(visible, ["listing-visible"], "listing is filtered to buckets the principal may list");
|
||||
|
||||
let mut insecure = session.clone();
|
||||
insecure.secure_transport = false;
|
||||
let mut foreign_ip = session.clone();
|
||||
foreign_ip.source_ip = "192.0.2.10".parse().expect("documentation address");
|
||||
let mut missing_header = session.clone();
|
||||
missing_header.request_headers.remove("user-agent");
|
||||
for denied_session in [insecure, foreign_ip, missing_header] {
|
||||
let result = backend
|
||||
.list_buckets_for_session(&denied_session, &denied_session.request_headers, denied_session.secure_transport)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(&result, Err(e) if *e.code() == S3ErrorCode::AccessDenied),
|
||||
"bucket discovery must enforce the session's request conditions: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
allow["Resource"] = json!(["arn:aws:s3:::listing-visible", "arn:aws:s3:::listing-hidden"]);
|
||||
iam.set_policy(
|
||||
"listing-policy",
|
||||
decode_policy(json!({"Version":"2012-10-17","Statement":[allow]})).expect("parse parent policy"),
|
||||
)
|
||||
.await
|
||||
.expect("allow the parent to list both buckets");
|
||||
for bucket in ["listing-visible", "listing-hidden"] {
|
||||
let session_policy = decode_policy(json!({
|
||||
"Version":"2012-10-17",
|
||||
"Statement":[{
|
||||
"Effect":"Allow", "Action":["s3:ListBucket","s3:GetBucketLocation"],
|
||||
"Resource":format!("arn:aws:s3:::{bucket}")
|
||||
}]
|
||||
}))
|
||||
.expect("parse service-account policy");
|
||||
iam.update_service_account(
|
||||
"listing-service-account",
|
||||
rustfs_iam::sys::UpdateServiceAccountOpts {
|
||||
session_policy: Some(session_policy),
|
||||
secret_key: None,
|
||||
name: None,
|
||||
description: None,
|
||||
expiration: None,
|
||||
status: None,
|
||||
parent_user: None,
|
||||
allow_site_replicator_account: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("update the service-account policy after login");
|
||||
let visible: Vec<_> = list()
|
||||
.await
|
||||
.expect("listing with the refreshed session policy")
|
||||
.buckets
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|b| b.name)
|
||||
.collect();
|
||||
assert_eq!(visible, [bucket], "listing must use the current service-account claims");
|
||||
}
|
||||
|
||||
iam.update_service_account(
|
||||
"listing-service-account",
|
||||
rustfs_iam::sys::UpdateServiceAccountOpts {
|
||||
session_policy: None,
|
||||
secret_key: None,
|
||||
name: None,
|
||||
description: None,
|
||||
expiration: None,
|
||||
status: Some("off".into()),
|
||||
parent_user: None,
|
||||
allow_site_replicator_account: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("disable service account");
|
||||
|
||||
let after_disable = list().await;
|
||||
assert!(
|
||||
matches!(&after_disable, Err(e) if *e.code() == S3ErrorCode::AccessDenied),
|
||||
"a key disabled after login must stop listing buckets: {after_disable:?}"
|
||||
);
|
||||
|
||||
#[cfg(feature = "webdav")]
|
||||
{
|
||||
let mut webdav_session = session.clone();
|
||||
webdav_session.protocol = Protocol::WebDav;
|
||||
let capacity = backend
|
||||
.session_capacity_view(&webdav_session, &webdav_session.request_headers, webdav_session.secure_transport)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(&capacity, Err(e) if *e.code() == S3ErrorCode::AccessDenied),
|
||||
"quota discovery must revalidate the session key before reading capacity: {capacity:?}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "webdav")]
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn protocol_capacity_view_uses_server_context_for_empty_listing() {
|
||||
crate::app::gating_test_env::run_large_stack_test("protocol-capacity-listing-context", || async {
|
||||
let root = Credentials {
|
||||
access_key: "capacity-listing-root".into(),
|
||||
secret_key: "capacity-listing-root-secret".into(),
|
||||
status: "on".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let (_temp, context, _iam) = protocol_test_context(root.clone()).await;
|
||||
let slot = ServerContextSlot::new();
|
||||
assert!(slot.install(context));
|
||||
let backend = crate::init::protocol_storage_client_for_server(slot);
|
||||
let session = SessionContext::new(
|
||||
ProtocolPrincipal::new(Arc::new(rustfs_policy::auth::UserIdentity {
|
||||
credentials: root,
|
||||
..Default::default()
|
||||
})),
|
||||
Protocol::WebDav,
|
||||
"127.0.0.1".parse().expect("loopback"),
|
||||
);
|
||||
|
||||
let capacity = backend
|
||||
.session_capacity_view(&session, &session.request_headers, session.secure_transport)
|
||||
.await
|
||||
.expect("capacity discovery uses the server's IAM context")
|
||||
.expect("capacity view is supported");
|
||||
assert!(capacity.buckets.is_empty(), "a server with no user buckets has an empty capacity view");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -241,30 +241,6 @@ impl TableDataPlaneListCursor {
|
||||
}
|
||||
|
||||
impl TableDataPlaneListAccess {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_listing_fixture(bucket: &str) -> Self {
|
||||
let resource = |table: &str, prefix: &str, allowed| TableDataPlaneListResource {
|
||||
resource: crate::table_catalog::TableDataPlaneResource {
|
||||
table_bucket: bucket.to_owned(),
|
||||
namespace: "test".to_owned(),
|
||||
table: table.to_owned(),
|
||||
table_id: table.to_owned(),
|
||||
warehouse_object_prefix: prefix.to_owned(),
|
||||
},
|
||||
allowed,
|
||||
};
|
||||
Self {
|
||||
bucket: bucket.to_owned(),
|
||||
principal_fingerprint: "principal".to_owned(),
|
||||
snapshot_fingerprint: "snapshot".to_owned(),
|
||||
cursor_key: [0x42; 32],
|
||||
resources: vec![
|
||||
resource("table-id", "tables/table-id/", true),
|
||||
resource("denied-id", "tables/zz-denied/", false),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn allows_object(&self, object: &str) -> bool {
|
||||
if crate::table_catalog::is_reserved_table_object_key(object) {
|
||||
return false;
|
||||
|
||||
@@ -393,6 +393,10 @@ pub(crate) fn parse_object_version_id(version_id: Option<String>) -> S3Result<Op
|
||||
}
|
||||
}
|
||||
|
||||
async fn reject_veeam_sosapi_mutation(fs: &FS, bucket: &str, key: &str) -> S3Result<()> {
|
||||
s3_api::reject_veeam_sosapi_mutation(|| fs.server_ctx.object_store(), bucket, key).await
|
||||
}
|
||||
|
||||
async fn validate_table_catalog_object_mutation(bucket: &str, key: &str) -> S3Result<()> {
|
||||
table_catalog::validate_bucket_object_mutation(bucket, key)
|
||||
.await
|
||||
@@ -663,6 +667,7 @@ impl S3 for FS {
|
||||
..
|
||||
} = req.input.clone();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, &bucket, &object).await?;
|
||||
validate_table_catalog_object_mutation(&bucket, &object).await?;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
@@ -1004,6 +1009,8 @@ impl S3 for FS {
|
||||
bucket, key, version_id, ..
|
||||
} = req.input.clone();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, &bucket, &key).await?;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
@@ -1113,6 +1120,8 @@ impl S3 for FS {
|
||||
bucket, key, version_id, ..
|
||||
} = req.input.clone();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, &bucket, &key).await?;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
@@ -1156,6 +1165,8 @@ impl S3 for FS {
|
||||
let bucket = req.input.bucket.as_str();
|
||||
let object = req.input.key.as_str();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, bucket, object).await?;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
error!(
|
||||
component = LOG_COMPONENT_STORAGE,
|
||||
@@ -1564,6 +1575,8 @@ impl S3 for FS {
|
||||
let key = &req.input.key;
|
||||
let version_id = req.input.version_id.clone();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, bucket, key).await?;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
||||
};
|
||||
@@ -1604,6 +1617,7 @@ impl S3 for FS {
|
||||
..
|
||||
} = req.input.clone();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, &bucket, &key).await?;
|
||||
validate_table_catalog_object_mutation(&bucket, &key).await?;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
@@ -1806,6 +1820,7 @@ impl S3 for FS {
|
||||
..
|
||||
} = req.input.clone();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, &bucket, &key).await?;
|
||||
validate_table_catalog_object_mutation(&bucket, &key).await?;
|
||||
|
||||
let Some(store) = self.server_ctx.object_store() else {
|
||||
@@ -1923,6 +1938,7 @@ impl S3 for FS {
|
||||
..
|
||||
} = req.input.clone();
|
||||
|
||||
reject_veeam_sosapi_mutation(self, &bucket, &object).await?;
|
||||
validate_table_catalog_object_mutation(&bucket, &object).await?;
|
||||
|
||||
crate::storage::s3_api::tagging::validate_object_tag_set(&tagging.tag_set)?;
|
||||
|
||||
@@ -391,21 +391,16 @@ fn calculate_next_marker(v2: &ListObjectsV2Output) -> Option<String> {
|
||||
// The token was built by this server around the internal cursor, which is
|
||||
// the last raw key the walker scanned: on a delimiter page that folded into
|
||||
// fewer prefixes than max-keys it lies past the last visible entry.
|
||||
if let Some(mut marker) = v2
|
||||
if let Some(marker) = v2
|
||||
.next_continuation_token
|
||||
.as_deref()
|
||||
.and_then(|token| decode_list_objects_v2_continuation_token(token).ok())
|
||||
{
|
||||
// V2 cursors protect literal cache-shaped keys with an outer tag. V1
|
||||
// exposes the key itself, which is sent back as a literal StartAfter.
|
||||
// RUSTFS_COMPAT_TODO(listing-cursor-legacy-tag): Remove after supported upgrade sources stop issuing tagged V2 cursors.
|
||||
let unescaped_len = marker
|
||||
.strip_suffix("[rustfs_cache:v2,return:]")
|
||||
.filter(|key| key.rfind("[rustfs_cache:").is_some_and(|start| key[start..].contains(']')))
|
||||
.map(str::len);
|
||||
if let Some(len) = unescaped_len {
|
||||
marker.truncate(len);
|
||||
}
|
||||
// Keep the escape that wraps cache-shaped literal keys: a V1 client
|
||||
// sends NextMarker back as `marker`, and the listing layer strips one
|
||||
// legacy cursor tag from it while that compatibility window is open.
|
||||
// Unwrapping it here would make the next page restart at the key's
|
||||
// stem and return the same key forever.
|
||||
return Some(encode_list_output_value(marker, v2.encoding_type.as_ref()));
|
||||
}
|
||||
|
||||
@@ -592,35 +587,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_objects_next_marker_returns_the_literal_key() {
|
||||
for key in [
|
||||
"key[rustfs_cache:v2,return:]",
|
||||
"key[rustfs_cache:v1,id:x]suffix",
|
||||
"key[rustfs_cache:v1,id:x][rustfs_cache:v2,return:]",
|
||||
] {
|
||||
let cursor = format!("{key}[rustfs_cache:v2,return:]");
|
||||
for encoding in [None, Some(EncodingType::from_static(EncodingType::URL))] {
|
||||
let v2 = ListObjectsV2Output {
|
||||
is_truncated: Some(true),
|
||||
encoding_type: encoding.clone(),
|
||||
next_continuation_token: Some(encode_list_objects_v2_continuation_token(&cursor)),
|
||||
..Default::default()
|
||||
};
|
||||
let expected = super::encode_list_output_value(key.to_owned(), encoding.as_ref());
|
||||
assert_eq!(build_list_objects_output(v2, None).next_marker.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_objects_next_marker_preserves_an_unescaped_literal_return_tag() {
|
||||
fn test_list_objects_next_marker_keeps_the_literal_key_escape() {
|
||||
let key = "key[rustfs_cache:v2,return:]";
|
||||
let cursor = format!("{key}[rustfs_cache:v2,return:]");
|
||||
let v2 = ListObjectsV2Output {
|
||||
is_truncated: Some(true),
|
||||
next_continuation_token: Some(encode_list_objects_v2_continuation_token(key)),
|
||||
next_continuation_token: Some(encode_list_objects_v2_continuation_token(&cursor)),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(build_list_objects_output(v2, None).next_marker.as_deref(), Some(key));
|
||||
|
||||
// The client sends this back as `marker`; the listing layer strips the
|
||||
// outer escape and resumes after the literal key.
|
||||
assert_eq!(build_list_objects_output(v2, None).next_marker.as_deref(), Some(cursor.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
//! This file intentionally starts as skeleton-only. Behavior remains in place
|
||||
//! until each helper is moved with dedicated small refactor steps.
|
||||
|
||||
pub(crate) use crate::app::object::s3_response_version_id;
|
||||
pub(crate) use crate::app::object::{reject_veeam_sosapi_mutation, s3_response_version_id};
|
||||
use crate::app::{
|
||||
bucket_usecase::DefaultBucketUsecase, multipart_usecase::DefaultMultipartUsecase, object_usecase::DefaultObjectUsecase,
|
||||
};
|
||||
|
||||
@@ -441,11 +441,9 @@ pub(crate) mod ecstore_bucket {
|
||||
|
||||
pub(crate) mod ecstore_capacity {
|
||||
pub(crate) use rustfs_ecstore::api::capacity::{
|
||||
DecommissionUnresolvedEntry, PoolAdminDiagnostic, PoolDecommissionInfo, PoolMeta, PoolStatus, get_total_usable_capacity,
|
||||
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolMeta, PoolStatus, get_total_usable_capacity,
|
||||
get_total_usable_capacity_free, is_pool_activation_fleet_proof_error, is_reserved_or_invalid_bucket,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::capacity::{PoolAvailability, PoolCapacityStatus, PoolDiagnosticStatus};
|
||||
}
|
||||
|
||||
pub(crate) mod ecstore_client {
|
||||
@@ -477,16 +475,15 @@ pub(crate) mod ecstore_config {
|
||||
pub(crate) mod ecstore_data_usage {
|
||||
pub(crate) use rustfs_ecstore::api::data_usage::{
|
||||
apply_bucket_usage_memory_overlay, get_bucket_usage_memory, init_compression_total_memory_from_backend,
|
||||
load_admin_data_usage_from_backend_cached, load_data_usage_from_backend, lookup_degraded_bucket_usage_baseline,
|
||||
quota_object_size, record_bucket_delete_marker_memory, record_bucket_object_delete_memory,
|
||||
record_bucket_object_version_write_memory, record_bucket_object_write_memory,
|
||||
load_admin_data_usage_from_backend_cached, load_data_usage_from_backend, load_data_usage_from_backend_cached,
|
||||
lookup_degraded_bucket_usage_baseline, quota_object_size, record_bucket_delete_marker_memory,
|
||||
record_bucket_object_delete_memory, record_bucket_object_version_write_memory, record_bucket_object_write_memory,
|
||||
record_bucket_object_write_unknown_previous_memory, store_compression_total_in_backend,
|
||||
};
|
||||
// Test-only observables for the rustfs/backlog#1306 revert detector.
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::data_usage::{
|
||||
compute_bucket_usage, live_bucket_usage_computations, load_data_usage_from_backend_cached,
|
||||
seed_bucket_usage_memory_for_test, store_data_usage_in_backend,
|
||||
compute_bucket_usage, live_bucket_usage_computations, seed_bucket_usage_memory_for_test, store_data_usage_in_backend,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -143,8 +143,6 @@ pub(crate) mod protocols {
|
||||
pub(crate) use crate::storage::storage_api::{StorageObjectOptions, StoragePutObjReader};
|
||||
pub(crate) type FS = crate::storage::storage_api::FS;
|
||||
pub(crate) use crate::storage::storage_api::request_context_consumer::RequestContext;
|
||||
#[cfg(all(test, any(feature = "webdav", feature = "sftp")))]
|
||||
pub(crate) use s3s::S3ErrorCode;
|
||||
#[cfg(test)]
|
||||
pub(crate) use s3s::dto::ListObjectsV2Input;
|
||||
|
||||
|
||||
@@ -45,9 +45,9 @@
|
||||
3|crates/ecstore/src/runtime/sources.rs
|
||||
4|crates/ecstore/src/services/batch_processor.rs
|
||||
12|crates/ecstore/src/services/notification_sys.rs
|
||||
15|crates/ecstore/src/services/rebalance/control.rs
|
||||
16|crates/ecstore/src/services/rebalance/control.rs
|
||||
1|crates/ecstore/src/services/rebalance/entry.rs
|
||||
6|crates/ecstore/src/services/rebalance/meta.rs
|
||||
8|crates/ecstore/src/services/rebalance/meta.rs
|
||||
8|crates/ecstore/src/services/rebalance/runtime.rs
|
||||
17|crates/ecstore/src/services/rebalance/worker.rs
|
||||
33|crates/ecstore/src/services/tier/tier.rs
|
||||
|
||||
@@ -14,44 +14,3 @@ rustfs_void rustfs_btf_void(const void *data)
|
||||
{
|
||||
__asm__ volatile("" : : "r"(data) : "memory");
|
||||
}
|
||||
|
||||
typedef unsigned long long rustfs_u64;
|
||||
struct rustfs_btf_inner {
|
||||
unsigned int prefix;
|
||||
const rustfs_u64 count;
|
||||
};
|
||||
struct rustfs_btf_layout {
|
||||
unsigned int kind;
|
||||
struct rustfs_btf_inner nested;
|
||||
volatile unsigned long flags;
|
||||
unsigned short slot;
|
||||
unsigned int bits : 3;
|
||||
};
|
||||
struct __attribute__((packed)) rustfs_btf_packed {
|
||||
char tag;
|
||||
unsigned int value;
|
||||
};
|
||||
volatile struct rustfs_btf_layout rustfs_btf_layout_instance;
|
||||
volatile struct rustfs_btf_packed rustfs_btf_packed_instance;
|
||||
|
||||
#ifdef RUSTFS_BTF_LAYOUT_REFERENCE
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
|
||||
// Independent compiler layout oracle; no BTF parser or kernel operations.
|
||||
int main(void)
|
||||
{
|
||||
printf("{\"rustfs_btf_layout\":{\"size\":%zu,\"fields\":{"
|
||||
"\"kind\":[%zu,%zu],\"nested.count\":[%zu,%zu],"
|
||||
"\"flags\":[%zu,%zu],\"slot\":[%zu,%zu]}},"
|
||||
"\"rustfs_btf_packed\":{\"size\":%zu,\"fields\":{\"value\":[%zu,%zu]}}}\n",
|
||||
sizeof(struct rustfs_btf_layout),
|
||||
offsetof(struct rustfs_btf_layout, kind), sizeof(rustfs_btf_layout_instance.kind),
|
||||
offsetof(struct rustfs_btf_layout, nested.count), sizeof(rustfs_btf_layout_instance.nested.count),
|
||||
offsetof(struct rustfs_btf_layout, flags), sizeof(rustfs_btf_layout_instance.flags),
|
||||
offsetof(struct rustfs_btf_layout, slot), sizeof(rustfs_btf_layout_instance.slot),
|
||||
sizeof(struct rustfs_btf_packed),
|
||||
offsetof(struct rustfs_btf_packed, value), sizeof(rustfs_btf_packed_instance.value));
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -7,7 +7,6 @@ import array
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import struct
|
||||
import sys
|
||||
@@ -15,7 +14,6 @@ from pathlib import Path
|
||||
|
||||
MAX_BTF_BYTES = 32 * 1024 * 1024
|
||||
MAX_FUNCTIONS = 16
|
||||
MAX_FIELDS = 16
|
||||
MAX_JSON_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
@@ -120,48 +118,6 @@ class _BTF:
|
||||
result, chain = self.unwrap(p['size_or_type'], allow_void=True)
|
||||
return {'function': fn, 'prototype': p, 'args': args, 'return': result, 'return_chain': chain}
|
||||
|
||||
def scalar_field(self, root, path):
|
||||
"""Resolve a declared, byte-aligned integer span without dereferencing pointers."""
|
||||
container, absolute, steps, seen = root, 0, [], set()
|
||||
parts = path.split('.')
|
||||
for index, name in enumerate(parts):
|
||||
need(container['kind'] == 4 and container['ID'] not in seen,
|
||||
'acyclic_struct_member_path')
|
||||
seen.add(container['ID'])
|
||||
members = []
|
||||
for member in range(container['count']):
|
||||
nm, ty, bits = struct.unpack_from('<III', self.raw, container['offset'] + 12 + member * 12)
|
||||
if self.string(nm) == name:
|
||||
members.append((ty, bits))
|
||||
need(len(members) == 1, 'unique_actual_named_struct_member')
|
||||
ident, encoded_offset = members[0]
|
||||
bitfield = encoded_offset >> 24 if container['kflag'] else 0
|
||||
offset = encoded_offset & 0xffffff if container['kflag'] else encoded_offset
|
||||
need(not bitfield and offset % 8 == 0, 'non_bitfield_byte_aligned_member')
|
||||
leaf, chain = self.unwrap(ident)
|
||||
if index + 1 < len(parts):
|
||||
need(leaf['kind'] == 4, 'member_path_cannot_cross_pointer_or_non_struct')
|
||||
width = leaf['size_or_type']
|
||||
else:
|
||||
need(leaf['kind'] == 1 and leaf['count'] == 0 and not leaf['kflag'],
|
||||
'actual_scalar_integer_header')
|
||||
encoding = struct.unpack_from('<I', self.raw, leaf['offset'] + 12)[0]
|
||||
flags, integer_offset, integer_bits = (encoding >> 24) & 15, (encoding >> 16) & 255, encoding & 255
|
||||
width = leaf['size_or_type']
|
||||
need(encoding & 0xf000ff00 == 0 and flags in (0, 1, 2, 4),
|
||||
'supported_integer_encoding')
|
||||
need(width in (1, 2, 4, 8) and integer_offset == 0 and integer_bits == width * 8,
|
||||
'complete_byte_scalar_integer')
|
||||
need(width > 0 and offset + width * 8 <= container['size_or_type'] * 8,
|
||||
'member_span_within_declared_container')
|
||||
absolute += offset
|
||||
steps.append({'container_ID': container['ID'], 'member': name, 'member_type_ID': ident,
|
||||
'member_bit_offset': offset, 'qualifiers': chain})
|
||||
container = leaf
|
||||
return {'path': path, 'byte_offset': absolute // 8, 'bytes': width, 'leaf': leaf,
|
||||
'integer': {'encoding_flags': flags, 'bit_offset': integer_offset, 'bits': integer_bits},
|
||||
'steps': steps}
|
||||
|
||||
|
||||
def inspect_prototypes(path, names):
|
||||
"""Read bounded source bytes and report declared prototypes, never runtime ABI."""
|
||||
@@ -170,30 +126,6 @@ def inspect_prototypes(path, names):
|
||||
or len(name.encode("utf-8")) > 4096 for name in names)
|
||||
or len(set(names)) != len(names)):
|
||||
raise ValueError("one_to_sixteen_unique_function_names_required")
|
||||
btf, source = _read_btf(path)
|
||||
return {"schema": 1, "source": source, "prototypes": [btf.prototype(name) for name in names]}
|
||||
|
||||
|
||||
def inspect_scalar_fields(path, struct_name, fields):
|
||||
"""Report selected declared integer layouts, never runtime memory or ABI."""
|
||||
if (not isinstance(struct_name, str) or not struct_name or "\0" in struct_name
|
||||
or len(struct_name.encode("utf-8")) > 4096):
|
||||
raise ValueError("one_exact_struct_name_required")
|
||||
if (not isinstance(fields, (list, tuple)) or not fields or len(fields) > MAX_FIELDS
|
||||
or any(not isinstance(field, str) or len(field.encode("utf-8")) > 4096
|
||||
or not 1 <= len(field.split('.')) <= 32
|
||||
or any(re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', part) is None for part in field.split('.'))
|
||||
for field in fields)
|
||||
or len(set(fields)) != len(fields)):
|
||||
raise ValueError("one_to_sixteen_unique_scalar_member_paths_required")
|
||||
btf, source = _read_btf(path)
|
||||
root = btf.find(struct_name, 4)
|
||||
return {"schema": 1, "source": source, "struct": root, "runtime_ABI_verified": False,
|
||||
"scalar_fields": [btf.scalar_field(root, field) for field in fields]}
|
||||
|
||||
|
||||
def _read_btf(path):
|
||||
"""Share the same bounded regular-file contract across inspection modes."""
|
||||
path = Path(path)
|
||||
if not stat.S_ISREG(path.lstat().st_mode):
|
||||
raise ValueError("BTF_input_must_be_a_regular_non_symlink_file")
|
||||
@@ -207,8 +139,11 @@ def _read_btf(path):
|
||||
raw = source.read(MAX_BTF_BYTES + 1)
|
||||
if len(raw) > MAX_BTF_BYTES:
|
||||
raise ValueError("BTF_source_exceeds_32_MiB")
|
||||
return _BTF(raw), {"format": "BTF-v1-little-endian", "bytes": len(raw),
|
||||
"sha256": hashlib.sha256(raw).hexdigest()}
|
||||
btf = _BTF(raw)
|
||||
return {"schema": 1,
|
||||
"source": {"format": "BTF-v1-little-endian", "bytes": len(raw),
|
||||
"sha256": hashlib.sha256(raw).hexdigest()},
|
||||
"prototypes": [btf.prototype(name) for name in names]}
|
||||
|
||||
|
||||
def encode_document(document):
|
||||
@@ -225,21 +160,13 @@ def encode_document(document):
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Read-only BTF prototypes or scalar member layouts; no probe loading or runtime ABI claims.")
|
||||
description="Read-only inspection of declared BTF function prototypes; no probe loading or runtime ABI claims.")
|
||||
parser.add_argument("--btf", required=True, type=Path, help="raw BTF v1 little-endian file (at most 32 MiB)")
|
||||
selection = parser.add_mutually_exclusive_group(required=True)
|
||||
selection.add_argument("--function", action="append", dest="functions",
|
||||
parser.add_argument("--function", required=True, action="append", dest="functions",
|
||||
help="exact, unique function name; repeat at most 16 times")
|
||||
selection.add_argument("--struct", dest="struct_name", help="exact, unique struct name for scalar fields")
|
||||
parser.add_argument("--field", action="append", dest="fields",
|
||||
help="named integer member path within --struct; repeat at most 16 times")
|
||||
args = parser.parse_args(argv)
|
||||
if (args.struct_name is not None) != (args.fields is not None):
|
||||
parser.error("--struct and --field must be supplied together")
|
||||
try:
|
||||
document = (inspect_scalar_fields(args.btf, args.struct_name, args.fields) if args.struct_name is not None
|
||||
else inspect_prototypes(args.btf, args.functions))
|
||||
output = encode_document(document)
|
||||
output = encode_document(inspect_prototypes(args.btf, args.functions))
|
||||
except OSError:
|
||||
parser.exit(2, "BTF input could not be read\n")
|
||||
except (ValueError, UnicodeError) as error:
|
||||
|
||||
@@ -50,240 +50,6 @@ def structure(types, field, size=8):
|
||||
return blob(types + [typ(4, size, "Root", count=1) + member])
|
||||
|
||||
|
||||
class LayoutFixture:
|
||||
def __init__(self):
|
||||
self.strings = bytearray(b'\0')
|
||||
self.types = []
|
||||
|
||||
def name(self, value):
|
||||
if not value:
|
||||
return 0
|
||||
offset = len(self.strings)
|
||||
self.strings.extend(value.encode() + b'\0')
|
||||
return offset
|
||||
|
||||
def add(self, kind, third, name='', payload=b'', count=0, kflag=False):
|
||||
self.types.append(struct.pack('<III', self.name(name), (kind << 24) | count | (int(kflag) << 31), third) + payload)
|
||||
return len(self.types)
|
||||
|
||||
def integer(self, size=8, encoding=None, **header):
|
||||
if encoding is None:
|
||||
encoding = size * 8
|
||||
return self.add(1, size, 'integer', struct.pack('<I', encoding), **header)
|
||||
|
||||
def structure(self, name, size, members, kflag=False, kind=4):
|
||||
raw = b''.join(struct.pack('<III', self.name(n), ty, offset) for n, ty, offset in members)
|
||||
return self.add(kind, size, name, raw, len(members), kflag)
|
||||
|
||||
def blob(self):
|
||||
raw = b''.join(self.types)
|
||||
return struct.pack('<HBBIIIII', 0xEB9F, 1, 0, 24, 0, len(raw), len(raw), len(self.strings)) + raw + self.strings
|
||||
|
||||
|
||||
class ScalarFieldTests(unittest.TestCase):
|
||||
def inspect(self, fixture, fields=('value',)):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'input.btf'
|
||||
raw = fixture.blob()
|
||||
path.write_bytes(raw)
|
||||
result = reader.inspect_scalar_fields(path, 'Root', fields)
|
||||
self.assertEqual(path.read_bytes(), raw)
|
||||
self.assertEqual(result['source']['sha256'], hashlib.sha256(raw).hexdigest())
|
||||
self.assertFalse(result['runtime_ABI_verified'])
|
||||
return result
|
||||
|
||||
def run_cli(self, fixture, *options):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'input.btf'
|
||||
path.write_bytes(fixture.blob())
|
||||
return subprocess.run([sys.executable, '-B', str(HERE / 'inspect_btf_prototypes.py'),
|
||||
'--btf', str(path), *options], capture_output=True, timeout=10)
|
||||
|
||||
def test_nested_offsets_and_qualifier_ids(self):
|
||||
f = LayoutFixture()
|
||||
small, wide = f.integer(4), f.integer()
|
||||
alias = f.add(8, wide, 'Counter')
|
||||
const = f.add(10, alias)
|
||||
inner = f.structure('Inner', 16, [('pad', small, 0), ('count', const, 64)])
|
||||
f.structure('Root', 24, [('kind', small, 0), ('nested', inner, 64)])
|
||||
report = self.inspect(f, ['nested.count', 'kind'])
|
||||
count, kind = report['scalar_fields']
|
||||
self.assertEqual((count['path'], count['byte_offset'], count['bytes']), ('nested.count', 16, 8))
|
||||
self.assertEqual(count['integer'], {'encoding_flags': 0, 'bit_offset': 0, 'bits': 64})
|
||||
self.assertEqual([t['ID'] for t in count['steps'][1]['qualifiers']], [const, alias, wide])
|
||||
self.assertEqual((kind['byte_offset'], kind['bytes']), (0, 4))
|
||||
|
||||
def test_full_integer_widths_and_encoding_flags(self):
|
||||
for size, flags in ((1, 0), (2, 1), (4, 0), (8, 1), (1, 2), (1, 4)):
|
||||
with self.subTest(size=size, flags=flags):
|
||||
f = LayoutFixture()
|
||||
value = f.integer(size, (flags << 24) | size * 8)
|
||||
f.structure('Root', size, [('value', value, 0)])
|
||||
report = self.inspect(f)['scalar_fields'][0]
|
||||
self.assertEqual((report['bytes'], report['integer']['encoding_flags']), (size, flags))
|
||||
|
||||
def test_kind_flag_normal_member_is_not_a_bitfield(self):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
f.structure('Root', 16, [('bits', value, 3 << 24), ('value', value, 64)], kflag=True)
|
||||
self.assertEqual(self.inspect(f)['scalar_fields'][0]['byte_offset'], 8)
|
||||
with self.assertRaisesRegex(ValueError, 'non_bitfield'):
|
||||
self.inspect(f, ['bits'])
|
||||
|
||||
def test_legacy_integer_bitfields_are_rejected(self):
|
||||
for encoding in (7, (1 << 16) | 8):
|
||||
f = LayoutFixture()
|
||||
value = f.integer(4, encoding)
|
||||
f.structure('Root', 4, [('value', value, 0)])
|
||||
with self.assertRaisesRegex(ValueError, 'complete_byte_scalar'):
|
||||
self.inspect(f)
|
||||
|
||||
def test_unsupported_and_reserved_integer_encodings(self):
|
||||
for encoding in ((3 << 24) | 32, (8 << 24) | 32, (1 << 31) | 32, (1 << 8) | 32):
|
||||
f = LayoutFixture()
|
||||
value = f.integer(4, encoding)
|
||||
f.structure('Root', 4, [('value', value, 0)])
|
||||
with self.assertRaisesRegex(ValueError, 'supported_integer_encoding'):
|
||||
self.inspect(f)
|
||||
|
||||
def test_integer_headers_and_unsupported_width(self):
|
||||
for kwargs in ({'count': 1}, {'kflag': True}, {'size': 16}):
|
||||
f = LayoutFixture()
|
||||
value = f.integer(**kwargs)
|
||||
f.structure('Root', 16, [('value', value, 0)])
|
||||
with self.assertRaises(ValueError):
|
||||
self.inspect(f)
|
||||
|
||||
def test_unaligned_and_out_of_container_members(self):
|
||||
for offset in (1, 8, 64):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
f.structure('Root', 8, [('value', value, offset)])
|
||||
with self.assertRaises(ValueError):
|
||||
self.inspect(f)
|
||||
|
||||
def test_nested_container_extent_is_checked(self):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
inner = f.structure('Inner', 16, [('value', value, 0)])
|
||||
f.structure('Root', 8, [('nested', inner, 0)])
|
||||
with self.assertRaisesRegex(ValueError, 'span_within'):
|
||||
self.inspect(f, ['nested.value'])
|
||||
|
||||
def test_pointer_and_non_integer_leaves_are_not_sized(self):
|
||||
for kind in (2, 3, 5, 7):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
payload = struct.pack('<III', value, value, 1) if kind == 3 else b''
|
||||
leaf = f.add(kind, value if kind == 2 else 0, payload=payload)
|
||||
f.structure('Root', 16, [('value', leaf, 0)])
|
||||
with self.assertRaisesRegex(ValueError, 'actual_scalar_integer'):
|
||||
self.inspect(f)
|
||||
|
||||
def test_paths_never_cross_pointers(self):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
inner = f.structure('Inner', 8, [('value', value, 0)])
|
||||
pointer = f.add(2, inner)
|
||||
f.structure('Root', 8, [('pointer', pointer, 0)])
|
||||
with self.assertRaisesRegex(ValueError, 'cannot_cross_pointer'):
|
||||
self.inspect(f, ['pointer.value'])
|
||||
|
||||
def test_by_value_cycles_are_rejected(self):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
f.structure('Root', 8, [('value', value, 0), ('self', 2, 0)])
|
||||
with self.assertRaisesRegex(ValueError, 'acyclic_struct'):
|
||||
self.inspect(f, ['self.value'])
|
||||
|
||||
def test_missing_type_and_qualifier_cycles_are_not_defaulted(self):
|
||||
for target in (0, 999, 1):
|
||||
f = LayoutFixture()
|
||||
alias = f.add(8, target, 'Alias')
|
||||
f.structure('Root', 8, [('value', alias, 0)])
|
||||
with self.assertRaises(ValueError):
|
||||
self.inspect(f)
|
||||
|
||||
def test_missing_or_duplicate_member(self):
|
||||
for names in (['other'], ['value', 'value']):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
f.structure('Root', 8, [(n, value, 0) for n in names])
|
||||
with self.assertRaisesRegex(ValueError, 'unique_actual_named_struct_member'):
|
||||
self.inspect(f)
|
||||
|
||||
def test_duplicate_root_and_invalid_member_name_offset(self):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
f.structure('Root', 8, [('value', value, 0)])
|
||||
f.structure('Root', 8, [('value', value, 0)])
|
||||
with self.assertRaisesRegex(ValueError, 'unique_actual_named_type'):
|
||||
self.inspect(f)
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
f.add(4, 8, 'Root', struct.pack('<III', 9999, value, 0), count=1)
|
||||
with self.assertRaisesRegex(ValueError, 'string_offset'):
|
||||
self.inspect(f)
|
||||
|
||||
def test_selector_bounds_are_checked_before_source_io(self):
|
||||
for fields in ([], ['value', 'value'], [''], ['a..b'], ['a[0]'], ['a'] * 17,
|
||||
['.'.join(['a'] * 33)], [None], 'value'):
|
||||
with self.subTest(fields=fields):
|
||||
with self.assertRaisesRegex(ValueError, 'member_paths_required'):
|
||||
reader.inspect_scalar_fields('missing', 'Root', fields)
|
||||
for name in ('', None, 'Root\0'):
|
||||
with self.assertRaisesRegex(ValueError, 'struct_name_required'):
|
||||
reader.inspect_scalar_fields('missing', name, ['value'])
|
||||
|
||||
def test_full_path_count_and_depth_boundaries(self):
|
||||
f = LayoutFixture()
|
||||
value = f.integer(1)
|
||||
fields = ['field_' + str(i) for i in range(16)]
|
||||
f.structure('Root', 16, [(name, value, i * 8) for i, name in enumerate(fields)])
|
||||
report = self.inspect(f, list(reversed(fields)))
|
||||
self.assertEqual([r['path'] for r in report['scalar_fields']], list(reversed(fields)))
|
||||
f = LayoutFixture()
|
||||
value = f.integer(1)
|
||||
for i in range(32):
|
||||
value = f.structure('Root' if i == 31 else 'Level_' + str(i), 1, [('next', value, 0)])
|
||||
field = self.inspect(f, ['.'.join(['next'] * 32)])['scalar_fields'][0]
|
||||
self.assertEqual((len(field['steps']), field['byte_offset'], field['bytes']), (32, 0, 1))
|
||||
|
||||
def test_qualifier_record_boundary(self):
|
||||
for aliases in (31, 32):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
for i in range(aliases):
|
||||
value = f.add(8, value, 'Alias_' + str(i))
|
||||
f.structure('Root', 8, [('value', value, 0)])
|
||||
if aliases == 31:
|
||||
field = self.inspect(f)['scalar_fields'][0]
|
||||
self.assertEqual(len(field['steps'][0]['qualifiers']), 32)
|
||||
else:
|
||||
with self.assertRaisesRegex(ValueError, 'qualifier_chain'):
|
||||
self.inspect(f)
|
||||
|
||||
def test_cli_success_and_later_failure_has_no_partial_json(self):
|
||||
f = LayoutFixture()
|
||||
value = f.integer()
|
||||
f.structure('Root', 8, [('value', value, 0)])
|
||||
result = self.run_cli(f, '--struct', 'Root', '--field', 'value')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
report = json.loads(result.stdout)
|
||||
self.assertFalse(report['runtime_ABI_verified'])
|
||||
self.assertEqual(report['scalar_fields'][0]['byte_offset'], 0)
|
||||
failed = self.run_cli(f, '--struct', 'Root', '--field', 'value', '--field', 'missing')
|
||||
self.assertEqual((failed.returncode, failed.stdout), (2, b''))
|
||||
|
||||
def test_cli_modes_and_incomplete_selection_are_rejected(self):
|
||||
f = LayoutFixture()
|
||||
for args in (['--struct', 'Root'], ['--field', 'value'], ['--struct', ''],
|
||||
['--function', 'demo', '--field', 'value'],
|
||||
['--struct', 'Root', '--field', 'value', '--function', 'demo']):
|
||||
result = self.run_cli(f, *args)
|
||||
self.assertEqual((result.returncode, result.stdout), (2, b''))
|
||||
|
||||
|
||||
class VoidBoundaryTests(unittest.TestCase):
|
||||
def test_scalar_and_existing_pointer_declarations_are_retained(self):
|
||||
result = reader._BTF(function([integer(), typ(2, 1)], 1, [2])).prototype("demo")
|
||||
@@ -579,88 +345,7 @@ class NativeCaptureTests(unittest.TestCase):
|
||||
bounded_reference_command([sys.executable, "-c", "import threading; threading.Event().wait()"], timeout=0.1)
|
||||
|
||||
|
||||
def native_scalar_reference(path, records, layout_reference, original):
|
||||
"""Use bpftool's member records and a compiler's offsets, not the BTF reader."""
|
||||
def number(record, field):
|
||||
value = re.search(r'\b' + field + r'=(\d+)', record['extra'])
|
||||
if not value:
|
||||
raise ValueError('missing_native_' + field)
|
||||
return int(value.group(1))
|
||||
|
||||
def unwrap(ident):
|
||||
chain = []
|
||||
while True:
|
||||
if ident in chain or len(chain) >= 32:
|
||||
raise ValueError('native_field_qualifier_cycle_or_bound')
|
||||
chain.append(ident)
|
||||
value = records[ident]
|
||||
if value['kind'] not in ('TYPEDEF', 'CONST', 'VOLATILE', 'RESTRICT', 'TYPE_TAG'):
|
||||
return ident, value, chain
|
||||
ident = number(value, 'type_id')
|
||||
|
||||
with Path(layout_reference).open('rb') as source:
|
||||
raw = source.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise ValueError('compiler_layout_reference_bound')
|
||||
compiler = json.loads(raw)
|
||||
expected_members = {'rustfs_btf_layout': ['kind', 'nested.count', 'flags', 'slot'],
|
||||
'rustfs_btf_packed': ['value']}
|
||||
if set(compiler) != set(expected_members):
|
||||
raise ValueError('compiler_layout_membership')
|
||||
for name, fields in expected_members.items():
|
||||
roots = [(ident, record) for ident, record in records.items()
|
||||
if record['kind'] == 'STRUCT' and record['name'] == name]
|
||||
if len(roots) != 1 or list(compiler[name]['fields']) != fields:
|
||||
raise ValueError('unique_native_struct_and_compiler_fields')
|
||||
root_id, root = roots[0]
|
||||
command = [sys.executable, '-B', str(HERE / 'inspect_btf_prototypes.py'),
|
||||
'--btf', str(path), '--struct', name]
|
||||
for field in fields:
|
||||
command.extend(['--field', field])
|
||||
result = bounded_reference_command(command)
|
||||
report = json.loads(result)
|
||||
if (report['source']['sha256'] != hashlib.sha256(original).hexdigest()
|
||||
or report['source']['bytes'] != len(original)
|
||||
or report['runtime_ABI_verified'] is not False or report['struct']['ID'] != root_id
|
||||
or report['struct']['size_or_type'] != compiler[name]['size']
|
||||
or compiler[name]['size'] != number(root, 'size')
|
||||
or [row['path'] for row in report['scalar_fields']] != fields):
|
||||
raise ValueError('native_scalar_root_or_membership')
|
||||
for actual in report['scalar_fields']:
|
||||
current, current_id, absolute = root, root_id, 0
|
||||
if len(actual['steps']) != len(actual['path'].split('.')):
|
||||
raise ValueError('native_scalar_step_count')
|
||||
for step, member_name in zip(actual['steps'], actual['path'].split('.')):
|
||||
matches = [row for row in current['members'] if row[0] == member_name]
|
||||
if len(matches) != 1:
|
||||
raise ValueError('unique_native_scalar_member')
|
||||
_, ty, offset, bits = matches[0]
|
||||
leaf_id, leaf, chain = unwrap(ty)
|
||||
if (bits or offset % 8 or step['container_ID'] != current_id
|
||||
or step['member'] != member_name or step['member_type_ID'] != ty
|
||||
or step['member_bit_offset'] != offset
|
||||
or [r['ID'] for r in step['qualifiers']] != chain):
|
||||
raise ValueError('native_scalar_member_or_chain')
|
||||
absolute += offset
|
||||
current, current_id = leaf, leaf_id
|
||||
if leaf['kind'] != 'INT' or actual['leaf']['ID'] != leaf_id:
|
||||
raise ValueError('native_scalar_leaf')
|
||||
encoding = re.search(r'\bencoding=(\S+)', leaf['extra']).group(1)
|
||||
flags = {'(none)': 0, 'SIGNED': 1, 'CHAR': 2, 'BOOL': 4}[encoding]
|
||||
expected = [absolute // 8, number(leaf, 'size')]
|
||||
if ([actual['byte_offset'], actual['bytes']] != expected
|
||||
or compiler[name]['fields'][actual['path']] != expected
|
||||
or actual['integer'] != {'encoding_flags': flags,
|
||||
'bit_offset': number(leaf, 'bits_offset'),
|
||||
'bits': number(leaf, 'nr_bits')}):
|
||||
raise ValueError('native_scalar_layout_or_payload')
|
||||
with Path(path).open('rb') as source:
|
||||
if source.read(65537) != original:
|
||||
raise ValueError('native_scalar_source_changed')
|
||||
print('Validated scalar layouts against bpftool and independent compiler offsetof/sizeof')
|
||||
|
||||
|
||||
def native_reference(path, layout_reference=None):
|
||||
def native_reference(path):
|
||||
"""Compare a small compiler-produced fixture with bpftool, not this decoder."""
|
||||
path = Path(path)
|
||||
with path.open("rb") as source:
|
||||
@@ -674,19 +359,13 @@ def native_reference(path, layout_reference=None):
|
||||
match = re.fullmatch(r"\[(\d+)\] (\w+) '(.*)'(?: (.*))?", line)
|
||||
if match:
|
||||
ident, kind, name, extra = match.groups()
|
||||
current = {"kind": kind, "name": name, "extra": extra or "", "args": [], "members": []}
|
||||
current = {"kind": kind, "name": name, "extra": extra or "", "args": []}
|
||||
records[int(ident)] = current
|
||||
elif current is not None and current["kind"] == "FUNC_PROTO":
|
||||
argument = re.fullmatch(r"\s*'(.*)' type_id=(\d+)", line)
|
||||
if not argument:
|
||||
raise ValueError("unexpected_native_prototype_row")
|
||||
current["args"].append((argument.group(1), int(argument.group(2))))
|
||||
elif current is not None and current['kind'] == 'STRUCT':
|
||||
member = re.fullmatch(r"\s*'(.*)' type_id=(\d+) bits_offset=(\d+)(?: bitfield_size=(\d+))?", line)
|
||||
if not member:
|
||||
raise ValueError('unexpected_native_struct_member_row')
|
||||
field, ty, offset, bits = member.groups()
|
||||
current['members'].append((field, int(ty), int(offset), int(bits or 0)))
|
||||
names = ["rustfs_btf_probe", "rustfs_btf_void"]
|
||||
command = [sys.executable, "-B", str(HERE / "inspect_btf_prototypes.py"), "--btf", str(path)]
|
||||
for name in names:
|
||||
@@ -766,14 +445,10 @@ def native_reference(path, layout_reference=None):
|
||||
if int(re.search(r"\btype_id=(\d+)", record["extra"]).group(1)) != qualifier["size_or_type"]:
|
||||
raise ValueError("native_reference_qualifier_mismatch")
|
||||
print("Validated both compiler-produced prototypes against independent bpftool output")
|
||||
if layout_reference is not None:
|
||||
native_scalar_reference(path, records, layout_reference, raw)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) in (3, 5) and sys.argv[1] == "--native-reference":
|
||||
if len(sys.argv) == 5 and sys.argv[3] != '--layout-reference':
|
||||
raise ValueError('expected_layout_reference')
|
||||
native_reference(sys.argv[2], sys.argv[4] if len(sys.argv) == 5 else None)
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "--native-reference":
|
||||
native_reference(sys.argv[2])
|
||||
else:
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user