Compare commits

..

9 Commits

Author SHA1 Message Date
overtrue 3c9c35f144 Merge branch 'fix/get-dangling-latest-version' of https://github.com/rustfs/rustfs into fix/dangling-latest-read-before-write 2026-10-28 01:08:00 +08:00
overtrue 3e98105da9 fix(ecstore): refuse version-only copies below write quorum up front
Review of the dangling-latest metadata updates found three gaps:

- A version-only CopyObject wrote the new version and reverted on a
  write-quorum miss; refuse it before writing when the served version's
  disks cannot reach its write quorum, like update_object_meta_with_opts.
- Replication status write-back, including the zero-write ValidateOnly
  mode, now keeps its read-quorum error and never walks past dangling
  versions, since the walk queues heal for the versions it skips.
- The erasure-coding doc overstated that these callers never report a
  dangling key as absent; put_object_metadata and CopyObject keep their
  existing dangling cleanup when the walk finds no readable version.
2026-10-28 00:57:14 +08:00
overtrue 9c59e1505a Merge remote-tracking branch 'origin/main' into fix/get-dangling-latest-version
# Conflicts:
#	docs/architecture/erasure-coding.md
2026-10-28 00:53:07 +08:00
overtrue db51552071 fix(ecstore): resolve dangling latest versions in metadata updates
A failed versioned PUT whose rollback also failed can leave the new
version on fewer drives than its data blocks. GET and HEAD of the
latest version already look past such versions, but read-before-write
callers without a version id still answered 503: object tagging read
each drive's top version with the quorum-error read, and
put_object_metadata and the CopyObject source read selected from
read_all_xl, whose merged latest version is missing on the other
drives, so object_quorum_from_meta failed before selection.

Object tagging now reads through a variant that runs the same walk past
dangling versions as a latest GET while keeping the quorum error for a
dangling key. put_object_metadata and the CopyObject source try the walk
when their quorum or selection step fails with read quorum, before the
existing dangling cleanup could report a key with a committed version
absent. A version-only copy takes the served version's own copies and
write quorum. The walk still applies only to S3 buckets, latest reads,
and sets where every drive answered, and the update still targets only
the served version's disks, so a version below write quorum is refused
before writing.
2026-10-28 00:25:20 +08:00
overtrue e1d08544de fix(ecstore): serve the latest readable version past dangling versions
A failed versioned PUT whose rollback also failed can leave the new
version on top of fewer drives than its data blocks. Reading only each
drive's top version then left the committed version below it short of
read quorum, so GET and HEAD of the latest version answered 503 although
that version is intact in every stack. MinIO resolves the stacks in
pickLatestQuorumFilesInfo.

When every drive answered and an S3 latest read still cannot select a
version, walk the newest version headers of each stack through
exact-version reads: serve the readable version with the newest selected
modification time, skip versions whose exact read proves them dangling
(the NoSuchVersion an explicit GET reports) and queue heal for them, and
keep the read-quorum error on any other outcome. Candidates are ordered
by each version's newest modification time across drives, and at most
eight exact reads are spent.

Test PUTs now wait for every drive so tests that edit one drive's
metadata no longer race the PUT's background tail writes.
2026-10-27 23:34:15 +08:00
overtrue e472989407 fix(ecstore): keep commit-proof selection for system-bucket reads
Pool metadata's PREPARE/COMMIT recovery and identity epochs treat an
ordinary read of pool.bin as proof that the record committed. Under the
read-quorum rule, a COMMIT that reached only half of a 2+2 set was served
as committed, and the pool_meta_native_physical_* integration tests
failed.

Route reads through select_fileinfo_for_read: S3 buckets keep MinIO's
read-quorum rule, while is_meta_bucketname reads use the previous
algorithm unchanged as select_committed_fileinfo, so a split stays
unreadable until native recovery resolves it.
2026-10-27 13:44:13 +08:00
overtrue 8835936d0e fix(ecstore): count identity copies in the data-blocks guard
The first-candidate shortcut counts byte-identical copies, so copies
that differ only in identity-neutral fields such as replication status
were missing from the data-blocks check. When the shortcut's count
falls short, count every copy of the selected content identity before
refusing the version.
2026-10-27 11:46:33 +08:00
overtrue 939bffdc45 fix(ecstore): resolve selection ties by disk order and guard writes
Review of the read-quorum selection found three gaps:

- Rejecting a same-time half split made explicit-version reads of a
  partially applied tagging update fail and blocked the retry that
  repairs it. Resolve ties to the identity on the lowest-index disk,
  for reads and heal alike, instead of rejecting or using map order.
- Delete markers and empty versions can lower the common read quorum
  below the selected version's data blocks, so HEAD could report a
  version GET cannot decode. Require that many copies, as MinIO's
  getLatestFileInfo does.
- A selection can now return exactly read-quorum disks, and partial
  metadata writes are not rolled back. Refuse an update before writing
  when its target disks cannot reach write quorum.
2026-10-27 11:21:07 +08:00
overtrue 597232f287 fix(ecstore): select the latest version at read quorum like MinIO
GET and HEAD escalated latest-version selection to write quorum when
enough metadata answered and skipped a newer version below write quorum
on degraded reads. LIST and heal already pick the newer side of a split
write at read quorum, so such keys listed but answered 503 until heal.

Select with MinIO's getObjectFileInfo rule: the most common mod time
that reaches the object's data blocks wins, ties go to the newer time,
and one content identity at that time must reach the same quorum. Two
identities tied at the selected time fail instead of resolving by hash
order, and the selected identity's version-stack shape follows disk
order.
2026-10-27 09:33:16 +08:00
61 changed files with 1226 additions and 4710 deletions
@@ -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.
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=eb33067d90ee66c0a97e8f3658e46c152e47f897d2fbfdc6cb5ad93cfa55414b
sha256-darwin=eb33067d90ee66c0a97e8f3658e46c152e47f897d2fbfdc6cb5ad93cfa55414b
sha256-linux=6190506b9e4bdb60fd392544a7bc47be708077bb4fe7a2ef689d047455e1af86
sha256-darwin=6190506b9e4bdb60fd392544a7bc47be708077bb4fe7a2ef689d047455e1af86
@@ -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"
-1
View File
@@ -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(())
}
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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;
}
+16 -593
View File
@@ -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(&degraded, 0, 2, 2, 1),
PoolDiagnosticStatus {
availability: PoolAvailability::Degraded,
capacity_status: PoolCapacityStatus::Cached,
}
);
degraded[1] = diagnostic_disk(0, 1, false);
assert_eq!(pool_diagnostic_status(&degraded, 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));
-87
View File
@@ -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(&copy_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(
+16 -16
View File
@@ -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]
+170 -266
View File
@@ -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 => (),
_ => {
@@ -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(&current_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(&current_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?;
@@ -26534,71 +26503,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
View File
@@ -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::*;
+4 -17
View File
@@ -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 {
+105 -57
View File
@@ -8839,7 +8839,7 @@ impl SetDisks {
// quorum, failing write quorum on update_object_meta (backlog#872).
let read_opts = opts.as_read_opts();
let (mut fi, _, disks) = self
.get_object_fileinfo_gated(bucket, object, &read_opts, false, false)
.get_object_fileinfo_for_metadata_update(bucket, object, &read_opts)
.await?
.into_owned();
@@ -9294,36 +9294,56 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
}
};
let (read_quorum, write_quorum) = match Self::object_quorum_from_meta(&metas, &errs, self.default_parity_count) {
Ok((r, w)) => (
usize::try_from(r)
.map_err(|_| to_object_err(DiskError::ErasureReadQuorum.into(), vec![src_bucket, src_object]))?,
usize::try_from(w)
.map_err(|_| to_object_err(DiskError::ErasureWriteQuorum.into(), vec![src_bucket, src_object]))?,
),
let src_version_id = src_opts.version_id.as_deref().unwrap_or_default();
let quorum = Self::object_quorum_from_meta(&metas, &errs, self.default_parity_count);
let selection = match &quorum {
Ok((read_quorum, write_quorum)) => {
let read_quorum = usize::try_from(*read_quorum)
.map_err(|_| to_object_err(DiskError::ErasureReadQuorum.into(), vec![src_bucket, src_object]))?;
let write_quorum = usize::try_from(*write_quorum)
.map_err(|_| to_object_err(DiskError::ErasureWriteQuorum.into(), vec![src_bucket, src_object]))?;
Self::select_fileinfo_for_read(src_bucket, &disks, &metas, &errs, src_version_id, read_quorum, write_quorum)
.map(|(online_disks, fi)| (online_disks, fi, write_quorum))
}
Err(err) => Err(err.clone()),
};
let (online_disks, mut fi, write_quorum) = match selection {
Ok(selection) => selection,
Err(mut err) => {
if err == DiskError::ErasureReadQuorum
&& !src_bucket.starts_with(RUSTFS_META_BUCKET)
&& self
.delete_if_dangling(src_bucket, src_object, &metas, &errs, &HashMap::new(), src_opts.clone())
.await
.is_ok()
// The merged latest version can be one a failed PUT left below
// quorum; resolve it like a latest GET before the dangling
// cleanup below can report a key with a committed version absent.
if let Some(snapshot) = self
.select_latest_past_dangling_versions(src_bucket, src_object, src_opts, true, &disks, &err)
.await
{
if src_opts.version_id.is_some() {
err = DiskError::FileVersionNotFound
} else {
err = DiskError::FileNotFound
// A version-only copy writes the served version's own
// copies, so they and its write quorum replace the latest
// read's.
let (fi, parts_metadata, online_disks) = snapshot.into_owned();
metas = parts_metadata;
let write_quorum = fi.write_quorum(self.default_write_quorum());
(online_disks, fi, write_quorum)
} else {
if quorum.is_err()
&& err == DiskError::ErasureReadQuorum
&& !src_bucket.starts_with(RUSTFS_META_BUCKET)
&& self
.delete_if_dangling(src_bucket, src_object, &metas, &errs, &HashMap::new(), src_opts.clone())
.await
.is_ok()
{
if src_opts.version_id.is_some() {
err = DiskError::FileVersionNotFound
} else {
err = DiskError::FileNotFound
}
}
return Err(to_object_err(err.into(), vec![src_bucket, src_object]));
}
return Err(to_object_err(err.into(), vec![src_bucket, src_object]));
}
};
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)
.map_err(|e| to_object_err(e.into(), vec![src_bucket, src_object]))?;
if fi.deleted {
if src_opts.version_id.is_none() {
return Err(to_object_err(Error::FileNotFound, vec![src_bucket, src_object]));
@@ -9461,6 +9481,16 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
}
}
// A version-only copy writes and then reverts on a quorum miss;
// refuse it before writing when the served version's disks cannot
// reach its write quorum even if every write succeeds.
let reachable = online_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(to_object_err(err.into(), vec![src_bucket, src_object]));
}
Self::write_unique_file_info(&online_disks, "", src_bucket, src_object, &metas, write_quorum)
.await
.map_err(|e| to_object_err(e.into(), vec![src_bucket, src_object]))?;
@@ -11147,45 +11177,63 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
}
};
let (read_quorum, write_quorum) = match Self::object_quorum_from_meta(&metas, &errs, self.default_parity_count) {
Ok((read_quorum, write_quorum)) => (read_quorum, write_quorum),
let version_id = opts.version_id.as_deref().unwrap_or_default();
let quorum = Self::object_quorum_from_meta(&metas, &errs, self.default_parity_count);
let selection = match &quorum {
Ok((read_quorum, write_quorum)) => {
let read_quorum = usize::try_from(*read_quorum)
.map_err(|_| to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]))?;
let write_quorum = usize::try_from(*write_quorum)
.map_err(|_| to_object_err(DiskError::ErasureWriteQuorum.into(), vec![bucket, object]))?;
Self::select_fileinfo_for_read(bucket, &disks, &metas, &errs, version_id, read_quorum, write_quorum)
}
Err(err) => Err(err.clone()),
};
let (online_disks, mut fi) = match selection {
Ok(selection) => selection,
Err(mut err) => {
// A replication terminal CAS is a validation/write-back
// attempt, never a heal operation. In particular,
// ValidateOnly must remain zero-write, and a worker whose
// outer replication lease is already lost must not delete the
// surviving xl.meta copies before the namespace fence below
// can reject it. Leave dangling cleanup to heal/scanner.
if opts.replication_status_writeback.is_some() {
// The merged latest version can be one a failed PUT left below
// quorum; resolve it like a latest GET before the dangling
// cleanup below can report a key with a committed version absent.
// A replication terminal CAS stays side-effect free: the walk
// queues heal for the versions it skips, so it does not run.
let resolved = if opts.replication_status_writeback.is_none() {
self.select_latest_past_dangling_versions(bucket, object, opts, false, &disks, &err)
.await
} else {
None
};
if let Some(snapshot) = resolved {
let (fi, _, online_disks) = snapshot.into_owned();
(online_disks, fi)
} else {
// A replication terminal CAS is a validation/write-back
// attempt, never a heal operation. In particular,
// ValidateOnly must remain zero-write, and a worker whose
// outer replication lease is already lost must not delete
// the surviving xl.meta copies before the namespace fence
// below can reject it. Leave dangling cleanup to
// heal/scanner.
if quorum.is_err()
&& opts.replication_status_writeback.is_none()
&& err == DiskError::ErasureReadQuorum
&& !bucket.starts_with(RUSTFS_META_BUCKET)
&& self
.delete_if_dangling(bucket, object, &metas, &errs, &HashMap::new(), opts.clone())
.await
.is_ok()
{
if opts.version_id.is_some() {
err = DiskError::FileVersionNotFound
} else {
err = DiskError::FileNotFound
}
}
return Err(to_object_err(err.into(), vec![bucket, object]));
}
if err == DiskError::ErasureReadQuorum
&& !bucket.starts_with(RUSTFS_META_BUCKET)
&& self
.delete_if_dangling(bucket, object, &metas, &errs, &HashMap::new(), opts.clone())
.await
.is_ok()
{
if opts.version_id.is_some() {
err = DiskError::FileVersionNotFound
} else {
err = DiskError::FileNotFound
}
}
return Err(to_object_err(err.into(), vec![bucket, object]));
}
};
let read_quorum =
usize::try_from(read_quorum).map_err(|_| to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]))?;
let write_quorum = usize::try_from(write_quorum)
.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]))?;
if fi.deleted {
return Err(to_object_err(Error::MethodNotAllowed, vec![bucket, object]));
}
+420 -170
View File
@@ -278,6 +278,10 @@ const MAX_DANGLING_LATEST_VERSIONS: usize = 8;
#[derive(Clone, Copy, PartialEq, Eq)]
enum DanglingRead {
KeepQuorumError,
/// Metadata updates of the latest version keep the quorum error but, like
/// object reads, look past newer versions proven dangling
/// (`read_latest_past_dangling_versions`).
SkipDanglingVersions,
ReportNotFound,
}
@@ -729,9 +733,10 @@ impl SetDisks {
}
/// Like `get_object_fileinfo`, but `allow_early_stop=false` forces the full
/// quorum fanout. Read-before-write callers (object tagging) must use this:
/// the returned online-disk set is the write target, and the early-stop
/// subset would fail write quorum (backlog#872 regression fix).
/// quorum fanout. Read-before-write callers must use this or
/// `get_object_fileinfo_for_metadata_update`: the returned online-disk set
/// is the write target, and the early-stop subset would fail write quorum
/// (backlog#872 regression fix).
#[tracing::instrument(level = "debug", skip(self))]
pub(in crate::set_disk) async fn get_object_fileinfo_gated(
&self,
@@ -753,6 +758,64 @@ impl SetDisks {
.await
}
/// The full-fanout read behind a metadata update such as object tagging.
/// A latest read whose newest versions never committed resolves to the
/// version a latest GET serves, while a dangling key keeps the quorum
/// error. The returned online disks are the write target.
#[tracing::instrument(level = "debug", skip(self))]
pub(in crate::set_disk) async fn get_object_fileinfo_for_metadata_update(
&self,
bucket: &str,
object: &str,
opts: &ObjectOptions,
) -> Result<GetObjectFileInfo> {
self.get_object_fileinfo_gated_inner(
bucket,
object,
opts,
false,
false,
MetadataReadPurpose::FullLayout,
DanglingRead::SkipDanglingVersions,
)
.await
}
/// Recovers a failed latest selection in read-before-write callers that
/// read and select metadata themselves (`put_object_metadata` and the
/// CopyObject source) the way a latest GET does. `None` keeps the caller's
/// error: a failure other than read quorum, an explicit version, the
/// system bucket's commit-proof rule, or a walk that finds no readable
/// version or cannot read every drive. The selected metadata keeps its
/// part checksums because a version-only copy carries them into the new
/// version.
pub(in crate::set_disk) async fn select_latest_past_dangling_versions(
&self,
bucket: &str,
object: &str,
opts: &ObjectOptions,
read_data: bool,
disks: &[Option<DiskStore>],
err: &DiskError,
) -> Option<GetObjectFileInfo> {
if *err != DiskError::ErasureReadQuorum || opts.version_id.is_some() || crate::bucket::utils::is_meta_bucketname(bucket) {
return None;
}
let opts = ObjectOptions {
include_part_checksums: true,
..opts.clone()
};
Box::pin(self.read_latest_past_dangling_versions(
bucket,
object,
&opts,
read_data,
MetadataReadPurpose::FullLayout,
disks,
))
.await
}
#[allow(clippy::too_many_arguments)]
async fn get_object_fileinfo_gated_inner(
&self,
@@ -978,7 +1041,7 @@ impl SetDisks {
Err(err) => {
if err == DiskError::ErasureReadQuorum
&& vid.is_empty()
&& dangling_read == DanglingRead::ReportNotFound
&& dangling_read != DanglingRead::KeepQuorumError
&& metadata_fanout_complete
&& !crate::bucket::utils::is_meta_bucketname(bucket)
&& let Some(snapshot) =
@@ -1098,74 +1161,23 @@ impl SetDisks {
// 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 meta = rustfs_filemeta::FileMeta::load(&raw.buf).ok()?;
let mut versions = meta.versions.iter().filter(|version| !version.header.free_version());
for 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) {
if best.as_ref().is_some_and(|(selected, _)| newest_mod_time <= *selected) {
settled = true;
break;
}
@@ -1191,14 +1203,7 @@ impl SetDisks {
{
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 {
if best.as_ref().is_none_or(|(current, _)| selected > *current) {
best = Some((selected, snapshot));
}
}
@@ -9930,6 +9935,7 @@ mod dangling_read_tests {
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
use crate::storage_api_contracts::object::ObjectOperations;
use http::HeaderMap;
use std::collections::HashMap;
use std::path::PathBuf;
use tempfile::TempDir;
use tokio::io::AsyncReadExt;
@@ -10325,109 +10331,6 @@ mod dangling_read_tests {
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";
@@ -10451,6 +10354,353 @@ mod dangling_read_tests {
assert_ne!(latest.version_id, Some(v2));
}
/// Version one on every drive under a version two that a failed PUT left
/// on drives `0..3`, below the read quorum of four.
async fn put_committed_below_dangling(set: &SetDisks, bucket: &str, object: &str) -> (Uuid, Uuid) {
let v1 = put(set, bucket, object, b"version one", true)
.await
.version_id
.expect("versioned PUT should assign a version id");
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;
(v1, v2)
}
/// Each drive's copy of `version_id`, or `None` where the drive lacks it.
async fn version_metadata(
set: &SetDisks,
bucket: &str,
object: &str,
version_id: Uuid,
) -> Vec<Option<HashMap<String, String>>> {
let disks = set.disks.read().await.clone();
let mut copies = Vec::with_capacity(disks.len());
for disk in &disks {
let disk = disk.as_ref().expect("every drive should be online");
copies.push(
disk.read_version("", bucket, object, &version_id.to_string(), &ReadOptions::default())
.await
.ok()
.map(|fi| fi.metadata),
);
}
copies
}
#[tokio::test]
async fn latest_tagging_skips_dangling_versions_above_a_committed_version() {
let bucket = "dangling-latest-tagging";
let object = "torn/latest";
let (_dirs, set) = dangling_test_set(bucket).await;
let (v1, v2) = put_committed_below_dangling(&set, bucket, object).await;
let tag_of = |metadata: &Option<HashMap<String, String>>| {
metadata
.as_ref()
.and_then(|metadata| metadata.get(rustfs_utils::http::headers::AMZ_OBJECT_TAGGING).cloned())
};
let tagged = set
.put_object_tags(bucket, object, "stage=committed", &ObjectOptions::default())
.await
.expect("PutObjectTagging must update the version a latest GET serves");
assert_eq!(tagged.version_id, Some(v1));
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
assert_eq!(
tag_of(metadata).as_deref(),
Some("stage=committed"),
"drive {disk} must carry the tag on version one"
);
}
for (disk, metadata) in version_metadata(&set, bucket, object, v2).await.iter().enumerate() {
assert_eq!(tag_of(metadata), None, "drive {disk} must leave the dangling version untouched");
}
assert_eq!(*head(&set, bucket, object, None).await.expect("latest HEAD").user_tags, "stage=committed");
let untagged = set
.delete_object_tags(bucket, object, &ObjectOptions::default())
.await
.expect("DeleteObjectTagging must update the version a latest GET serves");
assert_eq!(untagged.version_id, Some(v1));
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
assert_eq!(tag_of(metadata).as_deref(), Some(""), "drive {disk} must clear the tag on version one");
}
}
#[tokio::test]
async fn latest_metadata_update_skips_dangling_versions_above_a_committed_version() {
let bucket = "dangling-latest-metadata";
let object = "torn/latest";
let (_dirs, set) = dangling_test_set(bucket).await;
let (v1, v2) = put_committed_below_dangling(&set, bucket, object).await;
let updated = set
.put_object_metadata(
bucket,
object,
&ObjectOptions {
versioned: true,
eval_metadata: Some(HashMap::from([("x-amz-meta-probe".to_string(), "updated".to_string())])),
..Default::default()
},
)
.await
.expect("a latest metadata update must target the version a latest GET serves");
assert_eq!(updated.version_id, Some(v1));
assert!(updated.is_latest, "the updated version is the latest one");
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
let metadata = metadata.as_ref().expect("version one is on every drive");
assert_eq!(metadata.get("x-amz-meta-probe").map(String::as_str), Some("updated"), "drive {disk}");
}
for (disk, metadata) in version_metadata(&set, bucket, object, v2).await.iter().enumerate() {
assert!(
metadata
.as_ref()
.is_none_or(|metadata| !metadata.contains_key("x-amz-meta-probe")),
"drive {disk} must leave the dangling version untouched"
);
}
}
#[tokio::test]
async fn latest_copy_source_skips_dangling_versions_above_a_committed_version() {
let bucket = "dangling-latest-copy";
let (_dirs, set) = dangling_test_set(bucket).await;
// A metadata-only self-copy rewrites the latest version in place.
let object = "torn/in-place";
let (v1, _) = put_committed_below_dangling(&set, bucket, object).await;
let mut source = head(&set, bucket, object, None).await.expect("latest HEAD");
assert_eq!(source.version_id, Some(v1));
source.metadata_only = true;
source.user_defined = Arc::new(HashMap::from([("x-amz-meta-copied".to_string(), "in-place".to_string())]));
let copied = set
.copy_object(
bucket,
object,
bucket,
object,
&mut source,
&ObjectOptions::default(),
&ObjectOptions::default(),
)
.await
.expect("a CopyObject source without a version id must resolve like a latest GET");
assert_eq!(copied.version_id, Some(v1));
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
let metadata = metadata.as_ref().expect("version one is on every drive");
assert_eq!(metadata.get("x-amz-meta-copied").map(String::as_str), Some("in-place"), "drive {disk}");
}
// A version-only self-copy commits a new version that shares the
// served version's data.
let object = "torn/new-version";
let (_, v2) = put_committed_below_dangling(&set, bucket, object).await;
let v3 = Uuid::new_v4();
let mut source = head(&set, bucket, object, None).await.expect("latest HEAD");
source.version_only = true;
let copied = set
.copy_object(
bucket,
object,
bucket,
object,
&mut source,
&ObjectOptions {
versioned: true,
..Default::default()
},
&ObjectOptions {
version_id: Some(v3.to_string()),
versioned: true,
object_lock_config_snapshot: Some(Arc::new(crate::object_api::ObjectLockConfigSnapshot::new(
crate::bucket::metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
))),
..Default::default()
},
)
.await
.expect("a version-only CopyObject source without a version id must resolve like a latest GET");
assert_eq!(copied.version_id, Some(v3));
let latest = head(&set, bucket, object, None).await.expect("latest HEAD");
assert_eq!(latest.version_id, Some(v3));
assert_eq!(get(&set, bucket, object, None).await.expect("latest GET"), b"version one");
assert!(
version_metadata(&set, bucket, object, v2).await[..3]
.iter()
.all(Option::is_some),
"the copy must not remove the dangling version; heal owns its cleanup"
);
}
#[tokio::test]
async fn latest_metadata_update_past_dangling_versions_keeps_write_quorum() {
// EC 3+3: read quorum three, write quorum four. Version one reached
// three drives, so it is readable but below write quorum; versions two
// and three are dangling on two drives each. A latest read serves
// version one, and a metadata update of it is refused before writing.
let bucket = "dangling-latest-write-quorum";
let object = "torn/latest";
let (_dirs, set) = dangling_test_set_with(bucket, 6, 3).await;
let mut versions = Vec::new();
for body in [b"version zero".as_slice(), b"version one", b"version two", b"version three"] {
versions.push(
put(&set, bucket, object, body, true)
.await
.version_id
.expect("versioned PUT should assign a version id"),
);
}
let [_, v1, v2, v3] = versions[..] else {
unreachable!("four versions were written");
};
drop_version_from(&set, bucket, object, v1, 3..6).await;
drop_version_from(&set, bucket, object, v2, 2..6).await;
drop_version_from(&set, bucket, object, v3, 0..3).await;
drop_version_from(&set, bucket, object, v3, 5..6).await;
let latest = head(&set, bucket, object, None)
.await
.expect("version one is readable at read quorum");
assert_eq!(latest.version_id, Some(v1));
let is_write_quorum_error =
|err: &StorageError| matches!(err, StorageError::ErasureWriteQuorum | StorageError::InsufficientWriteQuorum(_, _));
let Err(err) = set
.put_object_tags(bucket, object, "stage=refused", &ObjectOptions::default())
.await
else {
panic!("tagging a version below write quorum must be refused");
};
assert!(is_write_quorum_error(&err), "tagging keeps the write-quorum error: {err:?}");
let Err(err) = set
.put_object_metadata(
bucket,
object,
&ObjectOptions {
versioned: true,
eval_metadata: Some(HashMap::from([("x-amz-meta-probe".to_string(), "refused".to_string())])),
..Default::default()
},
)
.await
else {
panic!("a metadata update of a version below write quorum must be refused");
};
assert!(is_write_quorum_error(&err), "the metadata update keeps the write-quorum error: {err:?}");
let v4 = Uuid::new_v4();
let mut source = head(&set, bucket, object, None).await.expect("latest HEAD");
source.version_only = true;
let Err(err) = set
.copy_object(
bucket,
object,
bucket,
object,
&mut source,
&ObjectOptions {
versioned: true,
..Default::default()
},
&ObjectOptions {
version_id: Some(v4.to_string()),
versioned: true,
object_lock_config_snapshot: Some(Arc::new(crate::object_api::ObjectLockConfigSnapshot::new(
crate::bucket::metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
))),
..Default::default()
},
)
.await
else {
panic!("a version-only copy of a version below write quorum must be refused");
};
assert!(is_write_quorum_error(&err), "the version-only copy keeps the write-quorum error: {err:?}");
assert!(
version_metadata(&set, bucket, object, v4).await.iter().all(Option::is_none),
"a refused version-only copy must not write the new version to any drive"
);
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
assert!(
metadata.as_ref().is_none_or(|metadata| {
!metadata.contains_key(rustfs_utils::http::headers::AMZ_OBJECT_TAGGING)
&& !metadata.contains_key("x-amz-meta-probe")
}),
"drive {disk} must not be written by a refused update"
);
}
}
#[tokio::test]
async fn replication_status_writeback_never_walks_past_dangling_versions() {
// A replication terminal CAS stays side-effect free: it keeps the
// read-quorum error instead of resolving the latest version, so it
// neither queues heal for the dangling version nor writes version one.
let bucket = "dangling-latest-replication-cas";
let object = "torn/latest";
let (_dirs, set) = dangling_test_set(bucket).await;
let (v1, _) = put_committed_below_dangling(&set, bucket, object).await;
let Err(err) = set
.put_object_metadata(
bucket,
object,
&ObjectOptions {
versioned: true,
eval_metadata: Some(HashMap::from([("x-amz-meta-probe".to_string(), "written".to_string())])),
replication_status_writeback: Some(Box::new(crate::object_api::ReplicationStatusWritebackCondition {
mode: crate::object_api::ReplicationStatusWritebackMode::ValidateOnly,
..Default::default()
})),
..Default::default()
},
)
.await
else {
panic!("a replication terminal CAS must not resolve past a dangling version");
};
assert!(is_read_quorum_error(&err), "the CAS keeps the read-quorum error: {err:?}");
for (disk, metadata) in version_metadata(&set, bucket, object, v1).await.iter().enumerate() {
let metadata = metadata.as_ref().expect("version one is on every drive");
assert!(!metadata.contains_key("x-amz-meta-probe"), "drive {disk} must not be written");
}
}
#[tokio::test]
async fn latest_metadata_updates_past_dangling_versions_require_every_drive_to_answer() {
let bucket = "dangling-latest-update-offline";
let object = "torn/latest";
let (_dirs, set) = dangling_test_set(bucket).await;
put_committed_below_dangling(&set, bucket, object).await;
let offline = set.disks.write().await[5].take().expect("drive five should start online");
let Err(err) = set
.put_object_tags(bucket, object, "stage=uncertain", &ObjectOptions::default())
.await
else {
panic!("tagging must not resolve past a version an offline drive may commit");
};
assert!(is_read_quorum_error(&err), "tagging keeps the read-quorum error: {err:?}");
let Err(err) = set
.put_object_metadata(
bucket,
object,
&ObjectOptions {
versioned: true,
eval_metadata: Some(HashMap::from([("x-amz-meta-probe".to_string(), "uncertain".to_string())])),
..Default::default()
},
)
.await
else {
panic!("a metadata update must not resolve past a version an offline drive may commit");
};
assert!(is_read_quorum_error(&err), "the metadata update keeps the read-quorum error: {err:?}");
set.disks.write().await[5] = Some(offline);
}
#[tokio::test]
async fn dangling_classification_keeps_quorum_error_for_corrupt_metadata() {
let bucket = "dangling-read-corrupt";
+54 -78
View File
@@ -507,6 +507,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 +586,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 +624,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 +689,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 +757,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 +849,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 +908,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()
}
@@ -1116,7 +1129,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 +1146,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 +2347,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 +2358,8 @@ impl SetDisks {
include_marker: has_version_marker,
..Default::default()
};
opts.parse_marker();
let mut page = self
.list_path_result(&opts)
.await
@@ -2813,8 +2832,8 @@ mod lifecycle_exact_tests;
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,
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,
version_marker_for_entries, walk_result_from_set_errors,
@@ -4322,10 +4341,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 +4399,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 +4409,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 +4418,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 +4589,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
@@ -7011,6 +6971,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 {
-2
View File
@@ -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)
}
+88 -493
View File
@@ -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]
+13 -25
View File
@@ -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,
+7 -15
View File
@@ -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())
+4 -8
View File
@@ -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}"),
}
});
}
-38
View File
@@ -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 [
+13 -235
View File
@@ -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() {
+1 -1
View File
@@ -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,
+6 -35
View File
@@ -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
+7 -182
View File
@@ -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());
-22
View File
@@ -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)
+13 -105
View File
@@ -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"
);
+1 -28
View File
@@ -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(),
-141
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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`. 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. Metadata updates of the latest version (object tagging, `put_object_metadata`) and the CopyObject source read resolve a failed selection through the same walk; when the walk finds no readable version, tagging keeps the read-quorum error while `put_object_metadata` and CopyObject keep their existing `delete_if_dangling` cleanup. Replication status write-back never walks. The update targets only the served version's disks and is refused before writing when they cannot reach its write quorum, including a version-only copy.
- **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)).
+9 -36
View File
@@ -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.
+8 -10
View File
@@ -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
-5
View File
@@ -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(&params)?;
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(&params)?;
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
View File
@@ -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;
-8
View File
@@ -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,
-7
View File
@@ -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;
+2 -56
View File
@@ -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 {
+6 -80
View File
@@ -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);
}
}
+19 -52
View File
@@ -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>()
-218
View File
@@ -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");
});
}
-24
View File
@@ -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;
+13 -35
View File
@@ -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]
+1 -3
View File
@@ -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 {
-2
View File
@@ -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;
+2 -2
View File
@@ -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
-41
View File
@@ -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
+8 -81
View File
@@ -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:
+4 -329
View File
@@ -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()