mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-14 00:53:14 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 62c5d7bd44 | |||
| 7e2eeb6fc3 |
@@ -182,12 +182,7 @@ jobs:
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Readers: test-and-lint-rio-v2 (per-PR), build-rustfs-debug-binary-rio-v2
|
||||
# (weekly schedule / manual dispatch only — dormant rio-v2 variant, see
|
||||
# rustfs/backlog#1835 and docs/architecture/minio-file-format-compat.md).
|
||||
# The second build below stays despite the reduced cadence: it warms the
|
||||
# rio-v2,e2e-test-hooks feature resolution the scheduled build restores,
|
||||
# which keeps that lane inside its 30-minute timeout.
|
||||
# Readers: test-and-lint-rio-v2, build-rustfs-debug-binary-rio-v2.
|
||||
warm-ci-feat-rio:
|
||||
name: Warm ci-feat-rio
|
||||
runs-on: sm-standard-4
|
||||
|
||||
@@ -533,12 +533,7 @@ jobs:
|
||||
|
||||
build-rustfs-debug-binary-rio-v2:
|
||||
name: Build RustFS Debug Binary (rio-v2)
|
||||
# Dormant rio-v2 variant (rustfs/backlog#1835): the feature ships in no
|
||||
# default build, so this full-suite lane runs only on the weekly schedule
|
||||
# and manual dispatch. Per-PR cfg-seam coverage stays with
|
||||
# test-and-lint-rio-v2. Lifecycle and the promote-or-delete condition:
|
||||
# docs/architecture/minio-file-format-compat.md ("rio-v2 variant lifecycle").
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 30
|
||||
@@ -829,9 +824,6 @@ jobs:
|
||||
|
||||
e2e-tests-rio-v2:
|
||||
name: End-to-End Tests (rio-v2)
|
||||
# Inherits the schedule/dispatch-only gate through needs: on every other
|
||||
# event build-rustfs-debug-binary-rio-v2 is skipped, so this job skips
|
||||
# with it (see the dormant-variant comment on that job).
|
||||
needs: [ build-rustfs-debug-binary-rio-v2 ]
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 30
|
||||
|
||||
+1
-4
@@ -101,10 +101,7 @@ refactors.
|
||||
|
||||
The `rustfs` binary crate composes these libraries into the running server.
|
||||
`ecstore` remains the storage engine at the architectural center; its internal
|
||||
module split is tracked under `docs/architecture/`. `rio-v2` is the
|
||||
feature-gated MinIO on-disk format compatibility I/O layer; it ships in no
|
||||
default build (lifecycle:
|
||||
[docs/architecture/minio-file-format-compat.md](docs/architecture/minio-file-format-compat.md)).
|
||||
module split is tracked under `docs/architecture/`.
|
||||
|
||||
## Architecture Invariants
|
||||
|
||||
|
||||
Generated
+30
-28
@@ -1162,9 +1162,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-eventstream"
|
||||
version = "0.61.2"
|
||||
version = "0.61.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6de526c7b567420a31bc283657a7921b45c4cafe0827fdf2490713dcc770c28f"
|
||||
checksum = "5a9381123ab62d20c13082b151f30f962a3b112b727345394536dfa39a482944"
|
||||
dependencies = [
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
@@ -1195,9 +1195,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http-client"
|
||||
version = "1.3.0"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c1c8a04cb31ba74d0115af5a890bb8c0d48fba64b52812fa13929a6ef0cc83c"
|
||||
checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-protocol-test",
|
||||
@@ -1277,9 +1277,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime"
|
||||
version = "1.13.1"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "483b858ff67522011c4786310c5cd8fd88d0be7ea3d5f1a48328446300c4269e"
|
||||
checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
@@ -1343,9 +1343,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-types"
|
||||
version = "1.6.2"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647"
|
||||
checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -5133,9 +5133,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.5"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
|
||||
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -5957,7 +5957,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.49"
|
||||
source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11#6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11"
|
||||
source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=ce6338661179c8be22e516b00af7483f151485a7#ce6338661179c8be22e516b00af7483f151485a7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cty",
|
||||
@@ -6259,9 +6259,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "metrique"
|
||||
version = "0.1.30"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dedbf06ffeef4c37990c73636fbd993aa34fb1948afd736e6114f239220993db"
|
||||
checksum = "d2e394c63e2d1a30aeb3b9392ecf3439d8475d2df810a8f4f6e66d6866754017"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"jiff",
|
||||
@@ -6289,9 +6289,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "metrique-macro"
|
||||
version = "0.1.21"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4fb1f30185f53f7f6e4c9e46745c1a1350af8e77fda5a88aded44b0637a82e0"
|
||||
checksum = "786df1fd0abebd0db685f7e9a353c78756d4b370fb98a52376c2015fa55f141f"
|
||||
dependencies = [
|
||||
"Inflector",
|
||||
"darling 0.23.0",
|
||||
@@ -6318,9 +6318,9 @@ checksum = "2faca4e4480069ff02b1763b3b79f5cec7e8628e24d9dc5b6073f53d2577a4d9"
|
||||
|
||||
[[package]]
|
||||
name = "metrique-writer"
|
||||
version = "0.1.26"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20bd17c1a3ca2719e31f19ce77a853948dc2102f35976b92276c42a64fdc5f3f"
|
||||
checksum = "82cdde44d241dab7fc8b7a32e0eb5dae6cd28f8de80b59f9a1e9f2f0b05e485e"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"crossbeam-queue",
|
||||
@@ -6339,9 +6339,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "metrique-writer-core"
|
||||
version = "0.1.20"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1a55b6aae1d85c557c729564c4e2b32a26dc65ba2d90d9647ca01f2bd4854c4"
|
||||
checksum = "e57379b7ee2272efaeaaa6de062503563e57333b24aadc7f2255b3d602899e8b"
|
||||
dependencies = [
|
||||
"derive-where",
|
||||
"itertools 0.14.0",
|
||||
@@ -6366,7 +6366,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.52"
|
||||
source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11#6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11"
|
||||
source = "git+https://github.com/xonatius/mimalloc_rust.git?rev=ce6338661179c8be22e516b00af7483f151485a7#ce6338661179c8be22e516b00af7483f151485a7"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
@@ -6882,7 +6882,7 @@ version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"getrandom 0.2.17",
|
||||
"http 1.5.0",
|
||||
@@ -8043,7 +8043,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"itertools 0.10.5",
|
||||
"itertools 0.14.0",
|
||||
"log",
|
||||
"multimap",
|
||||
"once_cell",
|
||||
@@ -8063,7 +8063,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"itertools 0.10.5",
|
||||
"itertools 0.14.0",
|
||||
"log",
|
||||
"multimap",
|
||||
"petgraph 0.8.3",
|
||||
@@ -8084,7 +8084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.10.5",
|
||||
"itertools 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
@@ -8097,7 +8097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.10.5",
|
||||
"itertools 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
@@ -9620,7 +9620,9 @@ dependencies = [
|
||||
"metrics",
|
||||
"metrics-util",
|
||||
"num_cpus",
|
||||
"rustfs-common",
|
||||
"rustfs-s3-ops",
|
||||
"rustfs-utils",
|
||||
"sysinfo",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
@@ -10559,9 +10561,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-connector"
|
||||
version = "0.23.8"
|
||||
version = "0.23.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1babecfcc65b139b812e74bcc7f9ec7b4e00db659fd42d99567b7e77f0c714c6"
|
||||
checksum = "09a5abe04eec18f8b9fbe87885bcaee6426de80bbc579958c0bc064b728ee617"
|
||||
dependencies = [
|
||||
"futures-io",
|
||||
"futures-rustls",
|
||||
@@ -11800,7 +11802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
+6
-6
@@ -41,7 +41,7 @@ members = [
|
||||
"crates/protocols", # Protocol implementations (FTPS, SFTP, etc.)
|
||||
"crates/protos", # Protocol buffer definitions
|
||||
"crates/rio", # Rust I/O utilities and abstractions
|
||||
"crates/rio-v2", # MinIO on-disk format compatibility I/O layer (feature-gated, ships in no default build)
|
||||
"crates/rio-v2", # Next-generation Rust I/O compatibility layer
|
||||
"crates/replication", # Replication contracts and wire formats
|
||||
"crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling
|
||||
"crates/s3-types", # S3 event type definitions
|
||||
@@ -154,7 +154,7 @@ hyper-rustls = { default-features = false, version = "0.27.9" }
|
||||
hyper-util = { version = "0.1.20" }
|
||||
http = "1.5.0"
|
||||
http-body = "1.1.0"
|
||||
http-body-util = "0.1.5"
|
||||
http-body-util = "0.1.4"
|
||||
minlz = "1.2.3"
|
||||
reqwest = "0.13.4"
|
||||
rustfs-kafka-async = { version = "1.2.0" }
|
||||
@@ -231,9 +231,9 @@ aws-credential-types = { version = "1.3.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.114.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.141.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.110.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.3.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.2.0" }
|
||||
aws-smithy-runtime-api = { version = "1.14.0" }
|
||||
aws-smithy-types = { version = "1.6.2" }
|
||||
aws-smithy-types = { version = "1.6.1" }
|
||||
base64 = "0.23.1"
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "8.0.4"
|
||||
@@ -348,8 +348,8 @@ russh-sftp = "2.4.0"
|
||||
dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
mimalloc = { version = "0.1.52", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11" }
|
||||
libmimalloc-sys = { version = "0.1.49", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "6d4c41bb10c6d9da1d1b6f07b38c4cc051667f11", features = ["extended"] }
|
||||
mimalloc = { version = "0.1.52", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "ce6338661179c8be22e516b00af7483f151485a7" }
|
||||
libmimalloc-sys = { version = "0.1.49", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "ce6338661179c8be22e516b00af7483f151485a7", features = ["extended"] }
|
||||
hotpath = { version = "0.23.2", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.48" }
|
||||
|
||||
@@ -353,11 +353,6 @@ pub const DEFAULT_OBS_TRACES_EXPORT_ENABLED: bool = true;
|
||||
/// Environment variable: RUSTFS_OBS_METRICS_EXPORT_ENABLED
|
||||
pub const DEFAULT_OBS_METRICS_EXPORT_ENABLED: bool = true;
|
||||
|
||||
/// Default detailed PUT stage metrics enabled
|
||||
/// Default value: false
|
||||
/// Environment variable: RUSTFS_OBS_PUT_STAGE_METRICS_ENABLED
|
||||
pub const DEFAULT_OBS_PUT_STAGE_METRICS_ENABLED: bool = false;
|
||||
|
||||
/// Default logs export enabled
|
||||
/// It is used to enable or disable exporting logs
|
||||
/// Default value: true
|
||||
|
||||
@@ -137,37 +137,6 @@ pub const DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: bool = false;
|
||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
|
||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
|
||||
|
||||
/// Request the object-transaction fencing contract used by storage-owned
|
||||
/// cleanup receipts and lock-window optimizations.
|
||||
///
|
||||
/// This is fail-closed: enabling the writer without a live fleet proof rejects
|
||||
/// the commit rather than silently using a legacy-safe path.
|
||||
pub const ENV_OBJECT_TRANSACTION_FENCING_WRITE: &str = "RUSTFS_OBJECT_TRANSACTION_FENCING_WRITE";
|
||||
pub const DEFAULT_OBJECT_TRANSACTION_FENCING_WRITE: bool = false;
|
||||
|
||||
/// Operator-attested confirmation that every serving node understands the
|
||||
/// object transaction fencing contract.
|
||||
pub const ENV_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED: &str = "RUSTFS_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED";
|
||||
pub const DEFAULT_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED: bool = false;
|
||||
|
||||
const _: () = assert!(!DEFAULT_OBJECT_TRANSACTION_FENCING_WRITE);
|
||||
const _: () = assert!(!DEFAULT_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED);
|
||||
|
||||
/// Request preserving legacy per-part checksum metadata during data movement.
|
||||
///
|
||||
/// This remains ineffective until
|
||||
/// [`ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED`] is also enabled.
|
||||
pub const ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE: &str = "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE";
|
||||
pub const DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_WRITE: bool = false;
|
||||
|
||||
/// Operator-attested confirmation that every serving node understands the
|
||||
/// data-movement per-part checksum sidecar.
|
||||
pub const ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED: &str = "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED";
|
||||
pub const DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED: bool = false;
|
||||
|
||||
const _: () = assert!(!DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_WRITE);
|
||||
const _: () = assert!(!DEFAULT_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED);
|
||||
|
||||
// =============================================================================
|
||||
// Concurrent Request Fix - Timeout and Backpressure Configuration
|
||||
// =============================================================================
|
||||
@@ -680,22 +649,4 @@ mod remote_version_state_tests {
|
||||
"RUSTFS_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_movement_part_checksum_gate_uses_stable_environment_names() {
|
||||
assert_eq!(super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE");
|
||||
assert_eq!(
|
||||
super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED,
|
||||
"RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_FLEET_CONFIRMED"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_transaction_fencing_gate_uses_stable_environment_names() {
|
||||
assert_eq!(super::ENV_OBJECT_TRANSACTION_FENCING_WRITE, "RUSTFS_OBJECT_TRANSACTION_FENCING_WRITE");
|
||||
assert_eq!(
|
||||
super::ENV_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED,
|
||||
"RUSTFS_OBJECT_TRANSACTION_FENCING_FLEET_CONFIRMED"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +44,6 @@ pub const ENV_OBS_METRICS_EXPORT_ENABLED: &str = "RUSTFS_OBS_METRICS_EXPORT_ENAB
|
||||
pub const ENV_OBS_LOGS_EXPORT_ENABLED: &str = "RUSTFS_OBS_LOGS_EXPORT_ENABLED";
|
||||
pub const ENV_OBS_PROFILING_EXPORT_ENABLED: &str = "RUSTFS_OBS_PROFILING_EXPORT_ENABLED";
|
||||
|
||||
/// Enables detailed per-stage PUT metrics. Disabled by default because each
|
||||
/// PUT records multiple timers and histograms when attribution is active.
|
||||
pub const ENV_OBS_PUT_STAGE_METRICS_ENABLED: &str = "RUSTFS_OBS_PUT_STAGE_METRICS_ENABLED";
|
||||
|
||||
pub const ENV_OBS_LOGGER_LEVEL: &str = "RUSTFS_OBS_LOGGER_LEVEL";
|
||||
pub const ENV_OBS_LOG_STDOUT_ENABLED: &str = "RUSTFS_OBS_LOG_STDOUT_ENABLED";
|
||||
pub const ENV_OBS_LOG_DIRECTORY: &str = "RUSTFS_OBS_LOG_DIRECTORY";
|
||||
@@ -145,7 +141,6 @@ mod tests {
|
||||
assert_eq!(ENV_OBS_METRICS_EXPORT_ENABLED, "RUSTFS_OBS_METRICS_EXPORT_ENABLED");
|
||||
assert_eq!(ENV_OBS_LOGS_EXPORT_ENABLED, "RUSTFS_OBS_LOGS_EXPORT_ENABLED");
|
||||
assert_eq!(ENV_OBS_PROFILING_EXPORT_ENABLED, "RUSTFS_OBS_PROFILING_EXPORT_ENABLED");
|
||||
assert_eq!(ENV_OBS_PUT_STAGE_METRICS_ENABLED, "RUSTFS_OBS_PUT_STAGE_METRICS_ENABLED");
|
||||
// Test log cleanup related env keys
|
||||
assert_eq!(ENV_OBS_LOG_MAX_TOTAL_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_TOTAL_SIZE_BYTES");
|
||||
assert_eq!(ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES");
|
||||
|
||||
@@ -2871,32 +2871,11 @@ fn stale_upload_default_due(initiated: OffsetDateTime, default_expiry: StdDurati
|
||||
}
|
||||
|
||||
async fn stale_upload_current_size(set: &Arc<SetDisks>, metadata: &HashMap<String, String>, upload_dir: &str) -> Option<usize> {
|
||||
stale_upload_current_size_with_opts(set, metadata, upload_dir, false).await
|
||||
}
|
||||
|
||||
async fn stale_upload_current_size_with_opts(
|
||||
set: &Arc<SetDisks>,
|
||||
metadata: &HashMap<String, String>,
|
||||
upload_dir: &str,
|
||||
no_lock: bool,
|
||||
) -> Option<usize> {
|
||||
let bucket = metadata.get(RUSTFS_MULTIPART_BUCKET_KEY)?;
|
||||
let object = metadata.get(RUSTFS_MULTIPART_OBJECT_KEY)?;
|
||||
let upload_id = encode_stale_upload_id(upload_dir);
|
||||
let data_movement = rustfs_utils::http::contains_key_str(metadata, rustfs_utils::http::SUFFIX_DATA_MOVEMENT_UPLOAD);
|
||||
let parts = set
|
||||
.list_object_parts(
|
||||
bucket,
|
||||
object,
|
||||
&upload_id,
|
||||
None,
|
||||
MAX_PARTS_COUNT,
|
||||
&ObjectOptions {
|
||||
data_movement,
|
||||
no_lock,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.list_object_parts(bucket, object, &upload_id, None, MAX_PARTS_COUNT, &ObjectOptions::default())
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
@@ -2914,12 +2893,7 @@ async fn stale_upload_lifecycle_due(
|
||||
metadata: &HashMap<String, String>,
|
||||
initiated: OffsetDateTime,
|
||||
upload_dir: &str,
|
||||
no_lock: bool,
|
||||
) -> Option<OffsetDateTime> {
|
||||
if rustfs_utils::http::contains_key_str(metadata, rustfs_utils::http::SUFFIX_DATA_MOVEMENT_UPLOAD) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bucket = metadata.get(RUSTFS_MULTIPART_BUCKET_KEY)?;
|
||||
let object = metadata.get(RUSTFS_MULTIPART_OBJECT_KEY)?;
|
||||
|
||||
@@ -2932,9 +2906,7 @@ async fn stale_upload_lifecycle_due(
|
||||
name: object.clone(),
|
||||
user_tags: metadata.get(AMZ_OBJECT_TAGGING).cloned().unwrap_or_default(),
|
||||
mod_time: Some(initiated),
|
||||
size: stale_upload_current_size_with_opts(set, metadata, upload_dir, no_lock)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
size: stale_upload_current_size(set, metadata, upload_dir).await.unwrap_or_default(),
|
||||
is_latest: true,
|
||||
delete_marker: false,
|
||||
user_defined: metadata.clone(),
|
||||
@@ -2962,7 +2934,6 @@ async fn read_stale_multipart_candidate(
|
||||
FileInfoOpts {
|
||||
data: false,
|
||||
include_free_versions: false,
|
||||
include_part_checksums: false,
|
||||
},
|
||||
) {
|
||||
Ok(file_info) => (Some(file_info.metadata), file_info.mod_time),
|
||||
@@ -3002,30 +2973,36 @@ fn merge_stale_multipart_candidate(
|
||||
}
|
||||
}
|
||||
|
||||
fn is_multipart_sha_dir(path: &str) -> bool {
|
||||
path.len() == 64 && path.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn multipart_sha_path(root: &str, entry: &str) -> Option<String> {
|
||||
let sha_dir = entry.trim_end_matches('/');
|
||||
is_multipart_sha_dir(sha_dir).then(|| {
|
||||
if root.is_empty() {
|
||||
sha_dir.to_string()
|
||||
} else {
|
||||
format!("{root}/{sha_dir}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn cleanup_empty_multipart_sha_dirs_on_local_disks(set: &Arc<SetDisks>) {
|
||||
for disk in set.get_local_disks().await.into_iter().flatten() {
|
||||
if !disk.is_online().await {
|
||||
continue;
|
||||
}
|
||||
|
||||
for root in ["", crate::set_disk::DATA_MOVEMENT_MULTIPART_PREFIX] {
|
||||
let sha_dirs = match disk
|
||||
.list_dir(RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_MULTIPART_BUCKET, root, -1)
|
||||
let sha_dirs = match disk
|
||||
.list_dir(RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_MULTIPART_BUCKET, "", -1)
|
||||
.await
|
||||
{
|
||||
Ok(entries) => entries,
|
||||
Err(err) => {
|
||||
if err != DiskError::FileNotFound && err != DiskError::VolumeNotFound {
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
error = ?err,
|
||||
reason = "multipart_root_list_failed",
|
||||
"Skipped empty multipart sha cleanup"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for sha_dir in sha_dirs {
|
||||
let sha_dir = sha_dir.trim_end_matches('/').to_string();
|
||||
let upload_dirs = match disk
|
||||
.list_dir(RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_MULTIPART_BUCKET, &sha_dir, -1)
|
||||
.await
|
||||
{
|
||||
Ok(entries) => entries,
|
||||
@@ -3035,8 +3012,9 @@ async fn cleanup_empty_multipart_sha_dirs_on_local_disks(set: &Arc<SetDisks>) {
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
sha_dir = %sha_dir,
|
||||
error = ?err,
|
||||
reason = "multipart_root_list_failed",
|
||||
reason = "multipart_sha_dir_list_failed",
|
||||
"Skipped empty multipart sha cleanup"
|
||||
);
|
||||
}
|
||||
@@ -3044,48 +3022,25 @@ async fn cleanup_empty_multipart_sha_dirs_on_local_disks(set: &Arc<SetDisks>) {
|
||||
}
|
||||
};
|
||||
|
||||
for sha_dir in sha_dirs.into_iter().filter_map(|entry| multipart_sha_path(root, &entry)) {
|
||||
let upload_dirs = match disk
|
||||
.list_dir(RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_MULTIPART_BUCKET, &sha_dir, -1)
|
||||
.await
|
||||
{
|
||||
Ok(entries) => entries,
|
||||
Err(err) => {
|
||||
if err != DiskError::FileNotFound && err != DiskError::VolumeNotFound {
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
sha_dir = %sha_dir,
|
||||
error = ?err,
|
||||
reason = "multipart_sha_dir_list_failed",
|
||||
"Skipped empty multipart sha cleanup"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !upload_dirs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !upload_dirs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = disk
|
||||
.delete(RUSTFS_META_MULTIPART_BUCKET, &sha_dir, DeleteOptions::default())
|
||||
.await
|
||||
&& err != DiskError::FileNotFound
|
||||
&& err != DiskError::VolumeNotFound
|
||||
{
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
sha_dir = %sha_dir,
|
||||
error = ?err,
|
||||
reason = "multipart_sha_dir_remove_failed",
|
||||
"Failed to remove empty multipart sha dir"
|
||||
);
|
||||
}
|
||||
if let Err(err) = disk
|
||||
.delete(RUSTFS_META_MULTIPART_BUCKET, &sha_dir, DeleteOptions::default())
|
||||
.await
|
||||
&& err != DiskError::FileNotFound
|
||||
&& err != DiskError::VolumeNotFound
|
||||
{
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
sha_dir = %sha_dir,
|
||||
error = ?err,
|
||||
reason = "multipart_sha_dir_remove_failed",
|
||||
"Failed to remove empty multipart sha dir"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3103,9 +3058,30 @@ async fn cleanup_stale_multipart_uploads_in_set(set: &Arc<SetDisks>, now: Offset
|
||||
continue;
|
||||
}
|
||||
|
||||
for root in ["", crate::set_disk::DATA_MOVEMENT_MULTIPART_PREFIX] {
|
||||
let sha_dirs = match disk
|
||||
.list_dir(RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_MULTIPART_BUCKET, root, -1)
|
||||
let sha_dirs = match disk
|
||||
.list_dir(RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_MULTIPART_BUCKET, "", -1)
|
||||
.await
|
||||
{
|
||||
Ok(entries) => entries,
|
||||
Err(err) => {
|
||||
if err != DiskError::FileNotFound && err != DiskError::VolumeNotFound {
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
error = ?err,
|
||||
reason = "multipart_root_list_failed",
|
||||
"Skipped stale multipart cleanup"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for sha_dir in sha_dirs {
|
||||
let sha_dir = sha_dir.trim_end_matches('/').to_string();
|
||||
let upload_dirs = match disk
|
||||
.list_dir(RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_MULTIPART_BUCKET, &sha_dir, -1)
|
||||
.await
|
||||
{
|
||||
Ok(entries) => entries,
|
||||
@@ -3115,8 +3091,9 @@ async fn cleanup_stale_multipart_uploads_in_set(set: &Arc<SetDisks>, now: Offset
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
sha_dir = %sha_dir,
|
||||
error = ?err,
|
||||
reason = "multipart_root_list_failed",
|
||||
reason = "multipart_sha_dir_list_failed",
|
||||
"Skipped stale multipart cleanup"
|
||||
);
|
||||
}
|
||||
@@ -3124,62 +3101,39 @@ async fn cleanup_stale_multipart_uploads_in_set(set: &Arc<SetDisks>, now: Offset
|
||||
}
|
||||
};
|
||||
|
||||
for sha_dir in sha_dirs.into_iter().filter_map(|entry| multipart_sha_path(root, &entry)) {
|
||||
let upload_dirs = match disk
|
||||
.list_dir(RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_MULTIPART_BUCKET, &sha_dir, -1)
|
||||
.await
|
||||
for upload_dir in upload_dirs {
|
||||
let upload_dir = upload_dir.trim_end_matches('/').to_string();
|
||||
let candidate_path = format!("{sha_dir}/{upload_dir}");
|
||||
if candidates
|
||||
.get(&candidate_path)
|
||||
.is_some_and(|existing: &StaleMultipartUploadCandidate| existing.metadata.is_some())
|
||||
{
|
||||
Ok(entries) => entries,
|
||||
continue;
|
||||
}
|
||||
|
||||
let candidate = match read_stale_multipart_candidate(disk.as_ref(), &sha_dir, &upload_dir).await {
|
||||
Ok(candidate) => candidate,
|
||||
Err(err) => {
|
||||
if err != DiskError::FileNotFound && err != DiskError::VolumeNotFound {
|
||||
if err != DiskError::FileNotFound {
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
sha_dir = %sha_dir,
|
||||
path = %candidate_path,
|
||||
error = ?err,
|
||||
reason = "multipart_sha_dir_list_failed",
|
||||
"Skipped stale multipart cleanup"
|
||||
reason = "multipart_metadata_read_failed",
|
||||
"Multipart metadata unavailable during stale cleanup"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
let initiated = initiated_from_upload_dir(&upload_dir, None);
|
||||
StaleMultipartUploadCandidate {
|
||||
path: candidate_path,
|
||||
initiated,
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for upload_dir in upload_dirs {
|
||||
let upload_dir = upload_dir.trim_end_matches('/').to_string();
|
||||
let candidate_path = format!("{sha_dir}/{upload_dir}");
|
||||
if candidates
|
||||
.get(&candidate_path)
|
||||
.is_some_and(|existing: &StaleMultipartUploadCandidate| existing.metadata.is_some())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let candidate = match read_stale_multipart_candidate(disk.as_ref(), &sha_dir, &upload_dir).await {
|
||||
Ok(candidate) => candidate,
|
||||
Err(err) => {
|
||||
if err != DiskError::FileNotFound {
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
path = %candidate_path,
|
||||
error = ?err,
|
||||
reason = "multipart_metadata_read_failed",
|
||||
"Multipart metadata unavailable during stale cleanup"
|
||||
);
|
||||
}
|
||||
let initiated = initiated_from_upload_dir(&upload_dir, None);
|
||||
StaleMultipartUploadCandidate {
|
||||
path: candidate_path,
|
||||
initiated,
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
merge_stale_multipart_candidate(&mut candidates, candidate);
|
||||
}
|
||||
merge_stale_multipart_candidate(&mut candidates, candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3188,7 +3142,7 @@ async fn cleanup_stale_multipart_uploads_in_set(set: &Arc<SetDisks>, now: Offset
|
||||
let upload_dir = candidate.path.rsplit('/').next().unwrap_or_default().to_string();
|
||||
let mut due = stale_upload_default_due(candidate.initiated, default_expiry);
|
||||
if let Some(metadata) = candidate.metadata.as_ref()
|
||||
&& let Some(lifecycle_due) = stale_upload_lifecycle_due(set, metadata, candidate.initiated, &upload_dir, false).await
|
||||
&& let Some(lifecycle_due) = stale_upload_lifecycle_due(set, metadata, candidate.initiated, &upload_dir).await
|
||||
&& lifecycle_due < due
|
||||
{
|
||||
due = lifecycle_due;
|
||||
@@ -3198,49 +3152,34 @@ async fn cleanup_stale_multipart_uploads_in_set(set: &Arc<SetDisks>, now: Offset
|
||||
continue;
|
||||
}
|
||||
|
||||
let cleanup_guard = match set.lock_stale_multipart_cleanup(&candidate.path).await {
|
||||
Ok(guard) => guard,
|
||||
Err(err) => {
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
path = %candidate.path,
|
||||
error = ?err,
|
||||
reason = "multipart_cleanup_lock_or_recheck_failed",
|
||||
"Skipped stale multipart cleanup"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let current_metadata = cleanup_guard.file_info().metadata.clone();
|
||||
let current_initiated = initiated_from_upload_dir(&upload_dir, cleanup_guard.file_info().mod_time);
|
||||
let mut current_due = stale_upload_default_due(current_initiated, default_expiry);
|
||||
if let Some(lifecycle_due) =
|
||||
stale_upload_lifecycle_due(set, ¤t_metadata, current_initiated, &upload_dir, true).await
|
||||
&& lifecycle_due < current_due
|
||||
{
|
||||
current_due = lifecycle_due;
|
||||
}
|
||||
if now < current_due || cleanup_guard.is_lock_lost() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match cleanup_guard.delete(set).await {
|
||||
match set.delete_all(RUSTFS_META_MULTIPART_BUCKET, &candidate.path).await {
|
||||
Ok(()) => {
|
||||
deleted += 1;
|
||||
let upload_id = encode_stale_upload_id(&upload_dir);
|
||||
debug!(
|
||||
bucket = current_metadata.get(RUSTFS_MULTIPART_BUCKET_KEY).cloned().unwrap_or_default(),
|
||||
object = current_metadata.get(RUSTFS_MULTIPART_OBJECT_KEY).cloned().unwrap_or_default(),
|
||||
upload_id = %upload_id,
|
||||
due = ?current_due,
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
state = "removed",
|
||||
"Removed stale multipart upload"
|
||||
);
|
||||
if let Some(metadata) = candidate.metadata.as_ref() {
|
||||
debug!(
|
||||
bucket = metadata.get(RUSTFS_MULTIPART_BUCKET_KEY).cloned().unwrap_or_default(),
|
||||
object = metadata.get(RUSTFS_MULTIPART_OBJECT_KEY).cloned().unwrap_or_default(),
|
||||
upload_id = %upload_id,
|
||||
due = ?due,
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
state = "removed",
|
||||
"Removed stale multipart upload"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
path = %candidate.path,
|
||||
upload_id = %upload_id,
|
||||
due = ?due,
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
state = "removed",
|
||||
"Removed stale multipart upload"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => debug!(
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
@@ -5223,7 +5162,6 @@ mod tests {
|
||||
use crate::services::tier::tier::TierConfigMgr;
|
||||
#[cfg(feature = "test-util")]
|
||||
use crate::services::tier::warm_backend::WarmBackend as _;
|
||||
use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause};
|
||||
use crate::set_disk::{RUSTFS_MULTIPART_BUCKET_KEY, RUSTFS_MULTIPART_OBJECT_KEY};
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
use crate::storage_api_contracts::{
|
||||
@@ -11988,135 +11926,6 @@ mod tests {
|
||||
assert!(is_err_invalid_upload_id(&err));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn stale_multipart_cleanup_handles_data_movement_namespace() {
|
||||
let (_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("stale-data-movement-{}", Uuid::new_v4().simple());
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
|
||||
let create_upload = |object: &'static str, mod_time| {
|
||||
let ecstore = ecstore.clone();
|
||||
let bucket = bucket.clone();
|
||||
async move {
|
||||
let mut metadata = HashMap::new();
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut metadata,
|
||||
rustfs_utils::http::SUFFIX_DATA_MOVEMENT_UPLOAD,
|
||||
"cleanup-test".to_string(),
|
||||
);
|
||||
ecstore
|
||||
.new_multipart_upload(
|
||||
&bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
data_movement: true,
|
||||
mod_time: Some(mod_time),
|
||||
user_defined: metadata,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("data movement multipart upload should be created")
|
||||
.upload_id
|
||||
}
|
||||
};
|
||||
|
||||
let stale_object = "stale-internal.bin";
|
||||
let active_object = "active-internal.bin";
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let stale_upload_id = create_upload(stale_object, now - time::Duration::hours(30)).await;
|
||||
let active_upload_id = create_upload(active_object, now).await;
|
||||
|
||||
let deleted = cleanup_stale_multipart_uploads_once_at(ecstore.clone(), now, StdDuration::from_secs(24 * 60 * 60)).await;
|
||||
assert!(deleted >= 1, "expected stale data movement upload to be removed");
|
||||
|
||||
let internal_opts = ObjectOptions {
|
||||
data_movement: true,
|
||||
..Default::default()
|
||||
};
|
||||
let stale_err = ecstore
|
||||
.get_multipart_info(&bucket, stale_object, &stale_upload_id, &internal_opts)
|
||||
.await
|
||||
.expect_err("stale data movement upload should be removed");
|
||||
assert!(is_err_invalid_upload_id(&stale_err));
|
||||
ecstore
|
||||
.get_multipart_info(&bucket, active_object, &active_upload_id, &internal_opts)
|
||||
.await
|
||||
.expect("active data movement upload should remain available");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn stale_multipart_cleanup_waits_for_data_movement_part_commit() {
|
||||
let (_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("stale-data-movement-lock-{}", Uuid::new_v4().simple());
|
||||
let object = "stale-internal.bin";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut metadata,
|
||||
rustfs_utils::http::SUFFIX_DATA_MOVEMENT_UPLOAD,
|
||||
"cleanup-lock-test".to_string(),
|
||||
);
|
||||
let opts = ObjectOptions {
|
||||
data_movement: true,
|
||||
mod_time: Some(OffsetDateTime::now_utc() - time::Duration::hours(30)),
|
||||
user_defined: metadata,
|
||||
..Default::default()
|
||||
};
|
||||
let upload = ecstore
|
||||
.new_multipart_upload(&bucket, object, &opts)
|
||||
.await
|
||||
.expect("data movement multipart upload should be created");
|
||||
let barrier = MultipartCommitBarrier::install(bucket.as_str(), object, MultipartCommitPause::PutPartAfterRename);
|
||||
let put_store = ecstore.clone();
|
||||
let put_bucket = bucket.clone();
|
||||
let upload_id = upload.upload_id.clone();
|
||||
let put_task = tokio::spawn(async move {
|
||||
let mut data = PutObjReader::from_vec(vec![1, 2, 3, 4]);
|
||||
put_store
|
||||
.put_object_part(
|
||||
&put_bucket,
|
||||
object,
|
||||
&upload_id,
|
||||
1,
|
||||
&mut data,
|
||||
&ObjectOptions {
|
||||
data_movement: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
});
|
||||
barrier.wait_until_paused().await;
|
||||
|
||||
let cleanup_store = ecstore.clone();
|
||||
let mut cleanup_task = tokio::spawn(async move {
|
||||
cleanup_stale_multipart_uploads_once_at(
|
||||
cleanup_store,
|
||||
OffsetDateTime::now_utc(),
|
||||
StdDuration::from_secs(24 * 60 * 60),
|
||||
)
|
||||
.await
|
||||
});
|
||||
assert!(
|
||||
tokio::time::timeout(StdDuration::from_millis(200), &mut cleanup_task)
|
||||
.await
|
||||
.is_err(),
|
||||
"stale cleanup must wait for the in-flight part commit upload lock"
|
||||
);
|
||||
|
||||
barrier.release();
|
||||
put_task
|
||||
.await
|
||||
.expect("part upload task should join")
|
||||
.expect("part upload should commit before stale cleanup");
|
||||
let deleted = cleanup_task.await.expect("stale cleanup task should join");
|
||||
assert!(deleted >= 1, "stale cleanup should proceed after the part commit releases its lock");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn stale_multipart_cleanup_applies_abort_incomplete_lifecycle_before_default_expiry() {
|
||||
@@ -12191,58 +12000,6 @@ mod tests {
|
||||
assert!(is_err_invalid_upload_id(&err));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn stale_multipart_cleanup_excludes_data_movement_from_abort_lifecycle() {
|
||||
let (_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("stale-internal-lifecycle-{}", Uuid::new_v4().simple());
|
||||
let object = "logs/internal/object.bin";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
set_abort_incomplete_lifecycle(&bucket, "logs/", 0).await;
|
||||
|
||||
let initiated = OffsetDateTime::now_utc() - time::Duration::minutes(5);
|
||||
let mut metadata = HashMap::new();
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut metadata,
|
||||
rustfs_utils::http::SUFFIX_DATA_MOVEMENT_UPLOAD,
|
||||
"lifecycle-exclusion-test".to_string(),
|
||||
);
|
||||
let upload = ecstore
|
||||
.new_multipart_upload(
|
||||
&bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
data_movement: true,
|
||||
mod_time: Some(initiated),
|
||||
user_defined: metadata,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("data movement multipart upload should be created");
|
||||
|
||||
let deleted = cleanup_stale_multipart_uploads_once_at(
|
||||
ecstore.clone(),
|
||||
OffsetDateTime::now_utc(),
|
||||
StdDuration::from_secs(7 * 24 * 60 * 60),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(deleted, 0, "bucket lifecycle must not remove active data movement uploads");
|
||||
|
||||
ecstore
|
||||
.get_multipart_info(
|
||||
&bucket,
|
||||
object,
|
||||
&upload.upload_id,
|
||||
&ObjectOptions {
|
||||
data_movement: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("active data movement upload should remain available");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn stale_multipart_cleanup_applies_abort_lifecycle_with_size_filter() {
|
||||
|
||||
@@ -31,7 +31,7 @@ use rustfs_config::{
|
||||
DEFAULT_INTERNODE_DATA_TRANSPORT, ENV_RUSTFS_INTERNODE_DATA_TRANSPORT, INTERNODE_DATA_TRANSPORT_TCP,
|
||||
KNOWN_INTERNODE_DATA_TRANSPORT_BACKENDS,
|
||||
};
|
||||
use rustfs_rio::{ChunkReaderBox, HttpChunkReader, HttpReader, HttpWriter};
|
||||
use rustfs_rio::{HttpReader, HttpWriter};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
@@ -221,11 +221,6 @@ pub struct NsScannerCapabilityRequest {
|
||||
#[async_trait]
|
||||
pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug {
|
||||
async fn open_read(&self, request: ReadStreamRequest) -> Result<FileReader>;
|
||||
/// Opens an owned-chunk stream when this transport can retain receive-buffer
|
||||
/// ownership. `None` preserves the established `open_read` fallback.
|
||||
async fn open_read_chunks(&self, _request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter>;
|
||||
async fn open_walk_dir(&self, request: WalkDirStreamRequest) -> Result<FileReader>;
|
||||
async fn open_ns_scanner(&self, _request: NsScannerStreamRequest) -> Result<FileReader> {
|
||||
@@ -252,15 +247,6 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
|
||||
))
|
||||
}
|
||||
|
||||
async fn open_read_chunks(&self, request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
|
||||
let url = build_read_file_stream_url(&request);
|
||||
let mut headers = json_headers();
|
||||
build_auth_headers(&url, &Method::GET, &mut headers)?;
|
||||
Ok(Some(Box::new(
|
||||
HttpChunkReader::new_with_stall_timeout(url, Method::GET, headers, None, request.stall_timeout).await?,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter> {
|
||||
let server_epoch = self.put_file_auth_capability(&request.endpoint).await?;
|
||||
let nonce = server_epoch.map(|_| Uuid::new_v4());
|
||||
|
||||
@@ -522,33 +522,6 @@ impl RemoteDisk {
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_read_chunks_with_retry(&self, request: ReadStreamRequest) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
|
||||
let mut attempt = 1;
|
||||
let mut last_retry_classification = None;
|
||||
loop {
|
||||
match self.data_transport.open_read_chunks(request.clone()).await {
|
||||
Ok(reader) => {
|
||||
if attempt > 1
|
||||
&& let Some(classification) = last_retry_classification
|
||||
{
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_open_read_retry_success(classification);
|
||||
}
|
||||
return Ok(reader);
|
||||
}
|
||||
Err(err) if attempt < REMOTE_DISK_OPEN_READ_MAX_ATTEMPTS && Self::is_retryable_open_read_error(&err) => {
|
||||
if let Some(classification) = err.internode_http_error_kind() {
|
||||
let classification = classification.metric_label();
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_open_read_retry(classification);
|
||||
last_retry_classification = Some(classification);
|
||||
}
|
||||
tokio::time::sleep(REMOTE_DISK_OPEN_READ_RETRY_BACKOFF).await;
|
||||
attempt += 1;
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_capacity_probe(&self, total: u64, used: u64, free: u64) {
|
||||
self.health.record_capacity_probe(total, used, free);
|
||||
}
|
||||
@@ -873,49 +846,31 @@ impl RemoteDisk {
|
||||
/// default to 1 (see [`internode_idempotent_read_retries`]). MUST NOT be used for write/lock
|
||||
/// RPCs — those must never auto-retry (quorum/idempotency safety). The `operation` closure is
|
||||
/// re-invoked per attempt, so it must be `Fn` (rebuild the request from borrowed inputs, do not
|
||||
/// move captured state out). Attempts and backoff share one total timeout budget.
|
||||
/// move captured state out).
|
||||
async fn execute_read_with_retry<T, F, Fut>(&self, op: &'static str, operation: F, timeout_duration: Duration) -> Result<T>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T>>,
|
||||
{
|
||||
let deadline = (!timeout_duration.is_zero()).then(|| {
|
||||
time::Instant::now()
|
||||
.checked_add(timeout_duration)
|
||||
.unwrap_or_else(|| time::sleep(timeout_duration).deadline())
|
||||
});
|
||||
let max_retries = internode_idempotent_read_retries();
|
||||
let mut attempt = 0usize;
|
||||
loop {
|
||||
let attempt_timeout = deadline
|
||||
.map(|deadline| deadline.saturating_duration_since(time::Instant::now()))
|
||||
.unwrap_or(Duration::ZERO);
|
||||
if deadline.is_some() && attempt_timeout.is_zero() {
|
||||
self.record_timeout(op, timeout_duration);
|
||||
return Err(DiskError::Timeout);
|
||||
}
|
||||
|
||||
// Only the final attempt marks the disk faulty / evicts the channel. Earlier retries
|
||||
// ignore the failure, so a transient error cannot flip the disk into a faulty
|
||||
// short-circuit (which would defeat the retry) or over-count failures.
|
||||
let health_action = if attempt >= max_retries {
|
||||
FailureHealthAction::MarkFailure
|
||||
} else {
|
||||
FailureHealthAction::IgnoreFailure
|
||||
};
|
||||
match self
|
||||
.execute_with_timeout_for_op_and_health_action(op, &operation, attempt_timeout, health_action)
|
||||
.execute_with_timeout_for_op_and_health_action(op, &operation, timeout_duration, health_action)
|
||||
.await
|
||||
{
|
||||
Err(err) if attempt < max_retries && is_network_like_disk_error(&err) => {
|
||||
if matches!(err, DiskError::Timeout) && deadline.is_some_and(|deadline| time::Instant::now() >= deadline) {
|
||||
self.mark_faulty("read_operation_deadline");
|
||||
return Err(err);
|
||||
}
|
||||
attempt += 1;
|
||||
let backoff = REMOTE_DISK_READ_RETRY_BASE_BACKOFF
|
||||
.saturating_mul(1u32 << u32::try_from(attempt - 1).unwrap_or(4).min(4));
|
||||
if deadline.is_some_and(|deadline| deadline.saturating_duration_since(time::Instant::now()) <= backoff) {
|
||||
attempt = max_retries;
|
||||
continue;
|
||||
}
|
||||
debug!(
|
||||
endpoint = %self.endpoint,
|
||||
addr = %self.addr,
|
||||
@@ -923,17 +878,7 @@ impl RemoteDisk {
|
||||
attempt,
|
||||
"retrying idempotent read-only RPC after transient network error"
|
||||
);
|
||||
if let Some(deadline) = deadline {
|
||||
if time::timeout_at(deadline, time::sleep(backoff)).await.is_err() {
|
||||
self.record_timeout(op, timeout_duration);
|
||||
return Err(DiskError::Timeout);
|
||||
}
|
||||
} else {
|
||||
time::sleep(backoff).await;
|
||||
}
|
||||
if self.health.is_faulty() {
|
||||
return Err(DiskError::FaultyDisk);
|
||||
}
|
||||
tokio::time::sleep(backoff).await;
|
||||
}
|
||||
other => return other,
|
||||
}
|
||||
@@ -1012,35 +957,32 @@ impl RemoteDisk {
|
||||
operation_result
|
||||
}
|
||||
Err(_) => {
|
||||
self.record_timeout(op, timeout_duration);
|
||||
// Timeout occurred, mark disk as potentially faulty
|
||||
counter!(
|
||||
"rustfs_drive_op_timeout_total",
|
||||
"endpoint" => self.endpoint.to_string(),
|
||||
"op" => op.to_string()
|
||||
)
|
||||
.increment(1);
|
||||
if failure_health_action == FailureHealthAction::MarkFailure {
|
||||
self.mark_faulty_and_evict("operation_timeout").await;
|
||||
}
|
||||
warn!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
|
||||
endpoint = %self.endpoint,
|
||||
addr = %self.addr,
|
||||
op,
|
||||
timeout_ms = timeout_duration.as_millis(),
|
||||
state = "timeout",
|
||||
"Remote disk operation timed out"
|
||||
);
|
||||
Err(DiskError::Timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_timeout(&self, op: &'static str, timeout_duration: Duration) {
|
||||
counter!(
|
||||
"rustfs_drive_op_timeout_total",
|
||||
"endpoint" => self.endpoint.to_string(),
|
||||
"op" => op.to_string()
|
||||
)
|
||||
.increment(1);
|
||||
warn!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
|
||||
endpoint = %self.endpoint,
|
||||
addr = %self.addr,
|
||||
op,
|
||||
timeout_ms = timeout_duration.as_millis(),
|
||||
state = "timeout",
|
||||
"Remote disk operation timed out"
|
||||
);
|
||||
}
|
||||
|
||||
async fn handle_network_like_error<T>(
|
||||
&self,
|
||||
op: &'static str,
|
||||
@@ -1074,7 +1016,7 @@ impl RemoteDisk {
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_faulty(&self, reason: &'static str) -> bool {
|
||||
async fn mark_faulty_and_evict(&self, reason: &'static str) {
|
||||
let previous_state = self.runtime_state();
|
||||
let transitioned_to_offline = self.mark_suspect_or_offline(reason);
|
||||
let state = self.runtime_state();
|
||||
@@ -1111,12 +1053,6 @@ impl RemoteDisk {
|
||||
"Remote disk marked suspect"
|
||||
);
|
||||
}
|
||||
}
|
||||
state != previous_state
|
||||
}
|
||||
|
||||
async fn mark_faulty_and_evict(&self, reason: &'static str) {
|
||||
if self.mark_faulty(reason) {
|
||||
counter!(
|
||||
"rustfs_drive_connection_evict_total",
|
||||
"endpoint" => self.endpoint.to_string(),
|
||||
@@ -2132,7 +2068,7 @@ impl DiskAPI for RemoteDisk {
|
||||
|
||||
Ok(file_info)
|
||||
},
|
||||
get_drive_metadata_timeout(),
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2481,30 +2417,6 @@ impl DiskAPI for RemoteDisk {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn read_file_stream_chunks(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
offset: usize,
|
||||
length: usize,
|
||||
) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
|
||||
if self.health.is_faulty() {
|
||||
return Err(DiskError::FaultyDisk);
|
||||
}
|
||||
let disk = self.disk_ref().await;
|
||||
let stall_timeout = get_object_disk_read_timeout();
|
||||
self.open_read_chunks_with_retry(ReadStreamRequest {
|
||||
endpoint: self.endpoint.grid_host(),
|
||||
disk,
|
||||
volume: volume.to_string(),
|
||||
path: path.to_string(),
|
||||
offset,
|
||||
length,
|
||||
stall_timeout: (!stall_timeout.is_zero()).then_some(stall_timeout),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Buffered read for remote disks.
|
||||
/// The transport stream is collected into owned Bytes for caller sharing.
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
@@ -5182,452 +5094,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial(remote_disk_read_retry)]
|
||||
async fn execute_read_with_retry_reset_during_backoff_preserves_recovery() {
|
||||
let remote_disk = Arc::new(new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await);
|
||||
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let first_attempt = Arc::new(tokio::sync::Notify::new());
|
||||
let started = time::Instant::now();
|
||||
|
||||
let task_disk = Arc::clone(&remote_disk);
|
||||
let task_attempts = Arc::clone(&attempts);
|
||||
let task_first_attempt = Arc::clone(&first_attempt);
|
||||
let task = tokio::spawn(async move {
|
||||
task_disk
|
||||
.execute_read_with_retry(
|
||||
"read_version",
|
||||
move || {
|
||||
let attempt = task_attempts.fetch_add(1, Ordering::SeqCst);
|
||||
let first_attempt = Arc::clone(&task_first_attempt);
|
||||
async move {
|
||||
if attempt == 0 {
|
||||
time::sleep(Duration::from_millis(20)).await;
|
||||
first_attempt.notify_one();
|
||||
return Err::<(), Error>(DiskError::Io(std_io::Error::new(
|
||||
std_io::ErrorKind::ConnectionRefused,
|
||||
"connection refused",
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
first_attempt.notified().await;
|
||||
tokio::task::yield_now().await;
|
||||
remote_disk.health.reset_for_store_init_retry(&remote_disk.endpoint);
|
||||
let channel = TonicEndpoint::from_shared(remote_disk.addr.clone())
|
||||
.expect("remote disk address should parse")
|
||||
.connect_lazy();
|
||||
runtime_sources::cache_test_node_channel(remote_disk.addr.clone(), channel).await;
|
||||
task.await
|
||||
.expect("retry task should finish")
|
||||
.expect("the retry should succeed after the health reset");
|
||||
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(started.elapsed(), Duration::from_millis(70));
|
||||
assert_eq!(
|
||||
remote_disk.health.waiting_count(),
|
||||
0,
|
||||
"health reset must not underflow the waiting counter"
|
||||
);
|
||||
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Online);
|
||||
assert!(
|
||||
runtime_sources::test_node_channel_is_cached(&remote_disk.addr).await,
|
||||
"a recovered channel must survive the retry backoff"
|
||||
);
|
||||
remote_disk.cancel_token.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial(remote_disk_read_retry)]
|
||||
async fn execute_read_with_retry_still_retries_within_shared_deadline() {
|
||||
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
|
||||
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let channel = TonicEndpoint::from_shared(remote_disk.addr.clone())
|
||||
.expect("remote disk address should parse")
|
||||
.connect_lazy();
|
||||
runtime_sources::cache_test_node_channel(remote_disk.addr.clone(), channel).await;
|
||||
|
||||
remote_disk
|
||||
.execute_read_with_retry(
|
||||
"read_version",
|
||||
|| {
|
||||
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if attempt == 0 {
|
||||
return Err::<(), Error>(DiskError::Io(std_io::Error::new(
|
||||
std_io::ErrorKind::ConnectionReset,
|
||||
"connection reset",
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.await
|
||||
.expect("a retry that fits the shared deadline should succeed");
|
||||
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Online);
|
||||
assert!(runtime_sources::test_node_channel_is_cached(&remote_disk.addr).await);
|
||||
remote_disk.cancel_token.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial(remote_disk_read_retry)]
|
||||
async fn execute_read_with_retry_uses_remaining_budget_for_final_attempt() {
|
||||
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
|
||||
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let started = time::Instant::now();
|
||||
let channel = TonicEndpoint::from_shared(remote_disk.addr.clone())
|
||||
.expect("remote disk address should parse")
|
||||
.connect_lazy();
|
||||
runtime_sources::cache_test_node_channel(remote_disk.addr.clone(), channel).await;
|
||||
|
||||
let err = remote_disk
|
||||
.execute_read_with_retry(
|
||||
"read_version",
|
||||
|| {
|
||||
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if attempt == 0 {
|
||||
time::sleep(Duration::from_millis(20)).await;
|
||||
return Err::<(), Error>(DiskError::Io(std_io::Error::new(
|
||||
std_io::ErrorKind::ConnectionRefused,
|
||||
"connection refused",
|
||||
)));
|
||||
}
|
||||
std::future::pending::<Result<()>>().await
|
||||
}
|
||||
},
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.await
|
||||
.expect_err("the final retry should consume only the remaining total budget");
|
||||
|
||||
assert_eq!(err, DiskError::Timeout);
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(started.elapsed(), Duration::from_millis(100));
|
||||
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Suspect);
|
||||
assert!(!runtime_sources::test_node_channel_is_cached(&remote_disk.addr).await);
|
||||
remote_disk.cancel_token.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial(remote_disk_read_retry)]
|
||||
async fn execute_read_with_retry_uses_final_attempt_at_exact_backoff_boundary() {
|
||||
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
|
||||
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let started = time::Instant::now();
|
||||
|
||||
let err = remote_disk
|
||||
.execute_read_with_retry(
|
||||
"read_version",
|
||||
|| {
|
||||
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if attempt == 0 {
|
||||
time::sleep(Duration::from_millis(50)).await;
|
||||
return Err::<(), Error>(DiskError::Io(std_io::Error::new(
|
||||
std_io::ErrorKind::ConnectionRefused,
|
||||
"connection refused",
|
||||
)));
|
||||
}
|
||||
std::future::pending::<Result<()>>().await
|
||||
}
|
||||
},
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.await
|
||||
.expect_err("the exact backoff boundary should be reserved for a final attempt");
|
||||
|
||||
assert_eq!(err, DiskError::Timeout);
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(started.elapsed(), Duration::from_millis(100));
|
||||
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Suspect);
|
||||
remote_disk.cancel_token.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial(remote_disk_read_retry)]
|
||||
async fn execute_read_with_retry_uses_final_attempt_below_backoff_budget() {
|
||||
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
|
||||
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let started = time::Instant::now();
|
||||
|
||||
let err = remote_disk
|
||||
.execute_read_with_retry(
|
||||
"read_version",
|
||||
|| {
|
||||
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if attempt == 0 {
|
||||
time::sleep(Duration::from_millis(80)).await;
|
||||
return Err::<(), Error>(DiskError::Io(std_io::Error::new(
|
||||
std_io::ErrorKind::ConnectionRefused,
|
||||
"connection refused",
|
||||
)));
|
||||
}
|
||||
std::future::pending::<Result<()>>().await
|
||||
}
|
||||
},
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.await
|
||||
.expect_err("remaining budget below backoff should be reserved for a final attempt");
|
||||
|
||||
assert_eq!(err, DiskError::Timeout);
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(started.elapsed(), Duration::from_millis(100));
|
||||
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Suspect);
|
||||
remote_disk.cancel_token.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial(remote_disk_read_retry)]
|
||||
async fn execute_read_with_retry_zero_timeout_disables_the_deadline() {
|
||||
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
|
||||
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let started = time::Instant::now();
|
||||
|
||||
remote_disk
|
||||
.execute_read_with_retry(
|
||||
"read_version",
|
||||
|| {
|
||||
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if attempt == 0 {
|
||||
return Err::<(), Error>(DiskError::Io(std_io::Error::new(
|
||||
std_io::ErrorKind::ConnectionReset,
|
||||
"connection reset",
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await
|
||||
.expect("zero timeout should allow a retry without a deadline");
|
||||
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(started.elapsed(), REMOTE_DISK_READ_RETRY_BASE_BACKOFF);
|
||||
remote_disk.cancel_token.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(remote_disk_read_retry)]
|
||||
async fn execute_read_with_retry_accepts_max_metadata_timeout() {
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_DRIVE_METADATA_TIMEOUT_SECS, Some(u64::MAX.to_string()))], async {
|
||||
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
|
||||
|
||||
remote_disk
|
||||
.execute_read_with_retry("read_version", || async { Ok::<(), Error>(()) }, get_drive_metadata_timeout())
|
||||
.await
|
||||
.expect("the maximum configured metadata timeout must not panic");
|
||||
|
||||
remote_disk.cancel_token.cancel();
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial(remote_disk_read_retry)]
|
||||
async fn execute_read_with_retry_zero_retries_runs_once() {
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_INTERNODE_IDEMPOTENT_READ_RETRIES, Some("0"))], async {
|
||||
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
|
||||
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let started = time::Instant::now();
|
||||
let channel = TonicEndpoint::from_shared(remote_disk.addr.clone())
|
||||
.expect("remote disk address should parse")
|
||||
.connect_lazy();
|
||||
runtime_sources::cache_test_node_channel(remote_disk.addr.clone(), channel).await;
|
||||
|
||||
let err = remote_disk
|
||||
.execute_read_with_retry(
|
||||
"read_version",
|
||||
|| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
async {
|
||||
Err::<(), Error>(DiskError::Io(std_io::Error::new(
|
||||
std_io::ErrorKind::ConnectionReset,
|
||||
"connection reset",
|
||||
)))
|
||||
}
|
||||
},
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.await
|
||||
.expect_err("zero retries should return the first network error");
|
||||
|
||||
assert!(matches!(err, DiskError::Io(ref io_err) if io_err.kind() == std_io::ErrorKind::ConnectionReset));
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(started.elapsed(), Duration::ZERO);
|
||||
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Suspect);
|
||||
assert!(!runtime_sources::test_node_channel_is_cached(&remote_disk.addr).await);
|
||||
remote_disk.cancel_token.cancel();
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial(remote_disk_read_retry)]
|
||||
async fn execute_read_with_retry_attempt_timeout_marks_health_without_evicting() {
|
||||
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
|
||||
let recorder = crate::test_metrics::CapturingRecorder::default();
|
||||
let _recorder_guard = metrics::set_default_local_recorder(&recorder);
|
||||
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let channel = TonicEndpoint::from_shared(remote_disk.addr.clone())
|
||||
.expect("remote disk address should parse")
|
||||
.connect_lazy();
|
||||
runtime_sources::cache_test_node_channel(remote_disk.addr.clone(), channel).await;
|
||||
|
||||
let err = remote_disk
|
||||
.execute_read_with_retry(
|
||||
"read_version",
|
||||
|| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
std::future::pending::<Result<()>>()
|
||||
},
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.await
|
||||
.expect_err("an in-flight attempt that consumes the deadline should time out");
|
||||
|
||||
assert_eq!(err, DiskError::Timeout);
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Suspect);
|
||||
assert!(runtime_sources::test_node_channel_is_cached(&remote_disk.addr).await);
|
||||
assert_eq!(
|
||||
recorder.counter_value(
|
||||
"rustfs_drive_op_timeout_total",
|
||||
&[
|
||||
("endpoint", remote_disk.endpoint.to_string().as_str()),
|
||||
("op", "read_version")
|
||||
]
|
||||
),
|
||||
1
|
||||
);
|
||||
remote_disk.cancel_token.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial(remote_disk_read_retry)]
|
||||
async fn execute_read_with_retry_does_not_retry_business_errors() {
|
||||
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
|
||||
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
|
||||
let err = remote_disk
|
||||
.execute_read_with_retry(
|
||||
"read_version",
|
||||
|| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
async { Err::<(), Error>(DiskError::FileNotFound) }
|
||||
},
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.await
|
||||
.expect_err("business errors should be returned directly");
|
||||
|
||||
assert_eq!(err, DiskError::FileNotFound);
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
remote_disk.cancel_token.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial(remote_disk_read_retry)]
|
||||
async fn execute_read_with_retry_honors_configured_retry_count() {
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_INTERNODE_IDEMPOTENT_READ_RETRIES, Some("2"))], async {
|
||||
let remote_disk = new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await;
|
||||
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let started = time::Instant::now();
|
||||
let channel = TonicEndpoint::from_shared(remote_disk.addr.clone())
|
||||
.expect("remote disk address should parse")
|
||||
.connect_lazy();
|
||||
runtime_sources::cache_test_node_channel(remote_disk.addr.clone(), channel).await;
|
||||
|
||||
let err = remote_disk
|
||||
.execute_read_with_retry(
|
||||
"read_version",
|
||||
|| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
async {
|
||||
Err::<(), Error>(DiskError::Io(std_io::Error::new(
|
||||
std_io::ErrorKind::ConnectionReset,
|
||||
"connection reset",
|
||||
)))
|
||||
}
|
||||
},
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.await
|
||||
.expect_err("exhausted retries should return the last network error");
|
||||
|
||||
assert!(matches!(err, DiskError::Io(ref io_err) if io_err.kind() == std_io::ErrorKind::ConnectionReset));
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 3);
|
||||
assert_eq!(started.elapsed(), Duration::from_millis(150));
|
||||
assert_eq!(remote_disk.runtime_state(), RuntimeDriveHealthState::Suspect);
|
||||
assert!(!runtime_sources::test_node_channel_is_cached(&remote_disk.addr).await);
|
||||
remote_disk.cancel_token.cancel();
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial(remote_disk_read_retry)]
|
||||
async fn execute_read_with_retry_stops_when_disk_turns_offline_during_backoff() {
|
||||
let remote_disk = Arc::new(new_remote_disk_with_transport(Arc::new(RecordingInternodeDataTransport::default())).await);
|
||||
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let first_attempt = Arc::new(tokio::sync::Notify::new());
|
||||
let task_disk = Arc::clone(&remote_disk);
|
||||
let task_attempts = Arc::clone(&attempts);
|
||||
let task_first_attempt = Arc::clone(&first_attempt);
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
task_disk
|
||||
.execute_read_with_retry(
|
||||
"read_version",
|
||||
move || {
|
||||
let attempt = task_attempts.fetch_add(1, Ordering::SeqCst);
|
||||
let first_attempt = Arc::clone(&task_first_attempt);
|
||||
async move {
|
||||
if attempt == 0 {
|
||||
first_attempt.notify_one();
|
||||
return Err::<(), Error>(DiskError::Io(std_io::Error::new(
|
||||
std_io::ErrorKind::ConnectionReset,
|
||||
"connection reset",
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
first_attempt.notified().await;
|
||||
tokio::task::yield_now().await;
|
||||
remote_disk
|
||||
.health
|
||||
.force_runtime_state_for_test(RuntimeDriveHealthState::Offline);
|
||||
time::advance(REMOTE_DISK_READ_RETRY_BASE_BACKOFF).await;
|
||||
let err = task
|
||||
.await
|
||||
.expect("retry task should finish")
|
||||
.expect_err("an offline disk must stop before the next attempt");
|
||||
|
||||
assert_eq!(err, DiskError::FaultyDisk);
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
remote_disk.cancel_token.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_with_timeout_evicts_cached_connection() {
|
||||
let addr = "http://127.0.0.1:59991".to_string();
|
||||
@@ -6137,40 +5603,6 @@ mod tests {
|
||||
accept_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_version_uses_the_metadata_timeout_on_a_stalled_peer() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let Some((base_addr, accept_task)) = spawn_stalled_grpc_peer().await else {
|
||||
return;
|
||||
};
|
||||
let remote_disk = remote_disk_for_addr(&base_addr).await;
|
||||
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_DRIVE_METADATA_TIMEOUT_SECS, Some("1")),
|
||||
(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, Some("10")),
|
||||
],
|
||||
async {
|
||||
let started = time::Instant::now();
|
||||
let err = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
remote_disk.read_version("bucket", "bucket", "object", "", &ReadOptions::default()),
|
||||
)
|
||||
.await
|
||||
.expect("read_version must use the shorter metadata deadline")
|
||||
.expect_err("a stalled peer must fail read_version");
|
||||
|
||||
assert!(matches!(err, DiskError::Timeout), "expected the metadata deadline to fire, got {err:?}");
|
||||
assert!(started.elapsed() >= Duration::from_millis(900));
|
||||
assert!(started.elapsed() < Duration::from_secs(2));
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
remote_disk.cancel_token.cancel();
|
||||
accept_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_volume_bounds_the_wait_on_a_stalled_peer() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
|
||||
@@ -46,6 +46,7 @@ use rustfs_config::{
|
||||
SCANNER_SUB_SYS,
|
||||
};
|
||||
use rustfs_filemeta::FileInfo;
|
||||
use rustfs_utils::path::SLASH_SEPARATOR;
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::LazyLock;
|
||||
@@ -199,6 +200,8 @@ pub const STORAGE_CLASS_SUB_SYS: &str = "storage_class";
|
||||
|
||||
pub const COMMA_SEPARATED_LISTS: &[&str] = &[rustfs_config::oidc::OIDC_SCOPES, rustfs_config::oidc::OIDC_OTHER_AUDIENCES];
|
||||
|
||||
static CONFIG_BUCKET: LazyLock<String> = LazyLock::new(|| format!("{RUSTFS_META_BUCKET}{SLASH_SEPARATOR}{CONFIG_PREFIX}"));
|
||||
|
||||
type ServerConfigDecryptFn = crate::bucket::migration::LegacyBlobDecryptFn;
|
||||
|
||||
static SERVER_CONFIG_DECRYPT_FN: LazyLock<RwLock<Option<ServerConfigDecryptFn>>> = LazyLock::new(|| RwLock::new(None));
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: configuration migration keeps legacy subsystem definitions available behind this module.
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod audit;
|
||||
pub mod com;
|
||||
|
||||
@@ -101,7 +101,6 @@ const DEFAULT_RRS_STORAGE_CLASS: &str = "EC:1";
|
||||
const ZERO_SET_DRIVE_COUNT_ERROR: &str = "set drive count must be greater than zero";
|
||||
|
||||
pub static DEFAULT_INLINE_BLOCK: usize = 128 * 1024;
|
||||
const DEFAULT_INLINE_OBJECT_BUDGET: usize = 2 * DEFAULT_INLINE_BLOCK;
|
||||
|
||||
pub static DEFAULT_KVS: LazyLock<KVS> = LazyLock::new(|| {
|
||||
let kvs = vec![
|
||||
@@ -151,8 +150,6 @@ pub struct Config {
|
||||
optimize: Option<String>,
|
||||
inline_block: usize,
|
||||
initialized: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
inline_block_explicit: bool,
|
||||
#[serde(skip)]
|
||||
standard_parities: Vec<PoolParity>,
|
||||
#[serde(skip)]
|
||||
@@ -189,10 +186,6 @@ impl Config {
|
||||
/// A topology-bound lookup fails closed for unknown drive counts and for
|
||||
/// deserialized legacy configurations that have no pool topology. Legacy
|
||||
/// callers retain scalar compatibility through [`Self::get_parity_for_sc`].
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "per-set parity resolution asserted by this file's tests (backlog#1823)"
|
||||
)]
|
||||
pub(crate) fn parity_for_sc(&self, sc: &str, drives_per_set: usize) -> Option<usize> {
|
||||
if !self.initialized {
|
||||
return None;
|
||||
@@ -240,19 +233,17 @@ impl Config {
|
||||
.map(|(pool_index, pool)| (pool_index, pool.drives_per_set))
|
||||
}
|
||||
|
||||
pub fn should_inline(&self, shard_size: i64, data_shards: usize, versioned: bool) -> bool {
|
||||
if shard_size < 0 || data_shards == 0 {
|
||||
pub fn should_inline(&self, shard_size: i64, versioned: bool) -> bool {
|
||||
if shard_size < 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let shard_size = shard_size as usize;
|
||||
// Keep the historical two-data-shard object budget while preventing
|
||||
// wider EC layouts from multiplying the maximum inline object size.
|
||||
let inline_block = if self.initialized && self.inline_block_explicit {
|
||||
self.inline_block
|
||||
} else {
|
||||
(DEFAULT_INLINE_OBJECT_BUDGET / data_shards).min(DEFAULT_INLINE_BLOCK)
|
||||
};
|
||||
|
||||
let mut inline_block = DEFAULT_INLINE_BLOCK;
|
||||
if self.initialized {
|
||||
inline_block = self.inline_block;
|
||||
}
|
||||
|
||||
if versioned {
|
||||
shard_size <= inline_block / 8
|
||||
@@ -401,7 +392,6 @@ fn lookup_config_for_pools_with_env(
|
||||
}
|
||||
|
||||
let optimize = overrides.optimize;
|
||||
let inline_block_explicit = overrides.inline_block.is_some();
|
||||
let inline_block = if let Some(value) = overrides.inline_block {
|
||||
let block = value
|
||||
.parse::<bytesize::ByteSize>()
|
||||
@@ -434,7 +424,6 @@ fn lookup_config_for_pools_with_env(
|
||||
optimize,
|
||||
inline_block,
|
||||
initialized: true,
|
||||
inline_block_explicit,
|
||||
standard_parities,
|
||||
rrs_parities,
|
||||
})
|
||||
@@ -552,26 +541,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_inline_scales_default_threshold_by_data_shards() {
|
||||
let config = lookup_config_for_pools_with_env(&KVS::new(), &[3, 12], no_env_overrides())
|
||||
.expect("default inline policy should resolve for EC2+1 and EC8+4");
|
||||
fn should_inline_preserves_exact_default_shard_boundaries() {
|
||||
let config = Config::default();
|
||||
|
||||
for (case, shard_size, data_shards, versioned, expected) in [
|
||||
("EC2+1 unversioned exact", 128 * 1024, 2, false, true),
|
||||
("EC2+1 unversioned above", 128 * 1024 + 1, 2, false, false),
|
||||
("EC2+1 versioned exact", 16 * 1024, 2, true, true),
|
||||
("EC2+1 versioned above", 16 * 1024 + 1, 2, true, false),
|
||||
("EC8+4 unversioned exact", 32 * 1024, 8, false, true),
|
||||
("EC8+4 unversioned above", 32 * 1024 + 1, 8, false, false),
|
||||
("EC8+4 versioned exact", 4 * 1024, 8, true, true),
|
||||
("EC8+4 versioned above", 4 * 1024 + 1, 8, true, false),
|
||||
("negative", -1, 2, false, false),
|
||||
("zero data shards", 0, 0, false, false),
|
||||
for (case, shard_size, versioned, expected) in [
|
||||
("unversioned below", 128 * 1024 - 1, false, true),
|
||||
("unversioned exact", 128 * 1024, false, true),
|
||||
("unversioned above", 128 * 1024 + 1, false, false),
|
||||
("versioned below", 16 * 1024 - 1, true, true),
|
||||
("versioned exact", 16 * 1024, true, true),
|
||||
("versioned above", 16 * 1024 + 1, true, false),
|
||||
("negative", -1, false, false),
|
||||
] {
|
||||
assert_eq!(
|
||||
config.should_inline(shard_size, data_shards, versioned),
|
||||
config.should_inline(shard_size, versioned),
|
||||
expected,
|
||||
"{case}: shard_size={shard_size}, data_shards={data_shards}, versioned={versioned}"
|
||||
"{case}: shard_size={shard_size}, versioned={versioned}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -592,28 +577,13 @@ mod tests {
|
||||
let shard_size = erasure.shard_file_size(object_size);
|
||||
assert_eq!(shard_size, expected_shard_size, "{case}: object_size={object_size}");
|
||||
assert_eq!(
|
||||
config.should_inline(shard_size, erasure.data_shards, versioned),
|
||||
config.should_inline(shard_size, versioned),
|
||||
expected,
|
||||
"{case}: object_size={object_size}, shard_size={shard_size}, versioned={versioned}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_inline_block_preserves_fixed_per_shard_rollback() {
|
||||
let overrides = StorageClassEnvOverrides {
|
||||
inline_block: Some("128KiB".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let config = lookup_config_for_pools_with_env(&KVS::new(), &[12], overrides)
|
||||
.expect("explicit inline block should resolve for EC8+4");
|
||||
|
||||
assert!(config.should_inline(128 * 1024, 8, false));
|
||||
assert!(!config.should_inline(128 * 1024 + 1, 8, false));
|
||||
assert!(config.should_inline(16 * 1024, 8, true));
|
||||
assert!(!config.should_inline(16 * 1024 + 1, 8, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_capability_contract_only_accepts_implemented_layouts() {
|
||||
assert_eq!(SUPPORTED_WRITE_CLASSES, [STANDARD, RRS]);
|
||||
@@ -807,7 +777,6 @@ mod tests {
|
||||
let encoded = serde_json::to_string(&cfg).expect("config should serialize");
|
||||
assert!(!encoded.contains("standard_parities"));
|
||||
assert!(!encoded.contains("rrs_parities"));
|
||||
assert!(!encoded.contains("inline_block_explicit"));
|
||||
|
||||
let decoded: Config = serde_json::from_str(&encoded).expect("legacy scalar config should deserialize");
|
||||
assert_eq!(decoded.get_parity_for_sc(STANDARD), Some(2));
|
||||
@@ -817,25 +786,6 @@ mod tests {
|
||||
assert!(validate_parity(0, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_inline_block_survives_config_round_trip() {
|
||||
let cfg = lookup_config_for_pools_with_env(
|
||||
&KVS::new(),
|
||||
&[12],
|
||||
StorageClassEnvOverrides {
|
||||
inline_block: Some("128KiB".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("explicit inline block should resolve");
|
||||
assert!(cfg.should_inline(100 * 1024, 8, false));
|
||||
|
||||
let encoded = serde_json::to_string(&cfg).expect("config should serialize");
|
||||
assert!(encoded.contains("\"inline_block_explicit\":true"));
|
||||
let decoded: Config = serde_json::from_str(&encoded).expect("explicit inline config should deserialize");
|
||||
assert!(decoded.should_inline(100 * 1024, 8, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_config_reads_rrs_from_class_rrs_key() {
|
||||
// Regression: kvs.get(RRS) used RRS="REDUCED_REDUNDANCY" instead of
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: pool coordination helpers are being migrated behind runtime owners.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub(crate) mod pools;
|
||||
pub(crate) mod sets;
|
||||
|
||||
@@ -226,7 +226,6 @@ fn ensure_decommission_start_rebalance_meta_allowed(meta: Option<&RebalanceMeta>
|
||||
ensure_decommission_not_rebalancing(meta.is_some_and(is_rebalance_conflicting_with_decommission))
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "leader precondition asserted by this file's tests (backlog#1823)")]
|
||||
fn ensure_local_decommission_pool_leaders(endpoints: &EndpointServerPools, indices: &[usize]) -> Result<()> {
|
||||
for idx in indices {
|
||||
ensure_local_decommission_pool_leader(endpoints, *idx)?;
|
||||
@@ -1059,19 +1058,11 @@ fn should_cleanup_decommission_source_entry(decommissioned: usize, total_version
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "terminal-state classification asserted by this file's tests (backlog#1823)"
|
||||
)]
|
||||
enum DecommissionTerminalState {
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "terminal-state classification asserted by this file's tests (backlog#1823)"
|
||||
)]
|
||||
fn classify_decommission_terminal_state(failed_items_present: bool) -> DecommissionTerminalState {
|
||||
if failed_items_present {
|
||||
DecommissionTerminalState::Failed
|
||||
@@ -2275,19 +2266,15 @@ fn decommission_delete_marker_opts(
|
||||
version: &rustfs_filemeta::FileInfo,
|
||||
version_id: Option<String>,
|
||||
src_pool_idx: usize,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> ObjectOptions {
|
||||
let version_suspended = version.version_id.is_none() && version_id.is_none();
|
||||
ObjectOptions {
|
||||
versioned: !version_suspended,
|
||||
version_suspended,
|
||||
version_id: version_id.or_else(|| version_suspended.then(|| uuid::Uuid::nil().to_string())),
|
||||
versioned: true,
|
||||
version_id,
|
||||
mod_time: version.mod_time,
|
||||
src_pool_idx,
|
||||
data_movement: true,
|
||||
delete_marker: true,
|
||||
skip_decommissioned: true,
|
||||
expected_bucket_incarnation_id,
|
||||
delete_replication: version
|
||||
.replication_state_internal
|
||||
.as_ref()
|
||||
@@ -2312,7 +2299,6 @@ fn decommission_remote_tiered_opts(
|
||||
version: &rustfs_filemeta::FileInfo,
|
||||
version_id: Option<String>,
|
||||
src_pool_idx: usize,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> ObjectOptions {
|
||||
ObjectOptions {
|
||||
versioned: version_id.is_some(),
|
||||
@@ -2321,9 +2307,6 @@ fn decommission_remote_tiered_opts(
|
||||
user_defined: version.metadata.clone(),
|
||||
src_pool_idx,
|
||||
data_movement: true,
|
||||
include_part_checksums: true,
|
||||
http_preconditions: Some(crate::data_movement::data_movement_target_precondition()),
|
||||
expected_bucket_incarnation_id,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -2822,7 +2805,6 @@ impl ECStore {
|
||||
lifecycle_config: Option<BucketLifecycleConfiguration>,
|
||||
object_lock_config: Option<ObjectLockConfiguration>,
|
||||
replication_config: Option<(ReplicationConfiguration, OffsetDateTime)>,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> Result<()> {
|
||||
debug!(
|
||||
event = EVENT_DECOMMISSION_ENTRY,
|
||||
@@ -2852,11 +2834,6 @@ impl ECStore {
|
||||
}
|
||||
decommission_cancel_signal_result(rx.is_cancelled())?;
|
||||
|
||||
let bucket_incarnation_fence = match expected_bucket_incarnation_id {
|
||||
Some(expected) => Some(self.acquire_bucket_incarnation_fence(&bucket, expected).await?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut fivs = load_decommission_entry_exact_versions(&set, &entry, &bucket, "file_info_versions").await?;
|
||||
|
||||
fivs.versions
|
||||
@@ -2917,7 +2894,7 @@ impl ECStore {
|
||||
.delete_object(
|
||||
bucket.as_str(),
|
||||
&version.name,
|
||||
decommission_delete_marker_opts(version, version_id.clone(), idx, expected_bucket_incarnation_id),
|
||||
decommission_delete_marker_opts(version, version_id.clone(), idx),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -3007,7 +2984,7 @@ impl ECStore {
|
||||
bucket.as_str(),
|
||||
&version.name,
|
||||
version,
|
||||
&decommission_remote_tiered_opts(version, version_id.clone(), idx, expected_bucket_incarnation_id),
|
||||
&decommission_remote_tiered_opts(version, version_id.clone(), idx),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -3079,11 +3056,7 @@ impl ECStore {
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Err(err) = self
|
||||
.clone()
|
||||
.decommission_object(idx, bucket, rd, expected_bucket_incarnation_id)
|
||||
.await
|
||||
{
|
||||
if let Err(err) = self.clone().decommission_object(idx, bucket, rd).await {
|
||||
if is_decommission_copy_cleanup_safe_error(&err) {
|
||||
ignore = true;
|
||||
cleanup_ignored = true;
|
||||
@@ -3160,9 +3133,6 @@ impl ECStore {
|
||||
}
|
||||
|
||||
if should_cleanup_decommission_source_entry(decommissioned, fivs.versions.len(), expired) {
|
||||
if bucket_incarnation_fence.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
||||
return Err(Error::other("decommission bucket incarnation fence was lost before source cleanup"));
|
||||
}
|
||||
decommission_cancel_signal_result(rx.is_cancelled())?;
|
||||
|
||||
self.save_decommission_entry_progress_stage(
|
||||
@@ -3187,12 +3157,6 @@ impl ECStore {
|
||||
entry.name.as_str(),
|
||||
&fivs,
|
||||
&cleanup_preflight_allowed_missing,
|
||||
data_movement::SourceCleanupBucketFence {
|
||||
expected_incarnation_id: expected_bucket_incarnation_id,
|
||||
lifecycle_guard: bucket_incarnation_fence
|
||||
.as_ref()
|
||||
.and_then(|guard| guard.namespace_lock_guard()),
|
||||
},
|
||||
"decommission",
|
||||
)
|
||||
.await
|
||||
@@ -3304,11 +3268,6 @@ impl ECStore {
|
||||
let mut lifecycle_config = None;
|
||||
let mut object_lock_config = None;
|
||||
let mut replication_config = None;
|
||||
let expected_bucket_incarnation_id = if bi.name == RUSTFS_META_BUCKET {
|
||||
None
|
||||
} else {
|
||||
Some(self.bucket_incarnation_id_from_disk(&bi.name).await?)
|
||||
};
|
||||
|
||||
if bi.name != RUSTFS_META_BUCKET {
|
||||
let _ = resolve_decommission_optional_bucket_config_result(
|
||||
@@ -3362,7 +3321,6 @@ impl ECStore {
|
||||
let lifecycle_config = lifecycle_config.clone();
|
||||
let object_lock_config = object_lock_config.clone();
|
||||
let replication_config = replication_config.clone();
|
||||
let expected_bucket_incarnation_id = expected_bucket_incarnation_id;
|
||||
let entry_error = entry_error.clone();
|
||||
let callback_rx = callback_rx.clone();
|
||||
|
||||
@@ -3425,7 +3383,6 @@ impl ECStore {
|
||||
lifecycle_config,
|
||||
object_lock_config,
|
||||
replication_config,
|
||||
expected_bucket_incarnation_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -4211,24 +4168,10 @@ impl ECStore {
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, rd))]
|
||||
async fn decommission_object(
|
||||
self: Arc<Self>,
|
||||
pool_idx: usize,
|
||||
bucket: String,
|
||||
rd: GetObjectReader,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> Result<()> {
|
||||
async fn decommission_object(self: Arc<Self>, pool_idx: usize, bucket: String, rd: GetObjectReader) -> Result<()> {
|
||||
warn!("decommission_object: start {} {}", &bucket, &rd.object_info.name);
|
||||
let object_name = rd.object_info.name.clone();
|
||||
let result = data_movement::migrate_object(
|
||||
self,
|
||||
pool_idx,
|
||||
bucket.clone(),
|
||||
rd,
|
||||
expected_bucket_incarnation_id,
|
||||
"decommission_object",
|
||||
)
|
||||
.await;
|
||||
let result = data_movement::migrate_object(self, pool_idx, bucket.clone(), rd, "decommission_object").await;
|
||||
if result.is_ok() {
|
||||
warn!("decommission_object: migrated {} {}", &bucket, &object_name);
|
||||
}
|
||||
@@ -4404,8 +4347,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let incarnation = uuid::Uuid::new_v4();
|
||||
let opts = decommission_delete_marker_opts(&version, Some("version-id".to_string()), 7, Some(incarnation));
|
||||
let opts = decommission_delete_marker_opts(&version, Some("version-id".to_string()), 7);
|
||||
let replication = opts.delete_replication.expect("replication state should be preserved");
|
||||
|
||||
assert!(opts.versioned);
|
||||
@@ -4415,25 +4357,11 @@ mod tests {
|
||||
assert_eq!(opts.src_pool_idx, 7);
|
||||
assert_eq!(opts.version_id.as_deref(), Some("version-id"));
|
||||
assert_eq!(opts.mod_time, Some(mod_time));
|
||||
assert_eq!(opts.expected_bucket_incarnation_id, Some(incarnation));
|
||||
assert_eq!(replication.replica_status, ReplicationStatusType::Replica);
|
||||
assert!(replication.delete_marker);
|
||||
assert_eq!(replication.replicate_decision_str, "existing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decommission_delete_marker_opts_preserves_suspended_null_version() {
|
||||
let version = rustfs_filemeta::FileInfo {
|
||||
deleted: true,
|
||||
..Default::default()
|
||||
};
|
||||
let opts = decommission_delete_marker_opts(&version, None, 7, None);
|
||||
|
||||
assert!(!opts.versioned);
|
||||
assert!(opts.version_suspended);
|
||||
assert_eq!(opts.version_id.as_deref(), Some(uuid::Uuid::nil().to_string().as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decommission_object_migration_read_opts_are_raw_data_movement() {
|
||||
let opts = decommission_object_migration_read_opts(Some("vid-1".to_string()));
|
||||
@@ -4455,8 +4383,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let incarnation = uuid::Uuid::new_v4();
|
||||
let opts = decommission_remote_tiered_opts(&version, Some("version-id".to_string()), 9, Some(incarnation));
|
||||
let opts = decommission_remote_tiered_opts(&version, Some("version-id".to_string()), 9);
|
||||
|
||||
assert!(opts.versioned);
|
||||
assert!(opts.data_movement);
|
||||
@@ -4464,9 +4391,6 @@ mod tests {
|
||||
assert_eq!(opts.version_id.as_deref(), Some("version-id"));
|
||||
assert_eq!(opts.mod_time, Some(mod_time));
|
||||
assert_eq!(opts.user_defined.get("x-amz-meta-key").map(String::as_str), Some("value"));
|
||||
assert!(opts.include_part_checksums);
|
||||
assert!(opts.http_preconditions.is_some());
|
||||
assert_eq!(opts.expected_bucket_incarnation_id, Some(incarnation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+253
-1564
File diff suppressed because it is too large
Load Diff
@@ -653,7 +653,6 @@ fn reconcile_servers_with_endpoint_topology(
|
||||
(added, report)
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "exercised by this file's topology tests (backlog#1823)")]
|
||||
fn server_topology_completeness_report(
|
||||
servers: &[ServerProperties],
|
||||
endpoints: &EndpointServerPools,
|
||||
|
||||
@@ -46,49 +46,21 @@ pub(crate) const GET_CODEC_STREAMING_OBJECT_CLASS_MULTIPART: &str = "multipart";
|
||||
pub(crate) const GET_STAGE_DECODE: &str = "decode";
|
||||
pub(crate) const GET_STAGE_EMIT: &str = "emit";
|
||||
pub(crate) const GET_STAGE_FILL: &str = "fill";
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
|
||||
)]
|
||||
pub(crate) const GET_STAGE_FIRST_BYTE: &str = "first_byte";
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
|
||||
)]
|
||||
pub(crate) const GET_STAGE_FIRST_METADATA_RESPONSE: &str = "first_metadata_response";
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
|
||||
)]
|
||||
pub(crate) const GET_STAGE_FIRST_VALID_METADATA_RESPONSE: &str = "first_valid_metadata_response";
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
|
||||
)]
|
||||
pub(crate) const GET_STAGE_FIRST_SHARD_READ: &str = "first_shard_read";
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
|
||||
)]
|
||||
pub(crate) const GET_STAGE_FULL_BODY: &str = "full_body";
|
||||
pub(crate) const GET_STAGE_INLINE_PREPARE: &str = "inline_prepare";
|
||||
pub(crate) const GET_STAGE_LOCK_ACQUIRE: &str = "lock_acquire";
|
||||
pub(crate) const GET_STAGE_METADATA: &str = "metadata";
|
||||
pub(crate) const GET_STAGE_METADATA_CACHE_LOOKUP: &str = "metadata_cache_lookup";
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
|
||||
)]
|
||||
pub(crate) const GET_STAGE_METADATA_FANOUT: &str = "metadata_fanout";
|
||||
pub(crate) const GET_STAGE_METADATA_RESOLVE: &str = "metadata_resolve";
|
||||
pub(crate) const GET_STAGE_OBJECT_INFO: &str = "object_info";
|
||||
pub(crate) const GET_STAGE_OUTPUT_LOCK_WAIT: &str = "output_lock_wait";
|
||||
pub(crate) const GET_STAGE_OUTPUT_POLL: &str = "output_poll";
|
||||
pub(crate) const GET_STAGE_PATH_DECISION: &str = "path_decision";
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
|
||||
)]
|
||||
pub(crate) const GET_STAGE_QUORUM_REACHED: &str = "quorum_reached";
|
||||
pub(crate) const GET_STAGE_RANGE: &str = "range";
|
||||
pub(crate) const GET_STAGE_READER_SETUP: &str = "reader_setup";
|
||||
@@ -112,28 +84,12 @@ pub(crate) const GET_STAGE_READER_STREAM_FIRST_READ: &str = "reader_stream_first
|
||||
pub(crate) const GET_STAGE_READER_TASK_BITROT_READER_INIT: &str = "reader_task_bitrot_reader_init";
|
||||
pub(crate) const GET_STAGE_READER_TASK_FILE_OPEN: &str = "reader_task_file_open";
|
||||
pub(crate) const GET_STAGE_READER_TASK_READER_CONSTRUCTION: &str = "reader_task_reader_construction";
|
||||
pub(crate) const GET_STAGE_READ_VERSION_DECODE: &str = "read_version_decode";
|
||||
pub(crate) const GET_STAGE_READ_VERSION_PATH_CHECK: &str = "read_version_path_check";
|
||||
pub(crate) const GET_STAGE_READ_VERSION_PATH_RESOLVE: &str = "read_version_path_resolve";
|
||||
pub(crate) const GET_STAGE_READ_VERSION_XLMETA_READ: &str = "read_version_xlmeta_read";
|
||||
pub(crate) const GET_STAGE_RECONSTRUCT: &str = "reconstruct";
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
|
||||
)]
|
||||
pub(crate) const GET_STAGE_RESPONSE_HANDOFF: &str = "response_handoff";
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
|
||||
)]
|
||||
pub(crate) const GET_STAGE_SLOWEST_METADATA_RESPONSE: &str = "slowest_metadata_response";
|
||||
pub(crate) const GET_STAGE_STRIPE_READ: &str = "stripe_read";
|
||||
pub(crate) const GET_STAGE_STRIPE_READ_FIRST_SHARD: &str = "stripe_read_first_shard";
|
||||
pub(crate) const GET_STAGE_STRIPE_READ_QUORUM: &str = "stripe_read_quorum";
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
|
||||
)]
|
||||
pub(crate) const GET_STAGE_BITROT_VERIFY: &str = "bitrot_verify";
|
||||
|
||||
pub(crate) const GET_READER_BUFFER_OUTPUT: &str = "output";
|
||||
@@ -181,7 +137,6 @@ pub(crate) const GET_METADATA_CACHE_REASON_NO_LOCK: &str = "no_lock";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_NOT_FOUND_OR_EXPIRED: &str = "not_found_or_expired";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_NOT_READ_DATA: &str = "not_read_data";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_PART_NUMBER: &str = "part_number";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_PART_CHECKSUMS: &str = "part_checksums";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_RAW_DATA_MOVEMENT_READ: &str = "raw_data_movement_read";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_STALE_PUBLICATION: &str = "stale_publication";
|
||||
pub(crate) const GET_METADATA_CACHE_REASON_USABLE: &str = "usable";
|
||||
@@ -199,20 +154,8 @@ pub(crate) const GET_METADATA_EARLY_STOP_REASON_VERSION_NOT_FOUND: &str = "versi
|
||||
pub(crate) const GET_METADATA_EARLY_STOP_REASON_VERSION_MATCH_QUORUM: &str = "version_match_quorum";
|
||||
|
||||
/// Early-stop active state labels
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
|
||||
)]
|
||||
pub(crate) const EARLY_STOP_ACTIVE_HIT: &str = "hit";
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
|
||||
)]
|
||||
pub(crate) const EARLY_STOP_ACTIVE_MISS: &str = "miss";
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "GET stage vocabulary; value pinned by this file's tests, no writer yet (backlog#1823)"
|
||||
)]
|
||||
pub(crate) const EARLY_STOP_ACTIVE_DISABLED: &str = "disabled";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -498,10 +441,6 @@ mod tests {
|
||||
assert_eq!(GET_STAGE_QUORUM_REACHED, "quorum_reached");
|
||||
assert_eq!(GET_STAGE_RANGE, "range");
|
||||
assert_eq!(GET_STAGE_READER_SETUP, "reader_setup");
|
||||
assert_eq!(GET_STAGE_READ_VERSION_DECODE, "read_version_decode");
|
||||
assert_eq!(GET_STAGE_READ_VERSION_PATH_CHECK, "read_version_path_check");
|
||||
assert_eq!(GET_STAGE_READ_VERSION_PATH_RESOLVE, "read_version_path_resolve");
|
||||
assert_eq!(GET_STAGE_READ_VERSION_XLMETA_READ, "read_version_xlmeta_read");
|
||||
assert_eq!(GET_STAGE_RECONSTRUCT, "reconstruct");
|
||||
assert_eq!(GET_STAGE_RESPONSE_HANDOFF, "response_handoff");
|
||||
assert_eq!(GET_STAGE_SLOWEST_METADATA_RESPONSE, "slowest_metadata_response");
|
||||
@@ -541,7 +480,6 @@ mod tests {
|
||||
assert_eq!(GET_METADATA_CACHE_REASON_NO_LOCK, "no_lock");
|
||||
assert_eq!(GET_METADATA_CACHE_REASON_NOT_FOUND_OR_EXPIRED, "not_found_or_expired");
|
||||
assert_eq!(GET_METADATA_CACHE_REASON_NOT_READ_DATA, "not_read_data");
|
||||
assert_eq!(GET_METADATA_CACHE_REASON_PART_CHECKSUMS, "part_checksums");
|
||||
assert_eq!(GET_METADATA_CACHE_REASON_PART_NUMBER, "part_number");
|
||||
assert_eq!(GET_METADATA_CACHE_REASON_RAW_DATA_MOVEMENT_READ, "raw_data_movement_read");
|
||||
assert_eq!(GET_METADATA_CACHE_REASON_STALE_PUBLICATION, "stale_publication");
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: diagnostics constants are staged for request-path telemetry migration.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub(crate) mod admin_server_info;
|
||||
pub(crate) mod get;
|
||||
pub(crate) mod pool;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// 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.
|
||||
|
||||
//! BytesPool metric label constants.
|
||||
//!
|
||||
//! These constants are used when recording pool acquisition and return
|
||||
//! metrics to avoid string allocations and ensure label consistency.
|
||||
|
||||
/// BytesPool tier labels
|
||||
pub const POOL_TIER_SMALL: &str = "small";
|
||||
pub const POOL_TIER_MEDIUM: &str = "medium";
|
||||
pub const POOL_TIER_LARGE: &str = "large";
|
||||
pub const POOL_TIER_XLARGE: &str = "xlarge";
|
||||
|
||||
/// BytesPool outcome labels
|
||||
pub const POOL_OUTCOME_HIT: &str = "hit";
|
||||
pub const POOL_OUTCOME_MISS: &str = "miss";
|
||||
pub const POOL_OUTCOME_RECYCLED: &str = "recycled";
|
||||
pub const POOL_OUTCOME_DROPPED: &str = "dropped";
|
||||
@@ -2022,21 +2022,6 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn read_file_stream_chunks(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
offset: usize,
|
||||
length: usize,
|
||||
) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
|
||||
self.track_disk_health_with_op(
|
||||
"read_file_stream_chunks",
|
||||
|| async { self.disk.read_file_stream_chunks(volume, path, offset, length).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn read_file_mmap_copy(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<bytes::Bytes> {
|
||||
self.track_disk_health_with_op(
|
||||
"read_file_mmap_copy",
|
||||
|
||||
@@ -15,11 +15,6 @@
|
||||
use crate::config::storageclass::DEFAULT_INLINE_BLOCK;
|
||||
use crate::crash_inject::{self, CrashPoint};
|
||||
use crate::data_usage::local_snapshot::ensure_data_usage_layout;
|
||||
use crate::diagnostics::get::{
|
||||
GET_OBJECT_PATH_INTERNAL_META, GET_OBJECT_PATH_LEGACY_DUPLEX, GET_STAGE_READ_VERSION_DECODE,
|
||||
GET_STAGE_READ_VERSION_PATH_CHECK, GET_STAGE_READ_VERSION_PATH_RESOLVE, GET_STAGE_READ_VERSION_XLMETA_READ,
|
||||
get_stage_timer_if_enabled, record_get_stage_duration_if_enabled,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::disk::HEALING_MARKER_PATH;
|
||||
use crate::disk::disk_store::{get_drive_walkdir_stall_timeout, get_object_disk_read_timeout};
|
||||
@@ -9179,7 +9174,7 @@ impl DiskAPI for LocalDisk {
|
||||
|
||||
if let Some(src_file_path_parent) = src_file_path.parent() {
|
||||
if src_volume != super::RUSTFS_META_MULTIPART_BUCKET {
|
||||
let _ = std::fs::remove_dir(src_file_path_parent);
|
||||
let _ = remove_std(src_file_path_parent);
|
||||
} else {
|
||||
let _ = self
|
||||
.delete_file(&dst_volume_dir, &src_file_path_parent.to_path_buf(), true, false)
|
||||
@@ -9504,7 +9499,7 @@ impl DiskAPI for LocalDisk {
|
||||
if let Some(ref cleanup) = cleanup_path {
|
||||
let _ = self.delete_file(&dst_volume_dir, cleanup, true, false).await;
|
||||
} else if let Some(parent) = src_file_path.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
let _ = remove_std(parent);
|
||||
}
|
||||
|
||||
// Heal reuses a version's `data_dir` and lands the rebuilt shard on
|
||||
@@ -9845,12 +9840,6 @@ impl DiskAPI for LocalDisk {
|
||||
opts: &ReadOptions,
|
||||
) -> Result<FileInfo> {
|
||||
crate::hp_guard!("LocalDisk::read_version");
|
||||
let stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled();
|
||||
let metrics_path = if stage_metrics_enabled && crate::bucket::utils::is_meta_bucketname(volume) {
|
||||
GET_OBJECT_PATH_INTERNAL_META
|
||||
} else {
|
||||
GET_OBJECT_PATH_LEGACY_DUPLEX
|
||||
};
|
||||
if !org_volume.is_empty() {
|
||||
let org_volume_path = self.io_get_bucket_path(org_volume)?;
|
||||
if !skip_access_checks(org_volume) {
|
||||
@@ -9860,46 +9849,36 @@ impl DiskAPI for LocalDisk {
|
||||
}
|
||||
}
|
||||
|
||||
let path_resolve_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
let file_path = self.io_get_object_path(volume, path)?;
|
||||
let volume_dir = self.io_get_bucket_path(volume)?;
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_READ_VERSION_PATH_RESOLVE, path_resolve_start);
|
||||
|
||||
let path_check_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
check_path_length(file_path.to_string_lossy().as_ref())?;
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_READ_VERSION_PATH_CHECK, path_check_start);
|
||||
|
||||
let read_data = opts.read_data;
|
||||
|
||||
let xlmeta_read_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
let raw_read_result = self.read_raw(volume, volume_dir.clone(), file_path, read_data).await;
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_READ_VERSION_XLMETA_READ, xlmeta_read_start);
|
||||
let (data, _) = raw_read_result.map_err(|e| {
|
||||
if e == DiskError::FileNotFound && !version_id.is_empty() {
|
||||
DiskError::FileVersionNotFound
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
let (data, _) = self
|
||||
.read_raw(volume, volume_dir.clone(), file_path, read_data)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e == DiskError::FileNotFound && !version_id.is_empty() {
|
||||
DiskError::FileVersionNotFound
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
|
||||
let decode_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
let file_info_result: Result<FileInfo> = (|| {
|
||||
let fi = get_file_info(
|
||||
&data,
|
||||
volume,
|
||||
path,
|
||||
version_id,
|
||||
FileInfoOpts {
|
||||
data: read_data,
|
||||
include_free_versions: opts.incl_free_versions,
|
||||
include_part_checksums: false,
|
||||
},
|
||||
)?;
|
||||
fi.validate_for_metadata_read()?;
|
||||
Ok(fi)
|
||||
})();
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_READ_VERSION_DECODE, decode_start);
|
||||
let mut fi = file_info_result?;
|
||||
let mut fi = get_file_info(
|
||||
&data,
|
||||
volume,
|
||||
path,
|
||||
version_id,
|
||||
FileInfoOpts {
|
||||
data: read_data,
|
||||
include_free_versions: opts.incl_free_versions,
|
||||
},
|
||||
)?;
|
||||
|
||||
fi.validate_for_metadata_read()?;
|
||||
if fi.is_canonical_delete_marker() {
|
||||
return Ok(fi);
|
||||
}
|
||||
@@ -10582,108 +10561,6 @@ mod test {
|
||||
meta.marshal_msg().expect("test metadata should encode")
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn read_version_records_local_metadata_stage_breakdown() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("test runtime should be created");
|
||||
let recorder = crate::test_metrics::CapturingRecorder::default();
|
||||
let previous_gate = rustfs_io_metrics::get_stage_metrics_enabled();
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
|
||||
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
runtime.block_on(async {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let endpoint =
|
||||
Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
||||
let bucket = "bucket";
|
||||
let object = "stage-breakdown";
|
||||
ensure_test_volume(&disk, bucket).await;
|
||||
|
||||
let object_dir = dir.path().join(bucket).join(object);
|
||||
fs::create_dir_all(&object_dir)
|
||||
.await
|
||||
.expect("object directory should be created");
|
||||
fs::write(
|
||||
object_dir.join(STORAGE_FORMAT_FILE),
|
||||
test_meta(test_file_info(object, Uuid::new_v4(), None, Some(Bytes::from_static(b"inline")))),
|
||||
)
|
||||
.await
|
||||
.expect("object metadata should be written");
|
||||
|
||||
disk.read_version(
|
||||
"",
|
||||
bucket,
|
||||
object,
|
||||
"",
|
||||
&ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read_version should succeed");
|
||||
|
||||
let meta_object = "stage-breakdown-meta";
|
||||
let meta_object_dir = dir.path().join(RUSTFS_META_BUCKET).join(meta_object);
|
||||
fs::create_dir_all(&meta_object_dir)
|
||||
.await
|
||||
.expect("internal metadata object directory should be created");
|
||||
fs::write(
|
||||
meta_object_dir.join(STORAGE_FORMAT_FILE),
|
||||
test_meta(test_file_info(meta_object, Uuid::new_v4(), None, Some(Bytes::from_static(b"meta")))),
|
||||
)
|
||||
.await
|
||||
.expect("internal metadata should be written");
|
||||
|
||||
disk.read_version(
|
||||
"",
|
||||
RUSTFS_META_BUCKET,
|
||||
meta_object,
|
||||
"",
|
||||
&ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("internal metadata read_version should succeed");
|
||||
});
|
||||
});
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(previous_gate);
|
||||
|
||||
for stage in [
|
||||
GET_STAGE_READ_VERSION_PATH_RESOLVE,
|
||||
GET_STAGE_READ_VERSION_PATH_CHECK,
|
||||
GET_STAGE_READ_VERSION_XLMETA_READ,
|
||||
GET_STAGE_READ_VERSION_DECODE,
|
||||
] {
|
||||
assert_eq!(
|
||||
recorder
|
||||
.histogram_values(
|
||||
"rustfs_io_get_object_stage_duration_seconds",
|
||||
&[("path", GET_OBJECT_PATH_LEGACY_DUPLEX), ("stage", stage)]
|
||||
)
|
||||
.len(),
|
||||
1,
|
||||
"{stage} should be recorded once for user-bucket LocalDisk::read_version"
|
||||
);
|
||||
assert_eq!(
|
||||
recorder
|
||||
.histogram_values(
|
||||
"rustfs_io_get_object_stage_duration_seconds",
|
||||
&[("path", GET_OBJECT_PATH_INTERNAL_META), ("stage", stage)]
|
||||
)
|
||||
.len(),
|
||||
1,
|
||||
"{stage} should be recorded once for internal-meta LocalDisk::read_version"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_metadata_rollback_dir_avoids_real_data_dir_collision() {
|
||||
let target_version = Uuid::parse_str("11111111-2222-3333-4444-555555555555").expect("version id should parse");
|
||||
@@ -12562,10 +12439,6 @@ mod test {
|
||||
.join(RUSTFS_META_TMP_BUCKET)
|
||||
.join(tmp_object)
|
||||
.join(new_data_dir.to_string());
|
||||
let tmp_parent = tmp_data_dir
|
||||
.parent()
|
||||
.expect("tmp data dir should have a parent")
|
||||
.to_path_buf();
|
||||
fs::create_dir_all(&tmp_data_dir)
|
||||
.await
|
||||
.expect("new tmp data dir should be created");
|
||||
@@ -12577,10 +12450,6 @@ mod test {
|
||||
disk.rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, new_fi, bucket, object)
|
||||
.await
|
||||
.expect("rename_data should commit");
|
||||
assert!(
|
||||
!tmp_parent.exists(),
|
||||
"successful non-inline commit should remove the empty staging parent"
|
||||
);
|
||||
|
||||
// The tmp xl.meta write point uses SyncMode::FileOnly: its parent dir
|
||||
// ({tmp}/{tmp_object}) must not be fsynced.
|
||||
@@ -12785,9 +12654,6 @@ mod test {
|
||||
let tmp_object = "tmp-new-inline";
|
||||
ensure_test_volume(&disk, bucket).await;
|
||||
ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await;
|
||||
let tmp_parent = disk
|
||||
.get_object_path(RUSTFS_META_TMP_BUCKET, tmp_object)
|
||||
.expect("tmp parent should resolve");
|
||||
|
||||
let _mode = durability_mode_override::set(DurabilityMode::Strict);
|
||||
let version_id = Uuid::parse_str("99999999-9999-9999-9999-999999999999").expect("version id should parse");
|
||||
@@ -12796,7 +12662,6 @@ mod test {
|
||||
disk.rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, new_fi, bucket, object)
|
||||
.await
|
||||
.expect("inline rename_data should commit the new object");
|
||||
assert!(!tmp_parent.exists(), "successful inline commit should remove the empty staging parent");
|
||||
|
||||
let bucket_dir = disk.get_bucket_path(bucket).expect("bucket path should resolve");
|
||||
let prefix_dir = disk.get_object_path(bucket, "prefix").expect("prefix path should resolve");
|
||||
@@ -12820,34 +12685,6 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_data_inline_preserves_non_empty_staging_parent() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
let dir = tempdir().expect("temp dir should be created");
|
||||
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
||||
let bucket = "inline-staging-sentinel-bucket";
|
||||
let object = "inline-object";
|
||||
let tmp_object = "inline-stage-with-sentinel";
|
||||
ensure_test_volume(&disk, bucket).await;
|
||||
ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await;
|
||||
|
||||
let tmp_parent = disk
|
||||
.get_object_path(RUSTFS_META_TMP_BUCKET, tmp_object)
|
||||
.expect("tmp parent should resolve");
|
||||
fs::create_dir_all(&tmp_parent).await.expect("tmp parent should be created");
|
||||
let sentinel = tmp_parent.join("sentinel");
|
||||
fs::write(&sentinel, b"keep").await.expect("sentinel should be written");
|
||||
|
||||
let fi = test_file_info(object, Uuid::new_v4(), None, Some(Bytes::from_static(b"inline-payload")));
|
||||
disk.rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, fi, bucket, object)
|
||||
.await
|
||||
.expect("non-empty staging cleanup must not negate the committed object");
|
||||
|
||||
assert_eq!(fs::read(&sentinel).await.expect("sentinel should remain"), b"keep");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
@@ -13022,10 +12859,7 @@ mod test {
|
||||
.expect("non-inline rename_data should commit");
|
||||
|
||||
assert!(!replacement_dir.exists(), "the destination object directory must not be replaced");
|
||||
assert!(
|
||||
!staging_parent.exists(),
|
||||
"successful commit should remove the empty staging parent after releasing its guard"
|
||||
);
|
||||
assert!(staging_parent.exists(), "the guarded staging parent must retain its identity");
|
||||
assert!(
|
||||
!replacement_staging_parent.exists(),
|
||||
"the staging parent must not be replaced between data and metadata publication"
|
||||
|
||||
@@ -65,7 +65,6 @@ use error::{Error, Result};
|
||||
use local::LocalDisk;
|
||||
use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
|
||||
use rustfs_madmin::info_commands::DiskMetrics;
|
||||
use rustfs_rio::ChunkReaderBox;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fmt::Debug, path::PathBuf, sync::Arc, time::Duration};
|
||||
use time::OffsetDateTime;
|
||||
@@ -428,19 +427,6 @@ impl DiskAPI for Disk {
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_file_stream_chunks(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
offset: usize,
|
||||
length: usize,
|
||||
) -> Result<Option<ChunkReaderBox>> {
|
||||
match self {
|
||||
Disk::Local(_) => Ok(None),
|
||||
Disk::Remote(remote_disk) => remote_disk.read_file_stream_chunks(volume, path, offset, length).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
async fn read_file_mmap_copy(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<Bytes> {
|
||||
match self {
|
||||
@@ -879,18 +865,6 @@ pub trait DiskAPI: Debug + Send + Sync + 'static {
|
||||
async fn read_file(&self, volume: &str, path: &str) -> Result<FileReader>;
|
||||
async fn read_file_stream(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<FileReader>;
|
||||
|
||||
/// Returns an owned-chunk stream when the backing transport can preserve
|
||||
/// receive-buffer ownership. `None` retains the ordinary reader path.
|
||||
async fn read_file_stream_chunks(
|
||||
&self,
|
||||
_volume: &str,
|
||||
_path: &str,
|
||||
_offset: usize,
|
||||
_length: usize,
|
||||
) -> Result<Option<ChunkReaderBox>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// File read using mmap-then-copy on Unix or an efficient read on non-Unix.
|
||||
async fn read_file_mmap_copy(&self, volume: &str, path: &str, offset: usize, length: usize) -> Result<Bytes>;
|
||||
|
||||
|
||||
@@ -76,13 +76,6 @@ impl ShardBufferPool {
|
||||
self.buffers[index] = Some(buf);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn stored_allocation(&self, index: usize) -> Option<(*const u8, usize)> {
|
||||
self.buffers
|
||||
.get(index)
|
||||
.and_then(|buf| buf.as_ref().map(|buf| (buf.as_ptr(), buf.capacity())))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn stored_capacity(&self, index: usize) -> Option<usize> {
|
||||
self.buffers.get(index).and_then(|buf| buf.as_ref().map(Vec::capacity))
|
||||
|
||||
@@ -14,10 +14,7 @@
|
||||
|
||||
use pin_project_lite::pin_project;
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use std::future::poll_fn;
|
||||
use std::io::IoSlice;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
use tracing::error;
|
||||
@@ -26,18 +23,6 @@ const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
const LOG_SUBSYSTEM_ERASURE: &str = "erasure";
|
||||
const EVENT_BITROT_SHORT_SHARD_READ: &str = "bitrot_short_shard_read";
|
||||
const EVENT_BITROT_HASH_MISMATCH: &str = "bitrot_hash_mismatch";
|
||||
const MAX_RETAINED_CHUNKS_PER_BLOCK: usize = 64;
|
||||
const MAX_CHUNK_POLLS_PER_YIELD: usize = MAX_RETAINED_CHUNKS_PER_BLOCK + 1;
|
||||
|
||||
/// Result of polling an optional owned-chunk handoff.
|
||||
pub enum ShardChunkRead {
|
||||
/// The source does not support owned-chunk handoff and remains untouched.
|
||||
Unsupported,
|
||||
/// The source reached EOF.
|
||||
Eof,
|
||||
/// A non-empty chunk containing at most the requested number of bytes.
|
||||
Chunk(bytes::Bytes),
|
||||
}
|
||||
|
||||
/// A shard source that may already hold its bytes in memory.
|
||||
///
|
||||
@@ -57,12 +42,6 @@ pub trait ShardSource: AsyncRead + Send + Sync + Unpin {
|
||||
fn try_take_block(&mut self, _n: usize) -> Option<bytes::Bytes> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Polls one owned chunk when the source supports chunk handoff.
|
||||
/// `Unsupported` must leave the source untouched.
|
||||
fn poll_read_chunk(self: Pin<&mut Self>, _cx: &mut Context<'_>, _max: usize) -> Poll<std::io::Result<ShardChunkRead>> {
|
||||
Poll::Ready(Ok(ShardChunkRead::Unsupported))
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrowed and owned byte slices are ordinary streaming sources: they carry no
|
||||
@@ -96,9 +75,6 @@ pin_project! {
|
||||
// contiguous on-disk `[hash][data]` block so both are pulled in a single
|
||||
// pass; grown lazily and never shrunk.
|
||||
buf: Vec<u8>,
|
||||
// Reused owned chunk vector for the remote HTTP fast path. Keeping the
|
||||
// allocation with the reader avoids allocating once per bitrot block.
|
||||
chunks: Vec<bytes::Bytes>,
|
||||
skip_verify: bool,
|
||||
last_verify_duration: Duration,
|
||||
}
|
||||
@@ -115,7 +91,6 @@ where
|
||||
hash_algo: algo,
|
||||
shard_size,
|
||||
buf: Vec::new(),
|
||||
chunks: Vec::new(),
|
||||
skip_verify,
|
||||
last_verify_duration: Duration::ZERO,
|
||||
}
|
||||
@@ -125,11 +100,6 @@ where
|
||||
self.last_verify_duration
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn inner_ref(&self) -> &R {
|
||||
&self.inner
|
||||
}
|
||||
|
||||
/// Read a single (hash+data) block, verify hash, and copy `out.len()` bytes
|
||||
/// into `out`. Returns an error if the shard is short, the hash mismatches,
|
||||
/// or `out` is larger than one shard. On error `out`'s contents are
|
||||
@@ -290,6 +260,11 @@ where
|
||||
|
||||
let need = hash_size + want;
|
||||
|
||||
// In-memory fast path: the block is already resident, so slice it instead
|
||||
// of copying it into the scratch buffer first (rustfs/backlog#1159). One
|
||||
// copy (`extend_from_slice`) instead of two. A source that cannot serve
|
||||
// `need` bytes returns `None` and falls through to the scratch path,
|
||||
// keeping the short-read contract.
|
||||
if let Some(block) = self.inner.try_take_block(need) {
|
||||
let (data, verify) = split_and_verify(&self.hash_algo, self.skip_verify, &block)?;
|
||||
out.extend_from_slice(data);
|
||||
@@ -297,126 +272,6 @@ where
|
||||
return Ok(want);
|
||||
}
|
||||
|
||||
self.chunks.clear();
|
||||
let handed_off = {
|
||||
let inner = &mut self.inner;
|
||||
let chunks = &mut self.chunks;
|
||||
let tail_buf = &mut self.buf;
|
||||
let mut received = 0usize;
|
||||
poll_fn(|cx| {
|
||||
for _ in 0..MAX_CHUNK_POLLS_PER_YIELD {
|
||||
let next = match Pin::new(&mut *inner).poll_read_chunk(cx, need - received) {
|
||||
Poll::Ready(Ok(next)) => next,
|
||||
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
|
||||
Poll::Pending => return Poll::Pending,
|
||||
};
|
||||
let chunk = match next {
|
||||
ShardChunkRead::Unsupported if received == 0 => return Poll::Ready(Ok(false)),
|
||||
ShardChunkRead::Unsupported => {
|
||||
return Poll::Ready(Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"chunk handoff became unavailable after transferring data",
|
||||
)));
|
||||
}
|
||||
ShardChunkRead::Eof => {
|
||||
return Poll::Ready(Err(short_shard_read(received.saturating_sub(hash_size), want)));
|
||||
}
|
||||
ShardChunkRead::Chunk(chunk) => chunk,
|
||||
};
|
||||
|
||||
if received == 0 {
|
||||
tail_buf.clear();
|
||||
}
|
||||
if chunk.is_empty() {
|
||||
return Poll::Ready(Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"chunk handoff returned an empty chunk",
|
||||
)));
|
||||
}
|
||||
let remaining = need - received;
|
||||
if chunk.len() > remaining {
|
||||
return Poll::Ready(Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"chunk handoff exceeded its requested boundary",
|
||||
)));
|
||||
}
|
||||
received += chunk.len();
|
||||
|
||||
if chunks.len() == MAX_RETAINED_CHUNKS_PER_BLOCK {
|
||||
if tail_buf.is_empty() {
|
||||
tail_buf.reserve_exact(need - (received - chunk.len()));
|
||||
}
|
||||
tail_buf.extend_from_slice(&chunk);
|
||||
} else {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
if received == need {
|
||||
return Poll::Ready(Ok(true));
|
||||
}
|
||||
}
|
||||
cx.waker().wake_by_ref();
|
||||
Poll::Pending
|
||||
})
|
||||
.await?
|
||||
};
|
||||
if handed_off {
|
||||
if self.chunks.len() == 1 && self.buf.is_empty() {
|
||||
let block = &self.chunks[0];
|
||||
let (data, verify) = split_and_verify(&self.hash_algo, self.skip_verify, block)?;
|
||||
out.extend_from_slice(data);
|
||||
self.last_verify_duration = verify;
|
||||
return Ok(want);
|
||||
}
|
||||
|
||||
let block_chunks = || {
|
||||
self.chunks
|
||||
.iter()
|
||||
.map(|chunk| chunk.as_ref())
|
||||
.chain((!self.buf.is_empty()).then_some(self.buf.as_slice()))
|
||||
};
|
||||
if !self.skip_verify {
|
||||
let verify_start = std::time::Instant::now();
|
||||
let actual_hash = self
|
||||
.hash_algo
|
||||
.hash_encode_slices(block_chunks().scan(hash_size, |skip, chunk| {
|
||||
let start = (*skip).min(chunk.len());
|
||||
*skip -= start;
|
||||
Some(&chunk[start..])
|
||||
}));
|
||||
let verify = verify_start.elapsed();
|
||||
let mut hash_offset = 0;
|
||||
let mut remaining = hash_size;
|
||||
for chunk in block_chunks() {
|
||||
let take = remaining.min(chunk.len());
|
||||
if actual_hash.as_ref()[hash_offset..hash_offset + take] != chunk[..take] {
|
||||
error!(
|
||||
event = EVENT_BITROT_HASH_MISMATCH,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_ERASURE,
|
||||
state = "failed",
|
||||
data_len = want,
|
||||
"bitrot hash mismatch"
|
||||
);
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "bitrot hash mismatch"));
|
||||
}
|
||||
hash_offset += take;
|
||||
remaining -= take;
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.last_verify_duration = verify;
|
||||
}
|
||||
let mut skip = hash_size;
|
||||
for chunk in block_chunks() {
|
||||
let start = skip.min(chunk.len());
|
||||
skip -= start;
|
||||
out.extend_from_slice(&chunk[start..]);
|
||||
}
|
||||
return Ok(want);
|
||||
}
|
||||
|
||||
// Streaming path: same single pass and same verification as `read`; only
|
||||
// the sink differs (`extend_from_slice` into `out` instead of
|
||||
// `copy_from_slice` into a pre-zeroed buffer).
|
||||
@@ -822,167 +677,18 @@ impl BitrotWriterWrapper {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ShardSource;
|
||||
use super::{
|
||||
BitrotReader, BitrotWriter, BitrotWriterWrapper, CustomWriter, bitrot_shard_file_size, bitrot_verify, write_all_vectored,
|
||||
};
|
||||
use super::{MAX_RETAINED_CHUNKS_PER_BLOCK, ShardChunkRead, ShardSource};
|
||||
use bytes::Bytes;
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Cursor, IoSlice};
|
||||
use std::pin::Pin;
|
||||
use std::io::{Cursor, IoSlice};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf};
|
||||
|
||||
struct FragmentedSource {
|
||||
chunks: VecDeque<Bytes>,
|
||||
}
|
||||
|
||||
impl FragmentedSource {
|
||||
fn new(bytes: Vec<u8>, fragment_sizes: &[usize]) -> Self {
|
||||
let mut chunks = VecDeque::new();
|
||||
let mut offset = 0;
|
||||
for &size in fragment_sizes {
|
||||
let end = (offset + size).min(bytes.len());
|
||||
if offset < end {
|
||||
chunks.push_back(Bytes::copy_from_slice(&bytes[offset..end]));
|
||||
}
|
||||
offset = end;
|
||||
}
|
||||
if offset < bytes.len() {
|
||||
chunks.push_back(Bytes::copy_from_slice(&bytes[offset..]));
|
||||
}
|
||||
Self { chunks }
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for FragmentedSource {
|
||||
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Err(io::Error::other("fragmented source must use chunk handoff")))
|
||||
}
|
||||
}
|
||||
|
||||
impl ShardSource for FragmentedSource {
|
||||
fn poll_read_chunk(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, max: usize) -> Poll<io::Result<ShardChunkRead>> {
|
||||
let Some(mut chunk) = self.chunks.pop_front() else {
|
||||
return Poll::Ready(Ok(ShardChunkRead::Eof));
|
||||
};
|
||||
if chunk.len() > max {
|
||||
self.chunks.push_front(chunk.split_off(max));
|
||||
chunk.truncate(max);
|
||||
}
|
||||
Poll::Ready(Ok(ShardChunkRead::Chunk(chunk)))
|
||||
}
|
||||
}
|
||||
|
||||
struct GeneratedChunkSource {
|
||||
bytes: Bytes,
|
||||
offset: usize,
|
||||
fragment_size: usize,
|
||||
fail_at: Option<usize>,
|
||||
}
|
||||
|
||||
impl GeneratedChunkSource {
|
||||
fn new(bytes: Vec<u8>, fragment_size: usize) -> Self {
|
||||
assert!(fragment_size > 0);
|
||||
Self {
|
||||
bytes: Bytes::from(bytes),
|
||||
offset: 0,
|
||||
fragment_size,
|
||||
fail_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failing(bytes: Vec<u8>, fragment_size: usize, fail_at: usize) -> Self {
|
||||
Self {
|
||||
fail_at: Some(fail_at),
|
||||
..Self::new(bytes, fragment_size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for GeneratedChunkSource {
|
||||
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Err(io::Error::other("generated source must use chunk handoff")))
|
||||
}
|
||||
}
|
||||
|
||||
impl ShardSource for GeneratedChunkSource {
|
||||
fn poll_read_chunk(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, max: usize) -> Poll<io::Result<ShardChunkRead>> {
|
||||
if self.fail_at == Some(self.offset) {
|
||||
return Poll::Ready(Err(rustfs_rio::new_test_internode_http_io_error(
|
||||
rustfs_rio::InternodeHttpErrorKind::BodyStreamAborted,
|
||||
)));
|
||||
}
|
||||
if self.offset == self.bytes.len() {
|
||||
return Poll::Ready(Ok(ShardChunkRead::Eof));
|
||||
}
|
||||
let error_limit = self.fail_at.unwrap_or(self.bytes.len());
|
||||
let take = self
|
||||
.fragment_size
|
||||
.min(max)
|
||||
.min(error_limit - self.offset)
|
||||
.min(self.bytes.len() - self.offset);
|
||||
let start = self.offset;
|
||||
self.offset += take;
|
||||
Poll::Ready(Ok(ShardChunkRead::Chunk(self.bytes.slice(start..start + take))))
|
||||
}
|
||||
}
|
||||
|
||||
struct InvalidChunkSource {
|
||||
mode: InvalidChunkMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum InvalidChunkMode {
|
||||
Empty,
|
||||
Oversized,
|
||||
UnsupportedAfterChunk,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl AsyncRead for InvalidChunkSource {
|
||||
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Err(io::Error::other("invalid source must use chunk handoff")))
|
||||
}
|
||||
}
|
||||
|
||||
impl ShardSource for InvalidChunkSource {
|
||||
fn poll_read_chunk(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, max: usize) -> Poll<io::Result<ShardChunkRead>> {
|
||||
match self.mode {
|
||||
InvalidChunkMode::Empty => Poll::Ready(Ok(ShardChunkRead::Chunk(Bytes::new()))),
|
||||
InvalidChunkMode::Oversized => Poll::Ready(Ok(ShardChunkRead::Chunk(Bytes::from(vec![0; max + 1])))),
|
||||
InvalidChunkMode::UnsupportedAfterChunk => {
|
||||
self.mode = InvalidChunkMode::Unsupported;
|
||||
Poll::Ready(Ok(ShardChunkRead::Chunk(Bytes::from_static(b"x"))))
|
||||
}
|
||||
InvalidChunkMode::Unsupported => Poll::Ready(Ok(ShardChunkRead::Unsupported)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ScratchReuseSource {
|
||||
block: Option<Bytes>,
|
||||
saw_reused_scratch: bool,
|
||||
}
|
||||
|
||||
impl AsyncRead for ScratchReuseSource {
|
||||
fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
||||
let Some(block) = self.block.take() else {
|
||||
return Poll::Ready(Ok(()));
|
||||
};
|
||||
self.saw_reused_scratch = buf.initialize_unfilled()[..block.len()].iter().all(|byte| *byte == 0xa5);
|
||||
buf.put_slice(&block);
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
impl ShardSource for ScratchReuseSource {}
|
||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||
|
||||
#[derive(Default)]
|
||||
struct VectoredCountingWriter {
|
||||
@@ -1740,70 +1446,6 @@ mod tests {
|
||||
assert!(out.is_empty(), "corrupt bytes must never reach the caller's buffer");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chunked_handoff_verifies_data_split_across_hash_boundaries() {
|
||||
const SHARD: usize = 4096;
|
||||
let algo = HashAlgorithm::HighwayHash256S;
|
||||
let data: Vec<u8> = (0..SHARD).map(|index| (index % 251) as u8).collect();
|
||||
let mut encoded = Vec::new();
|
||||
BitrotWriter::new(&mut encoded, SHARD, algo.clone())
|
||||
.write(&data)
|
||||
.await
|
||||
.expect("write shard");
|
||||
|
||||
let mut output = Vec::with_capacity(SHARD);
|
||||
BitrotReader::new(FragmentedSource::new(encoded, &[3, 11, 19, 37, 128]), SHARD, algo, false)
|
||||
.read_appending(&mut output, SHARD)
|
||||
.await
|
||||
.expect("fragmented shard must verify");
|
||||
|
||||
assert_eq!(output, data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chunked_handoff_never_appends_a_corrupt_shard() {
|
||||
const SHARD: usize = 4096;
|
||||
let algo = HashAlgorithm::HighwayHash256S;
|
||||
let mut encoded = Vec::new();
|
||||
BitrotWriter::new(&mut encoded, SHARD, algo.clone())
|
||||
.write(&vec![9u8; SHARD])
|
||||
.await
|
||||
.expect("write shard");
|
||||
let last = encoded.len() - 1;
|
||||
encoded[last] ^= 0xff;
|
||||
|
||||
let mut output = Vec::with_capacity(SHARD);
|
||||
let err = BitrotReader::new(FragmentedSource::new(encoded, &[7, 17, 31]), SHARD, algo, false)
|
||||
.read_appending(&mut output, SHARD)
|
||||
.await
|
||||
.expect_err("corrupt fragmented shard must fail");
|
||||
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chunked_handoff_does_not_hash_when_verification_is_skipped() {
|
||||
const SHARD: usize = 4096;
|
||||
let algo = HashAlgorithm::HighwayHash256S;
|
||||
let mut encoded = Vec::new();
|
||||
BitrotWriter::new(&mut encoded, SHARD, algo.clone())
|
||||
.write(&vec![9u8; SHARD])
|
||||
.await
|
||||
.expect("write shard");
|
||||
encoded[0] ^= 0xff;
|
||||
|
||||
let mut output = Vec::with_capacity(SHARD);
|
||||
let mut reader = BitrotReader::new(FragmentedSource::new(encoded, &[7, 17, 31]), SHARD, algo, true);
|
||||
reader
|
||||
.read_appending(&mut output, SHARD)
|
||||
.await
|
||||
.expect("skipped verification must accept fragmented shard bytes");
|
||||
|
||||
assert_eq!(reader.last_verify_duration(), Duration::ZERO);
|
||||
assert_eq!(output, vec![9u8; SHARD]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_appending_rejects_a_want_larger_than_the_shard() {
|
||||
let algo = HashAlgorithm::HighwayHash256;
|
||||
@@ -1855,21 +1497,10 @@ mod tests {
|
||||
|
||||
// Equivalence: same bytes out of both paths.
|
||||
let mut via_mem: Vec<u8> = Vec::with_capacity(SHARD);
|
||||
let mut memory_reader = BitrotReader::new(Cursor::new(Bytes::from(encoded.clone())), SHARD, algo.clone(), false);
|
||||
memory_reader
|
||||
BitrotReader::new(Cursor::new(Bytes::from(encoded.clone())), SHARD, algo.clone(), false)
|
||||
.read_appending(&mut via_mem, SHARD)
|
||||
.await
|
||||
.expect("in-memory read");
|
||||
assert_eq!(
|
||||
memory_reader.chunks.capacity(),
|
||||
0,
|
||||
"the synchronous fast path must not allocate chunk storage"
|
||||
);
|
||||
assert_eq!(
|
||||
memory_reader.buf.capacity(),
|
||||
0,
|
||||
"the synchronous fast path must not allocate scratch storage"
|
||||
);
|
||||
|
||||
let mut via_stream: Vec<u8> = Vec::with_capacity(SHARD);
|
||||
BitrotReader::new(Cursor::new(encoded), SHARD, algo, false)
|
||||
@@ -1906,152 +1537,4 @@ mod tests {
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(out.is_empty(), "corrupt bytes must never reach the caller's buffer");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_fallback_reuses_initialized_scratch() {
|
||||
const SHARD: usize = 4096;
|
||||
let algo = HashAlgorithm::HighwayHash256S;
|
||||
let data = vec![7u8; SHARD];
|
||||
let encoded = encode_one_block(&data, SHARD, algo.clone()).await;
|
||||
let source = ScratchReuseSource {
|
||||
block: Some(Bytes::copy_from_slice(&encoded)),
|
||||
saw_reused_scratch: false,
|
||||
};
|
||||
let mut reader = BitrotReader::new(source, SHARD, algo, false);
|
||||
reader.buf = vec![0xa5; encoded.len()];
|
||||
let mut output = Vec::new();
|
||||
|
||||
reader
|
||||
.read_appending(&mut output, SHARD)
|
||||
.await
|
||||
.expect("streaming fallback should verify");
|
||||
|
||||
assert!(reader.inner.saw_reused_scratch, "capability probing must not clear reusable scratch");
|
||||
assert_eq!(output, data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chunked_handoff_bounds_production_sized_one_byte_fragments() {
|
||||
const SHARD: usize = 1024 * 1024 / 4;
|
||||
let algo = HashAlgorithm::HighwayHash256S;
|
||||
let data: Vec<u8> = (0..SHARD).map(|index| (index % 251) as u8).collect();
|
||||
let encoded = encode_one_block(&data, SHARD, algo.clone()).await;
|
||||
let encoded_len = encoded.len();
|
||||
let mut reader = BitrotReader::new(GeneratedChunkSource::new(encoded, 1), SHARD, algo, false);
|
||||
let mut output = Vec::with_capacity(SHARD);
|
||||
|
||||
reader
|
||||
.read_appending(&mut output, SHARD)
|
||||
.await
|
||||
.expect("one-byte fragments should verify with bounded retained state");
|
||||
|
||||
assert_eq!(output, data);
|
||||
assert_eq!(reader.chunks.len(), MAX_RETAINED_CHUNKS_PER_BLOCK);
|
||||
assert!(reader.chunks.capacity() <= MAX_RETAINED_CHUNKS_PER_BLOCK);
|
||||
assert_eq!(reader.buf.len(), encoded_len - MAX_RETAINED_CHUNKS_PER_BLOCK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chunked_handoff_keeps_sixty_four_frames_zero_copy_and_respects_poll_budget() {
|
||||
const SHARD: usize = 1024 * 1024;
|
||||
const FRAME: usize = 16 * 1024;
|
||||
let algo = HashAlgorithm::HighwayHash256S;
|
||||
|
||||
let small_data = vec![3u8; 4096];
|
||||
let small_encoded = encode_one_block(&small_data, 4096, algo.clone()).await;
|
||||
let mut exact_reader =
|
||||
BitrotReader::new(FragmentedSource::new(small_encoded.clone(), &[1; 63]), 4096, algo.clone(), false);
|
||||
let mut exact_output = Vec::new();
|
||||
exact_reader
|
||||
.read_appending(&mut exact_output, 4096)
|
||||
.await
|
||||
.expect("exactly sixty-four frames should verify");
|
||||
assert_eq!(exact_output, small_data);
|
||||
assert_eq!(exact_reader.chunks.len(), MAX_RETAINED_CHUNKS_PER_BLOCK);
|
||||
assert!(exact_reader.buf.is_empty(), "the threshold itself must remain zero-copy");
|
||||
|
||||
let mut yielded_reader = BitrotReader::new(FragmentedSource::new(small_encoded, &[1; 65]), 4096, algo.clone(), false);
|
||||
let mut yielded_output = Vec::new();
|
||||
let mut yielded_read = Box::pin(yielded_reader.read_appending(&mut yielded_output, 4096));
|
||||
let mut cx = Context::from_waker(std::task::Waker::noop());
|
||||
assert!(std::future::Future::poll(yielded_read.as_mut(), &mut cx).is_pending());
|
||||
assert!(matches!(std::future::Future::poll(yielded_read.as_mut(), &mut cx), Poll::Ready(Ok(4096))));
|
||||
drop(yielded_read);
|
||||
assert_eq!(yielded_output, small_data);
|
||||
|
||||
let data = vec![7u8; SHARD];
|
||||
let encoded = encode_one_block(&data, SHARD, algo.clone()).await;
|
||||
let mut reader = BitrotReader::new(FragmentedSource::new(encoded, &[FRAME; 64]), SHARD, algo, false);
|
||||
let mut output = Vec::with_capacity(SHARD);
|
||||
let mut read = Box::pin(reader.read_appending(&mut output, SHARD));
|
||||
assert!(
|
||||
matches!(std::future::Future::poll(read.as_mut(), &mut cx), Poll::Ready(Ok(SHARD))),
|
||||
"sixty-five normal HTTP frames should complete without a cooperative yield"
|
||||
);
|
||||
drop(read);
|
||||
|
||||
assert_eq!(output, data);
|
||||
assert_eq!(reader.chunks.len(), MAX_RETAINED_CHUNKS_PER_BLOCK);
|
||||
assert_eq!(reader.buf.len(), HashAlgorithm::HighwayHash256S.size());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chunked_tail_failures_preserve_errors_and_output() {
|
||||
const SHARD: usize = 4096;
|
||||
let algo = HashAlgorithm::HighwayHash256S;
|
||||
let data = vec![7u8; SHARD];
|
||||
let encoded = encode_one_block(&data, SHARD, algo.clone()).await;
|
||||
let sentinel = vec![1u8, 2, 3];
|
||||
|
||||
let mut short_output = sentinel.clone();
|
||||
let short_err = BitrotReader::new(GeneratedChunkSource::new(encoded[..100].to_vec(), 1), SHARD, algo.clone(), false)
|
||||
.read_appending(&mut short_output, SHARD)
|
||||
.await
|
||||
.expect_err("EOF after the retention threshold must stay a short read");
|
||||
assert_eq!(short_err.kind(), io::ErrorKind::UnexpectedEof);
|
||||
assert_eq!(short_output, sentinel);
|
||||
|
||||
let mut corrupt = encoded.clone();
|
||||
let last = corrupt.len() - 1;
|
||||
corrupt[last] ^= 0xff;
|
||||
let mut corrupt_output = sentinel.clone();
|
||||
let corrupt_err = BitrotReader::new(GeneratedChunkSource::new(corrupt, 1), SHARD, algo.clone(), false)
|
||||
.read_appending(&mut corrupt_output, SHARD)
|
||||
.await
|
||||
.expect_err("corrupt coalesced tail must fail verification");
|
||||
assert_eq!(corrupt_err.kind(), io::ErrorKind::InvalidData);
|
||||
assert_eq!(corrupt_output, sentinel);
|
||||
|
||||
let mut failed_output = sentinel.clone();
|
||||
let body_err = BitrotReader::new(GeneratedChunkSource::failing(encoded, 1, 65), SHARD, algo, false)
|
||||
.read_appending(&mut failed_output, SHARD)
|
||||
.await
|
||||
.expect_err("a terminal body error must not become EOF");
|
||||
let source = body_err
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<rustfs_rio::InternodeHttpError>())
|
||||
.expect("body error should retain internode classification");
|
||||
assert_eq!(source.kind(), rustfs_rio::InternodeHttpErrorKind::BodyStreamAborted);
|
||||
assert_eq!(failed_output, sentinel);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chunked_handoff_rejects_invalid_source_contracts() {
|
||||
const SHARD: usize = 64;
|
||||
for mode in [
|
||||
InvalidChunkMode::Empty,
|
||||
InvalidChunkMode::Oversized,
|
||||
InvalidChunkMode::UnsupportedAfterChunk,
|
||||
] {
|
||||
let source = InvalidChunkSource { mode };
|
||||
let mut output = vec![9u8];
|
||||
let err = BitrotReader::new(source, SHARD, HashAlgorithm::HighwayHash256S, false)
|
||||
.read_appending(&mut output, SHARD)
|
||||
.await
|
||||
.expect_err("invalid chunk contracts must fail closed");
|
||||
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
assert_eq!(output, vec![9u8]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,7 @@ use crate::disk::error_reduce::reduce_errs;
|
||||
use crate::erasure::codec::workspace::ShardBufferPool;
|
||||
use crate::erasure::coding::{BitrotReader, Erasure};
|
||||
use crate::io_support::bitrot::DeferredReaderStripeHandle;
|
||||
use crate::set_disk::shard_source::{
|
||||
INLINE_SHARD_SLOTS, ShardBuffers, ShardErrors, ShardReadCost, ShardStripeSource, StripeReadState,
|
||||
};
|
||||
use crate::set_disk::shard_source::{ShardReadCost, ShardStripeSource, StripeReadState};
|
||||
use futures::FutureExt;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use pin_project_lite::pin_project;
|
||||
@@ -43,6 +41,9 @@ use tracing::{debug, error, warn};
|
||||
|
||||
type ShardReadFuture<'a> = Pin<Box<dyn Future<Output = (usize, ShardReadCost, Result<Vec<u8>, Error>, bool)> + Send + 'a>>;
|
||||
|
||||
const INLINE_SHARD_SLOTS: usize = 32;
|
||||
type ShardBuffers = SmallVec<[Option<Vec<u8>>; INLINE_SHARD_SLOTS]>;
|
||||
type ShardErrors = SmallVec<[Option<Error>; INLINE_SHARD_SLOTS]>;
|
||||
type ShardIndexes = SmallVec<[usize; INLINE_SHARD_SLOTS]>;
|
||||
type ActiveReaders = SmallVec<[bool; INLINE_SHARD_SLOTS]>;
|
||||
|
||||
@@ -391,7 +392,6 @@ pub(crate) struct ParallelReader<R> {
|
||||
// Request-scoped shard buffers keyed by shard index. Keeping ownership in
|
||||
// `ParallelReader` avoids dropping unused parity/backup slot buffers between stripes.
|
||||
buffers: ShardBufferPool,
|
||||
stripe_state: Option<Box<StripeReadState>>,
|
||||
// Lockstep-path state (verify_reconstruction == true). `engaged[i]` marks
|
||||
// readers that participate in each stripe read: all data slots from the
|
||||
// start, parity slots only once a data shard is missing/dead. Unengaged
|
||||
@@ -596,7 +596,6 @@ where
|
||||
verify_reconstruction,
|
||||
locality_preference_enabled: get_shard_locality_preference_enabled(),
|
||||
buffers: ShardBufferPool::new(e.data_shards + e.parity_shards),
|
||||
stripe_state: None,
|
||||
engaged,
|
||||
deferred_handles: Vec::new(),
|
||||
stripe_index: 0,
|
||||
@@ -701,12 +700,6 @@ where
|
||||
{
|
||||
#[hotpath::measure(impl_type = "ParallelReader")]
|
||||
pub async fn read(&mut self) -> StripeReadOutput {
|
||||
let mut state = StripeReadState::with_slot_count(self.readers.len(), self.data_shards);
|
||||
self.read_into_state(&mut state).await;
|
||||
state.into_parts()
|
||||
}
|
||||
|
||||
async fn read_into_state(&mut self, state: &mut StripeReadState) {
|
||||
// On the reconstruction-verifying GET path, read every live shard reader
|
||||
// in lockstep so all readers advance one block per stripe and stay
|
||||
// mutually aligned. The adaptive data-first path below only reads
|
||||
@@ -716,14 +709,12 @@ where
|
||||
// than the data shards, producing "inconsistent read source shards" and
|
||||
// truncating large-object GETs under concurrency (backlog#832).
|
||||
if self.verify_reconstruction {
|
||||
self.read_lockstep(state).await;
|
||||
return;
|
||||
return self.read_lockstep().await;
|
||||
}
|
||||
// if self.readers.len() != self.total_shards {
|
||||
// return Err(io::Error::new(ErrorKind::InvalidInput, "Invalid number of readers"));
|
||||
// }
|
||||
let num_readers = self.readers.len();
|
||||
state.reset(num_readers, self.data_shards);
|
||||
|
||||
let shard_size = if self.offset + self.shard_size > self.shard_file_size {
|
||||
self.shard_file_size - self.offset
|
||||
@@ -732,7 +723,7 @@ where
|
||||
};
|
||||
|
||||
if shard_size == 0 {
|
||||
return;
|
||||
return (smallvec![None; num_readers], smallvec![None; num_readers]);
|
||||
}
|
||||
|
||||
// Advance to the next stripe so the following read() computes the correct
|
||||
@@ -743,7 +734,8 @@ where
|
||||
// is only read above to derive `shard_size`, so advancing here is safe.
|
||||
self.offset += shard_size;
|
||||
|
||||
let (shards, errs) = state.parts_mut();
|
||||
let mut shards: ShardBuffers = smallvec![None; num_readers];
|
||||
let mut errs: ShardErrors = smallvec![None; num_readers];
|
||||
let read_costs = self.read_costs.as_slice();
|
||||
let locality_preference_enabled = self.locality_preference_enabled;
|
||||
let low_cost_available = self
|
||||
@@ -890,8 +882,8 @@ where
|
||||
}
|
||||
|
||||
let result_is_err = record_shard_read_result(
|
||||
shards,
|
||||
errs,
|
||||
&mut shards,
|
||||
&mut errs,
|
||||
&mut retire_readers,
|
||||
&mut success,
|
||||
&mut successful_costs,
|
||||
@@ -952,8 +944,8 @@ where
|
||||
active_readers[i] = false;
|
||||
completed += 1;
|
||||
if record_shard_read_result(
|
||||
shards,
|
||||
errs,
|
||||
&mut shards,
|
||||
&mut errs,
|
||||
&mut retire_readers,
|
||||
&mut success,
|
||||
&mut successful_costs,
|
||||
@@ -965,7 +957,7 @@ where
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
retire_abandoned_readers(errs, &mut retire_readers, &active_readers);
|
||||
retire_abandoned_readers(&mut errs, &mut retire_readers, &active_readers);
|
||||
}
|
||||
|
||||
if let Some(path) = self.metrics_path {
|
||||
@@ -1009,6 +1001,8 @@ where
|
||||
for i in retire_readers {
|
||||
self.readers[i] = None;
|
||||
}
|
||||
|
||||
(shards, errs)
|
||||
}
|
||||
|
||||
/// Lockstep stripe read for the reconstruction-verifying GET path.
|
||||
@@ -1036,18 +1030,18 @@ where
|
||||
/// stripe would reintroduce the desync. A parity reader that cannot be
|
||||
/// realigned (no pending deferred handle) is likewise retired instead of
|
||||
/// being read out of position.
|
||||
async fn read_lockstep(&mut self, state: &mut StripeReadState) {
|
||||
async fn read_lockstep(&mut self) -> StripeReadOutput {
|
||||
let num_readers = self.readers.len();
|
||||
state.reset(num_readers, self.data_shards);
|
||||
let shard_size = if self.offset + self.shard_size > self.shard_file_size {
|
||||
self.shard_file_size - self.offset
|
||||
} else {
|
||||
self.shard_size
|
||||
};
|
||||
|
||||
let (shards, errs) = state.parts_mut();
|
||||
let mut shards: ShardBuffers = smallvec![None; num_readers];
|
||||
let mut errs: ShardErrors = smallvec![None; num_readers];
|
||||
if shard_size == 0 {
|
||||
return;
|
||||
return (shards, errs);
|
||||
}
|
||||
|
||||
// Advance to the next stripe (see the matching note in `read`); the
|
||||
@@ -1285,6 +1279,8 @@ where
|
||||
for i in retire_readers {
|
||||
self.readers[i] = None;
|
||||
}
|
||||
|
||||
(shards, errs)
|
||||
}
|
||||
|
||||
/// Attempt to bring an as-yet-unread parity reader into the lockstep read
|
||||
@@ -1341,20 +1337,10 @@ impl<R> ShardStripeSource for ParallelReader<R>
|
||||
where
|
||||
R: crate::erasure::coding::ShardSource,
|
||||
{
|
||||
async fn read_next_stripe(&mut self) -> Box<StripeReadState> {
|
||||
let mut state = self
|
||||
.stripe_state
|
||||
.take()
|
||||
.unwrap_or_else(|| Box::new(StripeReadState::with_slot_count(self.readers.len(), self.data_shards)));
|
||||
self.read_into_state(&mut state).await;
|
||||
state
|
||||
}
|
||||
|
||||
fn recycle_stripe(&mut self, mut state: Box<StripeReadState>) {
|
||||
self.recycle_shards(state.shards_mut());
|
||||
state.reset(0, self.data_shards);
|
||||
debug_assert!(self.stripe_state.is_none(), "a stripe cannot be recycled twice");
|
||||
self.stripe_state = Some(state);
|
||||
async fn read_next_stripe(&mut self) -> StripeReadState {
|
||||
let read_quorum = self.data_shards;
|
||||
let (shards, errors) = ParallelReader::read(self).await;
|
||||
StripeReadState::from_parts_with_read_costs(shards, errors, &self.read_costs, read_quorum)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1986,18 +1972,13 @@ mod tests {
|
||||
type BoxedShardReader = crate::io_support::bitrot::ShardReader;
|
||||
|
||||
#[test]
|
||||
fn parallel_reader_keeps_stripe_scratch_out_of_line() {
|
||||
eprintln!(
|
||||
"parallel_reader={} stripe_state={} cached_state={}",
|
||||
std::mem::size_of::<ParallelReader<Cursor<Vec<u8>>>>(),
|
||||
std::mem::size_of::<StripeReadState>(),
|
||||
std::mem::size_of::<Option<Box<StripeReadState>>>()
|
||||
);
|
||||
assert_eq!(
|
||||
std::mem::size_of::<Option<Box<StripeReadState>>>(),
|
||||
std::mem::size_of::<usize>(),
|
||||
"the request-scoped cache must remain pointer-sized",
|
||||
);
|
||||
fn shard_scratch_stays_inline_through_the_common_limit_and_spills_safely() {
|
||||
let inline: ShardBuffers = smallvec![None; INLINE_SHARD_SLOTS];
|
||||
assert!(!inline.spilled(), "the common shard-count boundary must not allocate");
|
||||
|
||||
let spilled: ShardBuffers = smallvec![None; INLINE_SHARD_SLOTS + 1];
|
||||
assert!(spilled.spilled(), "larger supported shard counts must fall back to the heap");
|
||||
assert_eq!(spilled.len(), INLINE_SHARD_SLOTS + 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2016,62 +1997,6 @@ mod tests {
|
||||
assert_eq!(errors.len(), TOTAL_SHARDS);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codec_reader_reuses_inline_and_spilled_stripe_scratch_between_reads() {
|
||||
for total_shards in [INLINE_SHARD_SLOTS, INLINE_SHARD_SLOTS + 1] {
|
||||
let data_shards = total_shards - 1;
|
||||
let readers = std::iter::repeat_with(|| None).take(total_shards).collect();
|
||||
let erasure = Erasure::new(data_shards, 1, data_shards * 2);
|
||||
let mut reader: ParallelReader<Cursor<Vec<u8>>> = ParallelReader::new(readers, erasure, 0, data_shards * 2);
|
||||
|
||||
let first = ShardStripeSource::read_next_stripe(&mut reader).await;
|
||||
let first_state = (&*first) as *const StripeReadState;
|
||||
let first_storage = first.scratch_storage();
|
||||
assert_eq!(first_storage.2, total_shards > INLINE_SHARD_SLOTS);
|
||||
assert_eq!(first_storage.3, total_shards > INLINE_SHARD_SLOTS);
|
||||
ShardStripeSource::recycle_stripe(&mut reader, first);
|
||||
|
||||
let second = ShardStripeSource::read_next_stripe(&mut reader).await;
|
||||
let second_storage = second.scratch_storage();
|
||||
|
||||
assert_eq!(
|
||||
(&*second) as *const StripeReadState,
|
||||
first_state,
|
||||
"the request-scoped state must be reused"
|
||||
);
|
||||
assert_eq!(second_storage.0, first_storage.0, "shard slots must reuse their allocation");
|
||||
assert_eq!(second_storage.1, first_storage.1, "error slots must reuse their allocation");
|
||||
assert_eq!(second.into_parts().0.len(), total_shards);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codec_reader_returns_shard_allocations_to_the_request_pool() {
|
||||
const SHARD_SIZE: usize = 16;
|
||||
let hash_algo = HashAlgorithm::None;
|
||||
let readers = vec![Some(create_reader(SHARD_SIZE, 2, 0x5a, &hash_algo, false).await)];
|
||||
let erasure = Erasure::new(1, 0, SHARD_SIZE);
|
||||
let mut reader = ParallelReader::new(readers, erasure, 0, SHARD_SIZE * 2);
|
||||
|
||||
let first = ShardStripeSource::read_next_stripe(&mut reader).await;
|
||||
let first_allocation = first
|
||||
.shard_allocation(0)
|
||||
.expect("the first stripe should own its shard allocation");
|
||||
ShardStripeSource::recycle_stripe(&mut reader, first);
|
||||
assert_eq!(
|
||||
reader.buffers.stored_allocation(0),
|
||||
Some(first_allocation),
|
||||
"recycling a stripe must return its shard allocation to the request pool"
|
||||
);
|
||||
|
||||
let second = ShardStripeSource::read_next_stripe(&mut reader).await;
|
||||
assert_eq!(
|
||||
second.shard_allocation(0),
|
||||
Some(first_allocation),
|
||||
"the next stripe must reuse the pooled shard allocation"
|
||||
);
|
||||
}
|
||||
|
||||
/// Counts the raw bytes pulled from a shard stream, to prove which shards
|
||||
/// a decode path actually touches (backlog#923 call-count evidence).
|
||||
struct CountingShardReader {
|
||||
|
||||
@@ -65,7 +65,7 @@ enum FillPolicy {
|
||||
}
|
||||
|
||||
impl FillPolicy {
|
||||
fn load() -> Self {
|
||||
fn from_env() -> Self {
|
||||
match rustfs_utils::get_env_usize(
|
||||
ENV_RUSTFS_GET_CODEC_STREAMING_MAX_INFLIGHT,
|
||||
DEFAULT_RUSTFS_GET_CODEC_STREAMING_MAX_INFLIGHT,
|
||||
@@ -75,22 +75,6 @@ impl FillPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
fn from_env() -> Self {
|
||||
#[cfg(test)]
|
||||
{
|
||||
Self::load()
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
Self::cached_core(Self::load)
|
||||
}
|
||||
}
|
||||
|
||||
fn cached_core(load: impl FnOnce() -> Self) -> Self {
|
||||
static CACHED: std::sync::OnceLock<FillPolicy> = std::sync::OnceLock::new();
|
||||
*CACHED.get_or_init(load)
|
||||
}
|
||||
|
||||
const fn max_inflight(self) -> usize {
|
||||
match self {
|
||||
Self::SingleInFlight => 1,
|
||||
@@ -495,30 +479,22 @@ where
|
||||
let mut deferred_error = None;
|
||||
let fill_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
let stripe_read_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
let mut state = source.read_next_stripe().await;
|
||||
let state = source.read_next_stripe().await;
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_STRIPE_READ, stripe_read_stage_start);
|
||||
let decode_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
let mut output_buf = reusable_buffers.pop().unwrap_or_default();
|
||||
let result = match decode_stripe_into(
|
||||
metrics_path,
|
||||
stage_metrics_enabled,
|
||||
engine,
|
||||
workspace,
|
||||
&mut state,
|
||||
remaining,
|
||||
&mut output_buf,
|
||||
) {
|
||||
Ok(true) => Ok(Some(output_buf)),
|
||||
Ok(false) => {
|
||||
reusable_buffers.push(output_buf);
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) => {
|
||||
reusable_buffers.push(output_buf);
|
||||
Err(err)
|
||||
}
|
||||
};
|
||||
source.recycle_stripe(state);
|
||||
let result =
|
||||
match decode_stripe_into(metrics_path, stage_metrics_enabled, engine, workspace, state, remaining, &mut output_buf) {
|
||||
Ok(true) => Ok(Some(output_buf)),
|
||||
Ok(false) => {
|
||||
reusable_buffers.push(output_buf);
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) => {
|
||||
reusable_buffers.push(output_buf);
|
||||
Err(err)
|
||||
}
|
||||
};
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_DECODE, decode_stage_start);
|
||||
if let Ok(Some(first_buf)) = result.as_ref() {
|
||||
let mut remaining_after_first = remaining.saturating_sub(first_buf.len());
|
||||
@@ -527,7 +503,7 @@ where
|
||||
break;
|
||||
}
|
||||
let stripe_read_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
let mut state = source.read_next_stripe().await;
|
||||
let state = source.read_next_stripe().await;
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_STRIPE_READ, stripe_read_stage_start);
|
||||
let decode_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
let mut queued_buf = reusable_buffers.pop().unwrap_or_default();
|
||||
@@ -536,11 +512,10 @@ where
|
||||
stage_metrics_enabled,
|
||||
engine,
|
||||
workspace,
|
||||
&mut state,
|
||||
state,
|
||||
remaining_after_first,
|
||||
&mut queued_buf,
|
||||
);
|
||||
source.recycle_stripe(state);
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_DECODE, decode_stage_start);
|
||||
match queued_result {
|
||||
Ok(true) => {
|
||||
@@ -742,7 +717,7 @@ fn decode_stripe_into<E>(
|
||||
stage_metrics_enabled: bool,
|
||||
engine: &E,
|
||||
workspace: &mut E::Workspace,
|
||||
state: &mut StripeReadState,
|
||||
state: StripeReadState,
|
||||
remaining: usize,
|
||||
output: &mut Vec<u8>,
|
||||
) -> io::Result<bool>
|
||||
@@ -750,7 +725,7 @@ where
|
||||
E: ErasureDecodeEngine,
|
||||
{
|
||||
output.clear();
|
||||
if state.is_empty() {
|
||||
if state.slots().is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
if !state.can_decode() {
|
||||
@@ -766,12 +741,13 @@ where
|
||||
);
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_RECONSTRUCT, reconstruct_stage_start);
|
||||
let emit_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
emit_data_shards_into(state, engine.data_shards(), engine.block_size(), remaining, output)?;
|
||||
emit_data_shards_into(&state, engine.data_shards(), engine.block_size(), remaining, output)?;
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_EMIT, emit_stage_start);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let reconstruct_outcome = match engine.reconstruct_into(state.shards_mut(), workspace) {
|
||||
let (mut shards, _errs) = state.into_parts();
|
||||
let reconstruct_outcome = match engine.reconstruct_into(&mut shards, workspace) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_RECONSTRUCT, reconstruct_stage_start);
|
||||
@@ -781,7 +757,7 @@ where
|
||||
rustfs_io_metrics::record_get_object_reconstruct_outcome(metrics_path, engine.engine_name(), reconstruct_outcome);
|
||||
record_get_stage_duration_if_enabled(metrics_path, GET_STAGE_RECONSTRUCT, reconstruct_stage_start);
|
||||
|
||||
if state.shards_mut().len() < engine.data_shards() {
|
||||
if shards.len() < engine.data_shards() {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::UnexpectedEof,
|
||||
"decoded stripe has fewer shards than data shard count",
|
||||
@@ -790,7 +766,7 @@ where
|
||||
|
||||
let emit_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
|
||||
reserve_output_capacity(output, engine.block_size().min(remaining));
|
||||
for shard in state.shards_mut().iter().take(engine.data_shards()) {
|
||||
for shard in shards.iter().take(engine.data_shards()) {
|
||||
if output.len() >= remaining {
|
||||
break;
|
||||
}
|
||||
@@ -830,7 +806,10 @@ fn emit_data_shards_into(
|
||||
if output.len() >= remaining {
|
||||
break;
|
||||
}
|
||||
let Some(shard) = state.data_bytes(index) else {
|
||||
let Some(slot) = state.slot_by_index(index) else {
|
||||
return Err(io::Error::new(ErrorKind::UnexpectedEof, "decoded stripe is missing a data shard"));
|
||||
};
|
||||
let Some(shard) = slot.data_bytes() else {
|
||||
return Err(io::Error::new(ErrorKind::UnexpectedEof, "decoded stripe is missing a data shard"));
|
||||
};
|
||||
let copy_len = shard.len().min(remaining - output.len());
|
||||
@@ -847,7 +826,7 @@ mod tests {
|
||||
};
|
||||
use crate::erasure::coding::decode::ParallelReader;
|
||||
use crate::erasure::coding::{BitrotReader, BitrotWriter, Erasure};
|
||||
use crate::set_disk::shard_source::StripeReadState;
|
||||
use crate::set_disk::shard_source::{ShardSlot, StripeReadState};
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use std::collections::VecDeque;
|
||||
use std::future::{pending, poll_fn};
|
||||
@@ -866,13 +845,6 @@ mod tests {
|
||||
read_count: Option<Arc<AtomicUsize>>,
|
||||
}
|
||||
|
||||
struct RecordingStripeSource {
|
||||
stripes: VecDeque<StripeReadState>,
|
||||
read_quorum: usize,
|
||||
reads: usize,
|
||||
recycles: usize,
|
||||
}
|
||||
|
||||
struct BlockingSource {
|
||||
started: Arc<Notify>,
|
||||
dropped: Arc<AtomicUsize>,
|
||||
@@ -927,43 +899,25 @@ mod tests {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ShardStripeSource for VecStripeSource {
|
||||
async fn read_next_stripe(&mut self) -> Box<StripeReadState> {
|
||||
async fn read_next_stripe(&mut self) -> StripeReadState {
|
||||
if let Some(read_count) = &self.read_count {
|
||||
read_count.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
Box::new(
|
||||
self.stripes
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| StripeReadState::from_parts(Vec::new(), Vec::new(), self.read_quorum)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ShardStripeSource for RecordingStripeSource {
|
||||
async fn read_next_stripe(&mut self) -> Box<StripeReadState> {
|
||||
self.reads += 1;
|
||||
Box::new(
|
||||
self.stripes
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| StripeReadState::from_parts(Vec::new(), Vec::new(), self.read_quorum)),
|
||||
)
|
||||
}
|
||||
|
||||
fn recycle_stripe(&mut self, _state: Box<StripeReadState>) {
|
||||
self.recycles += 1;
|
||||
self.stripes
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| StripeReadState::new(Vec::new(), self.read_quorum))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ShardStripeSource for BlockingSource {
|
||||
async fn read_next_stripe(&mut self) -> Box<StripeReadState> {
|
||||
async fn read_next_stripe(&mut self) -> StripeReadState {
|
||||
let _guard = BlockingSourceDropGuard {
|
||||
dropped: Arc::clone(&self.dropped),
|
||||
};
|
||||
self.started.notify_one();
|
||||
pending::<()>().await;
|
||||
Box::new(StripeReadState::from_parts(Vec::new(), Vec::new(), self.read_quorum))
|
||||
StripeReadState::new(Vec::new(), self.read_quorum)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1136,23 +1090,6 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_policy_production_cache_loads_once() {
|
||||
use std::cell::Cell;
|
||||
|
||||
let loads = Cell::new(0);
|
||||
for _ in 0..3 {
|
||||
assert_eq!(
|
||||
FillPolicy::cached_core(|| {
|
||||
loads.set(loads.get() + 1);
|
||||
FillPolicy::DualInFlight
|
||||
}),
|
||||
FillPolicy::DualInFlight
|
||||
);
|
||||
}
|
||||
assert_eq!(loads.get(), 1, "the production fill policy must not re-read the environment per reader");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erasure_decode_reader_rejects_invalid_engine_shape() {
|
||||
let source = VecStripeSource {
|
||||
@@ -1752,10 +1689,7 @@ mod tests {
|
||||
.pop_front()
|
||||
.expect("first stripe should exist");
|
||||
let mut source = VecStripeSource {
|
||||
stripes: VecDeque::from([
|
||||
first_state,
|
||||
StripeReadState::from_parts(Vec::new(), Vec::new(), erasure.data_shards),
|
||||
]),
|
||||
stripes: VecDeque::from([first_state, StripeReadState::new(Vec::new(), erasure.data_shards)]),
|
||||
read_quorum: erasure.data_shards,
|
||||
read_count: None,
|
||||
};
|
||||
@@ -1790,14 +1724,13 @@ mod tests {
|
||||
.stripes
|
||||
.pop_front()
|
||||
.expect("first stripe should exist");
|
||||
let mut source = RecordingStripeSource {
|
||||
let mut source = VecStripeSource {
|
||||
stripes: VecDeque::from([
|
||||
first_state,
|
||||
StripeReadState::from_parts(vec![Some(vec![1])], Vec::new(), erasure.data_shards),
|
||||
StripeReadState::new(vec![ShardSlot::data(0, vec![1])], erasure.data_shards),
|
||||
]),
|
||||
read_quorum: erasure.data_shards,
|
||||
reads: 0,
|
||||
recycles: 0,
|
||||
read_count: None,
|
||||
};
|
||||
let engine = LegacyEcDecodeEngine::new(erasure);
|
||||
let mut workspace = engine.prepare_workspace(4).expect("workspace should be prepared");
|
||||
@@ -1823,8 +1756,6 @@ mod tests {
|
||||
.kind(),
|
||||
ErrorKind::Other
|
||||
);
|
||||
assert_eq!(source.reads, 2, "the fill must read the primary and queued stripe");
|
||||
assert_eq!(source.recycles, source.reads, "every completed stripe read must be recycled");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1837,7 +1768,7 @@ mod tests {
|
||||
.stripes
|
||||
.pop_front()
|
||||
.expect("first stripe should exist"),
|
||||
StripeReadState::from_parts(Vec::new(), Vec::new(), erasure.data_shards),
|
||||
StripeReadState::new(Vec::new(), erasure.data_shards),
|
||||
]),
|
||||
read_quorum: erasure.data_shards,
|
||||
read_count: None,
|
||||
@@ -2097,11 +2028,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_data_shards_preserves_output_order() {
|
||||
let state =
|
||||
StripeReadState::from_parts(vec![Some(b"ab".to_vec()), Some(b"cd".to_vec()), Some(b"ef".to_vec())], Vec::new(), 2);
|
||||
fn emit_data_shards_preserves_output_order_for_out_of_order_slots() {
|
||||
let state = StripeReadState::new(
|
||||
vec![
|
||||
ShardSlot::data(1, b"cd".to_vec()),
|
||||
ShardSlot::data(0, b"ab".to_vec()),
|
||||
ShardSlot::data(2, b"ef".to_vec()),
|
||||
],
|
||||
2,
|
||||
);
|
||||
|
||||
let output = emit_data_shards(&state, 3, 6, 5).expect("data slots should emit by shard index");
|
||||
let output = emit_data_shards(&state, 3, 6, 5).expect("out-of-order data slots should emit by shard index");
|
||||
|
||||
assert_eq!(output, b"abcde");
|
||||
}
|
||||
@@ -2114,27 +2051,27 @@ mod tests {
|
||||
};
|
||||
let mut workspace = engine.prepare_workspace(4).expect("workspace should be prepared");
|
||||
let mut output = Vec::with_capacity(1);
|
||||
let mut short_state = StripeReadState::from_parts(vec![Some(vec![1, 2, 3, 4])], Vec::new(), 1);
|
||||
let short_state = StripeReadState::new(vec![ShardSlot::data(0, vec![1, 2, 3, 4])], 1);
|
||||
|
||||
let err = decode_stripe_into(
|
||||
GET_OBJECT_PATH_CODEC_STREAMING,
|
||||
false,
|
||||
&engine,
|
||||
&mut workspace,
|
||||
&mut short_state,
|
||||
short_state,
|
||||
8,
|
||||
&mut output,
|
||||
)
|
||||
.expect_err("decoded stripe shorter than data shard count must fail");
|
||||
assert_eq!(err.kind(), ErrorKind::UnexpectedEof);
|
||||
|
||||
let mut missing_state = StripeReadState::from_parts(vec![None, Some(vec![5, 6, 7, 8])], Vec::new(), 1);
|
||||
let missing_state = StripeReadState::from_parts(vec![None, Some(vec![5, 6, 7, 8])], Vec::new(), 1);
|
||||
let err = decode_stripe_into(
|
||||
GET_OBJECT_PATH_CODEC_STREAMING,
|
||||
false,
|
||||
&engine,
|
||||
&mut workspace,
|
||||
&mut missing_state,
|
||||
missing_state,
|
||||
8,
|
||||
&mut output,
|
||||
)
|
||||
@@ -2145,35 +2082,6 @@ mod tests {
|
||||
assert!(output.capacity() >= 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_stripe_reconstructs_in_place_without_replacing_slot_storage() {
|
||||
let erasure = Erasure::new(2, 1, 8);
|
||||
let engine = LegacyEcDecodeEngine::new(erasure.clone());
|
||||
let mut workspace = engine.prepare_workspace(4).expect("workspace should be prepared");
|
||||
let encoded = erasure.encode_data(b"abcdefgh").expect("test stripe should encode");
|
||||
let mut shards = encoded.into_iter().map(|shard| Some(shard.to_vec())).collect::<Vec<_>>();
|
||||
shards[0] = None;
|
||||
let mut state = StripeReadState::from_parts(shards, vec![Some(DiskError::FileCorrupt)], 2);
|
||||
let before = state.scratch_storage();
|
||||
let mut output = Vec::new();
|
||||
|
||||
let decoded = decode_stripe_into(
|
||||
GET_OBJECT_PATH_CODEC_STREAMING,
|
||||
false,
|
||||
&engine,
|
||||
&mut workspace,
|
||||
&mut state,
|
||||
8,
|
||||
&mut output,
|
||||
)
|
||||
.expect("degraded stripe should reconstruct");
|
||||
|
||||
assert!(decoded);
|
||||
assert_eq!(output, b"abcdefgh");
|
||||
assert_eq!(state.scratch_storage().0, before.0, "reconstruction must retain shard slot storage");
|
||||
assert_eq!(state.scratch_storage().1, before.1, "unused error storage must not be rebuilt");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn erasure_decode_reader_reports_short_source() {
|
||||
let erasure = Erasure::new(4, 2, 32);
|
||||
|
||||
@@ -18,12 +18,10 @@ use crate::disk::error_reduce::{
|
||||
};
|
||||
use crate::erasure::coding::BitrotWriterWrapper;
|
||||
use crate::erasure::coding::Erasure;
|
||||
use crate::erasure::coding::erasure::EncodedBlock;
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use bytes::{Bytes, BytesMut};
|
||||
use futures::StreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::vec;
|
||||
@@ -225,8 +223,8 @@ async fn send_queued<T>(
|
||||
sender.send(InflightEntry::new(entry, bytes)).await
|
||||
}
|
||||
|
||||
fn queued_batch_bytes(batch: &[EncodedBlock]) -> usize {
|
||||
batch.iter().map(EncodedBlock::queued_bytes).sum()
|
||||
fn queued_batch_bytes(batch: &[Vec<Bytes>]) -> usize {
|
||||
batch.iter().map(|block| queued_block_bytes(block)).sum()
|
||||
}
|
||||
|
||||
fn dominant_error_summary_label(summary: &WriteQuorumFailureSummary) -> &'static str {
|
||||
@@ -338,7 +336,7 @@ impl<'a> MultiWriter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_shard(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>, shard: &[u8]) {
|
||||
async fn write_shard(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>, shard: &Bytes) {
|
||||
match writer_opt {
|
||||
Some(writer) => {
|
||||
match writer.write(shard).await {
|
||||
@@ -363,20 +361,12 @@ impl<'a> MultiWriter<'a> {
|
||||
}
|
||||
|
||||
pub async fn write(&mut self, data: Vec<Bytes>) -> std::io::Result<()> {
|
||||
self.write_shards(data.iter().map(Bytes::as_ref)).await
|
||||
}
|
||||
|
||||
async fn write_block(&mut self, block: &EncodedBlock) -> std::io::Result<()> {
|
||||
self.write_shards(block.shards()).await
|
||||
}
|
||||
|
||||
async fn write_shards<'b>(&mut self, shards: impl ExactSizeIterator<Item = &'b [u8]>) -> std::io::Result<()> {
|
||||
assert_eq!(shards.len(), self.writers.len());
|
||||
assert_eq!(data.len(), self.writers.len());
|
||||
|
||||
let budget = self.next_progress_budget();
|
||||
{
|
||||
let mut futures = FuturesUnordered::new();
|
||||
for ((writer_opt, err), shard) in self.writers.iter_mut().zip(self.errs.iter_mut()).zip(shards) {
|
||||
for ((writer_opt, err), shard) in self.writers.iter_mut().zip(self.errs.iter_mut()).zip(data.iter()) {
|
||||
if err.is_some() {
|
||||
continue; // Skip if we already have an error for this writer
|
||||
}
|
||||
@@ -500,10 +490,10 @@ impl<'a> MultiWriter<'a> {
|
||||
}
|
||||
|
||||
impl Erasure {
|
||||
async fn encode_block(self: Arc<Self>, encode_buf: Vec<u8>, len: usize) -> std::io::Result<(EncodedBlock, Vec<u8>)> {
|
||||
async fn encode_block(self: Arc<Self>, encode_buf: Vec<u8>, len: usize) -> std::io::Result<(Vec<Bytes>, Vec<u8>)> {
|
||||
let encode_stage_start = stage_timer_if_enabled();
|
||||
let encode_once = move || {
|
||||
let res = self.encode_data_block(&encode_buf[..len]);
|
||||
let res = self.encode_data(&encode_buf[..len]);
|
||||
(res, encode_buf)
|
||||
};
|
||||
|
||||
@@ -528,9 +518,9 @@ impl Erasure {
|
||||
Ok((res?, returned_buf))
|
||||
}
|
||||
|
||||
async fn encode_block_bytes_mut(self: Arc<Self>, encode_buf: BytesMut, len: usize) -> std::io::Result<EncodedBlock> {
|
||||
async fn encode_block_bytes_mut(self: Arc<Self>, encode_buf: BytesMut, len: usize) -> std::io::Result<Vec<Bytes>> {
|
||||
let encode_stage_start = stage_timer_if_enabled();
|
||||
let encode_once = move || self.encode_data_bytes_mut_block(encode_buf, len);
|
||||
let encode_once = move || self.encode_data_bytes_mut(encode_buf, len);
|
||||
|
||||
let res = match tokio::runtime::Handle::current().runtime_flavor() {
|
||||
// Same rationale as encode_block: inline the short EC burst on the
|
||||
@@ -586,46 +576,13 @@ impl Erasure {
|
||||
));
|
||||
}
|
||||
|
||||
let block = self.encode_data_owned_block(buf)?;
|
||||
let shards = self.encode_data_owned(buf)?;
|
||||
let mut mw = MultiWriter::new(writers, quorum);
|
||||
mw.write_block(&block).await?;
|
||||
mw.write(shards).await?;
|
||||
mw.shutdown().await?;
|
||||
Ok((reader, total))
|
||||
}
|
||||
|
||||
/// Encode a small inline object directly into its per-disk bitrot payloads.
|
||||
/// The returned bytes are the same `[hash][shard]` representation produced
|
||||
/// by `BitrotWriter`, ready to be embedded in each disk's staged `xl.meta`.
|
||||
#[hotpath::measure(impl_type = "Erasure")]
|
||||
pub(crate) async fn encode_inline_shards_with_size_hint<R>(
|
||||
self: Arc<Self>,
|
||||
mut reader: R,
|
||||
size_hint: usize,
|
||||
) -> std::io::Result<(R, usize, Vec<Bytes>)>
|
||||
where
|
||||
R: AsyncRead + Send + Sync + Unpin,
|
||||
{
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let mut buf = Vec::with_capacity(small_ingest_capacity(&self, size_hint));
|
||||
let total = reader.read_to_end(&mut buf).await?;
|
||||
if total == 0 {
|
||||
return Ok((reader, 0, Vec::new()));
|
||||
}
|
||||
|
||||
let block = self.encode_data_owned_block(buf)?;
|
||||
let mut inline_shards = Vec::with_capacity(block.shards().len());
|
||||
for shard in block.shards() {
|
||||
let hash = HashAlgorithm::HighwayHash256S.hash_encode(shard);
|
||||
let mut encoded = BytesMut::with_capacity(hash.as_ref().len() + shard.len());
|
||||
encoded.extend_from_slice(hash.as_ref());
|
||||
encoded.extend_from_slice(shard);
|
||||
inline_shards.push(encoded.freeze());
|
||||
}
|
||||
|
||||
Ok((reader, total, inline_shards))
|
||||
}
|
||||
|
||||
#[hotpath::measure(impl_type = "Erasure")]
|
||||
pub async fn encode<R>(
|
||||
self: Arc<Self>,
|
||||
@@ -667,7 +624,7 @@ impl Erasure {
|
||||
let expanded_block_bytes = self.shard_size().saturating_mul(self.total_shard_count());
|
||||
let max_inflight_bytes = erasure_encode_max_inflight_bytes();
|
||||
let inflight_blocks = encode_channel_capacity(expanded_block_bytes, max_inflight_bytes);
|
||||
let (tx, mut rx) = mpsc::channel::<InflightEntry<EncodedBlock>>(inflight_blocks);
|
||||
let (tx, mut rx) = mpsc::channel::<InflightEntry<Vec<Bytes>>>(inflight_blocks);
|
||||
|
||||
let mut task = AbortOnDropTask::new(tokio::spawn(async move {
|
||||
let block_size = self.block_size;
|
||||
@@ -689,7 +646,7 @@ impl Erasure {
|
||||
let encode_buf = buf;
|
||||
let res = self.clone().encode_block_bytes_mut(encode_buf, n).await?;
|
||||
buf = BytesMut::with_capacity(ingest_capacity);
|
||||
let queued_bytes = res.queued_bytes();
|
||||
let queued_bytes = queued_block_bytes(&res);
|
||||
let _producer_stage = rustfs_io_metrics::track_ec_encode_producer_bytes(queued_bytes);
|
||||
let send_wait_stage_start = stage_timer_if_enabled();
|
||||
if let Err(err) = send_queued(&tx, res, queued_bytes).await {
|
||||
@@ -719,7 +676,7 @@ impl Erasure {
|
||||
let encode_buf = std::mem::take(&mut buf);
|
||||
let (res, returned_buf) = self.clone().encode_block(encode_buf, n).await?;
|
||||
buf = returned_buf;
|
||||
let queued_bytes = res.queued_bytes();
|
||||
let queued_bytes = queued_block_bytes(&res);
|
||||
let _producer_stage = rustfs_io_metrics::track_ec_encode_producer_bytes(queued_bytes);
|
||||
let send_wait_stage_start = stage_timer_if_enabled();
|
||||
if let Err(err) = send_queued(&tx, res, queued_bytes).await {
|
||||
@@ -763,9 +720,9 @@ impl Erasure {
|
||||
if block.is_empty() {
|
||||
break;
|
||||
}
|
||||
let _writer_stage = rustfs_io_metrics::track_ec_encode_writer_bytes(block.queued_bytes());
|
||||
let _writer_stage = rustfs_io_metrics::track_ec_encode_writer_bytes(queued_block_bytes(&block));
|
||||
let write_stage_start = stage_timer_if_enabled();
|
||||
if let Err(err) = writers.write_block(&block).await {
|
||||
if let Err(err) = writers.write(block).await {
|
||||
write_err = Some(err);
|
||||
break;
|
||||
}
|
||||
@@ -812,7 +769,7 @@ impl Erasure {
|
||||
let inflight_blocks = encode_channel_capacity(expanded_block_bytes, max_inflight_bytes);
|
||||
let batch_blocks = encode_batch_block_count().min(inflight_blocks);
|
||||
let channel_capacity = inflight_blocks.div_ceil(batch_blocks).max(1);
|
||||
let (tx, mut rx) = mpsc::channel::<InflightEntry<Vec<EncodedBlock>>>(channel_capacity);
|
||||
let (tx, mut rx) = mpsc::channel::<InflightEntry<Vec<Vec<Bytes>>>>(channel_capacity);
|
||||
|
||||
let mut task = AbortOnDropTask::new(tokio::spawn(async move {
|
||||
let block_size = self.block_size;
|
||||
@@ -829,7 +786,7 @@ impl Erasure {
|
||||
let encode_buf = std::mem::take(&mut buf);
|
||||
let (res, returned_buf) = self.clone().encode_block(encode_buf, n).await?;
|
||||
buf = returned_buf;
|
||||
let queued_bytes = res.queued_bytes();
|
||||
let queued_bytes = queued_block_bytes(&res);
|
||||
pending_batch_bytes = pending_batch_bytes.saturating_add(queued_bytes);
|
||||
pending_batch.push(res);
|
||||
drop(pending_batch_stage.take());
|
||||
@@ -888,7 +845,7 @@ impl Erasure {
|
||||
let _writer_stage = rustfs_io_metrics::track_ec_encode_writer_bytes(queued_batch_bytes(&batch));
|
||||
let write_stage_start = stage_timer_if_enabled();
|
||||
for block in batch {
|
||||
if let Err(err) = writers.write_block(&block).await {
|
||||
if let Err(err) = writers.write(block).await {
|
||||
write_err = Some(err);
|
||||
break;
|
||||
}
|
||||
@@ -1938,11 +1895,7 @@ mod tests {
|
||||
let baseline = rustfs_io_metrics::current_ec_encode_inflight_bytes();
|
||||
let (tx, rx) = mpsc::channel(2);
|
||||
let mut rx = rx;
|
||||
let erasure = Erasure::new(1, 0, 16);
|
||||
let batch = vec![
|
||||
erasure.encode_data_block(b"queued").expect("first block should encode"),
|
||||
erasure.encode_data_block(b"batch").expect("second block should encode"),
|
||||
];
|
||||
let batch = vec![vec![Bytes::from_static(b"queued")], vec![Bytes::from_static(b"batch")]];
|
||||
let batch_bytes = queued_batch_bytes(&batch);
|
||||
|
||||
send_queued(&tx, batch, batch_bytes).await.expect("batch should be queued");
|
||||
@@ -2164,39 +2117,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_inline_small_drops_stalled_write() {
|
||||
const BLOCK_SIZE: usize = 16;
|
||||
|
||||
let (writer_entered_tx, writer_entered) = oneshot::channel();
|
||||
let writes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let mut writers = vec![Some(bitrot_writer_plain(
|
||||
StallOnWriteWithSignal {
|
||||
entered: Some(writer_entered_tx),
|
||||
writes: writes.clone(),
|
||||
},
|
||||
BLOCK_SIZE,
|
||||
))];
|
||||
let erasure = Arc::new(Erasure::new(1, 0, BLOCK_SIZE));
|
||||
let reader = tokio::io::BufReader::new(Cursor::new(vec![0xA5; BLOCK_SIZE - 1]));
|
||||
let encode = tokio::spawn(async move { erasure.encode_inline_small(reader, &mut writers, 1).await });
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(1), writer_entered)
|
||||
.await
|
||||
.expect("inline writer should enter before cancellation")
|
||||
.expect("stalling writer should signal entry");
|
||||
encode.abort();
|
||||
assert!(
|
||||
matches!(encode.await, Err(err) if err.is_cancelled()),
|
||||
"inline encode task should be cancelled"
|
||||
);
|
||||
assert_eq!(
|
||||
writes.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1,
|
||||
"cancellation must drop the stalled write instead of polling it again"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn encode_returns_unexpected_eof_for_truncated_limited_reader() {
|
||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -2316,11 +2236,11 @@ mod tests {
|
||||
.expect("bytesmut encode should succeed on current-thread runtime");
|
||||
|
||||
let expected_shard_size = payload.len().div_ceil(erasure.data_shards);
|
||||
assert_eq!(shards.shards().len(), erasure.total_shard_count());
|
||||
assert!(shards.shards().all(|shard| shard.len() == expected_shard_size));
|
||||
assert_eq!(shards.len(), erasure.total_shard_count());
|
||||
assert!(shards.iter().all(|shard| shard.len() == expected_shard_size));
|
||||
|
||||
let mut restored = Vec::new();
|
||||
for shard in shards.shards().take(erasure.data_shards) {
|
||||
for shard in shards.iter().take(erasure.data_shards) {
|
||||
restored.extend_from_slice(shard);
|
||||
}
|
||||
restored.truncate(payload.len());
|
||||
@@ -2423,41 +2343,6 @@ mod tests {
|
||||
assert!(committed.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn encode_inline_shards_matches_writer_bitrot_layout() {
|
||||
const DATA_SHARDS: usize = 2;
|
||||
const PARITY_SHARDS: usize = 2;
|
||||
const BLOCK_SIZE: usize = 64;
|
||||
let checksum_algo = HashAlgorithm::HighwayHash256S;
|
||||
for uses_legacy in [false, true] {
|
||||
let erasure = Arc::new(Erasure::new_with_options(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE, uses_legacy));
|
||||
for payload in [Vec::new(), vec![0xA5], vec![0x5A; BLOCK_SIZE - 1], vec![0xC3; BLOCK_SIZE]] {
|
||||
let reader = tokio::io::BufReader::new(Cursor::new(payload.clone()));
|
||||
let (_reader, total, inline_shards) = erasure
|
||||
.clone()
|
||||
.encode_inline_shards_with_size_hint(reader, payload.len())
|
||||
.await
|
||||
.expect("inline shards should encode");
|
||||
|
||||
assert_eq!(total, payload.len());
|
||||
if payload.is_empty() {
|
||||
assert!(inline_shards.is_empty());
|
||||
continue;
|
||||
}
|
||||
|
||||
let raw_shards = erasure.encode_data(&payload).expect("reference shards should encode");
|
||||
assert_eq!(inline_shards.len(), DATA_SHARDS + PARITY_SHARDS);
|
||||
for (inline, raw) in inline_shards.iter().zip(raw_shards) {
|
||||
let mut writer =
|
||||
BitrotWriterWrapper::new(CustomWriter::new_inline_buffer(), raw.len(), checksum_algo.clone());
|
||||
writer.write(&raw).await.expect("reference writer should accept shard");
|
||||
writer.shutdown().await.expect("reference writer should shutdown");
|
||||
assert_eq!(inline.as_ref(), writer.into_inline_data().expect("reference writer should retain bytes"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// encode_inline_small: small payload is encoded into the correct number of shards
|
||||
/// and each writer receives data after shutdown.
|
||||
#[tokio::test]
|
||||
@@ -2621,7 +2506,7 @@ mod tests {
|
||||
assert_eq!(&next[..], &data[16..]);
|
||||
}
|
||||
|
||||
async fn committed_shards_for_pipeline(pipeline: EncodePipeline, uses_legacy: bool, payload: &[u8]) -> Vec<Vec<u8>> {
|
||||
async fn committed_shards_for_ingest_mode(use_bytesmut_ingest: bool, uses_legacy: bool, payload: &[u8]) -> Vec<Vec<u8>> {
|
||||
const DATA_SHARDS: usize = 2;
|
||||
const PARITY_SHARDS: usize = 2;
|
||||
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS;
|
||||
@@ -2635,16 +2520,10 @@ mod tests {
|
||||
|
||||
let erasure = Arc::new(Erasure::new_with_options(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE, uses_legacy));
|
||||
let reader = tokio::io::BufReader::new(Cursor::new(payload.to_vec()));
|
||||
let (_reader, total) = match pipeline {
|
||||
EncodePipeline::Vec => {
|
||||
erasure
|
||||
.encode_with_ingest_mode(reader, &mut writers, DATA_SHARDS, false)
|
||||
.await
|
||||
}
|
||||
EncodePipeline::BytesMut => erasure.encode_with_ingest_mode(reader, &mut writers, DATA_SHARDS, true).await,
|
||||
EncodePipeline::Batched => erasure.encode_batched(reader, &mut writers, DATA_SHARDS).await,
|
||||
}
|
||||
.expect("encode should succeed");
|
||||
let (_reader, total) = erasure
|
||||
.encode_with_ingest_mode(reader, &mut writers, DATA_SHARDS, use_bytesmut_ingest)
|
||||
.await
|
||||
.expect("encode should succeed");
|
||||
assert_eq!(total, payload.len());
|
||||
|
||||
committed
|
||||
@@ -2653,64 +2532,31 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn expected_committed_shards(uses_legacy: bool, payload: &[u8]) -> Vec<Vec<u8>> {
|
||||
const DATA_SHARDS: usize = 2;
|
||||
const PARITY_SHARDS: usize = 2;
|
||||
const TOTAL_SHARDS: usize = DATA_SHARDS + PARITY_SHARDS;
|
||||
const BLOCK_SIZE: usize = 64;
|
||||
|
||||
let committed: Vec<Arc<Mutex<Vec<u8>>>> = (0..TOTAL_SHARDS).map(|_| Arc::new(Mutex::new(Vec::new()))).collect();
|
||||
let mut writers: Vec<BitrotWriterWrapper> = committed
|
||||
.iter()
|
||||
.map(|c| bitrot_writer(DeferredCommitWriter::new(c.clone()), BLOCK_SIZE / DATA_SHARDS))
|
||||
.collect();
|
||||
let erasure = Erasure::new_with_options(DATA_SHARDS, PARITY_SHARDS, BLOCK_SIZE, uses_legacy);
|
||||
|
||||
for block in payload.chunks(BLOCK_SIZE) {
|
||||
let shards = erasure.encode_data(block).expect("reference block should encode");
|
||||
for (writer, shard) in writers.iter_mut().zip(shards) {
|
||||
let written = writer.write(&shard).await.expect("reference shard should write");
|
||||
assert_eq!(written, shard.len());
|
||||
}
|
||||
}
|
||||
for writer in &mut writers {
|
||||
writer.shutdown().await.expect("reference writer should commit");
|
||||
}
|
||||
|
||||
committed
|
||||
.iter()
|
||||
.map(|c| c.lock().expect("committed buffer should be lockable").clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The streaming and batched paths must produce the same bitrot-wrapped shard
|
||||
/// bytes as the public block encoder for both shard-size formulas and all block
|
||||
/// boundary shapes.
|
||||
/// HP-10 (rustfs/backlog#931) merge gate: the BytesMut ingest path must produce
|
||||
/// byte-for-byte identical shard streams to the default Vec ingest path, for both
|
||||
/// legacy-aware shard-size formulas, across empty, sub-block, exactly-full-block,
|
||||
/// and multi-block-with-partial-tail payloads.
|
||||
#[tokio::test]
|
||||
async fn bytesmut_ingest_matches_vec_ingest_byte_for_byte() {
|
||||
const BLOCK_SIZE: usize = 64;
|
||||
let payloads: Vec<Vec<u8>> = vec![
|
||||
Vec::new(),
|
||||
vec![1],
|
||||
vec![2; BLOCK_SIZE - 1],
|
||||
b"tiny".to_vec(),
|
||||
(0..BLOCK_SIZE as u32).map(|i| i as u8).collect(), // exactly one full block
|
||||
vec![4; BLOCK_SIZE + 1],
|
||||
vec![3u8; BLOCK_SIZE * 4], // whole number of blocks
|
||||
vec![3u8; BLOCK_SIZE * 4], // whole number of blocks
|
||||
(0..(BLOCK_SIZE * 3 + 7) as u32).map(|i| (i % 251) as u8).collect(), // partial tail
|
||||
];
|
||||
|
||||
for uses_legacy in [false, true] {
|
||||
for payload in &payloads {
|
||||
let expected = expected_committed_shards(uses_legacy, payload).await;
|
||||
for pipeline in [EncodePipeline::Vec, EncodePipeline::BytesMut, EncodePipeline::Batched] {
|
||||
let actual = committed_shards_for_pipeline(pipeline, uses_legacy, payload).await;
|
||||
assert_eq!(
|
||||
actual,
|
||||
expected,
|
||||
"streaming shards must match the public block encoder (legacy={uses_legacy}, payload_len={})",
|
||||
payload.len()
|
||||
);
|
||||
}
|
||||
let vec_path = committed_shards_for_ingest_mode(false, uses_legacy, payload).await;
|
||||
let bytesmut_path = committed_shards_for_ingest_mode(true, uses_legacy, payload).await;
|
||||
assert_eq!(
|
||||
vec_path,
|
||||
bytesmut_path,
|
||||
"ingest paths must be byte-identical (legacy={uses_legacy}, payload_len={})",
|
||||
payload.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,58 +29,12 @@ use tokio::io::AsyncRead;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) struct EncodedBlock {
|
||||
data: Bytes,
|
||||
shard_size: usize,
|
||||
}
|
||||
|
||||
impl EncodedBlock {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
data: Bytes::new(),
|
||||
shard_size: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.data.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn queued_bytes(&self) -> usize {
|
||||
self.data.len()
|
||||
}
|
||||
|
||||
pub(crate) fn shards(&self) -> impl ExactSizeIterator<Item = &[u8]> {
|
||||
debug_assert!(self.shard_size > 0, "only non-empty encoded blocks reach shard writers");
|
||||
debug_assert_eq!(self.data.len() % self.shard_size, 0);
|
||||
self.data.chunks_exact(self.shard_size)
|
||||
}
|
||||
|
||||
fn into_shards(mut self, shard_count: usize) -> Vec<Bytes> {
|
||||
if self.shard_size == 0 {
|
||||
return vec![Bytes::new(); shard_count];
|
||||
}
|
||||
|
||||
let mut shards = Vec::with_capacity(shard_count);
|
||||
for _ in 0..shard_count {
|
||||
shards.push(self.data.split_to(self.shard_size));
|
||||
}
|
||||
shards
|
||||
}
|
||||
}
|
||||
|
||||
const MODERN_MAX_TOTAL_SHARDS: usize = <reed_solomon_erasure::galois_8::Field as reed_solomon_erasure::Field>::ORDER;
|
||||
const MODERN_REED_SOLOMON_CACHE_MAX_ENTRIES: usize = 64;
|
||||
const LEGACY_REED_SOLOMON_CACHE_MAX_ENTRIES: usize = 16;
|
||||
// Vec growth may retain twice the requested logical length. Keeping the logical
|
||||
// workspace at half the budget bounds each cached workspace's shard allocation to 1 MiB.
|
||||
const LEGACY_REED_SOLOMON_CACHE_MAX_LOGICAL_SHARD_BYTES_PER_WORKSPACE: usize = 512 * 1024;
|
||||
|
||||
type ModernReedSolomonCache = RwLock<HashMap<(usize, usize), Arc<ReedSolomon>>>;
|
||||
type LegacyReedSolomonCache = RwLock<HashMap<(usize, usize), Arc<LegacyReedSolomonEncoder>>>;
|
||||
|
||||
static MODERN_REED_SOLOMON_CACHE: OnceLock<ModernReedSolomonCache> = OnceLock::new();
|
||||
static LEGACY_REED_SOLOMON_CACHE: OnceLock<LegacyReedSolomonCache> = OnceLock::new();
|
||||
|
||||
/// Errors returned when constructing an [`Erasure`] codec.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -147,61 +101,43 @@ pub fn calc_shard_size_legacy(block_size: usize, data_shards: usize) -> usize {
|
||||
struct LegacyReedSolomonEncoder {
|
||||
data_shards: usize,
|
||||
parity_shards: usize,
|
||||
cache_workspaces: bool,
|
||||
encoder_cache: RwLock<Option<reed_solomon_simd::ReedSolomonEncoder>>,
|
||||
decoder_cache: RwLock<Option<reed_solomon_simd::ReedSolomonDecoder>>,
|
||||
encoder_cache: std::sync::RwLock<Option<reed_solomon_simd::ReedSolomonEncoder>>,
|
||||
decoder_cache: std::sync::RwLock<Option<reed_solomon_simd::ReedSolomonDecoder>>,
|
||||
}
|
||||
|
||||
impl Clone for LegacyReedSolomonEncoder {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
data_shards: self.data_shards,
|
||||
parity_shards: self.parity_shards,
|
||||
encoder_cache: std::sync::RwLock::new(None),
|
||||
decoder_cache: std::sync::RwLock::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LegacyReedSolomonEncoder {
|
||||
fn new(data_shards: usize, parity_shards: usize) -> io::Result<Self> {
|
||||
Self::with_workspace_cache(data_shards, parity_shards, false)
|
||||
}
|
||||
|
||||
fn with_workspace_cache(data_shards: usize, parity_shards: usize, cache_workspaces: bool) -> io::Result<Self> {
|
||||
fn new(_data_shards: usize, _parity_shards: usize) -> io::Result<Self> {
|
||||
Ok(Self {
|
||||
data_shards,
|
||||
parity_shards,
|
||||
cache_workspaces,
|
||||
encoder_cache: RwLock::new(None),
|
||||
decoder_cache: RwLock::new(None),
|
||||
data_shards: _data_shards,
|
||||
parity_shards: _parity_shards,
|
||||
encoder_cache: std::sync::RwLock::new(None),
|
||||
decoder_cache: std::sync::RwLock::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
fn logical_shard_bytes_upper_bound(&self, shard_len: usize) -> Option<usize> {
|
||||
let aligned_shard_len = shard_len.checked_add(63)?.checked_div(64)?.checked_mul(64)?;
|
||||
let high_rate_decoder_work_count = self
|
||||
.parity_shards
|
||||
.checked_next_power_of_two()?
|
||||
.checked_add(self.data_shards)?
|
||||
.checked_next_power_of_two()?;
|
||||
let low_rate_decoder_work_count = self
|
||||
.data_shards
|
||||
.checked_next_power_of_two()?
|
||||
.checked_add(self.parity_shards)?
|
||||
.checked_next_power_of_two()?;
|
||||
aligned_shard_len.checked_mul(high_rate_decoder_work_count.max(low_rate_decoder_work_count))
|
||||
}
|
||||
|
||||
fn should_cache_workspace(&self, shard_len: usize) -> bool {
|
||||
self.cache_workspaces
|
||||
&& self
|
||||
.logical_shard_bytes_upper_bound(shard_len)
|
||||
.is_some_and(|bytes| bytes <= LEGACY_REED_SOLOMON_CACHE_MAX_LOGICAL_SHARD_BYTES_PER_WORKSPACE)
|
||||
}
|
||||
|
||||
fn encode(&self, shards: SmallVec<[&mut [u8]; 16]>) -> io::Result<()> {
|
||||
let mut shards_vec: Vec<&mut [u8]> = shards.into_vec();
|
||||
if shards_vec.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let shard_len = shards_vec[0].len();
|
||||
let cached_encoder = self
|
||||
.encoder_cache
|
||||
.write()
|
||||
.map_err(|_| io::Error::other("Failed to acquire encoder cache lock"))?
|
||||
.take();
|
||||
let mut encoder = {
|
||||
match cached_encoder {
|
||||
let mut cache_guard = self
|
||||
.encoder_cache
|
||||
.write()
|
||||
.map_err(|_| io::Error::other("Failed to acquire encoder cache lock"))?;
|
||||
match cache_guard.take() {
|
||||
Some(mut cached) => {
|
||||
if cached.reset(self.data_shards, self.parity_shards, shard_len).is_err() {
|
||||
reed_solomon_simd::ReedSolomonEncoder::new(self.data_shards, self.parity_shards, shard_len)
|
||||
@@ -228,15 +164,10 @@ impl LegacyReedSolomonEncoder {
|
||||
}
|
||||
}
|
||||
drop(result);
|
||||
if self.should_cache_workspace(shard_len) {
|
||||
let mut cache = self
|
||||
.encoder_cache
|
||||
.write()
|
||||
.map_err(|_| io::Error::other("Failed to return encoder to cache"))?;
|
||||
if cache.is_none() {
|
||||
*cache = Some(encoder);
|
||||
}
|
||||
}
|
||||
*self
|
||||
.encoder_cache
|
||||
.write()
|
||||
.map_err(|_| io::Error::other("Failed to return encoder to cache"))? = Some(encoder);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -250,13 +181,13 @@ impl LegacyReedSolomonEncoder {
|
||||
.find_map(|s| s.as_ref().map(|v| v.len()))
|
||||
.ok_or_else(|| io::Error::other("No valid shards found for reconstruction"))?;
|
||||
|
||||
let cached_decoder = self
|
||||
.decoder_cache
|
||||
.write()
|
||||
.map_err(|_| io::Error::other("Failed to acquire decoder cache lock"))?
|
||||
.take();
|
||||
let mut decoder = {
|
||||
match cached_decoder {
|
||||
let mut cache_guard = self
|
||||
.decoder_cache
|
||||
.write()
|
||||
.map_err(|_| io::Error::other("Failed to acquire decoder cache lock"))?;
|
||||
|
||||
match cache_guard.take() {
|
||||
Some(mut cached_decoder) => {
|
||||
if let Err(e) = cached_decoder.reset(self.data_shards, self.parity_shards, shard_len) {
|
||||
warn!("Failed to reset SIMD decoder: {:?}, creating new one", e);
|
||||
@@ -303,15 +234,10 @@ impl LegacyReedSolomonEncoder {
|
||||
|
||||
drop(result);
|
||||
|
||||
if self.should_cache_workspace(shard_len) {
|
||||
let mut cache = self
|
||||
.decoder_cache
|
||||
.write()
|
||||
.map_err(|_| io::Error::other("Failed to return decoder to cache"))?;
|
||||
if cache.is_none() {
|
||||
*cache = Some(decoder);
|
||||
}
|
||||
}
|
||||
*self
|
||||
.decoder_cache
|
||||
.write()
|
||||
.map_err(|_| io::Error::other("Failed to return decoder to cache"))? = Some(decoder);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -469,39 +395,6 @@ fn cached_modern_reed_solomon(data_shards: usize, parity_shards: usize) -> Resul
|
||||
Ok(encoder)
|
||||
}
|
||||
|
||||
fn cached_legacy_reed_solomon(data_shards: usize, parity_shards: usize) -> io::Result<Arc<LegacyReedSolomonEncoder>> {
|
||||
let cache = LEGACY_REED_SOLOMON_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
|
||||
cached_legacy_reed_solomon_in(cache, data_shards, parity_shards)
|
||||
}
|
||||
|
||||
fn cached_legacy_reed_solomon_in(
|
||||
cache: &LegacyReedSolomonCache,
|
||||
data_shards: usize,
|
||||
parity_shards: usize,
|
||||
) -> io::Result<Arc<LegacyReedSolomonEncoder>> {
|
||||
let key = (data_shards, parity_shards);
|
||||
if let Some(encoder) = cache
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.get(&key)
|
||||
.cloned()
|
||||
{
|
||||
return Ok(encoder);
|
||||
}
|
||||
|
||||
let mut cache = cache.write().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some(existing) = cache.get(&key) {
|
||||
return Ok(Arc::clone(existing));
|
||||
}
|
||||
if cache.len() < LEGACY_REED_SOLOMON_CACHE_MAX_ENTRIES {
|
||||
let encoder = Arc::new(LegacyReedSolomonEncoder::with_workspace_cache(data_shards, parity_shards, true)?);
|
||||
cache.insert(key, Arc::clone(&encoder));
|
||||
return Ok(encoder);
|
||||
}
|
||||
drop(cache);
|
||||
Ok(Arc::new(LegacyReedSolomonEncoder::new(data_shards, parity_shards)?))
|
||||
}
|
||||
|
||||
fn encode_parity_shards<F>(shards: &mut [Option<Vec<u8>>], data_shards: usize, parity_shards: usize, encode: F) -> io::Result<()>
|
||||
where
|
||||
F: FnOnce(SmallVec<[&mut [u8]; 16]>) -> io::Result<()>,
|
||||
@@ -618,7 +511,7 @@ pub struct Erasure {
|
||||
pub data_shards: usize,
|
||||
pub parity_shards: usize,
|
||||
encoder: Option<ReedSolomonEncoder>,
|
||||
legacy_encoder: Option<Arc<LegacyReedSolomonEncoder>>,
|
||||
legacy_encoder: Option<LegacyReedSolomonEncoder>,
|
||||
pub block_size: usize,
|
||||
uses_legacy: bool,
|
||||
_id: Uuid,
|
||||
@@ -754,7 +647,7 @@ impl Erasure {
|
||||
|
||||
let legacy_encoder = if uses_legacy && parity_shards > 0 {
|
||||
Some(
|
||||
cached_legacy_reed_solomon(data_shards, parity_shards)
|
||||
LegacyReedSolomonEncoder::new(data_shards, parity_shards)
|
||||
.map_err(|source| ErasureConstructionError::LegacyEncoder { source })?,
|
||||
)
|
||||
} else {
|
||||
@@ -782,48 +675,106 @@ impl Erasure {
|
||||
#[tracing::instrument(level = "debug", skip_all, fields(data_len=data.len()))]
|
||||
#[hotpath::measure(impl_type = "Erasure")]
|
||||
pub fn encode_data(&self, data: &[u8]) -> io::Result<Vec<Bytes>> {
|
||||
self.encode_data_block_inner(data)
|
||||
.map(|block| block.into_shards(self.total_shard_count()))
|
||||
}
|
||||
let shard_size_fn = if self.uses_legacy {
|
||||
calc_shard_size_legacy
|
||||
} else {
|
||||
calc_shard_size
|
||||
};
|
||||
let per_shard_size = shard_size_fn(data.len(), self.data_shards);
|
||||
if per_shard_size == 0 {
|
||||
return Ok(vec![Bytes::new(); self.total_shard_count()]);
|
||||
}
|
||||
let need_total_size = per_shard_size * self.total_shard_count();
|
||||
|
||||
#[tracing::instrument(level = "debug", skip_all, fields(data_len=data.len()))]
|
||||
#[hotpath::measure(label = "Erasure::encode_data", impl_type = "Erasure")]
|
||||
pub(crate) fn encode_data_block(&self, data: &[u8]) -> io::Result<EncodedBlock> {
|
||||
self.encode_data_block_inner(data)
|
||||
}
|
||||
|
||||
fn encode_data_block_inner(&self, data: &[u8]) -> io::Result<EncodedBlock> {
|
||||
let mut data_buffer = BytesMut::with_capacity(self.encoded_capacity_for_data_len(data.len()));
|
||||
let mut data_buffer = BytesMut::with_capacity(need_total_size);
|
||||
data_buffer.extend_from_slice(data);
|
||||
self.encode_buffer(data_buffer, data.len())
|
||||
data_buffer.resize(need_total_size, 0u8);
|
||||
|
||||
{
|
||||
let data_slices: SmallVec<[&mut [u8]; 16]> = data_buffer.chunks_exact_mut(per_shard_size).collect();
|
||||
|
||||
if self.parity_shards > 0 {
|
||||
if self.uses_legacy {
|
||||
if let Some(encoder) = self.legacy_encoder.as_ref() {
|
||||
encoder.encode(data_slices)?;
|
||||
} else {
|
||||
warn!("parity_shards > 0, uses_legacy but legacy_encoder is None");
|
||||
}
|
||||
} else if let Some(encoder) = self.encoder.as_ref() {
|
||||
encoder.encode(data_slices)?;
|
||||
} else {
|
||||
warn!("parity_shards > 0, but encoder is None");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zero-copy split, all shards reference data_buffer
|
||||
let mut data_buffer = data_buffer.freeze();
|
||||
let mut shards = Vec::with_capacity(self.total_shard_count());
|
||||
for _ in 0..self.total_shard_count() {
|
||||
let shard = data_buffer.split_to(per_shard_size);
|
||||
shards.push(shard);
|
||||
}
|
||||
|
||||
Ok(shards)
|
||||
}
|
||||
|
||||
/// Encode owned data, avoiding a copy when the caller already has a heap buffer.
|
||||
/// Falls back to copying into a new buffer if zero-copy conversion fails.
|
||||
#[hotpath::measure(impl_type = "Erasure")]
|
||||
pub fn encode_data_owned(&self, data: Vec<u8>) -> io::Result<Vec<Bytes>> {
|
||||
self.encode_data_owned_block_inner(data)
|
||||
.map(|block| block.into_shards(self.total_shard_count()))
|
||||
}
|
||||
let shard_size_fn = if self.uses_legacy {
|
||||
calc_shard_size_legacy
|
||||
} else {
|
||||
calc_shard_size
|
||||
};
|
||||
let per_shard_size = shard_size_fn(data.len(), self.data_shards);
|
||||
if per_shard_size == 0 {
|
||||
return Ok(vec![Bytes::new(); self.total_shard_count()]);
|
||||
}
|
||||
let need_total_size = per_shard_size * self.total_shard_count();
|
||||
|
||||
#[hotpath::measure(label = "Erasure::encode_data_owned", impl_type = "Erasure")]
|
||||
pub(crate) fn encode_data_owned_block(&self, data: Vec<u8>) -> io::Result<EncodedBlock> {
|
||||
self.encode_data_owned_block_inner(data)
|
||||
}
|
||||
|
||||
fn encode_data_owned_block_inner(&self, data: Vec<u8>) -> io::Result<EncodedBlock> {
|
||||
let data_len = data.len();
|
||||
// Try zero-copy: Vec<u8> -> Bytes -> BytesMut (succeeds when refcount == 1)
|
||||
let data_buffer = match Bytes::from(data).try_into_mut() {
|
||||
Ok(data_buffer) => data_buffer,
|
||||
let mut data_buffer = match Bytes::from(data).try_into_mut() {
|
||||
Ok(mut bm) => {
|
||||
bm.resize(need_total_size, 0u8);
|
||||
bm
|
||||
}
|
||||
Err(b) => {
|
||||
// Rare path: refcount != 1, fall back to copy
|
||||
let mut data_buffer = BytesMut::with_capacity(self.encoded_capacity_for_data_len(data_len));
|
||||
data_buffer.extend_from_slice(&b);
|
||||
data_buffer
|
||||
let mut bm = BytesMut::with_capacity(need_total_size);
|
||||
bm.extend_from_slice(&b);
|
||||
bm.resize(need_total_size, 0u8);
|
||||
bm
|
||||
}
|
||||
};
|
||||
self.encode_buffer(data_buffer, data_len)
|
||||
|
||||
{
|
||||
let data_slices: SmallVec<[&mut [u8]; 16]> = data_buffer.chunks_exact_mut(per_shard_size).collect();
|
||||
|
||||
if self.parity_shards > 0 {
|
||||
if self.uses_legacy {
|
||||
if let Some(encoder) = self.legacy_encoder.as_ref() {
|
||||
encoder.encode(data_slices)?;
|
||||
} else {
|
||||
warn!("parity_shards > 0, uses_legacy but legacy_encoder is None");
|
||||
}
|
||||
} else if let Some(encoder) = self.encoder.as_ref() {
|
||||
encoder.encode(data_slices)?;
|
||||
} else {
|
||||
warn!("parity_shards > 0, but encoder is None");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut data_buffer = data_buffer.freeze();
|
||||
let mut shards = Vec::with_capacity(self.total_shard_count());
|
||||
for _ in 0..self.total_shard_count() {
|
||||
let shard = data_buffer.split_to(per_shard_size);
|
||||
shards.push(shard);
|
||||
}
|
||||
|
||||
Ok(shards)
|
||||
}
|
||||
|
||||
/// Encode data from an owned `BytesMut` buffer, avoiding the initial copy
|
||||
@@ -835,17 +786,7 @@ impl Erasure {
|
||||
/// `data_len <= block_size` — both shard-size formulas are monotone in
|
||||
/// `data_len` — so this function never reallocates the buffer.
|
||||
#[hotpath::measure(impl_type = "Erasure")]
|
||||
pub fn encode_data_bytes_mut(&self, data_buffer: BytesMut, data_len: usize) -> io::Result<Vec<Bytes>> {
|
||||
self.encode_buffer(data_buffer, data_len)
|
||||
.map(|block| block.into_shards(self.total_shard_count()))
|
||||
}
|
||||
|
||||
#[hotpath::measure(label = "Erasure::encode_data_bytes_mut", impl_type = "Erasure")]
|
||||
pub(crate) fn encode_data_bytes_mut_block(&self, data_buffer: BytesMut, data_len: usize) -> io::Result<EncodedBlock> {
|
||||
self.encode_buffer(data_buffer, data_len)
|
||||
}
|
||||
|
||||
fn encode_buffer(&self, mut data_buffer: BytesMut, data_len: usize) -> io::Result<EncodedBlock> {
|
||||
pub fn encode_data_bytes_mut(&self, mut data_buffer: BytesMut, data_len: usize) -> io::Result<Vec<Bytes>> {
|
||||
let shard_size_fn = if self.uses_legacy {
|
||||
calc_shard_size_legacy
|
||||
} else {
|
||||
@@ -853,7 +794,7 @@ impl Erasure {
|
||||
};
|
||||
let per_shard_size = shard_size_fn(data_len, self.data_shards);
|
||||
if per_shard_size == 0 {
|
||||
return Ok(EncodedBlock::empty());
|
||||
return Ok(vec![Bytes::new(); self.total_shard_count()]);
|
||||
}
|
||||
let need_total_size = per_shard_size * self.total_shard_count();
|
||||
|
||||
@@ -880,10 +821,14 @@ impl Erasure {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(EncodedBlock {
|
||||
data: data_buffer.freeze(),
|
||||
shard_size: per_shard_size,
|
||||
})
|
||||
let mut data_buffer = data_buffer.freeze();
|
||||
let mut shards = Vec::with_capacity(self.total_shard_count());
|
||||
for _ in 0..self.total_shard_count() {
|
||||
let shard = data_buffer.split_to(per_shard_size);
|
||||
shards.push(shard);
|
||||
}
|
||||
|
||||
Ok(shards)
|
||||
}
|
||||
|
||||
/// Decode and reconstruct missing data shards in-place.
|
||||
@@ -1472,7 +1417,7 @@ mod tests {
|
||||
assert_eq!(cloned.block_size, legacy.block_size);
|
||||
assert!(cloned.uses_legacy);
|
||||
|
||||
let data = b"legacy clone should preserve SIMD codec behavior";
|
||||
let data = b"legacy clone should keep independent SIMD caches";
|
||||
let encoded = cloned.encode_data(data).expect("legacy clone should encode");
|
||||
let mut shards = optional_shards(&encoded);
|
||||
shards[0] = None;
|
||||
@@ -1480,93 +1425,6 @@ mod tests {
|
||||
assert_eq!(recover_data(&shards, cloned.data_shards, data.len()), data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_codecs_share_process_cache_across_erasure_instances() {
|
||||
let first = Erasure::new_with_options(6, 3, 64, true)
|
||||
.legacy_encoder
|
||||
.expect("legacy codec should be initialized");
|
||||
let second = Erasure::new_with_options(6, 3, 128, true)
|
||||
.legacy_encoder
|
||||
.expect("same legacy shard layout should be initialized");
|
||||
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_workspace_cache_rejects_oversize_buffers_and_isolates_layouts() {
|
||||
let four_plus_two = Erasure::new_with_options(4, 2, 64, true)
|
||||
.legacy_encoder
|
||||
.expect("legacy codec should be initialized");
|
||||
let four_plus_one = Erasure::new_with_options(4, 1, 64, true)
|
||||
.legacy_encoder
|
||||
.expect("distinct parity layout should be initialized");
|
||||
let three_plus_two = Erasure::new_with_options(3, 2, 64, true)
|
||||
.legacy_encoder
|
||||
.expect("distinct data layout should be initialized");
|
||||
|
||||
assert!(!Arc::ptr_eq(&four_plus_two, &four_plus_one));
|
||||
assert!(!Arc::ptr_eq(&four_plus_two, &three_plus_two));
|
||||
assert_eq!(four_plus_two.logical_shard_bytes_upper_bound(64 * 1024), Some(512 * 1024));
|
||||
assert!(four_plus_two.should_cache_workspace(64 * 1024));
|
||||
assert!(!four_plus_two.should_cache_workspace(64 * 1024 + 1));
|
||||
|
||||
let nine_plus_seven =
|
||||
LegacyReedSolomonEncoder::with_workspace_cache(9, 7, true).expect("9+7 legacy codec should construct");
|
||||
assert_eq!(nine_plus_seven.logical_shard_bytes_upper_bound(16 * 1024), Some(512 * 1024));
|
||||
assert!(nine_plus_seven.should_cache_workspace(16 * 1024));
|
||||
assert!(!nine_plus_seven.should_cache_workspace(16 * 1024 + 1));
|
||||
|
||||
let uncached = LegacyReedSolomonEncoder::new(4, 2).expect("uncached legacy codec should construct");
|
||||
assert!(!uncached.should_cache_workspace(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturated_legacy_codec_cache_does_not_retain_more_workspaces() {
|
||||
let cache = RwLock::new(HashMap::new());
|
||||
for parity_shards in 1..=LEGACY_REED_SOLOMON_CACHE_MAX_ENTRIES {
|
||||
let cached =
|
||||
cached_legacy_reed_solomon_in(&cache, 32, parity_shards).expect("cacheable legacy codec should construct");
|
||||
assert!(cached.cache_workspaces);
|
||||
}
|
||||
|
||||
let uncached =
|
||||
cached_legacy_reed_solomon_in(&cache, 31, 1).expect("uncached legacy codec should construct after saturation");
|
||||
assert!(!uncached.cache_workspaces);
|
||||
assert_eq!(
|
||||
cache.read().expect("cache lock should remain healthy").len(),
|
||||
LEGACY_REED_SOLOMON_CACHE_MAX_ENTRIES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_legacy_codecs_preserve_byte_exact_results() {
|
||||
let barrier = Arc::new(std::sync::Barrier::new(2));
|
||||
let payloads = [vec![0x35; 257], vec![0xca; 1025]];
|
||||
|
||||
std::thread::scope(|scope| {
|
||||
let handles = payloads.each_ref().map(|payload| {
|
||||
let barrier = Arc::clone(&barrier);
|
||||
scope.spawn(move || {
|
||||
let erasure = Erasure::new_with_options(6, 3, 2048, true);
|
||||
barrier.wait();
|
||||
let encoded = erasure.encode_data(payload).expect("concurrent legacy encode should succeed");
|
||||
barrier.wait();
|
||||
|
||||
let mut shards = optional_shards(&encoded);
|
||||
shards[0] = None;
|
||||
erasure
|
||||
.decode_data(&mut shards)
|
||||
.expect("concurrent legacy decode should reconstruct the missing shard");
|
||||
recover_data(&shards, erasure.data_shards, payload.len())
|
||||
})
|
||||
});
|
||||
|
||||
for (handle, payload) in handles.into_iter().zip(payloads.iter()) {
|
||||
assert_eq!(handle.join().expect("concurrent legacy codec worker should not panic"), *payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_verify_reports_invalid_empty_valid_and_corrupt_parity_sets() {
|
||||
let legacy = LegacyReedSolomonEncoder::new(2, 2).expect("legacy encoder should construct");
|
||||
@@ -1640,16 +1498,10 @@ mod tests {
|
||||
fn encode_data_owned_matches_borrowed_path() {
|
||||
for uses_legacy in [false, true] {
|
||||
let erasure = Erasure::new_with_options(4, 2, 64, uses_legacy);
|
||||
for data in [
|
||||
Vec::new(),
|
||||
vec![0xA5; 1],
|
||||
b"small payload".to_vec(),
|
||||
(0_u8..37).collect(),
|
||||
vec![0xA5; erasure.block_size - 1],
|
||||
vec![0x5A; erasure.block_size],
|
||||
] {
|
||||
assert_owned_encode_matches_borrowed(&erasure, data);
|
||||
}
|
||||
|
||||
assert_owned_encode_matches_borrowed(&erasure, Vec::new());
|
||||
assert_owned_encode_matches_borrowed(&erasure, b"small payload".to_vec());
|
||||
assert_owned_encode_matches_borrowed(&erasure, (0_u8..37).collect());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1695,52 +1547,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_encoded_block_uses_one_contiguous_backing_buffer() {
|
||||
for uses_legacy in [false, true] {
|
||||
let erasure = Erasure::new_with_options(8, 8, 64, uses_legacy);
|
||||
|
||||
for data_len in [0, 1, 63, 64] {
|
||||
let data = (0..data_len).map(|i| i as u8).collect::<Vec<_>>();
|
||||
let expected = erasure.encode_data(&data).expect("public encode should succeed");
|
||||
let borrowed = erasure
|
||||
.encode_data_block(&data)
|
||||
.expect("borrowed streaming encode should succeed");
|
||||
let owned = erasure
|
||||
.encode_data_owned_block(data.clone())
|
||||
.expect("owned streaming encode should succeed");
|
||||
let bytes_mut = erasure
|
||||
.encode_data_bytes_mut_block(BytesMut::from(&data[..]), data.len())
|
||||
.expect("BytesMut streaming encode should succeed");
|
||||
|
||||
assert_eq!(borrowed.queued_bytes(), owned.queued_bytes());
|
||||
assert_eq!(borrowed.queued_bytes(), bytes_mut.queued_bytes());
|
||||
|
||||
if data_len == 0 {
|
||||
assert!(expected.iter().all(Bytes::is_empty));
|
||||
assert!(borrowed.is_empty());
|
||||
assert!(owned.is_empty());
|
||||
assert!(bytes_mut.is_empty());
|
||||
continue;
|
||||
}
|
||||
|
||||
assert!(borrowed.shards().eq(expected.iter().map(Bytes::as_ref)));
|
||||
assert!(owned.shards().eq(expected.iter().map(Bytes::as_ref)));
|
||||
assert!(bytes_mut.shards().eq(expected.iter().map(Bytes::as_ref)));
|
||||
assert_eq!(borrowed.shards().len(), 16);
|
||||
let first = borrowed.shards().next().expect("encoded block should have shards").as_ptr();
|
||||
for (index, shard) in borrowed.shards().enumerate() {
|
||||
assert_eq!(shard.as_ptr(), first.wrapping_add(index * shard.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
std::mem::size_of::<EncodedBlock>(),
|
||||
std::mem::size_of::<Bytes>() + std::mem::size_of::<usize>(),
|
||||
"queue entries must contain one backing buffer handle, not per-shard handles"
|
||||
);
|
||||
}
|
||||
|
||||
/// HP-10 capacity invariant: both shard-size formulas are monotone in `data_len`,
|
||||
/// so pre-reserving `shard_size(block_size) * total_shard_count` covers the
|
||||
/// `need_total_size` of every block-or-smaller payload and the ingest buffer
|
||||
|
||||
@@ -13,12 +13,13 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: error taxonomy still exposes compatibility variants while callers move to contracts.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::bucket::error::BucketMetadataError;
|
||||
use crate::disk::error::DiskError;
|
||||
use crate::storage_api_contracts::{error::StorageErrorCode, range::HTTPRangeError};
|
||||
use rustfs_utils::path::decode_dir_object;
|
||||
use s3s::S3ErrorCode;
|
||||
use s3s::{S3Error, S3ErrorCode};
|
||||
|
||||
pub type Error = StorageError;
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
@@ -901,7 +902,6 @@ pub fn is_err_decommission_running(err: &Error) -> bool {
|
||||
matches!(err, &StorageError::DecommissionAlreadyRunning)
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "predicate asserted by this file's tests (backlog#1823)")]
|
||||
pub fn is_err_rebalance_running(err: &Error) -> bool {
|
||||
matches!(err, &StorageError::RebalanceAlreadyRunning)
|
||||
}
|
||||
@@ -910,11 +910,14 @@ pub fn is_err_operation_canceled(err: &Error) -> bool {
|
||||
matches!(err, &StorageError::OperationCanceled)
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "predicate asserted by this file's tests (backlog#1823)")]
|
||||
pub fn is_err_not_initialized(err: &Error) -> bool {
|
||||
err.to_string().contains("errServerNotInitialized") || err.to_string().contains("ServerNotInitialized")
|
||||
}
|
||||
|
||||
pub fn is_err_io(err: &Error) -> bool {
|
||||
matches!(err, &StorageError::Io(_))
|
||||
}
|
||||
|
||||
/// Strict "not found" predicate that only matches genuine object/version/volume
|
||||
/// absence errors: `FileNotFound`/`VolumeNotFound`/`FileVersionNotFound`/
|
||||
/// `ObjectNotFound`/`VersionNotFound`.
|
||||
@@ -1075,9 +1078,21 @@ pub struct GenericError {
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum ObjectApiError {
|
||||
#[error("Operation timed out")]
|
||||
OperationTimedOut,
|
||||
|
||||
#[error("etag of the object has changed")]
|
||||
InvalidETag,
|
||||
|
||||
#[error("BackendDown")]
|
||||
BackendDown(String),
|
||||
|
||||
#[error("Unsupported headers in Metadata")]
|
||||
UnsupportedMetadata,
|
||||
|
||||
#[error("Method not allowed: {}/{}", .0.bucket, .0.object)]
|
||||
MethodNotAllowed(GenericError),
|
||||
|
||||
#[error("The operation is not valid for the current state of the object {}/{}({})", .0.bucket, .0.object, .0.version_id)]
|
||||
InvalidObjectState(GenericError),
|
||||
}
|
||||
@@ -1160,6 +1175,30 @@ pub fn error_resp_to_object_err(err: ErrorResponse, params: Vec<&str>) -> std::i
|
||||
err
|
||||
}
|
||||
|
||||
pub fn storage_to_object_err(err: Error, params: Vec<&str>) -> S3Error {
|
||||
let storage_err = &err;
|
||||
let mut bucket: String = "".to_string();
|
||||
let mut object: String = "".to_string();
|
||||
if !params.is_empty() {
|
||||
bucket = params[0].to_string();
|
||||
}
|
||||
if params.len() >= 2 {
|
||||
object = decode_dir_object(params[1]);
|
||||
}
|
||||
match storage_err {
|
||||
StorageError::MethodNotAllowed => S3Error::with_message(
|
||||
S3ErrorCode::MethodNotAllowed,
|
||||
ObjectApiError::MethodNotAllowed(GenericError {
|
||||
bucket,
|
||||
object,
|
||||
..Default::default()
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
_ => s3s::S3Error::with_message(S3ErrorCode::Custom("err".into()), err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: event target types are retained for notification owner migration.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod name;
|
||||
pub mod targetid;
|
||||
pub mod targetlist;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#![allow(clippy::all)]
|
||||
// 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.
|
||||
|
||||
pub struct TargetID {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl TargetID {
|
||||
fn to_string(&self) -> String {
|
||||
format!("{}:{}", self.id, self.name)
|
||||
}
|
||||
}
|
||||
@@ -12,16 +12,18 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::event::targetid::TargetID;
|
||||
use std::sync::atomic::AtomicI64;
|
||||
|
||||
/// Placeholder notification target list held by `EventNotifier`.
|
||||
///
|
||||
/// The working notification stack lives in `rustfs-notify` / `rustfs-targets`;
|
||||
/// this type never grew past its counter. `total_events` is read by the
|
||||
/// notifier's log line but nothing increments it, so that field reports zero.
|
||||
#[derive(Default)]
|
||||
pub struct TargetList {
|
||||
pub current_send_calls: AtomicI64,
|
||||
pub total_events: AtomicI64,
|
||||
pub events_skipped: AtomicI64,
|
||||
pub events_errors_total: AtomicI64,
|
||||
//pub targets: HashMap<TargetID, Target>,
|
||||
//pub queue: AsyncEvent,
|
||||
//pub targetStats: HashMap<TargetID, TargetStat>,
|
||||
}
|
||||
|
||||
impl TargetList {
|
||||
@@ -29,3 +31,14 @@ impl TargetList {
|
||||
TargetList::default()
|
||||
}
|
||||
}
|
||||
|
||||
struct TargetStat {
|
||||
current_send_calls: i64,
|
||||
total_events: i64,
|
||||
failed_events: i64,
|
||||
}
|
||||
|
||||
struct TargetIDResult {
|
||||
id: TargetID,
|
||||
err: std::io::Error,
|
||||
}
|
||||
|
||||
@@ -22,13 +22,12 @@ use crate::diagnostics::get::{
|
||||
#[cfg(feature = "hotpath")]
|
||||
use crate::disk::FileWriter;
|
||||
use crate::disk::{self, DiskAPI as _, DiskStore, FileReader, MmapCopyStageMetrics, error::DiskError};
|
||||
use crate::erasure::coding::{BitrotReader, BitrotWriterWrapper, CustomWriter, ShardChunkRead};
|
||||
use crate::erasure::coding::{BitrotReader, BitrotWriterWrapper, CustomWriter};
|
||||
use bytes::Bytes;
|
||||
use rustfs_config::{
|
||||
DEFAULT_OBJECT_MMAP_READ_ENABLE, DEFAULT_OBJECT_MMAP_READ_MAX_LENGTH, ENV_OBJECT_MMAP_READ_ENABLE,
|
||||
ENV_OBJECT_MMAP_READ_MAX_LENGTH, ENV_OBJECT_ZERO_COPY_ENABLE,
|
||||
};
|
||||
use rustfs_rio::ChunkReaderBox;
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use std::future::Future;
|
||||
use std::io::{self, Cursor};
|
||||
@@ -52,25 +51,13 @@ tokio::task_local! {
|
||||
/// (rustfs/backlog#1159). Everything else is a stream and keeps the old path.
|
||||
pub enum ShardReader {
|
||||
InMemory(Cursor<Bytes>),
|
||||
Chunked(ChunkReaderBox),
|
||||
Stream(Box<dyn AsyncRead + Send + Sync + Unpin>),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl ShardReader {
|
||||
pub(crate) fn inline_bytes(&self) -> Option<&Bytes> {
|
||||
match self {
|
||||
Self::InMemory(cursor) => Some(cursor.get_ref()),
|
||||
Self::Chunked(_) | Self::Stream(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for ShardReader {
|
||||
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut tokio::io::ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
Self::InMemory(cursor) => Pin::new(cursor).poll_read(cx, buf),
|
||||
Self::Chunked(reader) => Pin::new(&mut **reader).poll_read(cx, buf),
|
||||
Self::Stream(reader) => Pin::new(reader).poll_read(cx, buf),
|
||||
}
|
||||
}
|
||||
@@ -80,19 +67,7 @@ impl crate::erasure::coding::ShardSource for ShardReader {
|
||||
fn try_take_block(&mut self, n: usize) -> Option<Bytes> {
|
||||
match self {
|
||||
Self::InMemory(cursor) => cursor.try_take_block(n),
|
||||
Self::Chunked(_) | Self::Stream(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_read_chunk(self: Pin<&mut Self>, cx: &mut Context<'_>, max: usize) -> Poll<io::Result<ShardChunkRead>> {
|
||||
let Self::Chunked(reader) = self.get_mut() else {
|
||||
return Poll::Ready(Ok(ShardChunkRead::Unsupported));
|
||||
};
|
||||
match Pin::new(&mut **reader).poll_read_chunk(cx, max) {
|
||||
Poll::Ready(Ok(Some(chunk))) => Poll::Ready(Ok(ShardChunkRead::Chunk(chunk))),
|
||||
Poll::Ready(Ok(None)) => Poll::Ready(Ok(ShardChunkRead::Eof)),
|
||||
Poll::Ready(Err(err)) => Poll::Ready(Err(err)),
|
||||
Poll::Pending => Poll::Pending,
|
||||
Self::Stream(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,17 +345,6 @@ async fn open_disk_reader(
|
||||
let metrics_path = metrics_path.filter(|_| rustfs_io_metrics::get_stage_metrics_enabled());
|
||||
let stage_metrics_enabled = metrics_path.is_some();
|
||||
|
||||
// Preserve HTTP body ownership only on healthy remote reads. Instrumented
|
||||
// and local paths retain their existing AsyncRead wrappers.
|
||||
if use_mmap_read
|
||||
&& !disk.is_local()
|
||||
&& !stage_metrics_enabled
|
||||
&& !cfg!(feature = "hotpath")
|
||||
&& let Some(reader) = disk.read_file_stream_chunks(bucket, path, offset, length).await?
|
||||
{
|
||||
return Ok(ShardReader::Chunked(reader));
|
||||
}
|
||||
|
||||
// Mmap-copy materializes the whole `offset..offset+length` range as one
|
||||
// owned allocation before any byte is served, and GET/heal shard reads
|
||||
// request the entire part span in one call. Over-cap reads (e.g. a huge
|
||||
@@ -656,7 +620,7 @@ pub async fn create_bitrot_reader_from_bytes(
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn create_bitrot_reader_from_bytes_with_stage_metrics(
|
||||
async fn create_bitrot_reader_from_bytes_with_stage_metrics(
|
||||
inline_data: Option<Bytes>,
|
||||
disk: Option<&DiskStore>,
|
||||
bucket: &str,
|
||||
@@ -816,50 +780,6 @@ pub async fn create_bitrot_writer(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rustfs_rio::ChunkReader;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
struct TestChunkReader {
|
||||
chunks: VecDeque<Bytes>,
|
||||
}
|
||||
|
||||
impl TestChunkReader {
|
||||
fn new(bytes: Bytes, fragment_sizes: &[usize]) -> Self {
|
||||
let mut chunks = VecDeque::new();
|
||||
let mut offset = 0;
|
||||
for &size in fragment_sizes {
|
||||
let end = (offset + size).min(bytes.len());
|
||||
if offset < end {
|
||||
chunks.push_back(bytes.slice(offset..end));
|
||||
}
|
||||
offset = end;
|
||||
}
|
||||
if offset < bytes.len() {
|
||||
chunks.push_back(bytes.slice(offset..));
|
||||
}
|
||||
Self { chunks }
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for TestChunkReader {
|
||||
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Err(io::Error::other("test chunk reader must use chunk handoff")))
|
||||
}
|
||||
}
|
||||
|
||||
impl ChunkReader for TestChunkReader {
|
||||
fn poll_read_chunk(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, max: usize) -> Poll<io::Result<Option<Bytes>>> {
|
||||
let Some(mut chunk) = self.chunks.pop_front() else {
|
||||
return Poll::Ready(Ok(None));
|
||||
};
|
||||
let take = chunk.len().min(max);
|
||||
if take < chunk.len() {
|
||||
self.chunks.push_front(chunk.split_off(take));
|
||||
}
|
||||
chunk.truncate(take);
|
||||
Poll::Ready(Ok(Some(chunk)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "hotpath")]
|
||||
use crate::cluster::rpc::RemoteDisk;
|
||||
@@ -1749,49 +1669,4 @@ mod tests {
|
||||
println!("error: {error:?}");
|
||||
assert_eq!(error, DiskError::DiskNotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shard_reader_chunked_path_verifies_fragmented_remote_block() {
|
||||
const SHARD_SIZE: usize = 1024;
|
||||
let algo = HashAlgorithm::HighwayHash256S;
|
||||
let data = vec![42u8; SHARD_SIZE];
|
||||
let mut encoded = Vec::new();
|
||||
crate::erasure::coding::BitrotWriter::new(&mut encoded, SHARD_SIZE, algo.clone())
|
||||
.write(&data)
|
||||
.await
|
||||
.expect("test shard should encode");
|
||||
|
||||
let source = TestChunkReader::new(Bytes::from(encoded), &[3, 7, 17, 31]);
|
||||
let mut reader = BitrotReader::new(ShardReader::Chunked(Box::new(source)), SHARD_SIZE, algo, false);
|
||||
let mut output = Vec::with_capacity(SHARD_SIZE);
|
||||
reader
|
||||
.read_appending(&mut output, SHARD_SIZE)
|
||||
.await
|
||||
.expect("fragmented remote shard should verify");
|
||||
|
||||
assert_eq!(output, data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shard_reader_chunked_path_handles_more_than_one_poll_budget() {
|
||||
const SHARD_SIZE: usize = 1024;
|
||||
let algo = HashAlgorithm::HighwayHash256S;
|
||||
let data = vec![42u8; SHARD_SIZE];
|
||||
let mut encoded = Vec::new();
|
||||
crate::erasure::coding::BitrotWriter::new(&mut encoded, SHARD_SIZE, algo.clone())
|
||||
.write(&data)
|
||||
.await
|
||||
.expect("test shard should encode");
|
||||
|
||||
let fragment_sizes = vec![1; encoded.len()];
|
||||
let source = TestChunkReader::new(Bytes::from(encoded), &fragment_sizes);
|
||||
let mut reader = BitrotReader::new(ShardReader::Chunked(Box::new(source)), SHARD_SIZE, algo, false);
|
||||
let mut output = Vec::with_capacity(SHARD_SIZE);
|
||||
reader
|
||||
.read_appending(&mut output, SHARD_SIZE)
|
||||
.await
|
||||
.expect("fragmented remote shard should verify after multiple polls");
|
||||
|
||||
assert_eq!(output, data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: I/O backend selection keeps test-only and staged rio helpers scoped here.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub(crate) mod bitrot;
|
||||
pub(crate) mod compress;
|
||||
|
||||
@@ -25,20 +25,9 @@ use tokio::io::AsyncRead;
|
||||
|
||||
#[cfg(feature = "rio-v2")]
|
||||
const MINIO_S2_COMPRESSION_SCHEME: &str = "klauspost/compress/s2";
|
||||
// The S2 padding multiple rio-v2 pads compressed streams to before
|
||||
// encryption. Only the padding test asserts it today, so the lib target sees
|
||||
// it as unused (backlog#1823).
|
||||
#[cfg(feature = "rio-v2")]
|
||||
#[allow(dead_code, reason = "on-disk contract asserted by the rio-v2 padding test (backlog#1823)")]
|
||||
const ENCRYPTED_S2_PADDING_MULTIPLE: usize = 256;
|
||||
|
||||
/// Which rio implementation this build compiled in. Only the feature-seam
|
||||
/// guard test in lib.rs reads it, so the lib target sees it as unused
|
||||
/// (backlog#1823).
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "asserted by the rio backend feature-seam test in lib.rs (backlog#1823)"
|
||||
)]
|
||||
pub const fn backend_name() -> &'static str {
|
||||
#[cfg(feature = "rio-v2")]
|
||||
{
|
||||
@@ -64,6 +53,17 @@ pub fn compression_metadata_value(algorithm: CompressionAlgorithm) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compression_scheme_to_algorithm(scheme: &str) -> std::io::Result<CompressionAlgorithm> {
|
||||
#[cfg(feature = "rio-v2")]
|
||||
if scheme.eq_ignore_ascii_case(MINIO_S2_COMPRESSION_SCHEME) {
|
||||
// rio_v2 currently routes all compressed-object handling through the S2
|
||||
// reader implementation, so the enum is only a placeholder token here.
|
||||
return Ok(CompressionAlgorithm::default());
|
||||
}
|
||||
|
||||
CompressionAlgorithm::from_str(scheme)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReadCompressionBackend {
|
||||
Legacy,
|
||||
@@ -82,11 +82,6 @@ pub fn compression_scheme_to_read_plan(scheme: &str) -> std::io::Result<(Compres
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReadEncryptionBackend {
|
||||
Legacy,
|
||||
// Never constructed today — every read still selects Legacy — but the
|
||||
// decrypt paths below carry live match arms for it. This is the rio-v2
|
||||
// read seam (backlog#1638 / #1835), not dead code: deleting the variant
|
||||
// would delete those arms with it.
|
||||
#[allow(dead_code, reason = "rio-v2 read seam; match arms below are live (backlog#1823)")]
|
||||
V2,
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,7 @@ use tracing::debug;
|
||||
|
||||
/// Supported set sizes this is used to find the optimal
|
||||
/// single set size.
|
||||
pub(crate) const MAX_ERASURE_SET_DRIVE_COUNT: usize = 16;
|
||||
const SET_SIZES: [usize; 15] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, MAX_ERASURE_SET_DRIVE_COUNT];
|
||||
const SET_SIZES: [usize; 15] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
|
||||
const ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT: &str = "RUSTFS_ERASURE_SET_DRIVE_COUNT";
|
||||
|
||||
#[derive(Deserialize, Debug, Default)]
|
||||
@@ -328,7 +327,7 @@ fn possible_set_counts(set_size: usize) -> Vec<usize> {
|
||||
|
||||
/// checks whether given count is a valid set size for erasure coding.
|
||||
fn is_valid_set_size(count: usize) -> bool {
|
||||
count >= SET_SIZES[0] && count <= MAX_ERASURE_SET_DRIVE_COUNT
|
||||
count >= SET_SIZES[0] && count <= SET_SIZES[SET_SIZES.len() - 1]
|
||||
}
|
||||
|
||||
/// Final set size with all the symmetry accounted for.
|
||||
|
||||
@@ -209,12 +209,15 @@ impl AsMut<Vec<Endpoints>> for PoolEndpointList {
|
||||
}
|
||||
|
||||
impl PoolEndpointList {
|
||||
/// Creates a list of endpoints per pool, resolves their relevant hostnames
|
||||
/// and discovers whether those are local or remote.
|
||||
///
|
||||
/// The policy and host overrides let tests inject an explicit startup
|
||||
/// topology convergence policy and local endpoint host instead of
|
||||
/// resolving them from the environment; production passes `None` for both.
|
||||
/// creates a list of endpoints per pool, resolves their relevant
|
||||
/// hostnames and discovers those are local or remote.
|
||||
async fn create_pool_endpoints(server_addr: &str, disks_layout: &DisksLayout) -> Result<Self> {
|
||||
Self::create_pool_endpoints_with(server_addr, disks_layout, None, None).await
|
||||
}
|
||||
|
||||
/// Same as [`create_pool_endpoints`] but lets tests inject an explicit
|
||||
/// startup topology convergence policy and local endpoint host instead of
|
||||
/// resolving them from the environment.
|
||||
async fn create_pool_endpoints_with(
|
||||
server_addr: &str,
|
||||
disks_layout: &DisksLayout,
|
||||
@@ -591,10 +594,6 @@ impl PoolEndpointList {
|
||||
}
|
||||
|
||||
const DNS_RETRY_BASE_DELAY: Duration = Duration::from_millis(500);
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "retry-cap bound asserted by this file's dns_retry_delay tests (backlog#1823)"
|
||||
)]
|
||||
const DNS_RETRY_MAX_DELAY: Duration = Duration::from_secs(8);
|
||||
const DNS_RETRY_JITTER_PERCENT: u64 = 20;
|
||||
/// Minimum spacing between "still retrying" warnings so a long orchestrated
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: set-layout contracts are staged while ECStore ownership boundaries shrink.
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Static ECStore layout boundaries.
|
||||
//!
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::io::{Error, Result};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[allow(dead_code, reason = "ESET-001 layout model; exercised by this file's tests (backlog#1823)")]
|
||||
pub(crate) struct StaticSetLayoutSnapshot {
|
||||
pub(crate) deployment_id: Uuid,
|
||||
pub(crate) set_count: usize,
|
||||
@@ -13,7 +12,6 @@ pub(crate) struct StaticSetLayoutSnapshot {
|
||||
pub(crate) distribution_algo: DistributionAlgoVersion,
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "ESET-001 layout model; exercised by this file's tests (backlog#1823)")]
|
||||
impl StaticSetLayoutSnapshot {
|
||||
pub(crate) fn from_format(format: &FormatV3) -> Self {
|
||||
let disk_ids = format.erasure.sets.clone();
|
||||
@@ -41,20 +39,17 @@ impl StaticSetLayoutSnapshot {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code, reason = "ESET-001 layout model; exercised by this file's tests (backlog#1823)")]
|
||||
pub(crate) struct SetDiskPosition {
|
||||
pub(crate) set_index: usize,
|
||||
pub(crate) disk_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[allow(dead_code, reason = "ESET-001 layout model; exercised by this file's tests (backlog#1823)")]
|
||||
pub(crate) struct RuntimeSetLayoutPlan {
|
||||
pub(crate) sets: Vec<Vec<RuntimeSetDrivePlan>>,
|
||||
lock_hosts_by_set: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "ESET-001 layout model; exercised by this file's tests (backlog#1823)")]
|
||||
impl RuntimeSetLayoutPlan {
|
||||
pub(crate) fn from_endpoint_hosts<S>(set_count: usize, drives_per_set: usize, endpoint_hosts: &[S]) -> Result<Self>
|
||||
where
|
||||
@@ -113,7 +108,6 @@ impl RuntimeSetLayoutPlan {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[allow(dead_code, reason = "ESET-001 layout model; exercised by this file's tests (backlog#1823)")]
|
||||
pub(crate) struct RuntimeSetDrivePlan {
|
||||
pub(crate) set_index: usize,
|
||||
pub(crate) disk_index: usize,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: object API readers keep staged compatibility paths during facade migration.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::bucket::metadata_sys::get_versioning_config;
|
||||
use crate::bucket::replication::{
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
use super::*;
|
||||
|
||||
use crate::io_support::rio::Index;
|
||||
use std::mem::MaybeUninit;
|
||||
|
||||
#[cfg(feature = "rio-v2")]
|
||||
const DARE_PAYLOAD_SIZE: i64 = 64 * 1024;
|
||||
@@ -449,16 +448,10 @@ impl GetObjectReader {
|
||||
}
|
||||
|
||||
enum ReadTransform {
|
||||
// Written but never read by production code: the enclosing struct already
|
||||
// carries the same pair as `storage_offset`/`storage_length`. They survive
|
||||
// as the read plan's test-visible record — four tests assert them by
|
||||
// literal pattern (`Plain { visible_offset: 6, visible_length: 4 }`), which
|
||||
// rustc does not count as a read.
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "asserted by literal pattern in this file's read-plan tests (backlog#1823)"
|
||||
)]
|
||||
Plain { visible_offset: usize, visible_length: i64 },
|
||||
Plain {
|
||||
visible_offset: usize,
|
||||
visible_length: i64,
|
||||
},
|
||||
Compressed {
|
||||
algorithm: CompressionAlgorithm,
|
||||
backend: crate::io_support::rio::ReadCompressionBackend,
|
||||
@@ -929,7 +922,7 @@ struct SkipReader<R> {
|
||||
inner: R,
|
||||
bytes_to_skip: usize,
|
||||
bytes_skipped: usize,
|
||||
scratch: Box<[MaybeUninit<u8>]>,
|
||||
scratch: Vec<u8>,
|
||||
}
|
||||
|
||||
impl<R: AsyncRead + Unpin + Send + Sync> SkipReader<R> {
|
||||
@@ -938,7 +931,7 @@ impl<R: AsyncRead + Unpin + Send + Sync> SkipReader<R> {
|
||||
inner,
|
||||
bytes_to_skip,
|
||||
bytes_skipped: 0,
|
||||
scratch: Box::<[u8]>::new_uninit_slice(8192),
|
||||
scratch: vec![0u8; 8192],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -950,7 +943,7 @@ impl<R: AsyncRead + Unpin + Send + Sync> AsyncRead for SkipReader<R> {
|
||||
while this.bytes_skipped < this.bytes_to_skip {
|
||||
let remaining = this.bytes_to_skip - this.bytes_skipped;
|
||||
let scratch_len = remaining.min(this.scratch.len());
|
||||
let mut scratch_buf = ReadBuf::uninit(&mut this.scratch[..scratch_len]);
|
||||
let mut scratch_buf = ReadBuf::new(&mut this.scratch[..scratch_len]);
|
||||
match Pin::new(&mut this.inner).poll_read(cx, &mut scratch_buf) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
|
||||
@@ -981,7 +974,7 @@ pub struct RangedDecompressReader<R: AsyncRead + Unpin + Send + Sync + 'static>
|
||||
target_length: usize,
|
||||
current_offset: usize,
|
||||
bytes_returned: usize,
|
||||
scratch: Box<[MaybeUninit<u8>]>,
|
||||
scratch: Vec<u8>,
|
||||
drain_on_done: bool,
|
||||
drain_task: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
@@ -1019,7 +1012,7 @@ impl<R: AsyncRead + Unpin + Send + Sync + 'static> RangedDecompressReader<R> {
|
||||
target_length: actual_length,
|
||||
current_offset: 0,
|
||||
bytes_returned: 0,
|
||||
scratch: Box::<[u8]>::new_uninit_slice(8192),
|
||||
scratch: vec![0u8; 8192],
|
||||
drain_on_done,
|
||||
drain_task: None,
|
||||
})
|
||||
@@ -1069,7 +1062,7 @@ impl<R: AsyncRead + Unpin + Send + Sync + 'static> AsyncRead for RangedDecompres
|
||||
}
|
||||
|
||||
let scratch_len = std::cmp::min(this.scratch.len(), std::cmp::max(buf_capacity, 1));
|
||||
let mut temp_read_buf = ReadBuf::uninit(&mut this.scratch[..scratch_len]);
|
||||
let mut temp_read_buf = ReadBuf::new(&mut this.scratch[..scratch_len]);
|
||||
|
||||
let Some(inner) = this.inner.as_mut() else {
|
||||
return Poll::Ready(Ok(()));
|
||||
@@ -1121,8 +1114,7 @@ impl<R: AsyncRead + Unpin + Send + Sync + 'static> AsyncRead for RangedDecompres
|
||||
);
|
||||
|
||||
if bytes_to_return > 0 {
|
||||
let data_slice =
|
||||
&temp_read_buf.filled()[data_start_in_buffer..data_start_in_buffer + bytes_to_return];
|
||||
let data_slice = &this.scratch[data_start_in_buffer..data_start_in_buffer + bytes_to_return];
|
||||
buf.put_slice(data_slice);
|
||||
this.bytes_returned += bytes_to_return;
|
||||
|
||||
@@ -1141,7 +1133,7 @@ impl<R: AsyncRead + Unpin + Send + Sync + 'static> AsyncRead for RangedDecompres
|
||||
std::cmp::min(n, std::cmp::min(buf.remaining(), this.target_length - this.bytes_returned));
|
||||
|
||||
if bytes_to_return > 0 {
|
||||
buf.put_slice(&temp_read_buf.filled()[..bytes_to_return]);
|
||||
buf.put_slice(&this.scratch[..bytes_to_return]);
|
||||
this.bytes_returned += bytes_to_return;
|
||||
|
||||
tracing::trace!("Returned {} bytes at offset {}", bytes_to_return, old_offset);
|
||||
@@ -1211,7 +1203,20 @@ impl<R: AsyncRead + Unpin + Send + 'static> AsyncRead for StreamConsumer<R> {
|
||||
|
||||
impl<R: AsyncRead + Unpin + Send + 'static> Drop for StreamConsumer<R> {
|
||||
fn drop(&mut self) {
|
||||
self.ensure_consumer_started();
|
||||
if self.consumer_task.is_none() && self.inner.is_some() {
|
||||
let mut inner = self.inner.take().unwrap();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match inner.read(&mut buf).await {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(_) => continue, // Keep consuming
|
||||
Err(_) => break, // Error, stop consuming
|
||||
}
|
||||
}
|
||||
});
|
||||
self.consumer_task = Some(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1258,43 +1263,6 @@ mod tests {
|
||||
use temp_env::async_with_vars;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PendingPartialReader {
|
||||
data: &'static [u8],
|
||||
position: usize,
|
||||
pending: bool,
|
||||
}
|
||||
|
||||
impl PendingPartialReader {
|
||||
fn new(data: &'static [u8]) -> Self {
|
||||
Self {
|
||||
data,
|
||||
position: 0,
|
||||
pending: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for PendingPartialReader {
|
||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||
if self.pending {
|
||||
self.pending = false;
|
||||
cx.waker().wake_by_ref();
|
||||
return Poll::Pending;
|
||||
}
|
||||
if self.position == self.data.len() {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
let length = buf.remaining().min(3).min(self.data.len() - self.position);
|
||||
let end = self.position + length;
|
||||
buf.put_slice(&self.data[self.position..end]);
|
||||
self.position = end;
|
||||
self.pending = true;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_DIRECT_KEY_HEADER: &str = "x-rustfs-test-direct-key";
|
||||
const TEST_OBJECT_KEY_HEADER: &str = "x-rustfs-test-object-key";
|
||||
const TEST_NONCE_HEADER: &str = "x-rustfs-test-nonce";
|
||||
@@ -1432,36 +1400,6 @@ mod tests {
|
||||
assert_eq!(result, b"World");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninitialized_scratch_preserves_partial_pending_and_eof_reads() {
|
||||
let mut skipped = SkipReader::new(PendingPartialReader::new(b"0123456789abcdef"), 5);
|
||||
let mut skipped_output = Vec::new();
|
||||
skipped
|
||||
.read_to_end(&mut skipped_output)
|
||||
.await
|
||||
.expect("skip reader should survive partial pending reads through EOF");
|
||||
assert_eq!(skipped_output, b"56789abcdef");
|
||||
|
||||
let mut ranged = RangedDecompressReader::new(PendingPartialReader::new(b"0123456789abcdef"), 5, 7, 16)
|
||||
.expect("valid range should construct");
|
||||
let mut ranged_output = Vec::new();
|
||||
ranged
|
||||
.read_to_end(&mut ranged_output)
|
||||
.await
|
||||
.expect("range reader should survive partial pending reads through EOF");
|
||||
assert_eq!(ranged_output, b"56789ab");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninitialized_skip_scratch_reports_early_eof() {
|
||||
let mut reader = SkipReader::new(PendingPartialReader::new(b"short"), 6);
|
||||
let error = reader
|
||||
.read_to_end(&mut Vec::new())
|
||||
.await
|
||||
.expect_err("EOF before the skip boundary must remain visible");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ranged_decompress_reader_from_start() {
|
||||
let original_data = b"Hello, World! This is a test.";
|
||||
|
||||
@@ -172,7 +172,6 @@ impl ObjectLockConfigSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "snapshot-scope predicate asserted by this file's tests (backlog#1823)")]
|
||||
pub(crate) fn is_for_store_bucket(
|
||||
&self,
|
||||
store_id: Uuid,
|
||||
@@ -261,9 +260,6 @@ pub struct ObjectOptions {
|
||||
|
||||
pub data_movement: bool,
|
||||
pub raw_data_movement_read: bool,
|
||||
/// Materialize the data-movement per-part checksum sidecar for APIs that
|
||||
/// return part checksums. Ordinary object reads leave it encoded.
|
||||
pub include_part_checksums: bool,
|
||||
pub src_pool_idx: usize,
|
||||
pub user_defined: HashMap<String, String>,
|
||||
pub preserve_etag: Option<String>,
|
||||
|
||||
@@ -31,6 +31,7 @@ use std::{
|
||||
use tokio::sync::{OnceCell, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const DISK_ASSUME_UNKNOWN_SIZE: u64 = 1 << 30;
|
||||
pub const DISK_MIN_INODES: u64 = 1000;
|
||||
@@ -108,6 +109,18 @@ pub fn set_global_rustfs_port(value: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the global deployment id
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - The Uuid to set as the global deployment id
|
||||
///
|
||||
/// # Returns
|
||||
/// * None
|
||||
///
|
||||
pub fn set_global_deployment_id(id: Uuid) {
|
||||
current_ctx().set_deployment_id(id);
|
||||
}
|
||||
|
||||
/// Get the global deployment id
|
||||
///
|
||||
/// # Returns
|
||||
@@ -275,6 +288,19 @@ pub fn get_global_region() -> Option<s3s::region::Region> {
|
||||
current_ctx().region()
|
||||
}
|
||||
|
||||
/// Initialize the global background services cancellation token
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `cancel_token` - The CancellationToken instance to set globally
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` if successful
|
||||
/// * `Err(CancellationToken)` if setting fails
|
||||
///
|
||||
pub fn init_background_services_cancel_token(cancel_token: CancellationToken) -> Result<(), CancellationToken> {
|
||||
current_ctx().init_background_cancel_token(cancel_token)
|
||||
}
|
||||
|
||||
/// Get the global background services cancellation token
|
||||
///
|
||||
/// # Returns
|
||||
@@ -284,6 +310,18 @@ pub fn get_background_services_cancel_token() -> Option<CancellationToken> {
|
||||
current_ctx().background_cancel_token()
|
||||
}
|
||||
|
||||
/// Create and initialize the global background services cancellation token
|
||||
///
|
||||
/// # Returns
|
||||
/// * `CancellationToken` - The newly created global cancellation token
|
||||
///
|
||||
pub fn create_background_services_cancel_token() -> CancellationToken {
|
||||
let cancel_token = CancellationToken::new();
|
||||
init_background_services_cancel_token(cancel_token.clone())
|
||||
.expect("background services cancel token should be initialized once during startup");
|
||||
cancel_token
|
||||
}
|
||||
|
||||
/// Shutdown all background services gracefully
|
||||
///
|
||||
/// # Returns
|
||||
|
||||
@@ -402,10 +402,6 @@ impl InstanceContext {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "driven by the tier-delete-journal recovery test behind `--features test-util` (backlog#1823)"
|
||||
)]
|
||||
pub(crate) fn wake_tier_delete_journal_recovery(&self) {
|
||||
self.tier_delete_journal_recovery_wakeup.notify_one();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
// #730: runtime source migration keeps fallback handles until all owners inject state.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub(crate) mod global;
|
||||
pub(crate) mod instance;
|
||||
|
||||
@@ -38,6 +38,7 @@ use crate::{
|
||||
set_object_layer, update_erasure_type,
|
||||
},
|
||||
services::batch_processor::{GlobalBatchProcessors, get_global_processors},
|
||||
services::event_notification::EventNotifier,
|
||||
services::notification_sys::{NotificationSys, get_global_notification_sys},
|
||||
services::tier::tier::TierConfigMgr,
|
||||
store::ECStore,
|
||||
@@ -142,10 +143,6 @@ pub async fn setup_is_erasure_sd() -> bool {
|
||||
is_erasure_sd().await
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "setup-type override used only by tests across this crate (backlog#1823)"
|
||||
)]
|
||||
pub(crate) async fn current_setup_type() -> SetupType {
|
||||
if setup_is_dist_erasure().await {
|
||||
SetupType::DistErasure
|
||||
@@ -158,10 +155,6 @@ pub(crate) async fn current_setup_type() -> SetupType {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "setup-type override used only by tests across this crate (backlog#1823)"
|
||||
)]
|
||||
pub(crate) async fn set_setup_type(setup_type: SetupType) {
|
||||
update_erasure_type(setup_type).await;
|
||||
}
|
||||
@@ -171,9 +164,6 @@ pub(crate) async fn local_node_name() -> String {
|
||||
}
|
||||
|
||||
pub(crate) async fn set_local_node_name(node_name: String) {
|
||||
// Also stamp the internode-metrics server label: io-metrics is a leaf
|
||||
// crate and no longer resolves node identity itself (backlog#1834).
|
||||
rustfs_io_metrics::internode_metrics::set_internode_server_label(node_name.as_str());
|
||||
rustfs_common::set_global_local_node_name(&node_name).await;
|
||||
}
|
||||
|
||||
@@ -239,6 +229,14 @@ pub(crate) fn ensure_test_rpc_secret() {
|
||||
let _ = rustfs_credentials::set_global_rpc_secret(TEST_RPC_SECRET.to_owned());
|
||||
}
|
||||
|
||||
pub(crate) fn storage_class_parity(storage_class: Option<&str>) -> Option<usize> {
|
||||
get_global_storage_class_snapshot().get_parity_for_sc(storage_class.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(crate) fn storage_class_should_inline(shard_size: i64, versioned: bool) -> bool {
|
||||
get_global_storage_class_snapshot().should_inline(shard_size, versioned)
|
||||
}
|
||||
|
||||
pub(crate) fn deployment_upload_id(upload_id: &str) -> String {
|
||||
base64_simd::URL_SAFE_NO_PAD
|
||||
.encode_to_string(format!("{}.{}", get_global_deployment_id().unwrap_or_default(), upload_id).as_bytes())
|
||||
@@ -331,6 +329,21 @@ pub(crate) fn storage_class_config_snapshot() -> Arc<storageclass::Config> {
|
||||
get_global_storage_class_snapshot()
|
||||
}
|
||||
|
||||
/// Scalar STANDARD / RRS parity for backend-info reporting.
|
||||
///
|
||||
/// Retained for the rebalance/backend-info path. `get_parity_for_sc` returns
|
||||
/// `None` when the runtime config is uninitialized or (post per-pool support)
|
||||
/// when pools disagree, so STANDARD falls back to the caller's default and RRS
|
||||
/// stays `None` — matching the pre-per-pool scalar reporting.
|
||||
pub(crate) fn backend_storage_class_parities(default_standard_parity: usize) -> (Option<usize>, Option<usize>) {
|
||||
let sc = get_global_storage_class_snapshot();
|
||||
let standard = sc
|
||||
.get_parity_for_sc(storageclass::CLASS_STANDARD)
|
||||
.or(Some(default_standard_parity));
|
||||
let reduced_redundancy = sc.get_parity_for_sc(storageclass::RRS);
|
||||
(standard, reduced_redundancy)
|
||||
}
|
||||
|
||||
pub(crate) fn set_storage_class_config(config: storageclass::Config) {
|
||||
set_global_storage_class(config);
|
||||
}
|
||||
@@ -398,6 +411,10 @@ pub fn transition_state_handle() -> Arc<TransitionState> {
|
||||
crate::runtime::global::current_ctx().transition_state()
|
||||
}
|
||||
|
||||
pub(crate) fn event_notifier_handle() -> Arc<RwLock<EventNotifier>> {
|
||||
crate::runtime::global::current_ctx().event_notifier()
|
||||
}
|
||||
|
||||
pub(crate) async fn local_disk_by_path(path: &str) -> Option<DiskStore> {
|
||||
local_disk_map_handle().read().await.get(path).cloned().flatten()
|
||||
}
|
||||
@@ -491,6 +508,30 @@ pub(crate) async fn local_disk_set_drive(
|
||||
instance_ctx.local_disk_set_drives().read().await[pool_idx][set_idx][disk_idx].clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn local_disk_for_endpoint(endpoint: &Endpoint) -> Option<DiskStore> {
|
||||
let set_drives = local_disk_set_drives_handle();
|
||||
let global_set_drives = set_drives.read().await;
|
||||
if global_set_drives.is_empty() {
|
||||
return local_disk_map_handle()
|
||||
.read()
|
||||
.await
|
||||
.get(&endpoint.to_string())
|
||||
.cloned()
|
||||
.unwrap_or(None);
|
||||
}
|
||||
|
||||
let pool_idx = usize::try_from(endpoint.pool_idx).ok()?;
|
||||
let set_idx = usize::try_from(endpoint.set_idx).ok()?;
|
||||
let disk_idx = usize::try_from(endpoint.disk_idx).ok()?;
|
||||
|
||||
global_set_drives
|
||||
.get(pool_idx)
|
||||
.and_then(|sets| sets.get(set_idx))
|
||||
.and_then(|disks| disks.get(disk_idx))
|
||||
.cloned()
|
||||
.unwrap_or(None)
|
||||
}
|
||||
|
||||
pub(crate) async fn local_disk_paths() -> Vec<String> {
|
||||
local_disk_map_handle().read().await.keys().cloned().collect()
|
||||
}
|
||||
|
||||
@@ -206,38 +206,6 @@ pub(crate) fn remote_version_state_fleet_proof_matches(proof: &RemoteVersionStat
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct RemoteVersionStateFleetProofGuard;
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for RemoteVersionStateFleetProofGuard {
|
||||
fn drop(&mut self) {
|
||||
replace_remote_version_state_fleet_proof(None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_remote_version_state_fleet_proof_for_test(topology_fingerprint: &str) -> RemoteVersionStateFleetProofGuard {
|
||||
match REMOTE_VERSION_STATE_PROBE_TOPOLOGY.set(topology_fingerprint.to_string()) {
|
||||
Ok(()) => {}
|
||||
Err(_)
|
||||
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY
|
||||
.get()
|
||||
.is_some_and(|current| current == topology_fingerprint) => {}
|
||||
Err(_) => panic!("remote version state test topology is already bound to another fingerprint"),
|
||||
}
|
||||
let peer_epochs = BTreeMap::new();
|
||||
if let Some(err) = publish_remote_version_state_probe_result(
|
||||
remote_version_state_fleet_proof_slot(),
|
||||
topology_fingerprint,
|
||||
Ok(peer_epochs),
|
||||
Instant::now(),
|
||||
) {
|
||||
panic!("test proof installation must not fail: {err}");
|
||||
}
|
||||
RemoteVersionStateFleetProofGuard
|
||||
}
|
||||
|
||||
fn remote_version_state_fleet_proof_valid_at(
|
||||
proof: Option<&RemoteVersionStateFleetProof>,
|
||||
expected_topology: &str,
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::meta::{
|
||||
clone_arc_by_index, ensure_valid_rebalance_pool_index, invalid_rebalance_pool_index_error,
|
||||
rebalance_metadata_not_initialized_error, should_ignore_rebalance_data_usage_cache,
|
||||
};
|
||||
use super::migration::{RebalanceMigrationBackend, migrate_entry_version};
|
||||
use super::migration::migrate_entry_version;
|
||||
use super::worker::{
|
||||
RebalanceEntryCleanupResult, RebalanceEntryTask, load_rebalance_bucket_configs, rebalance_max_attempts,
|
||||
resolve_rebalance_bucket_error, resolve_rebalance_entry_cleanup_delete_result, resolve_rebalance_file_info_versions_result,
|
||||
@@ -144,11 +144,6 @@ impl ECStore {
|
||||
return Ok(RebalanceEntryOutcome::Completed);
|
||||
}
|
||||
|
||||
let bucket_incarnation_fence = match bucket_configs.bucket_incarnation_id {
|
||||
Some(expected) => Some(self.acquire_bucket_incarnation_fence(&bucket, expected).await?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut fivs =
|
||||
resolve_rebalance_file_info_versions_result(entry.file_info_versions(&bucket), bucket.as_str(), entry.name.as_str())?;
|
||||
|
||||
@@ -208,14 +203,9 @@ impl ECStore {
|
||||
}
|
||||
|
||||
let version_id = version.version_id.map(|v| v.to_string());
|
||||
let expected_bucket_incarnation_id = bucket_configs.bucket_incarnation_id;
|
||||
let mut transfer = |src_pool_idx: usize, bucket: String, rd: GetObjectReader| {
|
||||
let store = self.clone();
|
||||
async move {
|
||||
store
|
||||
.rebalance_object(src_pool_idx, bucket, rd, expected_bucket_incarnation_id)
|
||||
.await
|
||||
}
|
||||
async move { store.rebalance_object(src_pool_idx, bucket, rd).await }
|
||||
};
|
||||
// Route delete-marker migration through the store layer so it lands on the
|
||||
// cross-pool target (excluding the source pool), not back onto the source set.
|
||||
@@ -224,12 +214,11 @@ impl ECStore {
|
||||
async move { store.delete_object(&bucket, &object, opts).await }
|
||||
};
|
||||
let result = migrate_entry_version(
|
||||
&RebalanceMigrationBackend::new(set.as_ref(), self.as_ref()),
|
||||
set.as_ref(),
|
||||
bucket.clone(),
|
||||
pool_index,
|
||||
version,
|
||||
version_id.clone(),
|
||||
expected_bucket_incarnation_id,
|
||||
rebalance_max_attempts(),
|
||||
should_ignore_rebalance_data_usage_cache(bucket.as_str()),
|
||||
&mut transfer,
|
||||
@@ -314,9 +303,6 @@ impl ECStore {
|
||||
}
|
||||
|
||||
if should_cleanup_rebalance_source_entry(rebalanced, fivs.versions.len(), expired) {
|
||||
if bucket_incarnation_fence.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
||||
return Err(Error::other("rebalance bucket incarnation fence was lost before source cleanup"));
|
||||
}
|
||||
let cleanup_result = self
|
||||
.finish_rebalance_entry_after_cleanup(
|
||||
pool_index,
|
||||
@@ -329,12 +315,6 @@ impl ECStore {
|
||||
entry.name.as_str(),
|
||||
&fivs,
|
||||
&cleanup_preflight_allowed_missing,
|
||||
data_movement::SourceCleanupBucketFence {
|
||||
expected_incarnation_id: bucket_configs.bucket_incarnation_id,
|
||||
lifecycle_guard: bucket_incarnation_fence
|
||||
.as_ref()
|
||||
.and_then(|guard| guard.namespace_lock_guard()),
|
||||
},
|
||||
"rebalance",
|
||||
),
|
||||
)
|
||||
@@ -409,14 +389,8 @@ impl ECStore {
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, rd))]
|
||||
async fn rebalance_object(
|
||||
self: Arc<Self>,
|
||||
pool_idx: usize,
|
||||
bucket: String,
|
||||
rd: GetObjectReader,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> Result<()> {
|
||||
data_movement::migrate_object(self, pool_idx, bucket, rd, expected_bucket_incarnation_id, "rebalance_object").await
|
||||
async fn rebalance_object(self: Arc<Self>, pool_idx: usize, bucket: String, rd: GetObjectReader) -> Result<()> {
|
||||
data_movement::migrate_object(self, pool_idx, bucket, rd, "rebalance_object").await
|
||||
}
|
||||
|
||||
async fn update_rebalance_last_error(&self, pool_idx: usize, message: String) -> Result<()> {
|
||||
|
||||
@@ -5,7 +5,6 @@ use crate::error::{Error, Result, is_err_object_not_found, is_err_version_not_fo
|
||||
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions};
|
||||
use crate::set_disk::SetDisks;
|
||||
use crate::storage_api_contracts::{object::ObjectIO, range::HTTPRangeSpec};
|
||||
use crate::store::ECStore;
|
||||
use http::HeaderMap;
|
||||
use rustfs_filemeta::FileInfo;
|
||||
use rustfs_utils::path::encode_dir_object;
|
||||
@@ -22,23 +21,15 @@ pub(crate) struct MigrationVersionResult {
|
||||
pub error: Option<Error>,
|
||||
}
|
||||
|
||||
pub(super) fn rebalance_delete_marker_opts(
|
||||
version: &FileInfo,
|
||||
version_id: Option<String>,
|
||||
src_pool_idx: usize,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> ObjectOptions {
|
||||
let version_suspended = version.version_id.is_none() && version_id.is_none();
|
||||
pub(super) fn rebalance_delete_marker_opts(version: &FileInfo, version_id: Option<String>, src_pool_idx: usize) -> ObjectOptions {
|
||||
ObjectOptions {
|
||||
versioned: !version_suspended,
|
||||
version_suspended,
|
||||
version_id: version_id.or_else(|| version_suspended.then(|| uuid::Uuid::nil().to_string())),
|
||||
versioned: true,
|
||||
version_id,
|
||||
mod_time: version.mod_time,
|
||||
src_pool_idx,
|
||||
data_movement: true,
|
||||
delete_marker: true,
|
||||
skip_decommissioned: true,
|
||||
expected_bucket_incarnation_id,
|
||||
delete_replication: version
|
||||
.replication_state_internal
|
||||
.as_ref()
|
||||
@@ -47,12 +38,7 @@ pub(super) fn rebalance_delete_marker_opts(
|
||||
}
|
||||
}
|
||||
|
||||
fn rebalance_remote_tiered_opts(
|
||||
version: &FileInfo,
|
||||
version_id: Option<String>,
|
||||
src_pool_idx: usize,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> ObjectOptions {
|
||||
fn rebalance_remote_tiered_opts(version: &FileInfo, version_id: Option<String>, src_pool_idx: usize) -> ObjectOptions {
|
||||
ObjectOptions {
|
||||
versioned: version_id.is_some(),
|
||||
version_id,
|
||||
@@ -60,21 +46,6 @@ fn rebalance_remote_tiered_opts(
|
||||
user_defined: version.metadata.clone(),
|
||||
src_pool_idx,
|
||||
data_movement: true,
|
||||
include_part_checksums: true,
|
||||
http_preconditions: Some(crate::data_movement::data_movement_target_precondition()),
|
||||
expected_bucket_incarnation_id,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn rebalance_object_migration_read_opts(version_id: Option<String>) -> ObjectOptions {
|
||||
ObjectOptions {
|
||||
version_id,
|
||||
no_lock: true,
|
||||
data_movement: true,
|
||||
raw_data_movement_read: true,
|
||||
skip_decommissioned: true,
|
||||
skip_rebalancing: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -99,19 +70,8 @@ pub(crate) trait MigrationBackend: Send + Sync {
|
||||
) -> Result<()>;
|
||||
}
|
||||
|
||||
pub(crate) struct RebalanceMigrationBackend<'a> {
|
||||
source: &'a SetDisks,
|
||||
store: &'a ECStore,
|
||||
}
|
||||
|
||||
impl<'a> RebalanceMigrationBackend<'a> {
|
||||
pub(crate) fn new(source: &'a SetDisks, store: &'a ECStore) -> Self {
|
||||
Self { source, store }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationBackend for RebalanceMigrationBackend<'_> {
|
||||
impl MigrationBackend for SetDisks {
|
||||
async fn get_object_reader_for_migration(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -120,7 +80,7 @@ impl MigrationBackend for RebalanceMigrationBackend<'_> {
|
||||
h: HeaderMap,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<GetObjectReader> {
|
||||
self.source.get_object_reader(bucket, object, range, h, opts).await
|
||||
self.get_object_reader(bucket, object, range, h, opts).await
|
||||
}
|
||||
|
||||
async fn move_remote_version_for_migration(
|
||||
@@ -130,7 +90,7 @@ impl MigrationBackend for RebalanceMigrationBackend<'_> {
|
||||
fi: &FileInfo,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<()> {
|
||||
self.store.decommission_tiered_object(bucket, object, fi, opts).await
|
||||
self.decommission_tiered_object(bucket, object, fi, opts).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +101,6 @@ pub(crate) async fn migrate_entry_version<Backend, F, Fut, D, DFut>(
|
||||
pool_index: usize,
|
||||
version: &FileInfo,
|
||||
version_id: Option<String>,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
max_attempts: usize,
|
||||
ignore_data_usage_cache: bool,
|
||||
transfer: F,
|
||||
@@ -154,13 +113,12 @@ where
|
||||
D: FnMut(String, String, ObjectOptions) -> DFut + Send,
|
||||
DFut: Future<Output = Result<ObjectInfo>> + Send,
|
||||
{
|
||||
migrate_entry_version_with_retry_wait_and_incarnation(
|
||||
migrate_entry_version_with_retry_wait(
|
||||
set,
|
||||
bucket,
|
||||
pool_index,
|
||||
version,
|
||||
version_id,
|
||||
expected_bucket_incarnation_id,
|
||||
max_attempts,
|
||||
ignore_data_usage_cache,
|
||||
transfer,
|
||||
@@ -179,45 +137,6 @@ pub(super) async fn migrate_entry_version_with_retry_wait<Backend, F, Fut, D, DF
|
||||
version_id: Option<String>,
|
||||
max_attempts: usize,
|
||||
ignore_data_usage_cache: bool,
|
||||
transfer: F,
|
||||
delete_marker: D,
|
||||
wait_retry: W,
|
||||
) -> MigrationVersionResult
|
||||
where
|
||||
Backend: MigrationBackend + ?Sized,
|
||||
F: FnMut(usize, String, GetObjectReader) -> Fut + Send,
|
||||
Fut: Future<Output = Result<()>> + Send,
|
||||
D: FnMut(String, String, ObjectOptions) -> DFut + Send,
|
||||
DFut: Future<Output = Result<ObjectInfo>> + Send,
|
||||
W: FnMut(Duration) -> WFut + Send,
|
||||
WFut: Future<Output = ()> + Send,
|
||||
{
|
||||
migrate_entry_version_with_retry_wait_and_incarnation(
|
||||
set,
|
||||
bucket,
|
||||
pool_index,
|
||||
version,
|
||||
version_id,
|
||||
None,
|
||||
max_attempts,
|
||||
ignore_data_usage_cache,
|
||||
transfer,
|
||||
delete_marker,
|
||||
wait_retry,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn migrate_entry_version_with_retry_wait_and_incarnation<Backend, F, Fut, D, DFut, W, WFut>(
|
||||
set: &Backend,
|
||||
bucket: String,
|
||||
pool_index: usize,
|
||||
version: &FileInfo,
|
||||
version_id: Option<String>,
|
||||
expected_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
max_attempts: usize,
|
||||
ignore_data_usage_cache: bool,
|
||||
mut transfer: F,
|
||||
mut delete_marker: D,
|
||||
mut wait_retry: W,
|
||||
@@ -250,7 +169,7 @@ where
|
||||
&bucket,
|
||||
&version.name,
|
||||
version,
|
||||
&rebalance_remote_tiered_opts(version, version_id, pool_index, expected_bucket_incarnation_id),
|
||||
&rebalance_remote_tiered_opts(version, version_id, pool_index),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -293,7 +212,7 @@ where
|
||||
if let Err(err) = delete_marker(
|
||||
bucket.clone(),
|
||||
version.name.clone(),
|
||||
rebalance_delete_marker_opts(version, version_id, pool_index, expected_bucket_incarnation_id),
|
||||
rebalance_delete_marker_opts(version, version_id, pool_index),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -336,7 +255,11 @@ where
|
||||
&encode_dir_object(&version.name),
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&rebalance_object_migration_read_opts(version_id.clone()),
|
||||
&ObjectOptions {
|
||||
version_id: version_id.clone(),
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -113,8 +113,6 @@ struct LegacyRebalanceMeta {
|
||||
struct MigrationBackendSpy {
|
||||
get_object_reader: Mutex<Option<core::result::Result<GetObjectReader, Error>>>,
|
||||
move_remote: Mutex<Option<core::result::Result<(), Error>>>,
|
||||
get_opts: Mutex<Vec<ObjectOptions>>,
|
||||
move_remote_opts: Mutex<Vec<ObjectOptions>>,
|
||||
get_calls: AtomicUsize,
|
||||
move_remote_calls: AtomicUsize,
|
||||
}
|
||||
@@ -127,8 +125,6 @@ impl MigrationBackendSpy {
|
||||
Self {
|
||||
get_object_reader: Mutex::new(get_object_reader),
|
||||
move_remote: Mutex::new(move_remote),
|
||||
get_opts: Mutex::new(Vec::new()),
|
||||
move_remote_opts: Mutex::new(Vec::new()),
|
||||
get_calls: AtomicUsize::new(0),
|
||||
move_remote_calls: AtomicUsize::new(0),
|
||||
}
|
||||
@@ -142,24 +138,6 @@ impl MigrationBackendSpy {
|
||||
self.move_remote_calls.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn last_get_opts(&self) -> ObjectOptions {
|
||||
self.get_opts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("reader opts should be captured")
|
||||
}
|
||||
|
||||
fn last_move_remote_opts(&self) -> ObjectOptions {
|
||||
self.move_remote_opts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.last()
|
||||
.cloned()
|
||||
.expect("remote opts should be captured")
|
||||
}
|
||||
|
||||
fn make_reader() -> GetObjectReader {
|
||||
GetObjectReader {
|
||||
stream: Box::new(Cursor::new(vec![0_u8; 3])),
|
||||
@@ -178,10 +156,9 @@ impl MigrationBackend for MigrationBackendSpy {
|
||||
_object: &str,
|
||||
_range: Option<HTTPRangeSpec>,
|
||||
_h: http::HeaderMap,
|
||||
opts: &ObjectOptions,
|
||||
_opts: &ObjectOptions,
|
||||
) -> Result<GetObjectReader> {
|
||||
self.get_calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.get_opts.lock().unwrap().push(opts.clone());
|
||||
if let Some(result) = self.get_object_reader.lock().unwrap().take() {
|
||||
return result;
|
||||
}
|
||||
@@ -194,10 +171,9 @@ impl MigrationBackend for MigrationBackendSpy {
|
||||
_bucket: &str,
|
||||
_object: &str,
|
||||
_fi: &FileInfo,
|
||||
opts: &ObjectOptions,
|
||||
_opts: &ObjectOptions,
|
||||
) -> Result<()> {
|
||||
self.move_remote_calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.move_remote_opts.lock().unwrap().push(opts.clone());
|
||||
if let Some(result) = self.move_remote.lock().unwrap().take() {
|
||||
return result;
|
||||
}
|
||||
@@ -241,8 +217,7 @@ fn test_rebalance_delete_marker_opts_preserves_replication_state() {
|
||||
..version_deleted()
|
||||
};
|
||||
|
||||
let incarnation = uuid::Uuid::new_v4();
|
||||
let opts = rebalance_delete_marker_opts(&version, Some("version-id".to_string()), 7, Some(incarnation));
|
||||
let opts = rebalance_delete_marker_opts(&version, Some("version-id".to_string()), 7);
|
||||
let replication = opts.delete_replication.expect("replication state should be preserved");
|
||||
|
||||
assert!(opts.versioned);
|
||||
@@ -252,22 +227,11 @@ fn test_rebalance_delete_marker_opts_preserves_replication_state() {
|
||||
assert_eq!(opts.src_pool_idx, 7);
|
||||
assert_eq!(opts.version_id.as_deref(), Some("version-id"));
|
||||
assert_eq!(opts.mod_time, Some(mod_time));
|
||||
assert_eq!(opts.expected_bucket_incarnation_id, Some(incarnation));
|
||||
assert_eq!(replication.replica_status, ReplicationStatusType::Replica);
|
||||
assert!(replication.delete_marker);
|
||||
assert_eq!(replication.replicate_decision_str, "existing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rebalance_delete_marker_opts_preserves_suspended_null_version() {
|
||||
let version = version_deleted();
|
||||
let opts = rebalance_delete_marker_opts(&version, None, 7, None);
|
||||
|
||||
assert!(!opts.versioned);
|
||||
assert!(opts.version_suspended);
|
||||
assert_eq!(opts.version_id.as_deref(), Some(uuid::Uuid::nil().to_string().as_str()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrate_entry_version_remote_version_is_moved_without_transfer() {
|
||||
let backend = MigrationBackendSpy::new(None, Some(Ok(())));
|
||||
@@ -284,14 +248,12 @@ async fn test_migrate_entry_version_remote_version_is_moved_without_transfer() {
|
||||
}
|
||||
};
|
||||
|
||||
let incarnation = uuid::Uuid::new_v4();
|
||||
let result = migrate_entry_version(
|
||||
&backend,
|
||||
"bucket".to_string(),
|
||||
0,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
Some(incarnation),
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -307,10 +269,6 @@ async fn test_migrate_entry_version_remote_version_is_moved_without_transfer() {
|
||||
assert_eq!(transfer_count.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(backend.move_remote_calls(), 1);
|
||||
assert_eq!(backend.get_calls(), 0);
|
||||
let remote_opts = backend.last_move_remote_opts();
|
||||
assert!(remote_opts.include_part_checksums);
|
||||
assert!(remote_opts.http_preconditions.is_some());
|
||||
assert_eq!(remote_opts.expected_bucket_incarnation_id, Some(incarnation));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -336,7 +294,6 @@ async fn test_migrate_entry_version_remote_not_found_is_cleanup_ignored() {
|
||||
0,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -373,7 +330,6 @@ async fn test_migrate_entry_version_remote_overwrite_is_not_ignored() {
|
||||
0,
|
||||
&version,
|
||||
Some("vid-1".to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -412,7 +368,6 @@ async fn test_migrate_entry_version_remote_failure_is_reported() {
|
||||
0,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -455,7 +410,6 @@ async fn test_migrate_entry_version_deleted_version_routes_delete_through_store_
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -495,7 +449,6 @@ async fn test_migrate_entry_version_deleted_version_not_found_is_ignored() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -538,7 +491,6 @@ async fn test_migrate_entry_version_deleted_version_overwrite_is_not_ignored() {
|
||||
1,
|
||||
&version,
|
||||
Some("vid-1".to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -568,7 +520,6 @@ async fn test_migrate_entry_version_reader_not_found_is_ignored() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -696,7 +647,6 @@ async fn test_migrate_entry_version_reader_fails_after_retries() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -735,7 +685,6 @@ async fn test_migrate_entry_version_zero_max_attempts_still_attempts_once() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
0,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -801,13 +750,6 @@ async fn test_migrate_entry_version_transfer_retries_before_success() {
|
||||
assert_eq!(backend.get_calls(), 2);
|
||||
assert_eq!(transfer_count.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(wait_count.load(Ordering::SeqCst), 1);
|
||||
let read_opts = backend.last_get_opts();
|
||||
assert_eq!(read_opts.version_id.as_deref(), version.version_id.map(|id| id.to_string()).as_deref());
|
||||
assert!(read_opts.no_lock);
|
||||
assert!(read_opts.data_movement);
|
||||
assert!(read_opts.raw_data_movement_read);
|
||||
assert!(read_opts.skip_decommissioned);
|
||||
assert!(read_opts.skip_rebalancing);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -880,7 +822,6 @@ async fn test_migrate_entry_version_transfer_fails_after_retries() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
2,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -919,7 +860,6 @@ async fn test_migrate_entry_version_transfer_not_found_is_ignored() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -961,7 +901,6 @@ async fn test_migrate_entry_version_transfer_overwrite_is_not_ignored() {
|
||||
1,
|
||||
&version,
|
||||
Some("vid-1".to_string()),
|
||||
None,
|
||||
3,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -1004,7 +943,6 @@ async fn test_migrate_entry_version_ignores_data_usage_cache_when_enabled() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
2,
|
||||
true,
|
||||
&mut transfer,
|
||||
@@ -1047,7 +985,6 @@ async fn test_migrate_entry_version_data_usage_cache_moves_when_ignore_disabled(
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
2,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -2089,7 +2026,6 @@ async fn test_migrate_entry_version_transfer_failure_reports_write_target_stage(
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
1,
|
||||
false,
|
||||
&mut transfer,
|
||||
@@ -2114,7 +2050,6 @@ async fn test_migrate_entry_version_reader_failure_reports_read_source_stage() {
|
||||
1,
|
||||
&version,
|
||||
version.version_id.map(|v| v.to_string()),
|
||||
None,
|
||||
1,
|
||||
false,
|
||||
&mut transfer,
|
||||
|
||||
@@ -36,7 +36,6 @@ pub type RStats = Vec<Arc<RebalanceStats>>;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct RebalanceBucketConfigs {
|
||||
pub(super) bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
pub(super) lifecycle_config: Option<s3s::dto::BucketLifecycleConfiguration>,
|
||||
pub(super) object_lock_config: Option<s3s::dto::ObjectLockConfiguration>,
|
||||
pub(super) replication_config: Option<(s3s::dto::ReplicationConfiguration, OffsetDateTime)>,
|
||||
|
||||
@@ -406,7 +406,6 @@ pub(super) async fn load_rebalance_bucket_configs(api: &ECStore, bucket: &str) -
|
||||
|
||||
let expiry_configs = crate::bucket::lifecycle::get_expiry_configs(api, bucket).await?;
|
||||
Ok(RebalanceBucketConfigs {
|
||||
bucket_incarnation_id: Some(api.bucket_incarnation_id_from_disk(bucket).await?),
|
||||
lifecycle_config: expiry_configs.lifecycle.map(|config| (*config).clone()),
|
||||
object_lock_config: expiry_configs.object_lock.map(|config| (*config).clone()),
|
||||
replication_config: resolve_rebalance_optional_bucket_config_result(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -85,15 +85,6 @@ impl SetDisks {
|
||||
format!("{}/{}", Self::get_multipart_sha_dir(bucket, object), upload_uuid)
|
||||
}
|
||||
|
||||
pub(super) fn get_multipart_upload_dir(bucket: &str, object: &str, upload_id: &str, data_movement: bool) -> String {
|
||||
let upload_dir = Self::get_upload_id_dir(bucket, object, upload_id);
|
||||
if data_movement {
|
||||
format!("{DATA_MOVEMENT_MULTIPART_PREFIX}/{upload_dir}")
|
||||
} else {
|
||||
upload_dir
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn get_multipart_sha_dir(bucket: &str, object: &str) -> String {
|
||||
let path = format!("{bucket}/{object}");
|
||||
let mut hasher = Sha256::new();
|
||||
@@ -475,28 +466,6 @@ impl SetDisks {
|
||||
Self::find_file_info_in_quorum(metas, &mod_time, &etag, quorum)
|
||||
}
|
||||
|
||||
pub(crate) fn hydrate_selected_fileinfo_part_checksums(fi: &mut FileInfo) -> disk::error::Result<()> {
|
||||
fi.hydrate_data_movement_part_checksums().map_err(DiskError::from)?;
|
||||
for part in &fi.parts {
|
||||
let Some(checksums) = part.checksums.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let mut algorithms = HashSet::with_capacity(checksums.len());
|
||||
for (name, value) in checksums {
|
||||
let Some(checksum) = rustfs_rio::Checksum::new_from_string(name, value) else {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
};
|
||||
if checksum.checksum_type.is(rustfs_rio::ChecksumType::MULTIPART) {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
if !algorithms.insert(checksum.checksum_type.base().0) {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_hash_bytes(hasher: &mut Sha256, value: &[u8]) {
|
||||
hasher.update(value.len().to_le_bytes());
|
||||
hasher.update(value);
|
||||
@@ -1110,25 +1079,6 @@ impl SetDisks {
|
||||
shuffled_disks
|
||||
}
|
||||
|
||||
pub(super) fn shuffle_disks_owned(mut disks: Vec<Option<DiskStore>>, distribution: &[usize]) -> Vec<Option<DiskStore>> {
|
||||
if distribution.is_empty() {
|
||||
return disks;
|
||||
}
|
||||
|
||||
let mut shuffled_disks = vec![None; disks.len()];
|
||||
for (index, disk) in disks.iter_mut().enumerate() {
|
||||
let Some(slot) = distribution
|
||||
.get(index)
|
||||
.and_then(|block_index| block_index.checked_sub(1))
|
||||
.filter(|slot| *slot < shuffled_disks.len())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
shuffled_disks[slot] = disk.take();
|
||||
}
|
||||
shuffled_disks
|
||||
}
|
||||
|
||||
pub(super) fn shuffle_check_parts(parts_errs: &[usize], distribution: &[usize]) -> Vec<usize> {
|
||||
if distribution.is_empty() {
|
||||
return parts_errs.to_vec();
|
||||
@@ -1440,23 +1390,6 @@ mod tests {
|
||||
assert_eq!(owned_slots, expected_slots, "fallback disk slots must match the borrowing variant");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owned_shuffle_preserves_fresh_put_metadata() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir should be created");
|
||||
let fi = FileInfo::new("bucket/object", 2, 1);
|
||||
let parts = vec![fi.clone(); fi.erasure.distribution.len()];
|
||||
let disks = shuffle_test_disks(&tempdir, parts.len()).await;
|
||||
|
||||
let (owned_disks, owned_parts) = SetDisks::shuffle_disks_and_parts_metadata_by_index_owned(disks, parts, &fi);
|
||||
|
||||
assert!(owned_disks.iter().all(Option::is_some), "fresh PUT must retain every online disk");
|
||||
assert_eq!(
|
||||
owned_parts,
|
||||
vec![fi; owned_disks.len()],
|
||||
"fresh PUT metadata with pending shard indexes must survive init fallback"
|
||||
);
|
||||
}
|
||||
|
||||
// backlog#949: corrupt/adversarial distribution values (0 or > N) must not
|
||||
// trigger a `usize` underflow / out-of-bounds panic in the shuffle helpers.
|
||||
#[test]
|
||||
@@ -1486,22 +1419,6 @@ mod tests {
|
||||
assert_eq!(result.len(), disks.len(), "output length must be preserved");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owned_disk_shuffle_matches_borrowing_variant() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir should be created");
|
||||
let mut disks = shuffle_test_disks(&tempdir, 4).await;
|
||||
disks[1] = None;
|
||||
disks[3] = None;
|
||||
let distribution = [3, 1, 4, 2];
|
||||
|
||||
let expected = SetDisks::shuffle_disks(&disks, &distribution);
|
||||
let actual = SetDisks::shuffle_disks_owned(disks, &distribution);
|
||||
|
||||
let expected_slots = expected.iter().map(Option::is_some).collect::<Vec<_>>();
|
||||
let actual_slots = actual.iter().map(Option::is_some).collect::<Vec<_>>();
|
||||
assert_eq!(actual_slots, expected_slots, "owned shuffle must preserve disk placement");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shuffle_disks_and_parts_metadata_survives_corrupt_distribution() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir should be created");
|
||||
|
||||
+156
-985
File diff suppressed because it is too large
Load Diff
@@ -542,8 +542,7 @@ impl SetDisks {
|
||||
|
||||
let filter_by_etag = quorum_etag.is_some();
|
||||
match Self::pick_valid_fileinfo(&parts_metadata, quorum_mod_time, quorum_etag.clone(), read_quorum as usize) {
|
||||
Ok(mut latest_meta) => {
|
||||
Self::hydrate_selected_fileinfo_part_checksums(&mut latest_meta)?;
|
||||
Ok(latest_meta) => {
|
||||
trace!(
|
||||
event = EVENT_SET_DISK_HEAL,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -1425,33 +1424,6 @@ impl SetDisks {
|
||||
/// post-heal tail — reclaim identically. Never fails the heal: delete errors
|
||||
/// are logged and swallowed. Callers must gate this on `!opts.dry_run`.
|
||||
async fn reclaim_orphan_data_dirs_best_effort(&self, bucket: &str, object: &str) {
|
||||
match self.reconcile_old_data_cleanup_receipts(bucket, object).await {
|
||||
Ok(removed) if removed > 0 => {
|
||||
debug!(
|
||||
event = EVENT_SET_DISK_HEAL,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_SET_DISK,
|
||||
bucket,
|
||||
object,
|
||||
removed,
|
||||
state = "old_data_cleanup_receipt_reconciled",
|
||||
"Set disk old-data cleanup receipts reconciled"
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
event = EVENT_SET_DISK_HEAL,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_SET_DISK,
|
||||
bucket,
|
||||
object,
|
||||
error = %e,
|
||||
state = "old_data_cleanup_receipt_reconcile_failed",
|
||||
"Set disk old-data cleanup receipt reconcile failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
match self.reclaim_orphan_data_dirs(bucket, object).await {
|
||||
Ok(removed) if removed > 0 => {
|
||||
debug!(
|
||||
@@ -3198,11 +3170,10 @@ mod heal_result_report_tests {
|
||||
.await
|
||||
.expect("object should be written");
|
||||
|
||||
let snapshot = set
|
||||
let (fi, _, _) = set
|
||||
.get_object_fileinfo(bucket, object, &opts, true, false)
|
||||
.await
|
||||
.expect("object metadata should resolve");
|
||||
let fi = snapshot.fi();
|
||||
assert_eq!(fi.erasure.parity_blocks, 0);
|
||||
let data_dir = fi.data_dir.expect("non-inline object should have a data directory");
|
||||
let part_path = dir.path().join(bucket).join(object).join(data_dir.to_string()).join("part.1");
|
||||
|
||||
@@ -36,21 +36,10 @@ impl crate::storage_api_contracts::namespace::NamespaceLocking for SetDisks {
|
||||
// test's transient DistErasure window) would push this set's namespace
|
||||
// locking onto its own — possibly empty — dist locker list.
|
||||
let set_lock = if self.ctx.is_dist_erasure().await {
|
||||
let lockers = if self.lockers.len() == self.shared_lockers.len()
|
||||
&& self
|
||||
.lockers
|
||||
.iter()
|
||||
.zip(self.shared_lockers.iter())
|
||||
.all(|(current, shared)| Arc::ptr_eq(current, shared))
|
||||
{
|
||||
self.shared_lockers.clone()
|
||||
} else {
|
||||
Arc::from(self.lockers.clone())
|
||||
};
|
||||
// Calculate quorum from the exact client domain used by this lock.
|
||||
let lockers_count = lockers.len();
|
||||
// Calculate quorum based on lockers count (majority)
|
||||
let lockers_count = self.lockers.len();
|
||||
let write_quorum = if lockers_count > 1 { (lockers_count / 2) + 1 } else { 1 };
|
||||
NamespaceLock::with_clients_and_quorum_shared(self.set_lock_namespace.clone(), lockers, write_quorum)
|
||||
NamespaceLock::with_clients_and_quorum_shared(self.set_lock_namespace.clone(), self.lockers.clone(), write_quorum)
|
||||
} else {
|
||||
NamespaceLock::with_local_manager_shared(self.set_lock_namespace.clone(), self.local_lock_manager.clone())
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@ use crate::diagnostics::get::{
|
||||
GET_METADATA_CACHE_REASON_DATA_MOVEMENT, GET_METADATA_CACHE_REASON_DELETE_MARKER, GET_METADATA_CACHE_REASON_DIST_ERASURE,
|
||||
GET_METADATA_CACHE_REASON_INCL_FREE_VERSIONS, GET_METADATA_CACHE_REASON_INSUFFICIENT_CACHED_QUORUM,
|
||||
GET_METADATA_CACHE_REASON_META_BUCKET, GET_METADATA_CACHE_REASON_NO_LOCK, GET_METADATA_CACHE_REASON_NOT_FOUND_OR_EXPIRED,
|
||||
GET_METADATA_CACHE_REASON_NOT_READ_DATA, GET_METADATA_CACHE_REASON_PART_CHECKSUMS, GET_METADATA_CACHE_REASON_PART_NUMBER,
|
||||
GET_METADATA_CACHE_REASON_NOT_READ_DATA, GET_METADATA_CACHE_REASON_PART_NUMBER,
|
||||
GET_METADATA_CACHE_REASON_RAW_DATA_MOVEMENT_READ, GET_METADATA_CACHE_REASON_STALE_PUBLICATION,
|
||||
GET_METADATA_CACHE_REASON_USABLE, GET_METADATA_CACHE_REASON_VERSION_ID, GET_METADATA_CACHE_REASON_VERSION_SUSPENDED,
|
||||
GET_METADATA_CACHE_REASON_VERSIONED, GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA,
|
||||
@@ -180,9 +180,9 @@ impl SetDisks {
|
||||
let key = GetObjectMetadataCacheKey::new(bucket, object, generation);
|
||||
let entry = Arc::new(GetObjectMetadataCacheEntry {
|
||||
created_at: Instant::now(),
|
||||
fi: fi.clone(),
|
||||
parts_metadata: parts_metadata.to_vec(),
|
||||
online_disks: online_disks.to_vec(),
|
||||
fi: Arc::new(fi.clone()),
|
||||
parts_metadata: Arc::new(parts_metadata.to_vec()),
|
||||
online_disks: Arc::new(online_disks.to_vec()),
|
||||
read_quorum,
|
||||
});
|
||||
self.insert_get_object_metadata_cache_entry_after_insert(key, generation, entry, || {})
|
||||
@@ -300,7 +300,11 @@ impl SetDisks {
|
||||
GET_STAGE_METADATA_CACHE_LOOKUP,
|
||||
metadata_cache_lookup_start,
|
||||
);
|
||||
return Ok(GetObjectFileInfo::shared(cached));
|
||||
return Ok((
|
||||
GetObjectMetadata::Shared(Arc::clone(&cached.fi)),
|
||||
GetObjectMetadata::Shared(Arc::clone(&cached.parts_metadata)),
|
||||
GetObjectMetadata::Shared(Arc::clone(&cached.online_disks)),
|
||||
));
|
||||
}
|
||||
MetadataCacheLookup::Miss => {
|
||||
rustfs_io_metrics::record_get_object_metadata_cache_decision(
|
||||
@@ -336,7 +340,7 @@ impl SetDisks {
|
||||
// read_all_fileinfo_observed (see read_all_fileinfo_early_stop in
|
||||
// core/io_primitives.rs); unsafe requests and callers that opt out
|
||||
// (allow_early_stop=false) fall back to full-wait.
|
||||
let (mut parts_metadata, errs, metadata_fanout_diagnostics) = Self::read_all_fileinfo_observed(
|
||||
let (parts_metadata, errs, metadata_fanout_diagnostics) = Self::read_all_fileinfo_observed(
|
||||
&disks,
|
||||
"",
|
||||
bucket,
|
||||
@@ -390,17 +394,8 @@ impl SetDisks {
|
||||
return Err(to_object_err(err.into(), vec![bucket, object]));
|
||||
}
|
||||
|
||||
let (op_online_disks, mut fi, fileinfo_selection_quorum) =
|
||||
let (op_online_disks, fi, fileinfo_selection_quorum) =
|
||||
Self::select_valid_fileinfo(&disks, &parts_metadata, &errs, vid.as_str(), read_quorum, write_quorum)?;
|
||||
let include_part_checksums =
|
||||
opts.include_part_checksums || opts.part_number.is_some() || opts.data_movement || opts.raw_data_movement_read;
|
||||
if include_part_checksums {
|
||||
Self::hydrate_selected_fileinfo_part_checksums(&mut fi)?;
|
||||
} else {
|
||||
for metadata in std::iter::once(&mut fi).chain(parts_metadata.iter_mut()) {
|
||||
rustfs_utils::http::remove_str(&mut metadata.metadata, rustfs_utils::http::SUFFIX_PART_CHECKSUMS);
|
||||
}
|
||||
}
|
||||
metadata_fanout_diagnostics.record_quorum_candidate_latency(metadata_metrics_path, fileinfo_selection_quorum);
|
||||
if errs.iter().any(|err| err.is_some()) {
|
||||
let version_id = resolved_read_repair_version_id(&fi, opts.version_id.as_deref());
|
||||
@@ -432,7 +427,11 @@ impl SetDisks {
|
||||
|
||||
// let online_disks: Vec<Option<DiskStore>> = op_online_disks.iter().filter(|v| v.is_some()).cloned().collect();
|
||||
|
||||
Ok(GetObjectFileInfo::owned(fi, parts_metadata, op_online_disks))
|
||||
Ok((
|
||||
GetObjectMetadata::Owned(fi),
|
||||
GetObjectMetadata::Owned(parts_metadata),
|
||||
GetObjectMetadata::Owned(op_online_disks),
|
||||
))
|
||||
}
|
||||
|
||||
#[hotpath::measure(impl_type = "SetDisks")]
|
||||
@@ -442,15 +441,14 @@ impl SetDisks {
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> (ObjectInfo, usize, Option<StorageError>) {
|
||||
let snapshot = match self.get_object_fileinfo(bucket, object, opts, false, false).await {
|
||||
Ok(snapshot) => snapshot,
|
||||
let fi = match self.get_object_fileinfo(bucket, object, opts, false, false).await {
|
||||
Ok((fi, _, _)) => fi,
|
||||
Err(e) => return (ObjectInfo::default(), 0, Some(e)),
|
||||
};
|
||||
let fi = snapshot.fi();
|
||||
|
||||
let write_quorum = fi.write_quorum(self.default_write_quorum());
|
||||
|
||||
let oi = ObjectInfo::from_file_info(fi, bucket, object, opts.versioned || opts.version_suspended);
|
||||
let oi = ObjectInfo::from_file_info(&fi, bucket, object, opts.versioned || opts.version_suspended);
|
||||
|
||||
if !fi.version_purge_status().is_empty() && opts.version_id.is_some() {
|
||||
return (
|
||||
@@ -482,7 +480,6 @@ impl SetDisks {
|
||||
pub(super) async fn try_get_object_direct_data_shards_with_fileinfo(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
erasure_cache: Arc<ErasureCache>,
|
||||
fi: &FileInfo,
|
||||
files: &[FileInfo],
|
||||
disks: &[Option<DiskStore>],
|
||||
@@ -503,7 +500,13 @@ impl SetDisks {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let erasure = erasure_cache.get_for_file_info(fi)?;
|
||||
let erasure = coding::Erasure::try_new_with_options(
|
||||
fi.erasure.data_blocks,
|
||||
fi.erasure.parity_blocks,
|
||||
fi.erasure.block_size,
|
||||
fi.uses_legacy_checksum,
|
||||
)
|
||||
.map_err(Error::from)?;
|
||||
|
||||
let checksum_info = fi.erasure.get_checksum_info(part.number);
|
||||
let checksum_algo = if fi.uses_legacy_checksum && checksum_info.algorithm == HashAlgorithm::HighwayHash256S {
|
||||
@@ -631,7 +634,6 @@ impl SetDisks {
|
||||
// &self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
erasure_cache: Arc<ErasureCache>,
|
||||
offset: usize,
|
||||
length: i64,
|
||||
writer: &mut W,
|
||||
@@ -726,7 +728,13 @@ impl SetDisks {
|
||||
object, offset, length, end_offset, part_index, last_part_index, last_part_relative_offset, "Multipart read bounds"
|
||||
);
|
||||
|
||||
let erasure = erasure_cache.get_for_file_info(&fi)?;
|
||||
let erasure = coding::Erasure::try_new_with_options(
|
||||
fi.erasure.data_blocks,
|
||||
fi.erasure.parity_blocks,
|
||||
fi.erasure.block_size,
|
||||
fi.uses_legacy_checksum,
|
||||
)
|
||||
.map_err(Error::from)?;
|
||||
|
||||
let part_indices: Vec<usize> = (part_index..=last_part_index).collect();
|
||||
debug!(bucket, object, ?part_indices, "Multipart part indices to stream");
|
||||
@@ -1160,7 +1168,6 @@ impl SetDisks {
|
||||
pub(super) async fn get_object_decode_reader_with_fileinfo(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
erasure_cache: Arc<ErasureCache>,
|
||||
fi: &FileInfo,
|
||||
files: &[FileInfo],
|
||||
disks: &[Option<DiskStore>],
|
||||
@@ -1171,7 +1178,14 @@ impl SetDisks {
|
||||
metrics_size_bucket: &'static str,
|
||||
prefer_data_blocks_first_reader_setup: bool,
|
||||
) -> Result<GetCodecStreamingReaderBuildOutcome> {
|
||||
let erasure = erasure_cache.get_for_file_info(fi)?;
|
||||
let erasure = coding::Erasure::try_new_with_options(
|
||||
fi.erasure.data_blocks,
|
||||
fi.erasure.parity_blocks,
|
||||
fi.erasure.block_size,
|
||||
fi.uses_legacy_checksum,
|
||||
)
|
||||
.map_err(Error::from)?;
|
||||
|
||||
let (disks, files) = Self::shuffle_disks_and_parts_metadata_by_index(disks, files, fi);
|
||||
|
||||
if fi.parts.len() == 1 {
|
||||
@@ -1558,7 +1572,7 @@ struct LazyCodecPartContext {
|
||||
fi: FileInfo,
|
||||
files: Vec<FileInfo>,
|
||||
disks: Vec<Option<DiskStore>>,
|
||||
erasure: Arc<coding::Erasure>,
|
||||
erasure: coding::Erasure,
|
||||
skip_verify_bitrot: bool,
|
||||
metrics_object_class: &'static str,
|
||||
metrics_size_bucket: &'static str,
|
||||
@@ -1812,9 +1826,6 @@ fn get_object_metadata_cache_request_bypass_reason(bucket: &str, opts: &ObjectOp
|
||||
if opts.part_number.is_some() {
|
||||
return Some(GET_METADATA_CACHE_REASON_PART_NUMBER);
|
||||
}
|
||||
if opts.include_part_checksums {
|
||||
return Some(GET_METADATA_CACHE_REASON_PART_CHECKSUMS);
|
||||
}
|
||||
if opts.data_movement {
|
||||
return Some(GET_METADATA_CACHE_REASON_DATA_MOVEMENT);
|
||||
}
|
||||
@@ -2042,7 +2053,6 @@ mod metadata_cache_tests {
|
||||
let err = SetDisks::get_object_with_fileinfo(
|
||||
"bucket",
|
||||
"object",
|
||||
Arc::new(ErasureCache::new()),
|
||||
0,
|
||||
1,
|
||||
&mut output,
|
||||
@@ -2073,7 +2083,6 @@ mod metadata_cache_tests {
|
||||
let err = SetDisks::get_object_with_fileinfo(
|
||||
bucket,
|
||||
object,
|
||||
Arc::new(ErasureCache::new()),
|
||||
2,
|
||||
1,
|
||||
&mut output,
|
||||
@@ -2097,7 +2106,6 @@ mod metadata_cache_tests {
|
||||
let err = SetDisks::get_object_with_fileinfo(
|
||||
bucket,
|
||||
object,
|
||||
Arc::new(ErasureCache::new()),
|
||||
usize::MAX,
|
||||
1,
|
||||
&mut output,
|
||||
@@ -2119,7 +2127,6 @@ mod metadata_cache_tests {
|
||||
let err = SetDisks::get_object_with_fileinfo(
|
||||
bucket,
|
||||
object,
|
||||
Arc::new(ErasureCache::new()),
|
||||
1,
|
||||
1,
|
||||
&mut output,
|
||||
@@ -2143,7 +2150,6 @@ mod metadata_cache_tests {
|
||||
let err = SetDisks::get_object_with_fileinfo(
|
||||
bucket,
|
||||
object,
|
||||
Arc::new(ErasureCache::new()),
|
||||
0,
|
||||
1,
|
||||
&mut output,
|
||||
@@ -2181,7 +2187,6 @@ mod metadata_cache_tests {
|
||||
SetDisks::get_object_with_fileinfo(
|
||||
bucket,
|
||||
object,
|
||||
Arc::new(ErasureCache::new()),
|
||||
0,
|
||||
0,
|
||||
&mut output,
|
||||
@@ -2214,7 +2219,6 @@ mod metadata_cache_tests {
|
||||
let err = SetDisks::get_object_with_fileinfo(
|
||||
bucket,
|
||||
object,
|
||||
Arc::new(ErasureCache::new()),
|
||||
0,
|
||||
1,
|
||||
&mut output,
|
||||
@@ -2493,16 +2497,6 @@ mod metadata_cache_tests {
|
||||
Some(GET_METADATA_CACHE_REASON_PART_NUMBER)
|
||||
);
|
||||
|
||||
opts = ObjectOptions {
|
||||
include_part_checksums: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!is_get_object_metadata_cache_request_eligible("bucket", &opts, true));
|
||||
assert_eq!(
|
||||
get_object_metadata_cache_request_bypass_reason("bucket", &opts, true),
|
||||
Some(GET_METADATA_CACHE_REASON_PART_CHECKSUMS)
|
||||
);
|
||||
|
||||
opts = ObjectOptions {
|
||||
data_movement: true,
|
||||
..Default::default()
|
||||
@@ -2707,14 +2701,22 @@ mod metadata_cache_tests {
|
||||
.await
|
||||
.expect("fresh cache entry should be returned");
|
||||
|
||||
let returned = set
|
||||
let (returned_fi, returned_parts_metadata, returned_online_disks) = set
|
||||
.get_object_fileinfo("bucket", "object", &ObjectOptions::default(), true, false)
|
||||
.await
|
||||
.expect("cache-backed metadata lookup should succeed");
|
||||
|
||||
assert!(
|
||||
returned.shared_entry().is_some_and(|value| Arc::ptr_eq(value, &cached)),
|
||||
"cache hits must share the complete metadata snapshot"
|
||||
matches!(returned_fi, GetObjectMetadata::Shared(ref value) if Arc::ptr_eq(value, &cached.fi)),
|
||||
"cache hits must share FileInfo ownership"
|
||||
);
|
||||
assert!(
|
||||
matches!(returned_parts_metadata, GetObjectMetadata::Shared(ref value) if Arc::ptr_eq(value, &cached.parts_metadata)),
|
||||
"cache hits must share the metadata vector"
|
||||
);
|
||||
assert!(
|
||||
matches!(returned_online_disks, GetObjectMetadata::Shared(ref value) if Arc::ptr_eq(value, &cached.online_disks)),
|
||||
"cache hits must share the online-disk vector"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2757,9 +2759,9 @@ mod metadata_cache_tests {
|
||||
),
|
||||
Arc::new(GetObjectMetadataCacheEntry {
|
||||
created_at: Instant::now(),
|
||||
fi: fi.clone(),
|
||||
parts_metadata: vec![fi],
|
||||
online_disks: vec![None],
|
||||
fi: Arc::new(fi.clone()),
|
||||
parts_metadata: Arc::new(vec![fi]),
|
||||
online_disks: Arc::new(vec![None]),
|
||||
read_quorum: 1,
|
||||
}),
|
||||
)
|
||||
@@ -2853,12 +2855,13 @@ mod metadata_cache_tests {
|
||||
barrier.wait_until_paused().await;
|
||||
set.invalidate_get_object_metadata_cache(bucket, object).await;
|
||||
barrier.release();
|
||||
let snapshot = read
|
||||
let (fi, parts_metadata, online_disks) = read
|
||||
.await
|
||||
.expect("metadata read task should not panic")
|
||||
.expect("metadata fanout should still return its selected FileInfo");
|
||||
assert!(snapshot.owned.is_some());
|
||||
assert!(snapshot.has_valid_representation());
|
||||
assert!(matches!(fi, GetObjectMetadata::Owned(_)));
|
||||
assert!(matches!(parts_metadata, GetObjectMetadata::Owned(_)));
|
||||
assert!(matches!(online_disks, GetObjectMetadata::Owned(_)));
|
||||
|
||||
assert!(
|
||||
set.get_object_metadata_cache
|
||||
@@ -2905,9 +2908,9 @@ mod metadata_cache_tests {
|
||||
let key = GetObjectMetadataCacheKey::new("bucket", "object", generation);
|
||||
let entry = Arc::new(GetObjectMetadataCacheEntry {
|
||||
created_at: Instant::now(),
|
||||
fi: fi.clone(),
|
||||
parts_metadata: vec![fi],
|
||||
online_disks: Vec::new(),
|
||||
fi: Arc::new(fi.clone()),
|
||||
parts_metadata: Arc::new(vec![fi]),
|
||||
online_disks: Arc::new(Vec::new()),
|
||||
read_quorum: 0,
|
||||
});
|
||||
|
||||
@@ -3015,9 +3018,9 @@ mod metadata_cache_tests {
|
||||
let entry = |fi: FileInfo| {
|
||||
Arc::new(GetObjectMetadataCacheEntry {
|
||||
created_at: Instant::now(),
|
||||
parts_metadata: vec![fi.clone()],
|
||||
fi,
|
||||
online_disks: Vec::new(),
|
||||
parts_metadata: Arc::new(vec![fi.clone()]),
|
||||
fi: Arc::new(fi),
|
||||
online_disks: Arc::new(Vec::new()),
|
||||
read_quorum: 0,
|
||||
})
|
||||
};
|
||||
@@ -4080,8 +4083,8 @@ mod tests {
|
||||
get_codec_streaming_reader_gate(
|
||||
CODEC_STREAMING_TEST_BUCKET,
|
||||
CODEC_STREAMING_TEST_OBJECT,
|
||||
range,
|
||||
None,
|
||||
classify_get_codec_streaming_object_class(range, object_info, fi),
|
||||
object_info,
|
||||
fi,
|
||||
lock_optimization_enabled,
|
||||
@@ -4098,8 +4101,8 @@ mod tests {
|
||||
get_codec_streaming_reader_gate(
|
||||
CODEC_STREAMING_TEST_BUCKET,
|
||||
CODEC_STREAMING_TEST_OBJECT,
|
||||
range,
|
||||
part_number,
|
||||
classify_get_codec_streaming_object_class(range, object_info, fi),
|
||||
object_info,
|
||||
fi,
|
||||
lock_optimization_enabled,
|
||||
@@ -4119,7 +4122,6 @@ mod tests {
|
||||
let result = SetDisks::get_object_decode_reader_with_fileinfo(
|
||||
CODEC_STREAMING_TEST_BUCKET,
|
||||
CODEC_STREAMING_TEST_OBJECT,
|
||||
Arc::new(ErasureCache::new()),
|
||||
&fi,
|
||||
&[],
|
||||
&[],
|
||||
@@ -4142,7 +4144,6 @@ mod tests {
|
||||
let invalid_size = SetDisks::get_object_decode_reader_with_fileinfo(
|
||||
CODEC_STREAMING_TEST_BUCKET,
|
||||
CODEC_STREAMING_TEST_OBJECT,
|
||||
Arc::new(ErasureCache::new()),
|
||||
&single_part,
|
||||
&[],
|
||||
&[],
|
||||
@@ -4163,7 +4164,6 @@ mod tests {
|
||||
SetDisks::get_object_decode_reader_with_fileinfo(
|
||||
CODEC_STREAMING_TEST_BUCKET,
|
||||
CODEC_STREAMING_TEST_OBJECT,
|
||||
Arc::new(ErasureCache::new()),
|
||||
&multipart,
|
||||
&[],
|
||||
&[],
|
||||
@@ -4188,7 +4188,6 @@ mod tests {
|
||||
SetDisks::get_object_decode_reader_with_fileinfo(
|
||||
CODEC_STREAMING_TEST_BUCKET,
|
||||
CODEC_STREAMING_TEST_OBJECT,
|
||||
Arc::new(ErasureCache::new()),
|
||||
&multipart,
|
||||
&[],
|
||||
&[],
|
||||
@@ -4217,7 +4216,6 @@ mod tests {
|
||||
SetDisks::get_object_decode_reader_with_fileinfo(
|
||||
CODEC_STREAMING_TEST_BUCKET,
|
||||
CODEC_STREAMING_TEST_OBJECT,
|
||||
Arc::new(ErasureCache::new()),
|
||||
&multipart,
|
||||
&[],
|
||||
&[],
|
||||
@@ -4271,7 +4269,6 @@ mod tests {
|
||||
SetDisks::get_object_decode_reader_with_fileinfo(
|
||||
CODEC_STREAMING_TEST_BUCKET,
|
||||
CODEC_STREAMING_TEST_OBJECT,
|
||||
Arc::new(ErasureCache::new()),
|
||||
&fi,
|
||||
&files,
|
||||
&disks,
|
||||
@@ -4325,7 +4322,6 @@ mod tests {
|
||||
SetDisks::get_object_decode_reader_with_fileinfo(
|
||||
CODEC_STREAMING_TEST_BUCKET,
|
||||
CODEC_STREAMING_TEST_OBJECT,
|
||||
Arc::new(ErasureCache::new()),
|
||||
&fi,
|
||||
&files,
|
||||
&disks,
|
||||
@@ -4370,7 +4366,6 @@ mod tests {
|
||||
SetDisks::get_object_with_fileinfo(
|
||||
CODEC_STREAMING_TEST_BUCKET,
|
||||
CODEC_STREAMING_TEST_OBJECT,
|
||||
Arc::new(ErasureCache::new()),
|
||||
0,
|
||||
part_data.len() as i64,
|
||||
&mut output,
|
||||
@@ -4833,114 +4828,6 @@ mod tests {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn encoded_inline_blocks(blocks: &[&[u8]], shard_size: usize, hash_algo: HashAlgorithm) -> Bytes {
|
||||
let mut writer = BitrotWriter::new(Cursor::new(Vec::new()), shard_size, hash_algo);
|
||||
for block in blocks {
|
||||
writer.write(block).await.expect("test block should be encoded");
|
||||
}
|
||||
Bytes::from(writer.into_inner().into_inner())
|
||||
}
|
||||
|
||||
fn assert_reader_shares_inline_allocation(reader: &ObjectBitrotReader, source: &Bytes) {
|
||||
let reader_bytes = reader
|
||||
.inner_ref()
|
||||
.inline_bytes()
|
||||
.expect("inline scheduler should retain an in-memory Bytes source");
|
||||
assert_eq!(
|
||||
reader_bytes.as_ptr(),
|
||||
source.as_ptr(),
|
||||
"the scheduler must clone Bytes ownership instead of copying the inline shard payload"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inline_range_scheduler_shares_bytes_and_rejects_bitrot_mismatch() {
|
||||
const SHARD_SIZE: usize = 16;
|
||||
let hash_algo = HashAlgorithm::HighwayHash256S;
|
||||
let first = [b'a'; SHARD_SIZE];
|
||||
let second = [b'b'; SHARD_SIZE];
|
||||
let mut source = encoded_inline_blocks(&[&first, &second], SHARD_SIZE, hash_algo.clone()).await;
|
||||
let second_payload = hash_algo.size() * 2 + SHARD_SIZE;
|
||||
source = {
|
||||
let mut corrupt = source.to_vec();
|
||||
corrupt[second_payload] ^= 0xff;
|
||||
Bytes::from(corrupt)
|
||||
};
|
||||
let files = vec![encoded_reader_setup_fileinfo(Some(source.to_vec()))];
|
||||
let source = files[0].data.clone().expect("inline shard should exist");
|
||||
let disks = vec![None];
|
||||
|
||||
let mut setup = create_bitrot_readers_until_quorum_with_preference(
|
||||
&files,
|
||||
&disks,
|
||||
"bucket",
|
||||
"object",
|
||||
1,
|
||||
SHARD_SIZE,
|
||||
SHARD_SIZE,
|
||||
SHARD_SIZE,
|
||||
hash_algo,
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
0,
|
||||
BitrotReaderSetupMode::ReadQuorum,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let mut reader = setup.readers[0].take().expect("range reader should be ready");
|
||||
assert_reader_shares_inline_allocation(&reader, &source);
|
||||
|
||||
let err = reader
|
||||
.read(&mut [0; SHARD_SIZE])
|
||||
.await
|
||||
.expect_err("corrupt ranged inline block must fail bitrot verification");
|
||||
assert_eq!(err.kind(), ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inline_part_scheduler_shares_bytes_and_rejects_bitrot_mismatch() {
|
||||
const SHARD_SIZE: usize = 16;
|
||||
let hash_algo = HashAlgorithm::HighwayHash256S;
|
||||
let block = [b'p'; SHARD_SIZE];
|
||||
let encoded = encoded_inline_blocks(&[&block], SHARD_SIZE, hash_algo.clone()).await;
|
||||
let mut corrupt = encoded.to_vec();
|
||||
corrupt[hash_algo.size()] ^= 0xff;
|
||||
let files = vec![encoded_reader_setup_fileinfo(Some(corrupt))];
|
||||
let source = files[0].data.clone().expect("inline shard should exist");
|
||||
let disks = vec![None];
|
||||
|
||||
let mut setup = create_bitrot_readers_until_quorum_all_shards(
|
||||
&files,
|
||||
&disks,
|
||||
"bucket",
|
||||
"object",
|
||||
7,
|
||||
0,
|
||||
SHARD_SIZE,
|
||||
SHARD_SIZE,
|
||||
hash_algo,
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
0,
|
||||
BitrotReaderSetupMode::VerifyReconstruction,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let mut reader = setup.readers[0].take().expect("part reader should be ready");
|
||||
assert_reader_shares_inline_allocation(&reader, &source);
|
||||
|
||||
let err = reader
|
||||
.read(&mut [0; SHARD_SIZE])
|
||||
.await
|
||||
.expect_err("corrupt inline part must fail bitrot verification");
|
||||
assert_eq!(err.kind(), ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
async fn decode_codec_data_blocks_first_setup(
|
||||
erasure: coding::Erasure,
|
||||
data: &[u8],
|
||||
@@ -5641,63 +5528,6 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_streaming_config_cache_loads_once() {
|
||||
use std::cell::Cell;
|
||||
|
||||
let loads = Cell::new(0);
|
||||
let expected = GetCodecStreamingConfig {
|
||||
enabled: true,
|
||||
rollout: GetCodecStreamingRollout::Off,
|
||||
rollout_pct: 100,
|
||||
body_compat_confirmed: true,
|
||||
header_compat_confirmed: true,
|
||||
engine: GetCodecStreamingEngine::Legacy,
|
||||
min_size: DEFAULT_RUSTFS_GET_CODEC_STREAMING_MIN_SIZE,
|
||||
};
|
||||
|
||||
for _ in 0..3 {
|
||||
assert_eq!(
|
||||
get_codec_streaming_config_cached_core(|| {
|
||||
loads.set(loads.get() + 1);
|
||||
expected
|
||||
}),
|
||||
expected
|
||||
);
|
||||
}
|
||||
assert_eq!(loads.get(), 1, "production config cache must not reload env per GET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_streaming_config_loader_preserves_all_gate_env_overrides() {
|
||||
temp_env::with_vars(
|
||||
[
|
||||
(ENV_RUSTFS_GET_CODEC_STREAMING_ENABLE, Some("false")),
|
||||
(ENV_RUSTFS_GET_CODEC_STREAMING_ENGINE, Some(GET_CODEC_STREAMING_ENGINE_RUSTFS)),
|
||||
(ENV_RUSTFS_GET_CODEC_STREAMING_ROLLOUT, Some("production")),
|
||||
(ENV_RUSTFS_GET_CODEC_STREAMING_ROLLOUT_PCT, Some("37")),
|
||||
(ENV_RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED, Some("false")),
|
||||
(ENV_RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED, Some("false")),
|
||||
(ENV_RUSTFS_GET_CODEC_STREAMING_MIN_SIZE, None::<&str>),
|
||||
(ENV_RUSTFS_GET_CODEC_STREAMING_RUSTFS_MIN_SIZE, Some("262144")),
|
||||
],
|
||||
|| {
|
||||
assert_eq!(
|
||||
load_get_codec_streaming_config(),
|
||||
GetCodecStreamingConfig {
|
||||
enabled: false,
|
||||
rollout: GetCodecStreamingRollout::On,
|
||||
rollout_pct: 37,
|
||||
body_compat_confirmed: false,
|
||||
header_compat_confirmed: false,
|
||||
engine: GetCodecStreamingEngine::Rustfs,
|
||||
min_size: 262144,
|
||||
}
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_streaming_default_min_size_meets_direct_memory_ceiling() {
|
||||
for engine in [None, Some(GET_CODEC_STREAMING_ENGINE_RUSTFS)] {
|
||||
|
||||
@@ -48,23 +48,6 @@ impl RestoreCleanupIdentity {
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_restore_metadata_lock_held(bucket: &str, object: &str, opts: &ObjectOptions, mode: &'static str) -> Result<()> {
|
||||
if opts
|
||||
.namespace_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
{
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode,
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl SetDisks {
|
||||
pub(super) async fn finalize_restore_metadata(
|
||||
&self,
|
||||
@@ -92,20 +75,18 @@ impl SetDisks {
|
||||
version_id,
|
||||
versioned: opts.versioned,
|
||||
version_suspended: opts.version_suspended,
|
||||
include_part_checksums: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (mut fi, _, disks) = self
|
||||
let (fi, _, disks) = self
|
||||
.get_object_fileinfo_gated(bucket, object, &read_opts, false, false)
|
||||
.await?
|
||||
.into_owned();
|
||||
.await?;
|
||||
let mut fi = fi.into_owned();
|
||||
if let Some(expected_operation_id) = expected_operation_id {
|
||||
require_restore_operation_id(&fi.metadata, expected_operation_id)?;
|
||||
}
|
||||
if !expected.matches_file_info(&fi, &expected_etag) {
|
||||
return Err(Error::other("restored object changed before restore metadata finalization"));
|
||||
}
|
||||
ensure_restore_metadata_lock_held(bucket, object, opts, "restore_finalize_metadata")?;
|
||||
let restore_expiry =
|
||||
lifecycle::expected_expiry_time(OffsetDateTime::now_utc(), opts.transition.restore_request.days.unwrap_or(1));
|
||||
fi.metadata.insert(
|
||||
@@ -161,13 +142,12 @@ impl SetDisks {
|
||||
version_id,
|
||||
versioned: opts.versioned,
|
||||
version_suspended: opts.version_suspended,
|
||||
include_part_checksums: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (mut fi, _, disks) = self
|
||||
let (fi, _, disks) = self
|
||||
.get_object_fileinfo_gated(bucket, object, &read_opts, false, false)
|
||||
.await?
|
||||
.into_owned();
|
||||
.await?;
|
||||
let mut fi = fi.into_owned();
|
||||
if let Some(expected_operation_id) = expected_operation_id {
|
||||
match restore_operation_id_from_metadata(&fi.metadata)? {
|
||||
Some(actual_operation_id) if actual_operation_id == expected_operation_id => {}
|
||||
@@ -177,7 +157,6 @@ impl SetDisks {
|
||||
if !expected.matches_file_info(&fi, &expected_etag) {
|
||||
return Ok(());
|
||||
}
|
||||
ensure_restore_metadata_lock_held(bucket, object, opts, "restore_cleanup_metadata")?;
|
||||
fi.metadata.remove(X_AMZ_RESTORE.as_str());
|
||||
fi.metadata.remove(AMZ_RESTORE_EXPIRY_DAYS);
|
||||
fi.metadata.remove(AMZ_RESTORE_REQUEST_DATE);
|
||||
|
||||
@@ -16,14 +16,6 @@ use crate::diagnostics::get::{
|
||||
GET_SHARD_READ_COST_LOCAL, GET_SHARD_READ_COST_REMOTE, GET_SHARD_READ_COST_SAME_NODE, GET_SHARD_READ_COST_UNKNOWN,
|
||||
};
|
||||
use crate::disk::error::Error;
|
||||
use crate::layout::disks_layout::MAX_ERASURE_SET_DRIVE_COUNT;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
/// Generic codec callers may exceed the production set limit; `SmallVec` then
|
||||
/// spills without changing slot semantics.
|
||||
pub(crate) const INLINE_SHARD_SLOTS: usize = MAX_ERASURE_SET_DRIVE_COUNT;
|
||||
pub(crate) type ShardBuffers = SmallVec<[Option<Vec<u8>>; INLINE_SHARD_SLOTS]>;
|
||||
pub(crate) type ShardErrors = SmallVec<[Option<Error>; INLINE_SHARD_SLOTS]>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ShardReadCost {
|
||||
@@ -51,137 +43,202 @@ impl ShardReadCost {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ShardSlot {
|
||||
index: usize,
|
||||
read_cost: ShardReadCost,
|
||||
data: Option<Vec<u8>>,
|
||||
error: Option<Error>,
|
||||
}
|
||||
|
||||
impl ShardSlot {
|
||||
pub(crate) fn new(index: usize, data: Option<Vec<u8>>, error: Option<Error>) -> Self {
|
||||
Self::with_read_cost(index, ShardReadCost::Unknown, data, error)
|
||||
}
|
||||
|
||||
pub(crate) fn with_read_cost(index: usize, read_cost: ShardReadCost, data: Option<Vec<u8>>, error: Option<Error>) -> Self {
|
||||
Self {
|
||||
index,
|
||||
read_cost,
|
||||
data,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn data(index: usize, data: Vec<u8>) -> Self {
|
||||
Self::new(index, Some(data), None)
|
||||
}
|
||||
|
||||
pub(crate) fn data_with_read_cost(index: usize, read_cost: ShardReadCost, data: Vec<u8>) -> Self {
|
||||
Self::with_read_cost(index, read_cost, Some(data), None)
|
||||
}
|
||||
|
||||
pub(crate) fn missing(index: usize, error: Error) -> Self {
|
||||
Self::new(index, None, Some(error))
|
||||
}
|
||||
|
||||
pub(crate) fn missing_with_read_cost(index: usize, read_cost: ShardReadCost, error: Error) -> Self {
|
||||
Self::with_read_cost(index, read_cost, None, Some(error))
|
||||
}
|
||||
|
||||
pub(crate) fn index(&self) -> usize {
|
||||
self.index
|
||||
}
|
||||
|
||||
pub(crate) fn read_cost(&self) -> ShardReadCost {
|
||||
self.read_cost
|
||||
}
|
||||
|
||||
pub(crate) fn has_data(&self) -> bool {
|
||||
self.data.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn data_bytes(&self) -> Option<&[u8]> {
|
||||
self.data.as_deref()
|
||||
}
|
||||
|
||||
pub(crate) fn error(&self) -> Option<&Error> {
|
||||
self.error.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct StripeReadState {
|
||||
shards: ShardBuffers,
|
||||
errors: ShardErrors,
|
||||
slots: Vec<ShardSlot>,
|
||||
read_quorum: usize,
|
||||
}
|
||||
|
||||
impl StripeReadState {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new(slots: Vec<ShardSlot>, read_quorum: usize) -> Self {
|
||||
Self { slots, read_quorum }
|
||||
}
|
||||
|
||||
pub(crate) fn from_parts(shards: Vec<Option<Vec<u8>>>, errors: Vec<Option<Error>>, read_quorum: usize) -> Self {
|
||||
let mut shards = SmallVec::from_vec(shards);
|
||||
let mut errors = SmallVec::from_vec(errors);
|
||||
Self::from_parts_with_read_costs(shards, errors, &[], read_quorum)
|
||||
}
|
||||
|
||||
pub(crate) fn from_parts_with_read_costs<S, E>(shards: S, errors: E, read_costs: &[ShardReadCost], read_quorum: usize) -> Self
|
||||
where
|
||||
S: IntoIterator<Item = Option<Vec<u8>>>,
|
||||
S::IntoIter: ExactSizeIterator,
|
||||
E: IntoIterator<Item = Option<Error>>,
|
||||
E::IntoIter: ExactSizeIterator,
|
||||
{
|
||||
let mut shards = shards.into_iter();
|
||||
let mut errors = errors.into_iter();
|
||||
let slot_count = shards.len().max(errors.len());
|
||||
shards.resize_with(slot_count, || None);
|
||||
errors.resize_with(slot_count, || None);
|
||||
Self {
|
||||
shards,
|
||||
errors,
|
||||
read_quorum,
|
||||
let mut slots = Vec::with_capacity(slot_count);
|
||||
for index in 0..slot_count {
|
||||
let read_cost = read_costs.get(index).copied().unwrap_or(ShardReadCost::Unknown);
|
||||
slots.push(ShardSlot::with_read_cost(
|
||||
index,
|
||||
read_cost,
|
||||
shards.next().flatten(),
|
||||
errors.next().flatten(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_slot_count(slot_count: usize, read_quorum: usize) -> Self {
|
||||
let mut state = Self {
|
||||
shards: SmallVec::new(),
|
||||
errors: SmallVec::new(),
|
||||
read_quorum,
|
||||
};
|
||||
state.reset(slot_count, read_quorum);
|
||||
state
|
||||
}
|
||||
|
||||
pub(crate) fn reset(&mut self, slot_count: usize, read_quorum: usize) {
|
||||
self.shards.clear();
|
||||
self.shards.resize_with(slot_count, || None);
|
||||
self.errors.clear();
|
||||
self.errors.resize_with(slot_count, || None);
|
||||
self.read_quorum = read_quorum;
|
||||
Self::new(slots, read_quorum)
|
||||
}
|
||||
|
||||
pub(crate) fn available_shards(&self) -> usize {
|
||||
self.shards.iter().filter(|shard| shard.is_some()).count()
|
||||
self.slots.iter().filter(|slot| slot.has_data()).count()
|
||||
}
|
||||
|
||||
pub(crate) fn can_decode(&self) -> bool {
|
||||
self.available_shards() >= self.read_quorum
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.shards.is_empty()
|
||||
pub(crate) fn slots(&self) -> &[ShardSlot] {
|
||||
&self.slots
|
||||
}
|
||||
|
||||
pub(crate) fn data_bytes(&self, index: usize) -> Option<&[u8]> {
|
||||
self.shards.get(index).and_then(Option::as_deref)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn error(&self, index: usize) -> Option<&Error> {
|
||||
self.errors.get(index).and_then(Option::as_ref)
|
||||
pub(crate) fn slot_by_index(&self, index: usize) -> Option<&ShardSlot> {
|
||||
if let Some(slot) = self.slots.get(index)
|
||||
&& slot.index == index
|
||||
{
|
||||
return Some(slot);
|
||||
}
|
||||
self.slots.iter().find(|slot| slot.index == index)
|
||||
}
|
||||
|
||||
pub(crate) fn data_shards_complete(&self, data_shards: usize) -> bool {
|
||||
self.shards.len() >= data_shards && self.shards.iter().take(data_shards).all(Option::is_some)
|
||||
(0..data_shards).all(|index| self.slot_by_index(index).is_some_and(ShardSlot::has_data))
|
||||
}
|
||||
|
||||
pub(crate) fn parts_mut(&mut self) -> (&mut ShardBuffers, &mut ShardErrors) {
|
||||
(&mut self.shards, &mut self.errors)
|
||||
}
|
||||
|
||||
pub(crate) fn shards_mut(&mut self) -> &mut ShardBuffers {
|
||||
&mut self.shards
|
||||
}
|
||||
|
||||
pub(crate) fn into_parts(self) -> (ShardBuffers, ShardErrors) {
|
||||
(self.shards, self.errors)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn scratch_storage(&self) -> (*const Option<Vec<u8>>, *const Option<Error>, bool, bool) {
|
||||
(self.shards.as_ptr(), self.errors.as_ptr(), self.shards.spilled(), self.errors.spilled())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn shard_allocation(&self, index: usize) -> Option<(*const u8, usize)> {
|
||||
self.shards
|
||||
.get(index)
|
||||
.and_then(|shard| shard.as_ref().map(|shard| (shard.as_ptr(), shard.capacity())))
|
||||
pub(crate) fn into_parts(self) -> (Vec<Option<Vec<u8>>>, Vec<Option<Error>>) {
|
||||
let part_count = self.slots.iter().map(|slot| slot.index).max().map_or(0, |index| index + 1);
|
||||
let mut shards = Vec::with_capacity(part_count);
|
||||
shards.resize_with(part_count, || None);
|
||||
let mut errors = Vec::with_capacity(part_count);
|
||||
errors.resize_with(part_count, || None);
|
||||
for slot in self.slots {
|
||||
shards[slot.index] = slot.data;
|
||||
errors[slot.index] = slot.error;
|
||||
}
|
||||
(shards, errors)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub(crate) trait ShardStripeSource: Send {
|
||||
async fn read_next_stripe(&mut self) -> Box<StripeReadState>;
|
||||
|
||||
fn recycle_stripe(&mut self, _state: Box<StripeReadState>) {}
|
||||
async fn read_next_stripe(&mut self) -> StripeReadState;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::mem::size_of;
|
||||
|
||||
#[test]
|
||||
fn stripe_scratch_capacity_matches_the_production_set_limit() {
|
||||
type OversizedShardBuffers = SmallVec<[Option<Vec<u8>>; 32]>;
|
||||
type OversizedShardErrors = SmallVec<[Option<Error>; 32]>;
|
||||
|
||||
assert_eq!(INLINE_SHARD_SLOTS, MAX_ERASURE_SET_DRIVE_COUNT);
|
||||
assert!(size_of::<ShardBuffers>() < size_of::<OversizedShardBuffers>());
|
||||
assert!(size_of::<ShardErrors>() < size_of::<OversizedShardErrors>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stripe_read_state_tracks_decode_quorum_and_slot_access() {
|
||||
let state =
|
||||
StripeReadState::from_parts(vec![Some(vec![1]), None, Some(vec![2])], vec![None, Some(Error::FileNotFound), None], 2);
|
||||
fn stripe_read_state_tracks_decode_quorum() {
|
||||
let state = StripeReadState::new(
|
||||
vec![
|
||||
ShardSlot::data_with_read_cost(0, ShardReadCost::Local, vec![1]),
|
||||
ShardSlot::missing_with_read_cost(1, ShardReadCost::Remote, Error::FileNotFound),
|
||||
ShardSlot::data_with_read_cost(2, ShardReadCost::SameNode, vec![2]),
|
||||
],
|
||||
2,
|
||||
);
|
||||
|
||||
assert_eq!(state.available_shards(), 2);
|
||||
assert!(state.can_decode());
|
||||
assert_eq!(state.data_bytes(0), Some(&[1][..]));
|
||||
assert_eq!(state.error(1), Some(&Error::FileNotFound));
|
||||
assert_eq!(state.slots()[1].index(), 1);
|
||||
assert_eq!(state.slots()[0].read_cost(), ShardReadCost::Local);
|
||||
assert!(state.slots()[2].read_cost().is_low_cost());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stripe_read_state_preserves_shards_and_errors() {
|
||||
let state = StripeReadState::from_parts(vec![Some(vec![1, 2, 3]), None], vec![None, Some(Error::FileCorrupt)], 2);
|
||||
let state = StripeReadState::new(vec![ShardSlot::missing(1, Error::FileCorrupt), ShardSlot::data(0, vec![1, 2, 3])], 2);
|
||||
|
||||
assert!(!state.can_decode());
|
||||
let (shards, errors) = state.into_parts();
|
||||
assert_eq!(shards.as_slice(), &[Some(vec![1, 2, 3]), None]);
|
||||
assert_eq!(errors.as_slice(), &[None, Some(Error::FileCorrupt)]);
|
||||
assert_eq!(shards, vec![Some(vec![1, 2, 3]), None]);
|
||||
assert_eq!(errors, vec![None, Some(Error::FileCorrupt)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stripe_read_state_builds_slots_from_parallel_reader_parts() {
|
||||
let state =
|
||||
StripeReadState::from_parts(vec![Some(vec![1]), None, Some(vec![3])], vec![None, Some(Error::FileNotFound)], 2);
|
||||
|
||||
assert!(state.can_decode());
|
||||
assert_eq!(state.slots()[1].index(), 1);
|
||||
assert_eq!(state.slots()[1].error(), Some(&Error::FileNotFound));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stripe_read_state_preserves_read_cost_hints() {
|
||||
let state = StripeReadState::from_parts_with_read_costs(
|
||||
vec![Some(vec![1]), None, Some(vec![3])],
|
||||
vec![None, Some(Error::FileNotFound)],
|
||||
&[ShardReadCost::Local, ShardReadCost::Remote, ShardReadCost::Unknown],
|
||||
2,
|
||||
);
|
||||
|
||||
assert_eq!(state.slots()[0].read_cost(), ShardReadCost::Local);
|
||||
assert_eq!(state.slots()[1].read_cost(), ShardReadCost::Remote);
|
||||
assert_eq!(state.slots()[2].read_cost(), ShardReadCost::Unknown);
|
||||
assert_eq!(ShardReadCost::SameNode.as_str(), GET_SHARD_READ_COST_SAME_NODE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -201,8 +258,8 @@ mod tests {
|
||||
let state = StripeReadState::from_parts(vec![Some(vec![1]), Some(vec![2]), None], Vec::new(), 2);
|
||||
|
||||
assert!(state.data_shards_complete(2));
|
||||
assert_eq!(state.data_bytes(0), Some(&[1][..]));
|
||||
assert_eq!(state.data_bytes(1), Some(&[2][..]));
|
||||
assert_eq!(state.slots()[0].data_bytes(), Some(&[1][..]));
|
||||
assert_eq!(state.slot_by_index(1).and_then(ShardSlot::data_bytes), Some(&[2][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -211,4 +268,12 @@ mod tests {
|
||||
|
||||
assert!(!state.data_shards_complete(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stripe_read_state_finds_out_of_order_slots_by_index() {
|
||||
let state = StripeReadState::new(vec![ShardSlot::data(2, vec![3]), ShardSlot::data(0, vec![1])], 2);
|
||||
|
||||
assert_eq!(state.slot_by_index(0).and_then(ShardSlot::data_bytes), Some(&[1][..]));
|
||||
assert!(state.slot_by_index(1).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,10 +161,6 @@ impl BucketIncarnationFenceGuard {
|
||||
pub(crate) fn is_lock_lost(&self) -> bool {
|
||||
self.inner.as_ref().is_some_and(NamespaceLockGuard::is_lock_lost)
|
||||
}
|
||||
|
||||
pub(crate) fn namespace_lock_guard(&self) -> Option<&NamespaceLockGuard> {
|
||||
self.inner.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BucketIncarnationFenceGuard {
|
||||
|
||||
+117
-1494
File diff suppressed because it is too large
Load Diff
@@ -389,7 +389,7 @@ impl crate::storage_api_contracts::object::ObjectIO for ECStore {
|
||||
type GetObjectReader = GetObjectReader;
|
||||
type PutObjectReader = PutObjReader;
|
||||
|
||||
#[instrument(level = "debug", skip(self, h))]
|
||||
#[instrument(level = "debug", skip(self))]
|
||||
async fn get_object_reader(
|
||||
&self,
|
||||
bucket: &str,
|
||||
|
||||
@@ -66,76 +66,6 @@ fn ensure_multipart_bucket_lifecycle_guard_held(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct DataMovementMultipartCompletionBarrierState {
|
||||
bucket: String,
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct DataMovementMultipartCompletionBarrier {
|
||||
state: Arc<DataMovementMultipartCompletionBarrierState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static DATA_MOVEMENT_MULTIPART_COMPLETION_BARRIER: std::sync::OnceLock<
|
||||
std::sync::Mutex<Option<Arc<DataMovementMultipartCompletionBarrierState>>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
impl DataMovementMultipartCompletionBarrier {
|
||||
pub(crate) fn install(bucket: &str) -> Self {
|
||||
let state = Arc::new(DataMovementMultipartCompletionBarrierState {
|
||||
bucket: bucket.to_string(),
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Notify::new(),
|
||||
});
|
||||
let mut slot = DATA_MOVEMENT_MULTIPART_COMPLETION_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("data movement multipart completion barrier mutex should not poison");
|
||||
assert!(slot.is_none(), "data movement multipart completion barrier must be unique");
|
||||
*slot = Some(Arc::clone(&state));
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_until_paused(&self) {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), self.state.arrived.notified())
|
||||
.await
|
||||
.expect("data movement multipart operation should reach selected completion");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for DataMovementMultipartCompletionBarrier {
|
||||
fn drop(&mut self) {
|
||||
self.state.release.notify_one();
|
||||
let mut slot = DATA_MOVEMENT_MULTIPART_COMPLETION_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("data movement multipart completion barrier mutex should not poison");
|
||||
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn pause_data_movement_multipart_before_selected_completion(bucket: &str) {
|
||||
let barrier = DATA_MOVEMENT_MULTIPART_COMPLETION_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("data movement multipart completion barrier mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|barrier| barrier.bucket == bucket)
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.arrived.notify_one();
|
||||
barrier.release.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_pool_multipart_uploads_for_incarnation(
|
||||
pool: &crate::core::sets::Sets,
|
||||
bucket: &str,
|
||||
@@ -402,7 +332,7 @@ impl ECStore {
|
||||
) -> Result<MultipartUploadResult> {
|
||||
self.handle_new_multipart_upload_with_pool_idx(bucket, object, opts)
|
||||
.await
|
||||
.map(|(res, _, _)| res)
|
||||
.map(|(res, _)| res)
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_new_multipart_upload_with_pool_idx(
|
||||
@@ -410,7 +340,7 @@ impl ECStore {
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(MultipartUploadResult, usize, Option<Uuid>)> {
|
||||
) -> Result<(MultipartUploadResult, usize)> {
|
||||
check_new_multipart_args(bucket, object)?;
|
||||
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
let opts = &opts;
|
||||
@@ -419,20 +349,7 @@ impl ECStore {
|
||||
return self.pools[0]
|
||||
.new_multipart_upload(bucket, object, opts)
|
||||
.await
|
||||
.map(|res| (res, 0, opts.expected_bucket_incarnation_id));
|
||||
}
|
||||
|
||||
if opts.data_movement && opts.version_id.is_some() {
|
||||
let idx = self.select_data_movement_pool_idx(bucket, object, -1, opts, false).await?;
|
||||
if idx == opts.src_pool_idx {
|
||||
return Err(StorageError::DataMovementOverwriteErr(
|
||||
bucket.to_owned(),
|
||||
object.to_owned(),
|
||||
opts.version_id.clone().unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
let res = self.pools[idx].new_multipart_upload(bucket, object, opts).await?;
|
||||
return Ok((res, idx, opts.expected_bucket_incarnation_id));
|
||||
.map(|res| (res, 0));
|
||||
}
|
||||
|
||||
for (idx, pool) in self.pools.iter().enumerate() {
|
||||
@@ -455,7 +372,7 @@ impl ECStore {
|
||||
|
||||
if !res.uploads.is_empty() {
|
||||
let res = self.pools[idx].new_multipart_upload(bucket, object, opts).await?;
|
||||
return Ok((res, idx, opts.expected_bucket_incarnation_id));
|
||||
return Ok((res, idx));
|
||||
}
|
||||
}
|
||||
let idx = self.get_pool_idx(bucket, object, -1).await?;
|
||||
@@ -468,7 +385,7 @@ impl ECStore {
|
||||
}
|
||||
|
||||
let res = self.pools[idx].new_multipart_upload(bucket, object, opts).await?;
|
||||
Ok((res, idx, opts.expected_bucket_incarnation_id))
|
||||
Ok((res, idx))
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
@@ -539,30 +456,6 @@ impl ECStore {
|
||||
Err(StorageError::InvalidUploadID(bucket.to_owned(), object.to_owned(), upload_id.to_owned()))
|
||||
}
|
||||
|
||||
pub(crate) async fn put_object_part_for_data_movement(
|
||||
&self,
|
||||
target_pool_idx: usize,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
upload_id: &str,
|
||||
data: &mut PutObjReader,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<PartInfo> {
|
||||
let part_id = opts
|
||||
.part_number
|
||||
.ok_or_else(|| Error::other("targeted multipart upload requires a part number"))?;
|
||||
check_put_object_part_args(bucket, object, upload_id)?;
|
||||
if !opts.data_movement {
|
||||
return Err(Error::other("targeted multipart upload requires data_movement options"));
|
||||
}
|
||||
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
let pool = self
|
||||
.pools
|
||||
.get(target_pool_idx)
|
||||
.ok_or_else(|| Error::other(format!("data movement target pool {target_pool_idx} is out of range")))?;
|
||||
pool.put_object_part(bucket, object, upload_id, part_id, data, &opts).await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub(super) async fn handle_get_multipart_info(
|
||||
&self,
|
||||
@@ -637,26 +530,6 @@ impl ECStore {
|
||||
Err(StorageError::InvalidUploadID(bucket.to_owned(), object.to_owned(), upload_id.to_owned()))
|
||||
}
|
||||
|
||||
pub(crate) async fn abort_multipart_upload_for_data_movement(
|
||||
&self,
|
||||
target_pool_idx: usize,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
upload_id: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<()> {
|
||||
check_abort_multipart_args(bucket, object, upload_id)?;
|
||||
if !opts.data_movement {
|
||||
return Err(Error::other("targeted multipart abort requires data_movement options"));
|
||||
}
|
||||
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
let pool = self
|
||||
.pools
|
||||
.get(target_pool_idx)
|
||||
.ok_or_else(|| Error::other(format!("data movement target pool {target_pool_idx} is out of range")))?;
|
||||
pool.abort_multipart_upload(bucket, object, upload_id, &opts).await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub(super) async fn handle_complete_multipart_upload(
|
||||
self: Arc<Self>,
|
||||
@@ -701,62 +574,6 @@ impl ECStore {
|
||||
|
||||
Err(StorageError::InvalidUploadID(bucket.to_owned(), object.to_owned(), upload_id.to_owned()))
|
||||
}
|
||||
|
||||
pub(crate) async fn complete_multipart_upload_for_data_movement(
|
||||
self: Arc<Self>,
|
||||
target_pool_idx: usize,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
upload_id: &str,
|
||||
uploaded_parts: Vec<CompletePart>,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<ObjectInfo> {
|
||||
check_complete_multipart_args(bucket, object, upload_id)?;
|
||||
if !opts.data_movement {
|
||||
return Err(Error::other("targeted multipart completion requires data_movement options"));
|
||||
}
|
||||
let (mut opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
if opts.overwrites_existing_version() && !is_meta_bucketname(bucket) {
|
||||
let expected_incarnation_id = opts
|
||||
.expected_bucket_incarnation_id
|
||||
.ok_or_else(|| Error::other("data movement completion is missing its bucket incarnation"))?;
|
||||
let lifecycle_fence = opts
|
||||
.bucket_lifecycle_lock_fence
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::other("data movement completion is missing its bucket lifecycle fence"))?;
|
||||
let snapshot = match opts.object_lock_config_snapshot.as_ref() {
|
||||
Some(snapshot) => Arc::clone(snapshot),
|
||||
None => {
|
||||
self.object_lock_config_snapshot_under_lifecycle_fence(bucket, lifecycle_fence)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
if !snapshot.is_valid_for_destructive_put(self.id, bucket, expected_incarnation_id) {
|
||||
return Err(Error::other(
|
||||
"data movement Object Lock snapshot does not match the target bucket generation",
|
||||
));
|
||||
}
|
||||
snapshot.add_lock_fences(&mut opts);
|
||||
opts.object_lock_config_snapshot = Some(snapshot);
|
||||
}
|
||||
#[cfg(test)]
|
||||
pause_data_movement_multipart_before_selected_completion(bucket).await;
|
||||
let pool = self
|
||||
.pools
|
||||
.get(target_pool_idx)
|
||||
.ok_or_else(|| Error::other(format!("data movement target pool {target_pool_idx} is out of range")))?
|
||||
.clone();
|
||||
let result = enqueue_transition_after_write(
|
||||
pool.complete_multipart_upload(bucket, object, upload_id, uploaded_parts, &opts)
|
||||
.await,
|
||||
LcEventSrc::S3CompleteMultipartUpload,
|
||||
)
|
||||
.await;
|
||||
if result.is_ok() {
|
||||
list_objects::observe_list_objects_mutation(self.as_ref(), bucket).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Merges per-pool `ListMultipartUploads` pages into a single globally paginated
|
||||
|
||||
@@ -667,6 +667,18 @@ impl SelectObjectSnapshotLockLossWake {
|
||||
}
|
||||
}
|
||||
|
||||
fn select_object_ssec_headers(headers: &HeaderMap) -> HeaderMap {
|
||||
use rustfs_utils::http::headers::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER};
|
||||
|
||||
let mut selected = HeaderMap::new();
|
||||
for name in [SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER] {
|
||||
if let Some(value) = headers.get(name) {
|
||||
selected.insert(name, value.clone());
|
||||
}
|
||||
}
|
||||
selected
|
||||
}
|
||||
|
||||
// LockRegistry clones its canonical client Arc for each endpoint host, so an
|
||||
// exact Arc set identifies one distributed namespace-lock quorum domain.
|
||||
fn same_distributed_lock_domain(left: &[Arc<dyn rustfs_lock::LockClient>], right: &[Arc<dyn rustfs_lock::LockClient>]) -> bool {
|
||||
@@ -906,7 +918,7 @@ fn is_equivalent_data_movement_delete_marker(source: &ObjectInfo, target: &Objec
|
||||
&& is_data_movement_delete_marker(target)
|
||||
&& source.version_id == target.version_id
|
||||
&& source.mod_time == target.mod_time
|
||||
&& is_equivalent_data_movement_delete_marker_metadata(&source.user_defined, &target.user_defined)
|
||||
&& source.user_defined == target.user_defined
|
||||
&& source.user_tags == target.user_tags
|
||||
&& source.replication_status_internal == target.replication_status_internal
|
||||
&& source.replication_status == target.replication_status
|
||||
@@ -914,185 +926,24 @@ fn is_equivalent_data_movement_delete_marker(source: &ObjectInfo, target: &Objec
|
||||
&& source.version_purge_status == target.version_purge_status
|
||||
}
|
||||
|
||||
fn is_equivalent_data_movement_delete_marker_metadata(
|
||||
source: &HashMap<String, String>,
|
||||
target: &HashMap<String, String>,
|
||||
) -> bool {
|
||||
matches!(
|
||||
(
|
||||
data_movement_delete_marker_metadata_identity(source),
|
||||
data_movement_delete_marker_metadata_identity(target)
|
||||
),
|
||||
(Some(source), Some(target)) if source == target
|
||||
)
|
||||
}
|
||||
|
||||
fn data_movement_delete_marker_metadata_identity(metadata: &HashMap<String, String>) -> Option<HashMap<String, String>> {
|
||||
let mut identity = HashMap::with_capacity(metadata.len());
|
||||
let mut local_tier_free_version_id = None;
|
||||
for (key, value) in metadata {
|
||||
let Some(suffix) = rustfs_utils::http::strip_internal_prefix_preserving_case(key) else {
|
||||
identity.insert(key.clone(), value.clone());
|
||||
continue;
|
||||
};
|
||||
|
||||
if suffix.eq_ignore_ascii_case(rustfs_utils::http::SUFFIX_TIER_FV_ID) {
|
||||
let version_id = Uuid::parse_str(value).ok().filter(|version_id| !version_id.is_nil())?;
|
||||
if local_tier_free_version_id.is_some_and(|expected| expected != version_id) {
|
||||
return None;
|
||||
}
|
||||
local_tier_free_version_id = Some(version_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
let canonical_suffix = [
|
||||
rustfs_utils::http::SUFFIX_REPLICA_TIMESTAMP,
|
||||
rustfs_utils::http::SUFFIX_REPLICA_STATUS,
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_TIMESTAMP,
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_STATUS,
|
||||
rustfs_utils::http::SUFFIX_PURGESTATUS,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|candidate| suffix.eq_ignore_ascii_case(candidate))
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
[
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_RESET_ARN_PREFIX,
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX,
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|prefix| {
|
||||
suffix
|
||||
.get(..prefix.len())
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
|
||||
.then(|| format!("{prefix}{}", &suffix[prefix.len()..]))
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| suffix.to_string());
|
||||
let canonical_value = if canonical_suffix.eq_ignore_ascii_case(rustfs_utils::http::SUFFIX_REPLICA_TIMESTAMP)
|
||||
|| canonical_suffix.eq_ignore_ascii_case(rustfs_utils::http::SUFFIX_REPLICATION_TIMESTAMP)
|
||||
{
|
||||
rustfs_filemeta::parse_replication_timestamp(value)?
|
||||
.unix_timestamp_nanos()
|
||||
.to_string()
|
||||
} else {
|
||||
value.clone()
|
||||
};
|
||||
let canonical_key = format!("{}{canonical_suffix}", rustfs_utils::http::RUSTFS_INTERNAL_PREFIX);
|
||||
if identity
|
||||
.insert(canonical_key, canonical_value.clone())
|
||||
.is_some_and(|existing| existing != canonical_value)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
for (status_suffix, timestamp_suffix) in [
|
||||
(rustfs_utils::http::SUFFIX_REPLICA_STATUS, rustfs_utils::http::SUFFIX_REPLICA_TIMESTAMP),
|
||||
(
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_STATUS,
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_TIMESTAMP,
|
||||
),
|
||||
] {
|
||||
let status_key = format!("{}{status_suffix}", rustfs_utils::http::RUSTFS_INTERNAL_PREFIX);
|
||||
let timestamp_key = format!("{}{timestamp_suffix}", rustfs_utils::http::RUSTFS_INTERNAL_PREFIX);
|
||||
match (identity.contains_key(&status_key), identity.contains_key(×tamp_key)) {
|
||||
(true, false) => {
|
||||
identity.insert(timestamp_key, OffsetDateTime::UNIX_EPOCH.unix_timestamp_nanos().to_string());
|
||||
}
|
||||
(false, true) => return None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(identity)
|
||||
}
|
||||
|
||||
fn is_data_movement_delete_marker(info: &ObjectInfo) -> bool {
|
||||
info.delete_marker
|
||||
}
|
||||
|
||||
fn is_expected_data_movement_delete_marker_source(source: &ObjectInfo, expected_mod_time: Option<OffsetDateTime>) -> bool {
|
||||
is_data_movement_delete_marker(source)
|
||||
&& source.mod_time.is_some()
|
||||
&& source.mod_time == expected_mod_time
|
||||
&& data_movement_delete_marker_metadata_identity(&source.user_defined).is_some()
|
||||
}
|
||||
|
||||
fn current_data_movement_delete_marker_opts(source: &ObjectInfo, opts: &ObjectOptions) -> Option<ObjectOptions> {
|
||||
let replica_status = rustfs_utils::http::get_str(&source.user_defined, rustfs_utils::http::SUFFIX_REPLICA_STATUS);
|
||||
let replica_timestamp = rustfs_utils::http::get_str(&source.user_defined, rustfs_utils::http::SUFFIX_REPLICA_TIMESTAMP);
|
||||
let (replica_status, replica_timestamp) = match (replica_status, replica_timestamp) {
|
||||
(None, None) => Default::default(),
|
||||
(Some(status), timestamp) => {
|
||||
let status = crate::bucket::replication::ReplicationStatusType::from(status.as_str());
|
||||
if status.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let timestamp = match timestamp {
|
||||
Some(timestamp) => rustfs_filemeta::parse_replication_timestamp(×tamp)?,
|
||||
None => OffsetDateTime::UNIX_EPOCH,
|
||||
};
|
||||
(status, Some(timestamp))
|
||||
}
|
||||
(None, Some(_)) => return None,
|
||||
};
|
||||
let replication_status = rustfs_utils::http::get_str(&source.user_defined, rustfs_utils::http::SUFFIX_REPLICATION_STATUS);
|
||||
let replication_timestamp =
|
||||
rustfs_utils::http::get_str(&source.user_defined, rustfs_utils::http::SUFFIX_REPLICATION_TIMESTAMP);
|
||||
let (replication_status, replication_timestamp, replication_targets) = match (replication_status, replication_timestamp) {
|
||||
(None, None) => Default::default(),
|
||||
(Some(status), timestamp) => {
|
||||
let direct_status = crate::bucket::replication::ReplicationStatusType::from(status.as_str());
|
||||
let targets = crate::bucket::replication::replication_statuses_map(status.as_str());
|
||||
if direct_status.is_empty() && targets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let timestamp = match timestamp {
|
||||
Some(timestamp) => rustfs_filemeta::parse_replication_timestamp(×tamp)?,
|
||||
None => OffsetDateTime::UNIX_EPOCH,
|
||||
};
|
||||
(Some(status), Some(timestamp), targets)
|
||||
}
|
||||
(None, Some(_)) => return None,
|
||||
};
|
||||
let mut state = source.replication_state();
|
||||
if state.target_delete_marker_version_ids_corrupt {
|
||||
return None;
|
||||
}
|
||||
state.replica_status = replica_status;
|
||||
state.replica_timestamp = replica_timestamp;
|
||||
state.replication_status_internal = replication_status;
|
||||
state.replication_timestamp = replication_timestamp;
|
||||
state.targets = replication_targets;
|
||||
state.replicate_decision_str = source.replication_decision.clone();
|
||||
state.delete_marker = true;
|
||||
|
||||
let mut target_opts = opts.clone();
|
||||
target_opts.mod_time = source.mod_time;
|
||||
target_opts.delete_replication = Some(state);
|
||||
Some(target_opts)
|
||||
}
|
||||
|
||||
fn expected_data_movement_tiered_object(source: &rustfs_filemeta::FileInfo) -> ObjectInfo {
|
||||
ObjectInfo::from_file_info(source, "", &source.name, source.version_id.is_some())
|
||||
}
|
||||
|
||||
fn is_equivalent_data_movement_tiered_object(source: &rustfs_filemeta::FileInfo, target: &ObjectInfo) -> bool {
|
||||
let expected = expected_data_movement_tiered_object(source);
|
||||
let Some(source_actual_size) = effective_object_actual_size(&expected) else {
|
||||
return false;
|
||||
};
|
||||
let Some(target_actual_size) = effective_object_actual_size(target) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
source.version_id == target.version_id
|
||||
&& !target.delete_marker
|
||||
&& source.size == target.size
|
||||
&& source.get_etag() == target.etag
|
||||
&& source.checksum == target.checksum
|
||||
&& crate::data_movement::are_equivalent_data_movement_parts(&source.parts, &target.parts)
|
||||
&& source.mod_time == target.mod_time
|
||||
&& crate::data_movement::is_equivalent_data_movement_metadata(&expected, target, source_actual_size, target_actual_size)
|
||||
&& expected.user_defined == target.user_defined
|
||||
&& expected.user_tags == target.user_tags
|
||||
&& expected.expires == target.expires
|
||||
&& expected.storage_class == target.storage_class
|
||||
@@ -1101,12 +952,11 @@ fn is_equivalent_data_movement_tiered_object(source: &rustfs_filemeta::FileInfo,
|
||||
&& expected.version_purge_status_internal == target.version_purge_status_internal
|
||||
&& expected.version_purge_status == target.version_purge_status
|
||||
&& expected.transitioned_object.status == target.transitioned_object.status
|
||||
&& expected.transition_version_state == target.transition_version_state
|
||||
&& expected.transitioned_object.name == target.transitioned_object.name
|
||||
&& expected.transitioned_object.tier == target.transitioned_object.tier
|
||||
&& expected.transitioned_object.version_id == target.transitioned_object.version_id
|
||||
&& expected.transitioned_object.free_version == target.transitioned_object.free_version
|
||||
&& source_actual_size == target_actual_size
|
||||
&& effective_object_actual_size(target) == Some(source.size)
|
||||
}
|
||||
|
||||
fn should_check_data_movement_resume_target(src_pool_idx: usize, target_pool_idx: usize) -> bool {
|
||||
@@ -1204,7 +1054,7 @@ impl ECStore {
|
||||
)))
|
||||
}
|
||||
|
||||
pub(super) async fn object_lock_config_snapshot_under_lifecycle_fence(
|
||||
async fn object_lock_config_snapshot_under_lifecycle_fence(
|
||||
&self,
|
||||
bucket: &str,
|
||||
lifecycle_fence: &NamespaceLockFence,
|
||||
@@ -1284,7 +1134,7 @@ impl ECStore {
|
||||
pool,
|
||||
bucket: bucket.to_owned(),
|
||||
object,
|
||||
headers: rustfs_utils::http::project_ssec_transport_headers(headers),
|
||||
headers: select_object_ssec_headers(headers),
|
||||
opts,
|
||||
object_info,
|
||||
logical_size,
|
||||
@@ -1604,8 +1454,7 @@ impl ECStore {
|
||||
target_pool_idx: usize,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<Option<ObjectInfo>> {
|
||||
let mut lookup_opts = version_aware_lookup_opts(opts, true);
|
||||
lookup_opts.include_part_checksums = true;
|
||||
let lookup_opts = version_aware_lookup_opts(opts, true);
|
||||
|
||||
let Some(pool) = self.pools.get(target_pool_idx) else {
|
||||
return Err(Error::other(format!(
|
||||
@@ -1672,25 +1521,6 @@ impl ECStore {
|
||||
) -> Result<()> {
|
||||
check_put_object_args(bucket, object)?;
|
||||
|
||||
let mut opts = opts.clone();
|
||||
let bucket_incarnation_fence = if is_meta_bucketname(bucket) {
|
||||
None
|
||||
} else {
|
||||
let expected = opts
|
||||
.expected_bucket_incarnation_id
|
||||
.ok_or_else(|| Error::other("tiered data movement is missing its bucket incarnation snapshot"))?;
|
||||
let guard = self.acquire_bucket_incarnation_fence(bucket, expected).await?;
|
||||
if let Some(namespace_guard) = guard.namespace_lock_guard() {
|
||||
opts.add_bucket_lifecycle_lock_guard(namespace_guard);
|
||||
}
|
||||
Some(guard)
|
||||
};
|
||||
|
||||
let mut fi = fi.clone();
|
||||
if opts.data_movement {
|
||||
crate::data_movement::prepare_tiered_data_movement_file_info(&mut fi)?;
|
||||
}
|
||||
|
||||
let object = encode_dir_object(object);
|
||||
|
||||
if self.single_pool() {
|
||||
@@ -1703,8 +1533,7 @@ impl ECStore {
|
||||
|
||||
let idx = if opts.data_movement && opts.version_id.is_some() {
|
||||
Self::resolve_decommission_target_pool_idx_result(
|
||||
self.select_data_movement_pool_idx(bucket, &object, fi.size, &opts, true)
|
||||
.await,
|
||||
self.select_data_movement_pool_idx(bucket, &object, fi.size, opts, true).await,
|
||||
bucket,
|
||||
&object,
|
||||
)?
|
||||
@@ -1721,7 +1550,7 @@ impl ECStore {
|
||||
.await;
|
||||
let target_pool_idx = resolve_data_movement_resume_target_pool(idx, resume_target_pool_idx, opts.src_pool_idx);
|
||||
if self
|
||||
.has_equivalent_data_movement_tiered_object(bucket, &object, &fi, &opts, target_pool_idx)
|
||||
.has_equivalent_data_movement_tiered_object(bucket, &object, fi, opts, target_pool_idx)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
@@ -1734,30 +1563,17 @@ impl ECStore {
|
||||
));
|
||||
}
|
||||
|
||||
let result = self.pools[idx]
|
||||
.get_disks_by_key(&object)
|
||||
.decommission_tiered_object(bucket, &object, &fi, &opts)
|
||||
.await;
|
||||
if matches!(result, Err(Error::PreconditionFailed)) {
|
||||
if self
|
||||
.has_equivalent_data_movement_tiered_object(bucket, &object, &fi, &opts, idx)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
return Err(StorageError::DataMovementOverwriteErr(
|
||||
bucket.to_owned(),
|
||||
object,
|
||||
opts.version_id.clone().unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
if bucket_incarnation_fence.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
||||
return Err(Error::other("tiered data movement bucket incarnation fence was lost during target write"));
|
||||
}
|
||||
Self::resolve_decommission_tiered_object_result(result, bucket, &object)
|
||||
Self::resolve_decommission_tiered_object_result(
|
||||
self.pools[idx]
|
||||
.get_disks_by_key(&object)
|
||||
.decommission_tiered_object(bucket, &object, fi, opts)
|
||||
.await,
|
||||
bucket,
|
||||
&object,
|
||||
)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, h))]
|
||||
#[instrument(level = "debug", skip(self))]
|
||||
#[hotpath::measure(impl_type = "ECStore")]
|
||||
pub(super) async fn handle_get_object_reader(
|
||||
&self,
|
||||
@@ -1791,7 +1607,15 @@ impl ECStore {
|
||||
Ok(Self::attach_read_lock_guard(reader, read_lock_guard))
|
||||
}
|
||||
|
||||
async fn prepare_put_object(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<(String, ObjectOptions)> {
|
||||
#[instrument(level = "debug", skip(self, data))]
|
||||
#[hotpath::measure(impl_type = "ECStore")]
|
||||
pub(super) async fn handle_put_object(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
data: &mut PutObjReader,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(ObjectInfo, Option<OldCurrentSize>)> {
|
||||
check_put_object_args(bucket, object)?;
|
||||
|
||||
let object = encode_dir_object(object);
|
||||
@@ -1818,20 +1642,22 @@ impl ECStore {
|
||||
};
|
||||
snapshot.add_lock_fences(&mut opts);
|
||||
}
|
||||
Ok((object, opts))
|
||||
}
|
||||
|
||||
async fn select_put_object_pool_idx(&self, bucket: &str, object: &str, size: i64, opts: &ObjectOptions) -> Result<usize> {
|
||||
// Keep PUT atomic-read friendly: SetDisks takes the object write lock only
|
||||
// around precondition checks and the final rename/commit.
|
||||
if self.single_pool() {
|
||||
return Ok(0);
|
||||
return self.pools[0]
|
||||
.put_object_with_old_current_size(bucket, object.as_str(), data, &opts)
|
||||
.await;
|
||||
}
|
||||
|
||||
let idx = if opts.data_movement && opts.version_id.is_some() {
|
||||
self.select_data_movement_pool_idx(bucket, object, size, opts, false).await?
|
||||
self.select_data_movement_pool_idx(bucket, &object, data.size(), &opts, false)
|
||||
.await?
|
||||
} else if opts.no_lock {
|
||||
self.get_pool_idx_no_lock(bucket, object, size).await?
|
||||
self.get_pool_idx_no_lock(bucket, &object, data.size()).await?
|
||||
} else {
|
||||
self.get_pool_idx(bucket, object, size).await?
|
||||
self.get_pool_idx(bucket, &object, data.size()).await?
|
||||
};
|
||||
|
||||
if opts.data_movement && idx == opts.src_pool_idx {
|
||||
@@ -1841,50 +1667,7 @@ impl ECStore {
|
||||
opts.version_id.clone().unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
Ok(idx)
|
||||
}
|
||||
|
||||
pub(crate) async fn put_object_for_data_movement(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
data: &mut PutObjReader,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(usize, Result<ObjectInfo>)> {
|
||||
if !opts.data_movement {
|
||||
return Err(Error::other("data movement PUT requires data_movement options"));
|
||||
}
|
||||
let (object, opts) = self.prepare_put_object(bucket, object, opts).await?;
|
||||
let idx = self
|
||||
.select_put_object_pool_idx(bucket, object.as_str(), data.size(), &opts)
|
||||
.await?;
|
||||
let result = self.pools[idx]
|
||||
.put_object_with_old_current_size(bucket, &object, data, &opts)
|
||||
.await
|
||||
.map(|(object_info, _)| object_info);
|
||||
let result = enqueue_transition_after_write(result, LcEventSrc::S3PutObject).await;
|
||||
if result.is_ok() {
|
||||
list_objects::observe_list_objects_mutation(self, bucket).await;
|
||||
}
|
||||
Ok((idx, result))
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, data))]
|
||||
#[hotpath::measure(impl_type = "ECStore")]
|
||||
pub(super) async fn handle_put_object(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
data: &mut PutObjReader,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(ObjectInfo, Option<OldCurrentSize>)> {
|
||||
let (object, opts) = self.prepare_put_object(bucket, object, opts).await?;
|
||||
let idx = self
|
||||
.select_put_object_pool_idx(bucket, object.as_str(), data.size(), &opts)
|
||||
.await?;
|
||||
|
||||
// Keep PUT atomic-read friendly: SetDisks takes the object write lock only
|
||||
// around precondition checks and the final rename/commit.
|
||||
self.pools[idx]
|
||||
.put_object_with_old_current_size(bucket, &object, data, &opts)
|
||||
.await
|
||||
@@ -2327,50 +2110,6 @@ impl ECStore {
|
||||
};
|
||||
let target_pool_idx =
|
||||
resolve_data_movement_resume_target_pool(selected_target_pool_idx, resume_target_pool_idx, opts.src_pool_idx);
|
||||
let mut delete_marker_target_opts = None;
|
||||
|
||||
if opts.delete_marker && should_check_data_movement_resume_target(opts.src_pool_idx, target_pool_idx) {
|
||||
let source = self
|
||||
.find_data_movement_target_info(bucket, object, opts.src_pool_idx, &opts)
|
||||
.await?;
|
||||
let Some(source) = source else {
|
||||
return Err(StorageError::DataMovementOverwriteErr(
|
||||
bucket.to_owned(),
|
||||
object.to_owned(),
|
||||
opts.version_id.unwrap_or_default(),
|
||||
));
|
||||
};
|
||||
if !is_expected_data_movement_delete_marker_source(&source, opts.mod_time) {
|
||||
return Err(StorageError::DataMovementOverwriteErr(
|
||||
bucket.to_owned(),
|
||||
object.to_owned(),
|
||||
opts.version_id.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
let Some(target_opts) = current_data_movement_delete_marker_opts(&source, &opts) else {
|
||||
return Err(StorageError::DataMovementOverwriteErr(
|
||||
bucket.to_owned(),
|
||||
object.to_owned(),
|
||||
opts.version_id.unwrap_or_default(),
|
||||
));
|
||||
};
|
||||
let target = self
|
||||
.find_data_movement_target_info(bucket, object, target_pool_idx, &target_opts)
|
||||
.await?;
|
||||
if let Some(target) = target {
|
||||
if is_equivalent_data_movement_delete_marker(&source, &target) {
|
||||
let mut target = target;
|
||||
target.name = decode_dir_object(object);
|
||||
return Ok(target);
|
||||
}
|
||||
return Err(StorageError::DataMovementOverwriteErr(
|
||||
bucket.to_owned(),
|
||||
object.to_owned(),
|
||||
opts.version_id.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
delete_marker_target_opts = Some(target_opts);
|
||||
}
|
||||
|
||||
if !should_check_data_movement_resume_target(opts.src_pool_idx, target_pool_idx) {
|
||||
if let Ok((source_pool_info, _)) = existing_pool_info
|
||||
@@ -2398,8 +2137,7 @@ impl ECStore {
|
||||
));
|
||||
}
|
||||
|
||||
let target_opts = delete_marker_target_opts.unwrap_or(opts);
|
||||
let mut obj = self.pools[target_pool_idx].delete_object(bucket, object, target_opts).await?;
|
||||
let mut obj = self.pools[target_pool_idx].delete_object(bucket, object, opts).await?;
|
||||
obj.name = decode_dir_object(obj.name.as_str());
|
||||
return Ok(obj);
|
||||
}
|
||||
@@ -3348,6 +3086,25 @@ mod tests {
|
||||
assert!(second_signal.is_lost());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_snapshot_retains_only_ssec_headers() {
|
||||
use rustfs_utils::http::headers::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER};
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(SSEC_ALGORITHM_HEADER, "AES256".parse().expect("valid SSE-C algorithm header"));
|
||||
headers.insert(SSEC_KEY_HEADER, "secret-key".parse().expect("valid SSE-C key header"));
|
||||
headers.insert(SSEC_KEY_MD5_HEADER, "key-md5".parse().expect("valid SSE-C key digest header"));
|
||||
headers.insert("authorization", "credential".parse().expect("valid authorization header"));
|
||||
|
||||
let selected = select_object_ssec_headers(&headers);
|
||||
|
||||
assert_eq!(selected.len(), 3);
|
||||
assert_eq!(selected.get(SSEC_ALGORITHM_HEADER), headers.get(SSEC_ALGORITHM_HEADER));
|
||||
assert_eq!(selected.get(SSEC_KEY_HEADER), headers.get(SSEC_KEY_HEADER));
|
||||
assert_eq!(selected.get(SSEC_KEY_MD5_HEADER), headers.get(SSEC_KEY_MD5_HEADER));
|
||||
assert!(selected.get("authorization").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_entry_is_prepared_and_bound_to_source_generation() {
|
||||
let identity = [9_u8; 32];
|
||||
@@ -3443,247 +3200,6 @@ mod tests {
|
||||
assert!(!is_equivalent_data_movement_delete_marker(&source, &mismatched));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_data_movement_delete_marker_accepts_distinct_local_free_version_ids() {
|
||||
let mut source = ObjectInfo {
|
||||
version_id: Some(Uuid::from_u128(1)),
|
||||
delete_marker: true,
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
};
|
||||
rustfs_utils::http::insert_str(
|
||||
Arc::make_mut(&mut source.user_defined),
|
||||
rustfs_utils::http::SUFFIX_TIER_FV_ID,
|
||||
Uuid::from_u128(2).to_string(),
|
||||
);
|
||||
let mut target = source.clone();
|
||||
rustfs_utils::http::insert_str(
|
||||
Arc::make_mut(&mut target.user_defined),
|
||||
rustfs_utils::http::SUFFIX_TIER_FV_ID,
|
||||
Uuid::from_u128(3).to_string(),
|
||||
);
|
||||
|
||||
assert!(is_equivalent_data_movement_delete_marker(&source, &target));
|
||||
|
||||
Arc::make_mut(&mut target.user_defined).insert(
|
||||
format!("{}{}", rustfs_utils::http::MINIO_INTERNAL_PREFIX, rustfs_utils::http::SUFFIX_TIER_FV_ID),
|
||||
Uuid::from_u128(4).to_string(),
|
||||
);
|
||||
assert!(!is_equivalent_data_movement_delete_marker(&source, &target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_data_movement_delete_marker_accepts_replication_alias_expansion() {
|
||||
let key = format!(
|
||||
"{}{}",
|
||||
rustfs_utils::http::MINIO_INTERNAL_PREFIX,
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_STATUS
|
||||
);
|
||||
let timestamp_key = format!(
|
||||
"{}{}",
|
||||
rustfs_utils::http::MINIO_INTERNAL_PREFIX,
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_TIMESTAMP
|
||||
);
|
||||
let source = ObjectInfo {
|
||||
version_id: Some(Uuid::from_u128(1)),
|
||||
delete_marker: true,
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
user_defined: Arc::new(HashMap::from([
|
||||
(key.clone(), "arn=COMPLETED;".to_string()),
|
||||
(timestamp_key, "1970-01-01T00:00:01Z".to_string()),
|
||||
])),
|
||||
..Default::default()
|
||||
};
|
||||
let mut target = source.clone();
|
||||
rustfs_utils::http::insert_str(
|
||||
Arc::make_mut(&mut target.user_defined),
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_STATUS,
|
||||
"arn=COMPLETED;".to_string(),
|
||||
);
|
||||
rustfs_utils::http::insert_str(
|
||||
Arc::make_mut(&mut target.user_defined),
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_TIMESTAMP,
|
||||
(OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND).to_string(),
|
||||
);
|
||||
assert!(is_equivalent_data_movement_delete_marker(&source, &target));
|
||||
|
||||
Arc::make_mut(&mut target.user_defined).insert(key, "arn=FAILED;".to_string());
|
||||
assert!(!is_equivalent_data_movement_delete_marker(&source, &target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_movement_delete_marker_source_requires_persisted_mod_time() {
|
||||
let source = ObjectInfo {
|
||||
delete_marker: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!is_expected_data_movement_delete_marker_source(&source, None));
|
||||
|
||||
let source = ObjectInfo {
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..source
|
||||
};
|
||||
assert!(is_expected_data_movement_delete_marker_source(&source, Some(OffsetDateTime::UNIX_EPOCH)));
|
||||
assert!(!is_expected_data_movement_delete_marker_source(&source, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_movement_delete_marker_uses_current_source_replication_state() {
|
||||
let expected_timestamp = OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND;
|
||||
let timestamp = expected_timestamp.to_string();
|
||||
let mut metadata = HashMap::new();
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut metadata,
|
||||
rustfs_utils::http::SUFFIX_REPLICA_STATUS,
|
||||
ReplicationStatusType::Replica.to_string(),
|
||||
);
|
||||
rustfs_utils::http::insert_str(&mut metadata, rustfs_utils::http::SUFFIX_REPLICA_TIMESTAMP, timestamp.clone());
|
||||
rustfs_utils::http::insert_str(&mut metadata, rustfs_utils::http::SUFFIX_REPLICATION_TIMESTAMP, timestamp);
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut metadata,
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_STATUS,
|
||||
"arn=COMPLETED;".to_string(),
|
||||
);
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut metadata,
|
||||
&format!(
|
||||
"{}{}",
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_RESET_ARN_PREFIX,
|
||||
"arn:minio:replication::TenantA:bucket"
|
||||
),
|
||||
"reset-id".to_string(),
|
||||
);
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut metadata,
|
||||
&format!(
|
||||
"{}{}",
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX,
|
||||
"arn:minio:replication::TenantA:bucket"
|
||||
),
|
||||
"target-version".to_string(),
|
||||
);
|
||||
let source = ObjectInfo {
|
||||
delete_marker: true,
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
replication_status_internal: Some("arn=COMPLETED;".to_string()),
|
||||
replication_decision: "arn=replicate;".to_string(),
|
||||
user_defined: Arc::new(metadata),
|
||||
..Default::default()
|
||||
};
|
||||
let opts = ObjectOptions {
|
||||
mod_time: source.mod_time,
|
||||
delete_replication: Some(ReplicationState {
|
||||
replication_status_internal: Some("arn=PENDING;".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let target_opts = current_data_movement_delete_marker_opts(&source, &opts).expect("valid current source state");
|
||||
let state = target_opts.delete_replication.as_ref().expect("current replication state");
|
||||
assert_eq!(state.replication_status_internal.as_deref(), Some("arn=COMPLETED;"));
|
||||
assert_eq!(state.replica_status, crate::bucket::replication::ReplicationStatusType::Replica);
|
||||
assert_eq!(state.replica_timestamp, Some(expected_timestamp));
|
||||
assert_eq!(state.replication_timestamp, state.replica_timestamp);
|
||||
assert_eq!(state.replicate_decision_str, "arn=replicate;");
|
||||
assert_eq!(
|
||||
state
|
||||
.reset_statuses_map
|
||||
.get("arn:minio:replication::TenantA:bucket")
|
||||
.map(String::as_str),
|
||||
Some("reset-id")
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.target_delete_marker_version_ids
|
||||
.get("arn:minio:replication::TenantA:bucket")
|
||||
.map(String::as_str),
|
||||
Some("target-version")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_movement_delete_marker_rejects_corrupt_target_version_maps() {
|
||||
let suffix = format!("{}not-an-arn", rustfs_utils::http::SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX);
|
||||
let mut malformed = HashMap::new();
|
||||
rustfs_utils::http::insert_str(&mut malformed, &suffix, "target-version".to_string());
|
||||
let malformed_source = ObjectInfo {
|
||||
delete_marker: true,
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
user_defined: Arc::new(malformed),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(current_data_movement_delete_marker_opts(&malformed_source, &ObjectOptions::default()).is_none());
|
||||
|
||||
let mut conflicted = HashMap::new();
|
||||
let suffix = format!(
|
||||
"{}arn:minio:replication::target:bucket",
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX
|
||||
);
|
||||
rustfs_utils::http::insert_str(&mut conflicted, &suffix, "target-version-a".to_string());
|
||||
conflicted.insert(
|
||||
format!("{}{suffix}", rustfs_utils::http::MINIO_INTERNAL_PREFIX),
|
||||
"target-version-b".to_string(),
|
||||
);
|
||||
let conflicted_source = ObjectInfo {
|
||||
user_defined: Arc::new(conflicted),
|
||||
..malformed_source.clone()
|
||||
};
|
||||
assert!(current_data_movement_delete_marker_opts(&conflicted_source, &ObjectOptions::default()).is_none());
|
||||
|
||||
let mut over_cap = HashMap::new();
|
||||
for index in 0..=1_000 {
|
||||
let suffix = format!(
|
||||
"{}arn:minio:replication::target:bucket-{index}",
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX
|
||||
);
|
||||
rustfs_utils::http::insert_str(&mut over_cap, &suffix, format!("target-version-{index}"));
|
||||
}
|
||||
let over_cap_source = ObjectInfo {
|
||||
user_defined: Arc::new(over_cap),
|
||||
..malformed_source
|
||||
};
|
||||
assert!(current_data_movement_delete_marker_opts(&over_cap_source, &ObjectOptions::default()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_movement_delete_marker_normalizes_legacy_missing_replication_timestamps() {
|
||||
let mut source_metadata = HashMap::new();
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut source_metadata,
|
||||
rustfs_utils::http::SUFFIX_REPLICA_STATUS,
|
||||
ReplicationStatusType::Replica.to_string(),
|
||||
);
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut source_metadata,
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_STATUS,
|
||||
"arn=COMPLETED;".to_string(),
|
||||
);
|
||||
let source = ObjectInfo {
|
||||
delete_marker: true,
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
replication_status_internal: Some("arn=COMPLETED;".to_string()),
|
||||
user_defined: Arc::new(source_metadata),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let target_opts = current_data_movement_delete_marker_opts(&source, &ObjectOptions::default())
|
||||
.expect("legacy status-only metadata should remain migratable");
|
||||
let state = target_opts
|
||||
.delete_replication
|
||||
.expect("replication state should be reconstructed");
|
||||
assert_eq!(state.replica_timestamp, Some(OffsetDateTime::UNIX_EPOCH));
|
||||
assert_eq!(state.replication_timestamp, Some(OffsetDateTime::UNIX_EPOCH));
|
||||
|
||||
let mut target_metadata = (*source.user_defined).clone();
|
||||
let epoch = OffsetDateTime::UNIX_EPOCH
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap();
|
||||
rustfs_utils::http::insert_str(&mut target_metadata, rustfs_utils::http::SUFFIX_REPLICA_TIMESTAMP, epoch.clone());
|
||||
rustfs_utils::http::insert_str(&mut target_metadata, rustfs_utils::http::SUFFIX_REPLICATION_TIMESTAMP, epoch);
|
||||
assert!(is_equivalent_data_movement_delete_marker_metadata(&source.user_defined, &target_metadata));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_data_movement_delete_marker_rejects_metadata_and_replication_mismatch() {
|
||||
let version_id = Uuid::nil();
|
||||
@@ -3828,67 +3344,6 @@ mod tests {
|
||||
assert!(is_equivalent_data_movement_tiered_object(&source, &target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_data_movement_tiered_object_uses_logical_compressed_and_encrypted_sizes() {
|
||||
let mut compressed = tiered_equivalence_source();
|
||||
compressed.size = 600;
|
||||
rustfs_utils::http::insert_str(&mut compressed.metadata, rustfs_utils::http::SUFFIX_COMPRESSION, "S2".to_string());
|
||||
rustfs_utils::http::insert_str(&mut compressed.metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "1024".to_string());
|
||||
let compressed_target = tiered_equivalence_target(&compressed);
|
||||
assert!(is_equivalent_data_movement_tiered_object(&compressed, &compressed_target));
|
||||
|
||||
let mut encrypted = tiered_equivalence_source();
|
||||
encrypted.size = 640;
|
||||
encrypted.metadata.insert(
|
||||
rustfs_utils::http::object_encryption_keys::INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(),
|
||||
"key-id".to_string(),
|
||||
);
|
||||
encrypted.metadata.insert(
|
||||
rustfs_utils::http::object_encryption_keys::INTERNAL_ENCRYPTION_ORIGINAL_SIZE_HEADER.to_string(),
|
||||
"1024".to_string(),
|
||||
);
|
||||
let encrypted_target = tiered_equivalence_target(&encrypted);
|
||||
assert!(is_equivalent_data_movement_tiered_object(&encrypted, &encrypted_target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_data_movement_tiered_object_accepts_transition_alias_expansion() {
|
||||
let mut source = tiered_equivalence_source();
|
||||
let suffix = rustfs_utils::http::SUFFIX_TRANSITION_TIER;
|
||||
source.metadata.insert(
|
||||
format!("{}{suffix}", rustfs_utils::http::MINIO_INTERNAL_PREFIX),
|
||||
source.transition_tier.clone(),
|
||||
);
|
||||
let mut target = tiered_equivalence_target(&source);
|
||||
Arc::make_mut(&mut target.user_defined)
|
||||
.insert(rustfs_utils::http::internal_key_rustfs(suffix), source.transition_tier.clone());
|
||||
|
||||
assert!(is_equivalent_data_movement_tiered_object(&source, &target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_data_movement_tiered_object_requires_hydrated_part_checksums() {
|
||||
let mut source = tiered_equivalence_source();
|
||||
source.parts = vec![rustfs_filemeta::ObjectPartInfo {
|
||||
number: 1,
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND),
|
||||
checksums: Some(HashMap::from([("CRC32C".to_string(), "AAAAAA==".to_string())])),
|
||||
..Default::default()
|
||||
}];
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut source.metadata,
|
||||
rustfs_utils::http::SUFFIX_PART_CHECKSUMS,
|
||||
r#"[[1,[["CRC32C","AAAAAA=="]]]]"#.to_string(),
|
||||
);
|
||||
let mut target = tiered_equivalence_target(&source);
|
||||
Arc::make_mut(&mut target.parts)[0].mod_time = None;
|
||||
assert!(is_equivalent_data_movement_tiered_object(&source, &target));
|
||||
|
||||
let mut missing = target;
|
||||
Arc::make_mut(&mut missing.parts)[0].checksums = None;
|
||||
assert!(!is_equivalent_data_movement_tiered_object(&source, &missing));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_data_movement_tiered_object_rejects_transition_mismatch() {
|
||||
let source = tiered_equivalence_source();
|
||||
@@ -3898,16 +3353,6 @@ mod tests {
|
||||
assert!(!is_equivalent_data_movement_tiered_object(&source, &target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_data_movement_tiered_object_rejects_transition_version_state_mismatch() {
|
||||
let mut source = tiered_equivalence_source();
|
||||
source.transition_version_state = rustfs_filemeta::TransitionVersionState::Exact;
|
||||
let mut target = tiered_equivalence_target(&source);
|
||||
target.transition_version_state = rustfs_filemeta::TransitionVersionState::Unknown;
|
||||
|
||||
assert!(!is_equivalent_data_movement_tiered_object(&source, &target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equivalent_data_movement_tiered_object_rejects_user_metadata_mismatch() {
|
||||
let source = tiered_equivalence_source();
|
||||
@@ -4577,8 +4022,6 @@ mod tests {
|
||||
assert_eq!(snapshot.headers.get(SSEC_KEY_HEADER), request_headers.get(SSEC_KEY_HEADER));
|
||||
assert_eq!(snapshot.headers.get(SSEC_KEY_MD5_HEADER), request_headers.get(SSEC_KEY_MD5_HEADER));
|
||||
assert!(snapshot.headers.get("authorization").is_none());
|
||||
assert!(snapshot.headers.values().all(http::HeaderValue::is_sensitive));
|
||||
assert!(!format!("{:?}", snapshot.headers).contains("secret-key"));
|
||||
assert_eq!(
|
||||
snapshot.logical_size(),
|
||||
u64::try_from(payload.len()).expect("test payload length should fit in u64")
|
||||
|
||||
@@ -109,7 +109,6 @@ async fn run_legacy_bitrot_test_for_object(root: &std::path::Path, disk_name: &s
|
||||
FileInfoOpts {
|
||||
data: true, // need inline data for inline objects
|
||||
include_free_versions: false,
|
||||
include_part_checksums: true,
|
||||
},
|
||||
) {
|
||||
Ok(f) => f,
|
||||
|
||||
@@ -37,7 +37,6 @@ crc-fast = { workspace = true }
|
||||
rmp.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
time = { workspace = true, features = ["parsing", "formatting", "macros", "serde"] }
|
||||
uuid = { workspace = true, features = ["v4", "fast-rng", "serde", "macro-diagnostics"] }
|
||||
tokio = { workspace = true, features = ["io-util", "macros", "sync", "fs", "rt-multi-thread"] }
|
||||
@@ -55,6 +54,7 @@ arc-swap.workspace = true
|
||||
criterion = { workspace = true, features = ["html_reports"] }
|
||||
tempfile = { workspace = true }
|
||||
proptest = "1"
|
||||
serde_json.workspace = true
|
||||
|
||||
[[bench]]
|
||||
name = "xl_meta_bench"
|
||||
|
||||
@@ -52,7 +52,6 @@ fn main() {
|
||||
FileInfoOpts {
|
||||
data: false,
|
||||
include_free_versions: true,
|
||||
include_part_checksums: true,
|
||||
},
|
||||
)
|
||||
.expect("decode file info");
|
||||
|
||||
@@ -17,10 +17,9 @@ use bytes::Bytes;
|
||||
use rmp_serde::Serializer;
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use rustfs_utils::http::{
|
||||
AMZ_OBJECT_TAGGING, SUFFIX_COMPRESSION, SUFFIX_DATA_MOVED, SUFFIX_DATA_MOVED_TAGS, SUFFIX_FREE_VERSION, SUFFIX_HEALING,
|
||||
SUFFIX_INLINE_DATA, SUFFIX_OBJECT_TRANSACTION_EPOCH, SUFFIX_TIER_FV_ID, SUFFIX_TIER_FV_MARKER, SUFFIX_TIER_SKIP_FV_ID,
|
||||
contains_key_str, get_consistent_str, get_str, has_internal_suffix, insert_str, is_encryption_metadata_key,
|
||||
starts_with_ignore_ascii_case,
|
||||
SUFFIX_COMPRESSION, SUFFIX_DATA_MOVED, SUFFIX_FREE_VERSION, SUFFIX_HEALING, SUFFIX_INLINE_DATA, SUFFIX_TIER_FV_ID,
|
||||
SUFFIX_TIER_FV_MARKER, SUFFIX_TIER_SKIP_FV_ID, contains_key_str, get_str, has_internal_suffix, insert_str,
|
||||
is_encryption_metadata_key, starts_with_ignore_ascii_case,
|
||||
};
|
||||
use s3s::dto::{RestoreStatus, Timestamp};
|
||||
use s3s::header::X_AMZ_RESTORE;
|
||||
@@ -233,17 +232,6 @@ pub enum TransitionVersionState {
|
||||
Exact,
|
||||
}
|
||||
|
||||
impl TransitionVersionState {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::KnownDisabled => "known-disabled",
|
||||
Self::SuspendedNull => "suspended-null",
|
||||
Self::Exact => "exact",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Default)]
|
||||
pub struct FileInfo {
|
||||
pub volume: String,
|
||||
@@ -1163,32 +1151,9 @@ impl FileInfo {
|
||||
}
|
||||
|
||||
pub fn set_data_moved(&mut self) {
|
||||
let tags_proof = format!("v1:{}", self.metadata.get(AMZ_OBJECT_TAGGING).map(String::as_str).unwrap_or_default());
|
||||
insert_str(&mut self.metadata, SUFFIX_DATA_MOVED_TAGS, tags_proof);
|
||||
insert_str(&mut self.metadata, SUFFIX_DATA_MOVED, "true".to_string());
|
||||
}
|
||||
|
||||
pub fn acknowledge_data_movement(&mut self) {
|
||||
// Keep both empty aliases so mixed-version disks retain one metadata identity.
|
||||
insert_str(&mut self.metadata, SUFFIX_DATA_MOVED, String::new());
|
||||
}
|
||||
|
||||
pub fn set_object_transaction_epoch(&mut self, epoch: Uuid) {
|
||||
insert_str(&mut self.metadata, SUFFIX_OBJECT_TRANSACTION_EPOCH, epoch.to_string());
|
||||
}
|
||||
|
||||
pub fn object_transaction_epoch(&self) -> Result<Option<Uuid>> {
|
||||
if !contains_key_str(&self.metadata, SUFFIX_OBJECT_TRANSACTION_EPOCH) {
|
||||
return Ok(None);
|
||||
}
|
||||
let value = get_consistent_str(&self.metadata, SUFFIX_OBJECT_TRANSACTION_EPOCH).ok_or(Error::FileCorrupt)?;
|
||||
let epoch = Uuid::parse_str(value).map_err(|_| Error::FileCorrupt)?;
|
||||
if epoch.is_nil() {
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
Ok(Some(epoch))
|
||||
}
|
||||
|
||||
pub fn inline_data(&self) -> bool {
|
||||
contains_key_str(&self.metadata, SUFFIX_INLINE_DATA) && !self.is_remote()
|
||||
}
|
||||
@@ -1501,46 +1466,6 @@ mod tests {
|
||||
assert_eq!(ei.get_checksum_info(99).algorithm, HashAlgorithm::HighwayHash256S);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_transaction_epoch_uses_consistent_dual_internal_metadata() {
|
||||
let mut fi = validation_test_fileinfo();
|
||||
assert_eq!(fi.object_transaction_epoch().expect("absent epoch should decode"), None);
|
||||
|
||||
let epoch = Uuid::new_v4();
|
||||
let epoch_text = epoch.to_string();
|
||||
fi.set_object_transaction_epoch(epoch);
|
||||
assert_eq!(fi.object_transaction_epoch().expect("written epoch should decode"), Some(epoch));
|
||||
assert_eq!(fi.metadata.get("x-rustfs-internal-object-transaction-epoch"), Some(&epoch_text));
|
||||
assert_eq!(fi.metadata.get("x-minio-internal-object-transaction-epoch"), Some(&epoch_text));
|
||||
|
||||
let mut rustfs_only = validation_test_fileinfo();
|
||||
rustfs_only
|
||||
.metadata
|
||||
.insert("x-rustfs-internal-object-transaction-epoch".to_string(), epoch_text);
|
||||
assert_eq!(
|
||||
rustfs_only
|
||||
.object_transaction_epoch()
|
||||
.expect("single compatibility key should decode"),
|
||||
Some(epoch)
|
||||
);
|
||||
|
||||
let mut conflicting = fi.clone();
|
||||
conflicting
|
||||
.metadata
|
||||
.insert("x-minio-internal-object-transaction-epoch".to_string(), Uuid::new_v4().to_string());
|
||||
assert_eq!(conflicting.object_transaction_epoch(), Err(Error::FileCorrupt));
|
||||
|
||||
let mut malformed = validation_test_fileinfo();
|
||||
malformed
|
||||
.metadata
|
||||
.insert("x-rustfs-internal-object-transaction-epoch".to_string(), "not-a-uuid".to_string());
|
||||
assert_eq!(malformed.object_transaction_epoch(), Err(Error::FileCorrupt));
|
||||
|
||||
let mut nil = validation_test_fileinfo();
|
||||
nil.set_object_transaction_epoch(Uuid::nil());
|
||||
assert_eq!(nil.object_transaction_epoch(), Err(Error::FileCorrupt));
|
||||
}
|
||||
|
||||
// backlog#949: distribution range/permutation validation.
|
||||
#[test]
|
||||
fn is_valid_distribution_accepts_permutation() {
|
||||
|
||||
+97
-156
@@ -206,42 +206,6 @@ fn persist_reset_statuses(meta_sys: &mut HashMap<String, Vec<u8>>, reset_statuse
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_replication_timestamp(value: &str) -> Option<OffsetDateTime> {
|
||||
const DISPLAY_FORMAT: &[time::format_description::BorrowedFormatItem<'_>] = time::macros::format_description!(
|
||||
"[year sign:automatic]-[month]-[day] [hour padding:none]:[minute]:[second].[subsecond] [offset_hour sign:mandatory]:[offset_minute]:[offset_second]"
|
||||
);
|
||||
OffsetDateTime::parse(value, &Rfc3339)
|
||||
.or_else(|_| OffsetDateTime::parse(value, DISPLAY_FORMAT))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn format_replication_timestamp(value: Option<OffsetDateTime>) -> String {
|
||||
let value = value.unwrap_or(OffsetDateTime::UNIX_EPOCH);
|
||||
value
|
||||
.to_offset(time::UtcOffset::UTC)
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| value.to_string())
|
||||
}
|
||||
|
||||
fn persist_delete_marker_replication_state(meta_sys: &mut HashMap<String, Vec<u8>>, state: &ReplicationState) {
|
||||
if !state.replica_status.is_empty() {
|
||||
insert_bytes(meta_sys, SUFFIX_REPLICA_STATUS, state.replica_status.as_str().as_bytes().to_vec());
|
||||
insert_bytes(
|
||||
meta_sys,
|
||||
SUFFIX_REPLICA_TIMESTAMP,
|
||||
format_replication_timestamp(state.replica_timestamp).into_bytes(),
|
||||
);
|
||||
}
|
||||
if let Some(status) = state.replication_status_internal.as_ref().filter(|status| !status.is_empty()) {
|
||||
insert_bytes(meta_sys, SUFFIX_REPLICATION_STATUS, status.as_bytes().to_vec());
|
||||
insert_bytes(
|
||||
meta_sys,
|
||||
SUFFIX_REPLICATION_TIMESTAMP,
|
||||
format_replication_timestamp(state.replication_timestamp).into_bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FileMeta {
|
||||
pub versions: Vec<FileMetaShallowVersion>,
|
||||
@@ -249,13 +213,6 @@ pub struct FileMeta {
|
||||
pub meta_ver: u8,
|
||||
}
|
||||
|
||||
struct FileInfoDecodeOptions {
|
||||
read_data: bool,
|
||||
include_free_versions: bool,
|
||||
all_parts: bool,
|
||||
include_part_checksums: bool,
|
||||
}
|
||||
|
||||
impl FileMeta {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -554,8 +511,53 @@ impl FileMeta {
|
||||
}
|
||||
|
||||
if fi.deleted {
|
||||
if let (Some(delete_marker), Some(state)) = (ventry.delete_marker.as_mut(), fi.replication_state_internal.as_ref()) {
|
||||
persist_delete_marker_replication_state(&mut delete_marker.meta_sys, state);
|
||||
if !fi.delete_marker_replication_status().is_empty()
|
||||
&& let Some(delete_marker) = ventry.delete_marker.as_mut()
|
||||
{
|
||||
if fi.delete_marker_replication_status() == ReplicationStatusType::Replica {
|
||||
insert_bytes(
|
||||
&mut delete_marker.meta_sys,
|
||||
SUFFIX_REPLICA_STATUS,
|
||||
fi.replication_state_internal
|
||||
.as_ref()
|
||||
.map(|v| v.replica_status.clone())
|
||||
.unwrap_or_default()
|
||||
.as_str()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
);
|
||||
insert_bytes(
|
||||
&mut delete_marker.meta_sys,
|
||||
SUFFIX_REPLICA_TIMESTAMP,
|
||||
fi.replication_state_internal
|
||||
.as_ref()
|
||||
.map(|v| v.replica_timestamp.unwrap_or(OffsetDateTime::UNIX_EPOCH).to_string())
|
||||
.unwrap_or_default()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
);
|
||||
} else {
|
||||
insert_bytes(
|
||||
&mut delete_marker.meta_sys,
|
||||
SUFFIX_REPLICATION_STATUS,
|
||||
fi.replication_state_internal
|
||||
.as_ref()
|
||||
.map(|v| v.replication_status_internal.clone().unwrap_or_default())
|
||||
.unwrap_or_default()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
);
|
||||
insert_bytes(
|
||||
&mut delete_marker.meta_sys,
|
||||
SUFFIX_REPLICATION_TIMESTAMP,
|
||||
fi.replication_state_internal
|
||||
.as_ref()
|
||||
.map(|v| v.replication_timestamp.unwrap_or(OffsetDateTime::UNIX_EPOCH).to_string())
|
||||
.unwrap_or_default()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !fi.version_purge_status().is_empty()
|
||||
@@ -607,8 +609,51 @@ impl FileMeta {
|
||||
}
|
||||
|
||||
if let Some(delete_marker) = v.delete_marker.as_mut() {
|
||||
if let Some(state) = fi.replication_state_internal.as_ref() {
|
||||
persist_delete_marker_replication_state(&mut delete_marker.meta_sys, state);
|
||||
if !fi.delete_marker_replication_status().is_empty() {
|
||||
if fi.delete_marker_replication_status() == ReplicationStatusType::Replica {
|
||||
insert_bytes(
|
||||
&mut delete_marker.meta_sys,
|
||||
SUFFIX_REPLICA_STATUS,
|
||||
fi.replication_state_internal
|
||||
.as_ref()
|
||||
.map(|v| v.replica_status.clone())
|
||||
.unwrap_or_default()
|
||||
.as_str()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
);
|
||||
insert_bytes(
|
||||
&mut delete_marker.meta_sys,
|
||||
SUFFIX_REPLICA_TIMESTAMP,
|
||||
fi.replication_state_internal
|
||||
.as_ref()
|
||||
.map(|v| v.replica_timestamp.unwrap_or(OffsetDateTime::UNIX_EPOCH).to_string())
|
||||
.unwrap_or_default()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
);
|
||||
} else {
|
||||
insert_bytes(
|
||||
&mut delete_marker.meta_sys,
|
||||
SUFFIX_REPLICATION_STATUS,
|
||||
fi.replication_state_internal
|
||||
.as_ref()
|
||||
.map(|v| v.replication_status_internal.clone().unwrap_or_default())
|
||||
.unwrap_or_default()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
);
|
||||
insert_bytes(
|
||||
&mut delete_marker.meta_sys,
|
||||
SUFFIX_REPLICATION_TIMESTAMP,
|
||||
fi.replication_state_internal
|
||||
.as_ref()
|
||||
.map(|v| v.replication_timestamp.unwrap_or(OffsetDateTime::UNIX_EPOCH).to_string())
|
||||
.unwrap_or_default()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(state) = fi.replication_state_internal.as_ref() {
|
||||
@@ -728,47 +773,6 @@ impl FileMeta {
|
||||
read_data: bool,
|
||||
include_free_versions: bool,
|
||||
all_parts: bool,
|
||||
) -> Result<FileInfo> {
|
||||
self.to_fileinfo_with_part_checksums(
|
||||
volume,
|
||||
path,
|
||||
version_id,
|
||||
FileInfoDecodeOptions {
|
||||
read_data,
|
||||
include_free_versions,
|
||||
all_parts,
|
||||
include_part_checksums: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn into_fileinfo_without_part_checksums(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
version_id: &str,
|
||||
read_data: bool,
|
||||
include_free_versions: bool,
|
||||
) -> Result<FileInfo> {
|
||||
self.to_fileinfo_with_part_checksums(
|
||||
volume,
|
||||
path,
|
||||
version_id,
|
||||
FileInfoDecodeOptions {
|
||||
read_data,
|
||||
include_free_versions,
|
||||
all_parts: true,
|
||||
include_part_checksums: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn to_fileinfo_with_part_checksums(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
version_id: &str,
|
||||
opts: FileInfoDecodeOptions,
|
||||
) -> Result<FileInfo> {
|
||||
let vid = {
|
||||
if !version_id.is_empty() {
|
||||
@@ -791,7 +795,7 @@ impl FileMeta {
|
||||
|
||||
if header.free_version() {
|
||||
non_free_versions -= 1;
|
||||
if opts.include_free_versions
|
||||
if include_free_versions
|
||||
&& found_free_version.is_none()
|
||||
&& let Ok(found_free_fi) = ver.parse_version_meta()
|
||||
&& found_free_fi.version_type != VersionType::Invalid
|
||||
@@ -802,8 +806,7 @@ impl FileMeta {
|
||||
// Known side effect: if a disk holds only free versions and they are
|
||||
// corrupt, `into_fileinfo` falls through to `FileNotFound` (not
|
||||
// `FileCorrupt`), so that disk is not enqueued for heal.
|
||||
match found_free_fi.to_fileinfo_with_part_checksums(volume, path, opts.all_parts, opts.include_part_checksums)
|
||||
{
|
||||
match found_free_fi.into_fileinfo(volume, path, all_parts) {
|
||||
Ok(mut free_fi) => {
|
||||
free_fi.is_latest = true;
|
||||
found_free_version = Some(free_fi);
|
||||
@@ -831,14 +834,14 @@ impl FileMeta {
|
||||
|
||||
found = true;
|
||||
|
||||
let mut fi = ver.to_fileinfo_with_part_checksums(volume, path, opts.all_parts, opts.include_part_checksums)?;
|
||||
let mut fi = ver.into_fileinfo(volume, path, all_parts)?;
|
||||
fi.is_latest = is_latest;
|
||||
|
||||
if let Some(_d) = succ_mod_time {
|
||||
fi.successor_mod_time = succ_mod_time;
|
||||
}
|
||||
|
||||
if opts.read_data && fi.inline_data() {
|
||||
if read_data && fi.inline_data() {
|
||||
fi.data = self.find_inline_data_for_version(fi.version_id)?.map(bytes::Bytes::from);
|
||||
}
|
||||
|
||||
@@ -847,7 +850,7 @@ impl FileMeta {
|
||||
|
||||
if !found {
|
||||
if version_id.is_empty() {
|
||||
if opts.include_free_versions
|
||||
if include_free_versions
|
||||
&& non_free_versions == 0
|
||||
&& let Some(free_version) = found_free_version
|
||||
{
|
||||
@@ -1534,68 +1537,6 @@ mod test {
|
||||
assert_eq!(meta_sys2.len(), 2, "must not create a double-prefixed key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_delete_marker_replication_state_keeps_replica_and_target_statuses() {
|
||||
let replica_timestamp = OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND;
|
||||
let replication_timestamp = replica_timestamp + time::Duration::SECOND;
|
||||
let state = ReplicationState {
|
||||
replica_status: ReplicationStatusType::Replica,
|
||||
replica_timestamp: Some(replica_timestamp),
|
||||
replication_status_internal: Some("arn:target=COMPLETED;".to_string()),
|
||||
replication_timestamp: Some(replication_timestamp),
|
||||
..Default::default()
|
||||
};
|
||||
let mut meta_sys = HashMap::new();
|
||||
let replica_timestamp_string = replica_timestamp
|
||||
.format(&Rfc3339)
|
||||
.expect("timestamp should format as RFC3339");
|
||||
let replication_timestamp_string = replication_timestamp
|
||||
.format(&Rfc3339)
|
||||
.expect("timestamp should format as RFC3339");
|
||||
|
||||
persist_delete_marker_replication_state(&mut meta_sys, &state);
|
||||
|
||||
assert_eq!(
|
||||
rustfs_utils::http::get_bytes(&meta_sys, SUFFIX_REPLICA_STATUS).as_deref(),
|
||||
Some(b"REPLICA".as_slice())
|
||||
);
|
||||
assert_eq!(
|
||||
rustfs_utils::http::get_bytes(&meta_sys, SUFFIX_REPLICA_TIMESTAMP).as_deref(),
|
||||
Some(replica_timestamp_string.as_bytes())
|
||||
);
|
||||
assert_eq!(
|
||||
rustfs_utils::http::get_bytes(&meta_sys, SUFFIX_REPLICATION_STATUS).as_deref(),
|
||||
Some(b"arn:target=COMPLETED;".as_slice())
|
||||
);
|
||||
assert_eq!(
|
||||
rustfs_utils::http::get_bytes(&meta_sys, SUFFIX_REPLICATION_TIMESTAMP).as_deref(),
|
||||
Some(replication_timestamp_string.as_bytes())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_delete_marker_replication_timestamp_normalizes_second_offset_to_utc() {
|
||||
let timestamp = OffsetDateTime::UNIX_EPOCH.to_offset(time::UtcOffset::from_hms(5, 30, 15).expect("valid offset"));
|
||||
assert_eq!(parse_replication_timestamp(×tamp.to_string()), Some(timestamp));
|
||||
let state = ReplicationState {
|
||||
replica_status: ReplicationStatusType::Replica,
|
||||
replica_timestamp: Some(timestamp),
|
||||
replication_status_internal: Some("arn:target=COMPLETED;".to_string()),
|
||||
replication_timestamp: Some(timestamp),
|
||||
..Default::default()
|
||||
};
|
||||
let mut meta_sys = HashMap::new();
|
||||
|
||||
persist_delete_marker_replication_state(&mut meta_sys, &state);
|
||||
|
||||
for suffix in [SUFFIX_REPLICA_TIMESTAMP, SUFFIX_REPLICATION_TIMESTAMP] {
|
||||
let persisted = rustfs_utils::http::get_bytes(&meta_sys, suffix).expect("timestamp must be persisted");
|
||||
let persisted = std::str::from_utf8(&persisted).expect("timestamp must be UTF-8");
|
||||
assert_eq!(parse_replication_timestamp(persisted), Some(timestamp));
|
||||
assert_ne!(persisted, OffsetDateTime::UNIX_EPOCH.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test for rustfs/rustfs#2715: a corrupted version count in
|
||||
/// xl.meta must yield a decode error instead of sizing a huge allocation
|
||||
/// from the bogus count (which aborts the whole process).
|
||||
|
||||
@@ -29,12 +29,11 @@ use super::*;
|
||||
use crate::{ChecksumInfo, TransitionVersionState};
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use rustfs_utils::http::{
|
||||
RUSTFS_INTERNAL_PREFIX, SUFFIX_CRC, SUFFIX_FREE_VERSION, SUFFIX_INLINE_DATA, SUFFIX_PART_CHECKSUMS, SUFFIX_PURGESTATUS,
|
||||
SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX, SUFFIX_REPLICATION_RESET_ARN_PREFIX, SUFFIX_TIER_FV_ID,
|
||||
RUSTFS_INTERNAL_PREFIX, SUFFIX_CRC, SUFFIX_FREE_VERSION, SUFFIX_INLINE_DATA, SUFFIX_PURGESTATUS, SUFFIX_TIER_FV_ID,
|
||||
SUFFIX_TIER_FV_MARKER, SUFFIX_TRANSITION_STATUS, SUFFIX_TRANSITION_TIER, SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
||||
SUFFIX_TRANSITIONED_OBJECTNAME, SUFFIX_TRANSITIONED_VERSION_ID, SUFFIX_TRANSITIONED_VERSION_STATE, contains_key_bytes,
|
||||
get_bytes, get_consistent_bytes, get_str, has_internal_suffix, insert_bytes, is_internal_key, remove_bytes,
|
||||
strip_internal_prefix, strip_internal_prefix_preserving_case, target_delete_marker_versions,
|
||||
strip_internal_prefix, target_delete_marker_versions,
|
||||
};
|
||||
|
||||
const MSGPACK_EXT8: u8 = 0xc7;
|
||||
@@ -259,181 +258,63 @@ fn parse_legacy_uuid_bytes(bytes: &[u8], field: &str) -> Result<Option<Uuid>> {
|
||||
/// Legacy RustFS writes used 16 raw UUID bytes. New writes and MinIO-migrated
|
||||
/// records use the provider's exact UTF-8 version text. Empty, nil UUID, and
|
||||
/// malformed bytes are not usable remote versions.
|
||||
fn transition_version_state_from_bytes(value: Option<&[u8]>) -> Result<TransitionVersionState> {
|
||||
let Some(value) = value else {
|
||||
return Ok(TransitionVersionState::Unknown);
|
||||
fn transitioned_version_from_meta_sys(meta_sys: &HashMap<String, Vec<u8>>) -> Result<Option<String>> {
|
||||
if !contains_key_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_ID) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(value) = get_consistent_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_ID) else {
|
||||
return Ok(None);
|
||||
};
|
||||
match value {
|
||||
b"known-disabled" => Ok(TransitionVersionState::KnownDisabled),
|
||||
b"suspended-null" => Ok(TransitionVersionState::SuspendedNull),
|
||||
b"exact" => Ok(TransitionVersionState::Exact),
|
||||
b"unknown" => Ok(TransitionVersionState::Unknown),
|
||||
_ => Err(Error::FileCorrupt),
|
||||
}
|
||||
}
|
||||
|
||||
fn transitioned_version_from_bytes(value: Option<&[u8]>, state: TransitionVersionState) -> Option<String> {
|
||||
let value = value?;
|
||||
let value = value.to_vec();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
if state == TransitionVersionState::Unknown
|
||||
&& let Ok(id) = Uuid::from_slice(value)
|
||||
{
|
||||
return (!id.is_nil()).then(|| id.to_string());
|
||||
if let Ok(id) = Uuid::from_slice(&value) {
|
||||
return Ok((!id.is_nil()).then(|| id.to_string()));
|
||||
}
|
||||
let Ok(value) = std::str::from_utf8(value) else {
|
||||
return None;
|
||||
let Ok(value) = String::from_utf8(value) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_TRANSITION_VERSION_LEN
|
||||
|| value.chars().any(char::is_control)
|
||||
|| Uuid::parse_str(value).is_ok_and(|id| id.is_nil())
|
||||
|| Uuid::parse_str(&value).is_ok_and(|id| id.is_nil())
|
||||
{
|
||||
None
|
||||
Ok(None)
|
||||
} else {
|
||||
Some(value.to_string())
|
||||
Ok(Some(value))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_transition_version_state(state: TransitionVersionState, version: Option<&str>) -> Result<()> {
|
||||
fn transition_version_state_from_meta_sys(
|
||||
meta_sys: &HashMap<String, Vec<u8>>,
|
||||
version: Option<&str>,
|
||||
) -> Result<TransitionVersionState> {
|
||||
if !contains_key_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_STATE) {
|
||||
return Ok(TransitionVersionState::Unknown);
|
||||
}
|
||||
let value = get_consistent_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_STATE).ok_or(Error::FileCorrupt)?;
|
||||
let state = match value {
|
||||
b"known-disabled" => TransitionVersionState::KnownDisabled,
|
||||
b"suspended-null" => TransitionVersionState::SuspendedNull,
|
||||
b"exact" => TransitionVersionState::Exact,
|
||||
b"unknown" => TransitionVersionState::Unknown,
|
||||
_ => return Err(Error::FileCorrupt),
|
||||
};
|
||||
let valid = match state {
|
||||
TransitionVersionState::Unknown | TransitionVersionState::KnownDisabled => version.is_none(),
|
||||
TransitionVersionState::SuspendedNull => version == Some("null"),
|
||||
TransitionVersionState::Exact => version.is_some_and(|value| value != "null"),
|
||||
};
|
||||
valid.then_some(()).ok_or(Error::FileCorrupt)
|
||||
valid.then_some(state).ok_or(Error::FileCorrupt)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DerivedInternalMetadata<'a> {
|
||||
checksum: Option<&'a [u8]>,
|
||||
part_checksums: Option<&'a [u8]>,
|
||||
transition_status: Option<&'a [u8]>,
|
||||
transitioned_object: Option<&'a [u8]>,
|
||||
transitioned_version: Option<&'a [u8]>,
|
||||
transitioned_version_state: Option<&'a [u8]>,
|
||||
transition_tier: Option<&'a [u8]>,
|
||||
}
|
||||
|
||||
impl<'a> DerivedInternalMetadata<'a> {
|
||||
fn from_meta_sys(meta_sys: &'a HashMap<String, Vec<u8>>) -> Result<Self> {
|
||||
let mut canonical = Self::default();
|
||||
let mut legacy = Self::default();
|
||||
for (key, value) in meta_sys {
|
||||
let Some(suffix) = rustfs_utils::http::strip_internal_prefix_preserving_case(key) else {
|
||||
continue;
|
||||
};
|
||||
let (canonical_slot, legacy_slot, expected_suffix) = if suffix.eq_ignore_ascii_case(SUFFIX_CRC) {
|
||||
(&mut canonical.checksum, &mut legacy.checksum, SUFFIX_CRC)
|
||||
} else if suffix.eq_ignore_ascii_case(SUFFIX_PART_CHECKSUMS) {
|
||||
(&mut canonical.part_checksums, &mut legacy.part_checksums, SUFFIX_PART_CHECKSUMS)
|
||||
} else if suffix.eq_ignore_ascii_case(SUFFIX_TRANSITION_STATUS) {
|
||||
(&mut canonical.transition_status, &mut legacy.transition_status, SUFFIX_TRANSITION_STATUS)
|
||||
} else if suffix.eq_ignore_ascii_case(SUFFIX_TRANSITIONED_OBJECTNAME) {
|
||||
(
|
||||
&mut canonical.transitioned_object,
|
||||
&mut legacy.transitioned_object,
|
||||
SUFFIX_TRANSITIONED_OBJECTNAME,
|
||||
)
|
||||
} else if suffix.eq_ignore_ascii_case(SUFFIX_TRANSITIONED_VERSION_ID) {
|
||||
(
|
||||
&mut canonical.transitioned_version,
|
||||
&mut legacy.transitioned_version,
|
||||
SUFFIX_TRANSITIONED_VERSION_ID,
|
||||
)
|
||||
} else if suffix.eq_ignore_ascii_case(SUFFIX_TRANSITIONED_VERSION_STATE) {
|
||||
(
|
||||
&mut canonical.transitioned_version_state,
|
||||
&mut legacy.transitioned_version_state,
|
||||
SUFFIX_TRANSITIONED_VERSION_STATE,
|
||||
)
|
||||
} else if suffix.eq_ignore_ascii_case(SUFFIX_TRANSITION_TIER) {
|
||||
(&mut canonical.transition_tier, &mut legacy.transition_tier, SUFFIX_TRANSITION_TIER)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let slot = if suffix == expected_suffix
|
||||
&& (key.starts_with(RUSTFS_INTERNAL_PREFIX) || key.starts_with(rustfs_utils::http::MINIO_INTERNAL_PREFIX))
|
||||
{
|
||||
canonical_slot
|
||||
} else {
|
||||
legacy_slot
|
||||
};
|
||||
if slot.is_some_and(|current| current != value.as_slice()) {
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
*slot = Some(value.as_slice());
|
||||
}
|
||||
Ok(Self {
|
||||
checksum: canonical.checksum.or(legacy.checksum),
|
||||
part_checksums: canonical.part_checksums.or(legacy.part_checksums),
|
||||
transition_status: canonical.transition_status.or(legacy.transition_status),
|
||||
transitioned_object: canonical.transitioned_object.or(legacy.transitioned_object),
|
||||
transitioned_version: canonical.transitioned_version.or(legacy.transitioned_version),
|
||||
transitioned_version_state: canonical.transitioned_version_state.or(legacy.transitioned_version_state),
|
||||
transition_tier: canonical.transition_tier.or(legacy.transition_tier),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct UniquePartChecksums(HashMap<String, String>);
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for UniquePartChecksums {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct UniquePartChecksumsVisitor;
|
||||
|
||||
impl<'de> serde::de::Visitor<'de> for UniquePartChecksumsVisitor {
|
||||
type Value = UniquePartChecksums;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("an array of unique checksum name and value pairs")
|
||||
}
|
||||
|
||||
fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::SeqAccess<'de>,
|
||||
{
|
||||
let mut checksums = HashMap::with_capacity(seq.size_hint().unwrap_or_default());
|
||||
while let Some((key, value)) = seq.next_element::<(String, String)>()? {
|
||||
if checksums.insert(key, value).is_some() {
|
||||
return Err(serde::de::Error::custom("duplicate part checksum name"));
|
||||
}
|
||||
}
|
||||
Ok(UniquePartChecksums(checksums))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_seq(UniquePartChecksumsVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
impl FileInfo {
|
||||
pub fn hydrate_data_movement_part_checksums(&mut self) -> Result<()> {
|
||||
let present = self
|
||||
.metadata
|
||||
.keys()
|
||||
.any(|key| has_internal_suffix(key, SUFFIX_PART_CHECKSUMS));
|
||||
if !present {
|
||||
return Ok(());
|
||||
}
|
||||
let encoded = rustfs_utils::http::get_consistent_str(&self.metadata, SUFFIX_PART_CHECKSUMS).ok_or(Error::FileCorrupt)?;
|
||||
let persisted = serde_json::from_str::<Vec<(usize, UniquePartChecksums)>>(encoded).map_err(|_| Error::FileCorrupt)?;
|
||||
let mut part_indices = HashMap::with_capacity(self.parts.len());
|
||||
for (index, part) in self.parts.iter().enumerate() {
|
||||
if part_indices.insert(part.number, index).is_some() {
|
||||
return Err(Error::FileCorrupt);
|
||||
}
|
||||
}
|
||||
for (part_number, UniquePartChecksums(checksums)) in persisted {
|
||||
let index = part_indices.remove(&part_number).ok_or(Error::FileCorrupt)?;
|
||||
let part = self.parts.get_mut(index).ok_or(Error::FileCorrupt)?;
|
||||
part.checksums = Some(checksums);
|
||||
}
|
||||
Ok(())
|
||||
fn transition_version_state_bytes(state: TransitionVersionState) -> &'static [u8] {
|
||||
match state {
|
||||
TransitionVersionState::Unknown => b"unknown",
|
||||
TransitionVersionState::KnownDisabled => b"known-disabled",
|
||||
TransitionVersionState::SuspendedNull => b"suspended-null",
|
||||
TransitionVersionState::Exact => b"exact",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,10 +322,21 @@ fn set_transition_version_state(meta_sys: &mut HashMap<String, Vec<u8>>, state:
|
||||
if state == TransitionVersionState::Unknown {
|
||||
remove_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_STATE);
|
||||
} else {
|
||||
insert_bytes(meta_sys, SUFFIX_TRANSITIONED_VERSION_STATE, state.as_str().as_bytes().to_vec());
|
||||
insert_bytes(
|
||||
meta_sys,
|
||||
SUFFIX_TRANSITIONED_VERSION_STATE,
|
||||
transition_version_state_bytes(state).to_vec(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_transitioned_version_id_from_meta_sys(meta_sys: &HashMap<String, Vec<u8>>) -> Option<Uuid> {
|
||||
transitioned_version_from_meta_sys(meta_sys)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|value| Uuid::parse_str(&value).ok())
|
||||
}
|
||||
|
||||
fn transitioned_version_bytes(fi: &FileInfo) -> Option<Vec<u8>> {
|
||||
fi.transition_version
|
||||
.as_ref()
|
||||
@@ -556,17 +448,6 @@ impl FileMetaShallowVersion {
|
||||
pub fn into_fileinfo(&self, volume: &str, path: &str, all_parts: bool) -> Result<FileInfo> {
|
||||
self.parse_version_meta()?.into_fileinfo(volume, path, all_parts)
|
||||
}
|
||||
|
||||
pub(super) fn to_fileinfo_with_part_checksums(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
all_parts: bool,
|
||||
include_part_checksums: bool,
|
||||
) -> Result<FileInfo> {
|
||||
self.parse_version_meta()?
|
||||
.to_fileinfo_with_part_checksums(volume, path, all_parts, include_part_checksums)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<FileMetaVersion> for FileMetaShallowVersion {
|
||||
@@ -888,16 +769,8 @@ impl FileMetaVersion {
|
||||
}
|
||||
|
||||
pub fn into_fileinfo(&self, volume: &str, path: &str, all_parts: bool) -> Result<FileInfo> {
|
||||
self.to_fileinfo_with_part_checksums(volume, path, all_parts, true)
|
||||
}
|
||||
|
||||
pub(super) fn to_fileinfo_with_part_checksums(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
all_parts: bool,
|
||||
include_part_checksums: bool,
|
||||
) -> Result<FileInfo> {
|
||||
// Only the Object arm carries part arrays and can fail the length guard; the
|
||||
// Legacy and Delete arms have no part arrays and stay infallible.
|
||||
let mut fi = match self.version_type {
|
||||
VersionType::Invalid | VersionType::Legacy => {
|
||||
if let Some(ref legacy) = self.legacy_object {
|
||||
@@ -915,14 +788,14 @@ impl FileMetaVersion {
|
||||
self.object
|
||||
.as_ref()
|
||||
.unwrap_or(&default_object)
|
||||
.to_fileinfo_with_part_checksums(volume, path, all_parts, include_part_checksums)?
|
||||
.into_fileinfo(volume, path, all_parts)?
|
||||
}
|
||||
VersionType::Delete => {
|
||||
let default_marker = MetaDeleteMarker::default();
|
||||
self.delete_marker
|
||||
.as_ref()
|
||||
.unwrap_or(&default_marker)
|
||||
.into_fileinfo(volume, path, all_parts)?
|
||||
.into_fileinfo(volume, path, all_parts)
|
||||
}
|
||||
};
|
||||
fi.uses_legacy_checksum = self.uses_legacy_checksum;
|
||||
@@ -2517,18 +2390,7 @@ impl MetaObject {
|
||||
}
|
||||
|
||||
pub fn into_fileinfo(&self, volume: &str, path: &str, all_parts: bool) -> Result<FileInfo> {
|
||||
self.to_fileinfo_with_part_checksums(volume, path, all_parts, true)
|
||||
}
|
||||
|
||||
fn to_fileinfo_with_part_checksums(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
all_parts: bool,
|
||||
include_part_checksums: bool,
|
||||
) -> Result<FileInfo> {
|
||||
let version_id = self.version_id.filter(|&vid| !vid.is_nil());
|
||||
let derived_metadata = DerivedInternalMetadata::from_meta_sys(&self.meta_sys)?;
|
||||
|
||||
let parts = if all_parts {
|
||||
let n = self.part_numbers.len();
|
||||
@@ -2612,10 +2474,7 @@ impl MetaObject {
|
||||
}
|
||||
}
|
||||
|
||||
let checksum = derived_metadata
|
||||
.checksum
|
||||
.filter(|checksum| !checksum.is_empty())
|
||||
.map(Bytes::copy_from_slice);
|
||||
let checksum = get_bytes(&self.meta_sys, SUFFIX_CRC).map(Bytes::from);
|
||||
|
||||
let erasure = ErasureInfo {
|
||||
algorithm: self.erasure_algorithm.to_string(),
|
||||
@@ -2627,29 +2486,20 @@ impl MetaObject {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let transition_status = derived_metadata
|
||||
.transition_status
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|v| String::from_utf8_lossy(v).to_string())
|
||||
let transition_status = get_bytes(&self.meta_sys, SUFFIX_TRANSITION_STATUS)
|
||||
.map(|v| String::from_utf8_lossy(&v).to_string())
|
||||
.unwrap_or_default();
|
||||
let transitioned_objname = derived_metadata
|
||||
.transitioned_object
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|v| String::from_utf8_lossy(v).to_string())
|
||||
let transitioned_objname = get_bytes(&self.meta_sys, SUFFIX_TRANSITIONED_OBJECTNAME)
|
||||
.map(|v| String::from_utf8_lossy(&v).to_string())
|
||||
.unwrap_or_default();
|
||||
let transition_version_state = transition_version_state_from_bytes(derived_metadata.transitioned_version_state)?;
|
||||
let transition_version = transitioned_version_from_bytes(derived_metadata.transitioned_version, transition_version_state);
|
||||
if derived_metadata.transitioned_version_state.is_some() {
|
||||
validate_transition_version_state(transition_version_state, transition_version.as_deref())?;
|
||||
}
|
||||
let transition_version = transitioned_version_from_meta_sys(&self.meta_sys)?;
|
||||
let transition_version_state = transition_version_state_from_meta_sys(&self.meta_sys, transition_version.as_deref())?;
|
||||
let transition_version_id = transition_version.as_deref().and_then(|value| Uuid::parse_str(value).ok());
|
||||
let transition_tier = derived_metadata
|
||||
.transition_tier
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|v| String::from_utf8_lossy(v).to_string())
|
||||
let transition_tier = get_bytes(&self.meta_sys, SUFFIX_TRANSITION_TIER)
|
||||
.map(|v| String::from_utf8_lossy(&v).to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut file_info = FileInfo {
|
||||
Ok(FileInfo {
|
||||
version_id,
|
||||
erasure,
|
||||
data_dir: self.data_dir,
|
||||
@@ -2669,11 +2519,7 @@ impl MetaObject {
|
||||
transition_version_state,
|
||||
transition_tier,
|
||||
..Default::default()
|
||||
};
|
||||
if all_parts && include_part_checksums {
|
||||
file_info.hydrate_data_movement_part_checksums()?;
|
||||
}
|
||||
Ok(file_info)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_transition(&mut self, fi: &FileInfo) {
|
||||
@@ -2864,31 +2710,39 @@ fn get_internal_replication_state(metadata: &HashMap<String, String>) -> Option<
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(sub_key) = strip_internal_prefix_preserving_case(k) {
|
||||
if sub_key.eq_ignore_ascii_case(SUFFIX_REPLICA_TIMESTAMP) {
|
||||
has = true;
|
||||
rs.replica_timestamp = Some(parse_replication_timestamp(v).unwrap_or(OffsetDateTime::UNIX_EPOCH));
|
||||
} else if sub_key.eq_ignore_ascii_case(SUFFIX_REPLICA_STATUS) {
|
||||
has = true;
|
||||
rs.replica_status = ReplicationStatusType::from(v.as_str());
|
||||
} else if sub_key.eq_ignore_ascii_case(SUFFIX_REPLICATION_TIMESTAMP) {
|
||||
has = true;
|
||||
rs.replication_timestamp = Some(parse_replication_timestamp(v).unwrap_or(OffsetDateTime::UNIX_EPOCH))
|
||||
} else if sub_key.eq_ignore_ascii_case(SUFFIX_REPLICATION_STATUS) {
|
||||
has = true;
|
||||
rs.replication_status_internal = Some(v.clone());
|
||||
rs.targets = replication_statuses_map(v.as_str());
|
||||
} else if let Some(arn) = rustfs_utils::http::internal_key_strip_suffix_prefix(k, SUFFIX_REPLICATION_RESET_ARN_PREFIX)
|
||||
{
|
||||
has = true;
|
||||
// Store the canonical full-header key so the map matches
|
||||
// the key `target_reset_header()` produces on the
|
||||
// write/lookup side. Storing the bare ARN keyed the map
|
||||
// inconsistently (bare on read, full on write), which
|
||||
// could drop reset state across merge/reflatten cycles
|
||||
// (backlog#799 B16).
|
||||
rs.reset_statuses_map
|
||||
.insert(crate::replication::target_reset_header(&arn), v.clone());
|
||||
let sub_key_opt = strip_internal_prefix(k);
|
||||
if let Some(ref sub_key) = sub_key_opt {
|
||||
match sub_key.as_str() {
|
||||
"replica-timestamp" => {
|
||||
has = true;
|
||||
rs.replica_timestamp = Some(OffsetDateTime::parse(v, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH));
|
||||
}
|
||||
"replica-status" => {
|
||||
has = true;
|
||||
rs.replica_status = ReplicationStatusType::from(v.as_str());
|
||||
}
|
||||
"replication-timestamp" => {
|
||||
has = true;
|
||||
rs.replication_timestamp = Some(OffsetDateTime::parse(v, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH))
|
||||
}
|
||||
"replication-status" => {
|
||||
has = true;
|
||||
rs.replication_status_internal = Some(v.clone());
|
||||
rs.targets = replication_statuses_map(v.as_str());
|
||||
}
|
||||
_ => {
|
||||
if let Some(arn) = sub_key.strip_prefix("replication-reset-") {
|
||||
has = true;
|
||||
// Store the canonical full-header key so the map matches
|
||||
// the key `target_reset_header()` produces on the
|
||||
// write/lookup side. Storing the bare ARN keyed the map
|
||||
// inconsistently (bare on read, full on write), which
|
||||
// could drop reset state across merge/reflatten cycles
|
||||
// (backlog#799 B16).
|
||||
rs.reset_statuses_map
|
||||
.insert(crate::replication::target_reset_header(arn), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2932,7 +2786,7 @@ impl MetaDeleteMarker {
|
||||
contains_key_bytes(&self.meta_sys, SUFFIX_FREE_VERSION)
|
||||
}
|
||||
|
||||
pub fn into_fileinfo(&self, volume: &str, path: &str, _all_parts: bool) -> Result<FileInfo> {
|
||||
pub fn into_fileinfo(&self, volume: &str, path: &str, _all_parts: bool) -> FileInfo {
|
||||
let metadata = self
|
||||
.meta_sys
|
||||
.clone()
|
||||
@@ -2954,27 +2808,22 @@ impl MetaDeleteMarker {
|
||||
|
||||
if self.free_version() {
|
||||
fi.set_tier_free_version();
|
||||
let derived_metadata = DerivedInternalMetadata::from_meta_sys(&self.meta_sys)?;
|
||||
fi.transition_tier = derived_metadata
|
||||
.transition_tier
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| String::from_utf8_lossy(value).to_string())
|
||||
fi.transition_tier = get_bytes(&self.meta_sys, SUFFIX_TRANSITION_TIER)
|
||||
.map(|v| String::from_utf8_lossy(&v).to_string())
|
||||
.unwrap_or_default();
|
||||
fi.transitioned_objname = derived_metadata
|
||||
.transitioned_object
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| String::from_utf8_lossy(value).to_string())
|
||||
|
||||
fi.transitioned_objname = get_bytes(&self.meta_sys, SUFFIX_TRANSITIONED_OBJECTNAME)
|
||||
.map(|v| String::from_utf8_lossy(&v).to_string())
|
||||
.unwrap_or_default();
|
||||
fi.transition_version_state = transition_version_state_from_bytes(derived_metadata.transitioned_version_state)?;
|
||||
fi.transition_version =
|
||||
transitioned_version_from_bytes(derived_metadata.transitioned_version, fi.transition_version_state);
|
||||
fi.transition_version_id = fi.transition_version.as_deref().and_then(|value| Uuid::parse_str(value).ok());
|
||||
if derived_metadata.transitioned_version_state.is_some() {
|
||||
validate_transition_version_state(fi.transition_version_state, fi.transition_version.as_deref())?;
|
||||
}
|
||||
|
||||
fi.transition_version = transitioned_version_from_meta_sys(&self.meta_sys).ok().flatten();
|
||||
fi.transition_version_id = legacy_transitioned_version_id_from_meta_sys(&self.meta_sys);
|
||||
fi.transition_version_state =
|
||||
transition_version_state_from_meta_sys(&self.meta_sys, fi.transition_version.as_deref())
|
||||
.unwrap_or(TransitionVersionState::Unknown);
|
||||
}
|
||||
|
||||
Ok(fi)
|
||||
fi
|
||||
}
|
||||
|
||||
pub fn encode_to<W: std::io::Write>(&self, wr: &mut W) -> Result<()> {
|
||||
@@ -3092,13 +2941,6 @@ impl From<FileInfo> for MetaDeleteMarker {
|
||||
if !is_internal_key(key) || is_skip_meta_key(key) {
|
||||
continue;
|
||||
}
|
||||
if rustfs_utils::http::internal_key_strip_suffix_prefix(key, SUFFIX_REPLICATION_RESET_ARN_PREFIX).is_some()
|
||||
|| rustfs_utils::http::internal_key_strip_suffix_prefix(key, SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX)
|
||||
.is_some()
|
||||
{
|
||||
meta_sys.insert(key.clone(), metadata_value.as_bytes().to_vec());
|
||||
continue;
|
||||
}
|
||||
let Some(suffix) = strip_internal_prefix(key) else {
|
||||
continue;
|
||||
};
|
||||
@@ -3140,11 +2982,6 @@ impl From<FileInfo> for MetaDeleteMarker {
|
||||
if !value.transition_tier.is_empty() {
|
||||
insert_bytes(&mut meta_sys, SUFFIX_TRANSITION_TIER, value.transition_tier.as_bytes().to_vec());
|
||||
}
|
||||
if let Some(state) = value.replication_state_internal.as_ref() {
|
||||
persist_delete_marker_replication_state(&mut meta_sys, state);
|
||||
persist_reset_statuses(&mut meta_sys, &state.reset_statuses_map);
|
||||
persist_target_delete_marker_versions(&mut meta_sys, &state.target_delete_marker_version_ids, &value.metadata);
|
||||
}
|
||||
Self {
|
||||
version_id: value.version_id,
|
||||
mod_time: value.mod_time,
|
||||
@@ -3441,7 +3278,6 @@ pub fn file_info_from_raw(
|
||||
FileInfoOpts {
|
||||
data: read_data,
|
||||
include_free_versions,
|
||||
include_part_checksums: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -3449,7 +3285,6 @@ pub fn file_info_from_raw(
|
||||
pub struct FileInfoOpts {
|
||||
pub data: bool,
|
||||
pub include_free_versions: bool,
|
||||
pub include_part_checksums: bool,
|
||||
}
|
||||
|
||||
pub fn get_file_info(buf: &[u8], volume: &str, path: &str, version_id: &str, opts: FileInfoOpts) -> Result<FileInfo> {
|
||||
@@ -3474,11 +3309,7 @@ pub fn get_file_info(buf: &[u8], volume: &str, path: &str, version_id: &str, opt
|
||||
});
|
||||
}
|
||||
|
||||
let fi = if opts.include_part_checksums {
|
||||
meta.into_fileinfo(volume, path, version_id, opts.data, opts.include_free_versions, true)?
|
||||
} else {
|
||||
meta.into_fileinfo_without_part_checksums(volume, path, version_id, opts.data, opts.include_free_versions)?
|
||||
};
|
||||
let fi = meta.into_fileinfo(volume, path, version_id, opts.data, opts.include_free_versions, true)?;
|
||||
Ok(fi)
|
||||
}
|
||||
|
||||
@@ -3724,56 +3555,6 @@ mod tests {
|
||||
assert!(!converted.meta_sys.contains_key("content-type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_marker_conversion_does_not_lowercase_dynamic_replication_targets() {
|
||||
let arn = "arn:rustfs:replication:us-east-1:TenantA:bucket";
|
||||
let reset_suffix = format!("{SUFFIX_REPLICATION_RESET_ARN_PREFIX}{arn}");
|
||||
let version_suffix = format!("{SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX}{arn}");
|
||||
let mut marker = FileInfo::default();
|
||||
marker.metadata.insert(
|
||||
format!("{}{reset_suffix}", rustfs_utils::http::MINIO_INTERNAL_PREFIX),
|
||||
"2026-08-12T00:00:00Z;COMPLETED".to_string(),
|
||||
);
|
||||
marker.metadata.insert(
|
||||
format!("{}{version_suffix}", rustfs_utils::http::MINIO_INTERNAL_PREFIX),
|
||||
"remote-version".to_string(),
|
||||
);
|
||||
marker.replication_state_internal = get_internal_replication_state(&marker.metadata);
|
||||
|
||||
let converted = MetaDeleteMarker::from(marker);
|
||||
|
||||
assert!(converted.meta_sys.keys().any(|key| key.ends_with(&reset_suffix)));
|
||||
assert!(converted.meta_sys.keys().any(|key| key.ends_with(&version_suffix)));
|
||||
assert!(!converted.meta_sys.keys().any(|key| key.contains("tenanta")));
|
||||
|
||||
let mut conflicting = FileInfo::default();
|
||||
conflicting
|
||||
.metadata
|
||||
.insert(format!("{RUSTFS_INTERNAL_PREFIX}{version_suffix}"), "remote-version-a".to_string());
|
||||
conflicting.metadata.insert(
|
||||
format!("{}{version_suffix}", rustfs_utils::http::MINIO_INTERNAL_PREFIX),
|
||||
"remote-version-b".to_string(),
|
||||
);
|
||||
conflicting.replication_state_internal = get_internal_replication_state(&conflicting.metadata);
|
||||
assert!(
|
||||
conflicting
|
||||
.replication_state_internal
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.target_delete_marker_version_ids_corrupt)
|
||||
);
|
||||
|
||||
let roundtrip = MetaDeleteMarker::from(conflicting)
|
||||
.into_fileinfo("bucket", "object", false)
|
||||
.expect("dynamic replication aliases should remain decodable");
|
||||
assert!(
|
||||
roundtrip
|
||||
.replication_state_internal
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.target_delete_marker_version_ids_corrupt),
|
||||
"conflicting dynamic aliases must remain corrupt across persistence"
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
enum LegacyDeleteVersionTypeFixture {
|
||||
#[serde(rename = "DeleteMarker")]
|
||||
@@ -3882,138 +3663,6 @@ mod tests {
|
||||
assert!(matches!(res, Err(Error::FileCorrupt)), "short part_sizes must map to FileCorrupt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_fileinfo_rejects_conflicting_derived_internal_aliases() {
|
||||
for suffix in [
|
||||
SUFFIX_CRC,
|
||||
SUFFIX_TRANSITION_STATUS,
|
||||
SUFFIX_TRANSITIONED_OBJECTNAME,
|
||||
SUFFIX_TRANSITIONED_VERSION_ID,
|
||||
SUFFIX_TRANSITIONED_VERSION_STATE,
|
||||
SUFFIX_TRANSITION_TIER,
|
||||
] {
|
||||
let mut meta_sys = HashMap::from([(format!("{RUSTFS_INTERNAL_PREFIX}{suffix}"), vec![0xff, 1])]);
|
||||
meta_sys.insert(format!("{}{suffix}", rustfs_utils::http::MINIO_INTERNAL_PREFIX), vec![0xfe, 2]);
|
||||
let object = MetaObject {
|
||||
meta_sys,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
object
|
||||
.into_fileinfo("bucket", "key", false)
|
||||
.expect_err("conflicting aliases must fail closed"),
|
||||
Error::FileCorrupt,
|
||||
"suffix {suffix}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_fileinfo_recovers_noncanonical_binary_checksum_alias() {
|
||||
let checksum = vec![0xff, 0x00, 0x80, 0x01];
|
||||
let object = MetaObject {
|
||||
meta_sys: HashMap::from([("X-Minio-Internal-crc".to_string(), checksum.clone())]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let file_info = object
|
||||
.into_fileinfo("bucket", "key", false)
|
||||
.expect("a single legacy checksum alias should remain readable");
|
||||
|
||||
assert_eq!(file_info.checksum.as_deref(), Some(checksum.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_fileinfo_prefers_canonical_rewrite_over_stale_mixed_case_alias() {
|
||||
let checksum = vec![0xff, 0x00, 0x80, 0x01];
|
||||
let mut object = MetaObject {
|
||||
meta_sys: HashMap::from([("X-Minio-Internal-crc".to_string(), b"stale".to_vec())]),
|
||||
..Default::default()
|
||||
};
|
||||
insert_bytes(&mut object.meta_sys, SUFFIX_CRC, checksum.clone());
|
||||
|
||||
let file_info = object
|
||||
.into_fileinfo("bucket", "key", false)
|
||||
.expect("canonical rewrites should supersede legacy mixed-case aliases");
|
||||
|
||||
assert_eq!(file_info.checksum.as_deref(), Some(checksum.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_fileinfo_recovers_data_movement_part_checksums() {
|
||||
let mut object = object_with_parts(vec![1], vec![16], vec![16]);
|
||||
insert_bytes(&mut object.meta_sys, SUFFIX_PART_CHECKSUMS, br#"[[1,[["CRC32C","AAAAAA=="]]]]"#.to_vec());
|
||||
|
||||
let file_info = object
|
||||
.into_fileinfo("bucket", "key", true)
|
||||
.expect("data movement part checksums should decode");
|
||||
|
||||
assert_eq!(
|
||||
file_info.parts[0]
|
||||
.checksums
|
||||
.as_ref()
|
||||
.and_then(|checksums| checksums.get("CRC32C"))
|
||||
.map(String::as_str),
|
||||
Some("AAAAAA==")
|
||||
);
|
||||
|
||||
let mut deferred = object
|
||||
.to_fileinfo_with_part_checksums("bucket", "key", true, false)
|
||||
.expect("quorum candidates should retain raw checksum metadata");
|
||||
assert!(deferred.parts[0].checksums.is_none());
|
||||
deferred
|
||||
.hydrate_data_movement_part_checksums()
|
||||
.expect("the selected candidate should hydrate checksums once");
|
||||
assert_eq!(deferred.parts[0].checksums, file_info.parts[0].checksums);
|
||||
|
||||
insert_bytes(&mut object.meta_sys, SUFFIX_PART_CHECKSUMS, b"not-json".to_vec());
|
||||
assert_eq!(
|
||||
object
|
||||
.into_fileinfo("bucket", "key", true)
|
||||
.expect_err("malformed data movement part checksums must fail closed"),
|
||||
Error::FileCorrupt
|
||||
);
|
||||
|
||||
for encoded in [
|
||||
br#"[[1,[["CRC32C","AAAAAA=="]]],[1,[["CRC32C","BBBBBB=="]]]]"#.as_slice(),
|
||||
br#"[[1,[["CRC32C","AAAAAA=="],["CRC32C","BBBBBB=="]]]]"#.as_slice(),
|
||||
] {
|
||||
insert_bytes(&mut object.meta_sys, SUFFIX_PART_CHECKSUMS, encoded.to_vec());
|
||||
assert_eq!(
|
||||
object
|
||||
.into_fileinfo("bucket", "key", true)
|
||||
.expect_err("duplicate part checksum keys must fail closed"),
|
||||
Error::FileCorrupt
|
||||
);
|
||||
}
|
||||
|
||||
insert_bytes(&mut object.meta_sys, SUFFIX_PART_CHECKSUMS, br#"[[2,[["CRC32C","AAAAAA=="]]]]"#.to_vec());
|
||||
assert_eq!(
|
||||
object
|
||||
.into_fileinfo("bucket", "key", true)
|
||||
.expect_err("a sidecar for an unknown part must fail closed"),
|
||||
Error::FileCorrupt
|
||||
);
|
||||
|
||||
object.meta_sys = HashMap::from([
|
||||
(
|
||||
format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_PART_CHECKSUMS}"),
|
||||
br#"[[1,[["CRC32C","AAAAAA=="]]]]"#.to_vec(),
|
||||
),
|
||||
(
|
||||
format!("{}{}", rustfs_utils::http::MINIO_INTERNAL_PREFIX, SUFFIX_PART_CHECKSUMS),
|
||||
br#"[[1,[["CRC32C","BBBBBB=="]]]]"#.to_vec(),
|
||||
),
|
||||
]);
|
||||
assert_eq!(
|
||||
object
|
||||
.into_fileinfo("bucket", "key", true)
|
||||
.expect_err("conflicting sidecar aliases must fail closed"),
|
||||
Error::FileCorrupt
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_fileinfo_rejects_short_part_actual_sizes_including_empty() {
|
||||
let obj = object_with_parts(vec![1, 2], vec![10, 20], vec![]);
|
||||
@@ -4581,31 +4230,6 @@ mod tests {
|
||||
assert_eq!(fi.transition_version_state, TransitionVersionState::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_object_transition_exact_rejects_legacy_raw_uuid_encoding() {
|
||||
let mut sys = HashMap::new();
|
||||
insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_ID, sample_version_id().as_bytes().to_vec());
|
||||
insert_bytes(&mut sys, SUFFIX_TRANSITIONED_VERSION_STATE, b"exact".to_vec());
|
||||
|
||||
let err = make_meta_object_with_sys(sys)
|
||||
.into_fileinfo("b", "k", false)
|
||||
.expect_err("exact remote versions must use their UTF-8 provider representation");
|
||||
|
||||
assert_eq!(err, Error::FileCorrupt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_object_transition_version_id_mixed_case_alias_is_recovered() {
|
||||
let id = sample_version_id();
|
||||
let sys = HashMap::from([("X-Minio-Internal-transitioned-versionID".to_string(), id.as_bytes().to_vec())]);
|
||||
let fi = make_meta_object_with_sys(sys)
|
||||
.into_fileinfo("b", "k", false)
|
||||
.expect("a legacy mixed-case transition version alias should decode");
|
||||
|
||||
assert_eq!(fi.transition_version_id, Some(id));
|
||||
assert_eq!(fi.transition_version, Some(id.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_object_transition_version_id_opaque_text_is_preserved() {
|
||||
let mut sys = HashMap::new();
|
||||
@@ -4647,10 +4271,8 @@ mod tests {
|
||||
.map(Vec::as_slice),
|
||||
Some(b"exact".as_slice())
|
||||
);
|
||||
let persisted_version = get_consistent_bytes(&object.meta_sys, SUFFIX_TRANSITIONED_VERSION_ID);
|
||||
assert_eq!(
|
||||
transitioned_version_from_bytes(persisted_version, TransitionVersionState::Unknown)
|
||||
.and_then(|value| Uuid::parse_str(&value).ok()),
|
||||
legacy_transitioned_version_id_from_meta_sys(&object.meta_sys),
|
||||
Some(id),
|
||||
"UUID exact writes must remain readable by the legacy UUID consumer"
|
||||
);
|
||||
@@ -4659,26 +4281,6 @@ mod tests {
|
||||
assert_eq!(decoded.transition_version.as_deref(), Some(expected_version.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_object_transition_version_state_exact_preserves_sixteen_byte_opaque_text() {
|
||||
let expected_version = "opaque.wasabi_01";
|
||||
assert_eq!(expected_version.len(), 16);
|
||||
let fi = FileInfo {
|
||||
transition_status: "complete".to_string(),
|
||||
transition_version: Some(expected_version.to_string()),
|
||||
transition_version_state: TransitionVersionState::Exact,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let decoded = MetaObject::from(fi)
|
||||
.into_fileinfo("b", "k", false)
|
||||
.expect("exact opaque transition version should round trip");
|
||||
|
||||
assert_eq!(decoded.transition_version.as_deref(), Some(expected_version));
|
||||
assert_eq!(decoded.transition_version_id, None);
|
||||
assert_eq!(decoded.transition_version_state, TransitionVersionState::Exact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_transition_known_disabled_removes_stale_version_dual_keys() {
|
||||
let mut meta_sys = HashMap::new();
|
||||
@@ -4773,8 +4375,7 @@ mod tests {
|
||||
mod_time: None,
|
||||
meta_sys: sys,
|
||||
}
|
||||
.into_fileinfo("b", "k", false)
|
||||
.expect("nil tier version should remain an absent remote version");
|
||||
.into_fileinfo("b", "k", false);
|
||||
assert_eq!(fi.transition_version_id, None);
|
||||
}
|
||||
|
||||
@@ -4789,8 +4390,7 @@ mod tests {
|
||||
mod_time: None,
|
||||
meta_sys: sys,
|
||||
}
|
||||
.into_fileinfo("b", "k", false)
|
||||
.expect("legacy binary UUID tier version should decode");
|
||||
.into_fileinfo("b", "k", false);
|
||||
assert_eq!(fi.transition_version_id, Some(id));
|
||||
assert_eq!(fi.transition_version, Some(id.to_string()));
|
||||
}
|
||||
@@ -4807,8 +4407,7 @@ mod tests {
|
||||
mod_time: Some(sample_mod_time()),
|
||||
meta_sys: sys,
|
||||
}
|
||||
.into_fileinfo("b", "k", false)
|
||||
.expect("opaque tier version should remain readable");
|
||||
.into_fileinfo("b", "k", false);
|
||||
|
||||
assert_eq!(fi.transition_version_id, None);
|
||||
assert_eq!(fi.transition_version.as_deref(), Some("opaque-generation-42"));
|
||||
@@ -4830,67 +4429,12 @@ mod tests {
|
||||
mod_time: Some(sample_mod_time()),
|
||||
meta_sys: sys,
|
||||
}
|
||||
.into_fileinfo("b", "k", false)
|
||||
.expect("mixed-case tier aliases should decode");
|
||||
.into_fileinfo("b", "k", false);
|
||||
|
||||
assert_eq!(fi.transition_version_id, Some(id));
|
||||
assert_eq!(fi.transition_version, Some(id.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_marker_free_version_recovers_mixed_case_transition_aliases() {
|
||||
let id = sample_version_id();
|
||||
let id_text = id.to_string();
|
||||
let mut sys = HashMap::new();
|
||||
insert_bytes(&mut sys, SUFFIX_FREE_VERSION, vec![]);
|
||||
for (suffix, value) in [
|
||||
(SUFFIX_TRANSITIONED_VERSION_ID, id_text.as_bytes()),
|
||||
(SUFFIX_TRANSITIONED_VERSION_STATE, b"exact".as_slice()),
|
||||
(SUFFIX_TRANSITION_TIER, b"WARM".as_slice()),
|
||||
(SUFFIX_TRANSITIONED_OBJECTNAME, b"remote-object".as_slice()),
|
||||
] {
|
||||
sys.insert(format!("X-Minio-Internal-{suffix}"), value.to_vec());
|
||||
}
|
||||
|
||||
let fi = MetaDeleteMarker {
|
||||
version_id: Some(sample_version_id()),
|
||||
mod_time: Some(sample_mod_time()),
|
||||
meta_sys: sys,
|
||||
}
|
||||
.into_fileinfo("b", "k", false)
|
||||
.expect("mixed-case tier aliases should decode");
|
||||
|
||||
assert_eq!(fi.transition_version_id, Some(id));
|
||||
assert_eq!(fi.transition_version, Some(id.to_string()));
|
||||
assert_eq!(fi.transition_version_state, TransitionVersionState::Exact);
|
||||
assert_eq!(fi.transition_tier, "WARM");
|
||||
assert_eq!(fi.transitioned_objname, "remote-object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_marker_free_version_rejects_conflicting_transition_aliases() {
|
||||
let mut sys = HashMap::new();
|
||||
insert_bytes(&mut sys, SUFFIX_FREE_VERSION, vec![]);
|
||||
sys.insert(
|
||||
format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_TRANSITIONED_VERSION_ID}"),
|
||||
b"source-version".to_vec(),
|
||||
);
|
||||
sys.insert(
|
||||
format!("{}{}", rustfs_utils::http::MINIO_INTERNAL_PREFIX, SUFFIX_TRANSITIONED_VERSION_ID),
|
||||
b"target-version".to_vec(),
|
||||
);
|
||||
|
||||
let err = MetaDeleteMarker {
|
||||
version_id: Some(sample_version_id()),
|
||||
mod_time: Some(sample_mod_time()),
|
||||
meta_sys: sys,
|
||||
}
|
||||
.into_fileinfo("b", "k", false)
|
||||
.expect_err("conflicting transition aliases must fail closed");
|
||||
|
||||
assert_eq!(err, Error::FileCorrupt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_header_sorts_before_prefers_object_over_delete_marker_on_equal_mod_time() {
|
||||
let object = FileMetaVersionHeader {
|
||||
@@ -5214,7 +4758,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn target_delete_marker_version_metadata_is_forward_and_backward_compatible() {
|
||||
let arn = "arn:rustfs:replication:us-east-1:TenantA:bucket";
|
||||
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||
let suffix = format!("{}{arn}", rustfs_utils::http::SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX);
|
||||
let mut metadata = HashMap::from([(format!("{RUSTFS_INTERNAL_PREFIX}replication-status"), format!("{arn}=COMPLETED;"))]);
|
||||
|
||||
@@ -5259,7 +4803,7 @@ mod tests {
|
||||
// must keep it keyed by `target_reset_header(arn)` (not the bare ARN) so
|
||||
// `ReplicationState::target_state` finds it after a round trip
|
||||
// (backlog#799 B16).
|
||||
let arn = "arn:rustfs:replication:us-east-1:TenantA:bucket";
|
||||
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||
let ts = "2026-06-30T00:00:00Z;reset-1".to_string();
|
||||
let key = crate::replication::target_reset_header(arn);
|
||||
let mut metadata = HashMap::new();
|
||||
@@ -5278,33 +4822,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_internal_replication_state_accepts_rfc3339_and_rustfs_display_timestamps() {
|
||||
let replica_timestamp = OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND;
|
||||
let replication_timestamp = replica_timestamp + time::Duration::SECOND;
|
||||
let metadata = HashMap::from([
|
||||
(format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_REPLICA_STATUS}"), "REPLICA".to_string()),
|
||||
(
|
||||
format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_REPLICA_TIMESTAMP}"),
|
||||
replica_timestamp.to_string(),
|
||||
),
|
||||
(
|
||||
format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_REPLICATION_STATUS}"),
|
||||
"arn:rustfs:replication:us-east-1:TenantA:bucket=COMPLETED;".to_string(),
|
||||
),
|
||||
(
|
||||
format!("{RUSTFS_INTERNAL_PREFIX}{SUFFIX_REPLICATION_TIMESTAMP}"),
|
||||
replication_timestamp
|
||||
.format(&Rfc3339)
|
||||
.expect("RFC3339 timestamp should format"),
|
||||
),
|
||||
]);
|
||||
|
||||
let state = get_internal_replication_state(&metadata).expect("replication metadata should parse");
|
||||
assert_eq!(state.replica_timestamp, Some(replica_timestamp));
|
||||
assert_eq!(state.replication_timestamp, Some(replication_timestamp));
|
||||
}
|
||||
|
||||
// ---- Header signature (backlog#861 / B12) ----
|
||||
|
||||
fn signed_object() -> MetaObject {
|
||||
|
||||
@@ -157,7 +157,7 @@ impl MetaCacheEntry {
|
||||
});
|
||||
}
|
||||
|
||||
let fi = fm.into_fileinfo_without_part_checksums(bucket, self.name.as_str(), "", false, false)?;
|
||||
let fi = fm.into_fileinfo(bucket, self.name.as_str(), "", false, false, true)?;
|
||||
return Ok(fi);
|
||||
}
|
||||
|
||||
@@ -169,7 +169,6 @@ impl MetaCacheEntry {
|
||||
FileInfoOpts {
|
||||
data: false,
|
||||
include_free_versions: false,
|
||||
include_part_checksums: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -2158,38 +2157,4 @@ mod tests {
|
||||
let inner = caught.expect("file_info_versions must not panic");
|
||||
assert!(matches!(inner, Err(Error::FileCorrupt)), "expected FileCorrupt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metacache_to_fileinfo_ignores_part_checksum_sidecar_regardless_of_cache_state() {
|
||||
let mut meta = FileMeta::load(&create_real_xlmeta().expect("create real xl.meta")).expect("load real xl.meta");
|
||||
let version_id = Uuid::parse_str("01234567-89ab-cdef-0123-456789abcdef").expect("valid fixture version id");
|
||||
let (index, mut version) = meta.find_version(Some(version_id)).expect("find fixture object version");
|
||||
rustfs_utils::http::insert_bytes(
|
||||
&mut version.object.as_mut().expect("fixture object").meta_sys,
|
||||
rustfs_utils::http::SUFFIX_PART_CHECKSUMS,
|
||||
b"not-json".to_vec(),
|
||||
);
|
||||
meta.versions[index] = FileMetaShallowVersion::try_from(version).expect("replace fixture object version");
|
||||
let encoded = meta.marshal_msg().expect("marshal object metadata");
|
||||
|
||||
let uncached = MetaCacheEntry {
|
||||
name: "object".to_string(),
|
||||
metadata: encoded.clone(),
|
||||
cached: None,
|
||||
reusable: false,
|
||||
}
|
||||
.to_fileinfo("bucket")
|
||||
.expect("uncached metacache conversion must stay lazy");
|
||||
let cached = MetaCacheEntry {
|
||||
name: "object".to_string(),
|
||||
metadata: encoded,
|
||||
cached: Some(meta),
|
||||
reusable: false,
|
||||
}
|
||||
.to_fileinfo("bucket")
|
||||
.expect("cached metacache conversion must stay lazy");
|
||||
|
||||
assert_eq!(cached, uncached);
|
||||
assert!(cached.parts.iter().all(|part| part.checksums.is_none()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,21 +34,36 @@ pub enum Error {
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("Heal configuration error: {message}")]
|
||||
ConfigurationError { message: String },
|
||||
|
||||
#[error("Other error: {0}")]
|
||||
Other(String),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
IO(String),
|
||||
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Invalid checkpoint: {0}")]
|
||||
InvalidCheckpoint(String),
|
||||
|
||||
#[error("Heal task not found: {task_id}")]
|
||||
TaskNotFound { task_id: String },
|
||||
|
||||
#[error("Heal task already exists: {task_id}")]
|
||||
TaskAlreadyExists { task_id: String },
|
||||
|
||||
#[error("Invalid heal client token")]
|
||||
InvalidClientToken,
|
||||
|
||||
#[error("Heal manager is not running")]
|
||||
ManagerNotRunning,
|
||||
|
||||
#[error("Heal task execution failed: {message}")]
|
||||
TaskExecutionFailed { message: String },
|
||||
|
||||
@@ -63,6 +78,12 @@ pub enum Error {
|
||||
|
||||
#[error("Heal task timeout")]
|
||||
TaskTimeout,
|
||||
|
||||
#[error("Heal event processing failed: {message}")]
|
||||
EventProcessingFailed { message: String },
|
||||
|
||||
#[error("Heal progress tracking failed: {message}")]
|
||||
ProgressTrackingFailed { message: String },
|
||||
}
|
||||
|
||||
/// A specialized Result type for heal operations
|
||||
@@ -108,7 +129,9 @@ impl Error {
|
||||
| DiskError::FaultyDisk
|
||||
) || is_recoverable_heal_error_message(&err.to_string())
|
||||
}
|
||||
Error::TaskExecutionFailed { message } | Error::Other(message) => is_recoverable_heal_error_message(message),
|
||||
Error::TaskExecutionFailed { message } | Error::IO(message) | Error::Other(message) => {
|
||||
is_recoverable_heal_error_message(message)
|
||||
}
|
||||
Error::Io(err) => is_recoverable_heal_error_message(&err.to_string()),
|
||||
_ => false,
|
||||
}
|
||||
|
||||
@@ -597,7 +597,7 @@ impl HealTask {
|
||||
| EcstoreError::ObjectNotFound(_, _)
|
||||
| EcstoreError::VersionNotFound(_, _, _),
|
||||
) => true,
|
||||
Error::Other(message) => {
|
||||
Error::Other(message) | Error::IO(message) => {
|
||||
message.contains("File not found")
|
||||
|| message.contains("file not found")
|
||||
|| message.contains("File version not found")
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
|
||||
//! test endpoint index settings
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use tempfile::TempDir;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
//! bucket-metadata-sys OnceCell) — under `cargo nextest` each test runs
|
||||
//! in its own process so the OnceCell never collides.
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
use http::HeaderMap;
|
||||
use rustfs_common::heal_channel::{HealOpts, HealScanMode};
|
||||
use rustfs_heal::heal::{
|
||||
|
||||
@@ -21,8 +21,6 @@
|
||||
//! These drive the REAL `ECStoreHealStorage` + `ECStore` against real disks.
|
||||
//! Every test is `#[serial]`; under `cargo nextest` each runs in its own process.
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
use http::HeaderMap;
|
||||
use rustfs_common::heal_channel::{HealOpts, HealScanMode};
|
||||
use rustfs_heal::heal::storage::{
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
use http::HeaderMap;
|
||||
use rustfs_common::heal_channel::{HealOpts, HealScanMode};
|
||||
use rustfs_heal::heal::{
|
||||
|
||||
@@ -33,23 +33,31 @@ default = []
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
"hotpath/tokio",
|
||||
"rustfs-common/hotpath",
|
||||
"rustfs-s3-ops/hotpath",
|
||||
"rustfs-utils/hotpath",
|
||||
]
|
||||
hotpath-alloc = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-alloc",
|
||||
"rustfs-common/hotpath-alloc",
|
||||
"rustfs-s3-ops/hotpath-alloc",
|
||||
"rustfs-utils/hotpath-alloc",
|
||||
]
|
||||
hotpath-cpu = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-cpu",
|
||||
"rustfs-common/hotpath-cpu",
|
||||
"rustfs-s3-ops/hotpath-cpu",
|
||||
"rustfs-utils/hotpath-cpu",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
metrics = { workspace = true }
|
||||
rustfs-common = { workspace = true }
|
||||
rustfs-s3-ops = { workspace = true }
|
||||
rustfs-utils = { workspace = true, features = ["ip"] }
|
||||
num_cpus = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread"] }
|
||||
|
||||
@@ -196,19 +196,22 @@ pub const INTERNODE_OPERATION_METRICS: &[InternodeOperationMetricDescriptor] = &
|
||||
},
|
||||
];
|
||||
|
||||
static STABLE_SERVER_LABEL: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// Injects the stable server label (node name or address) stamped on
|
||||
/// internode metrics. The runtime calls this when the local node name is
|
||||
/// published (see ecstore's `set_local_node_name`); the first write wins.
|
||||
/// io-metrics is a leaf crate and no longer resolves node identity itself
|
||||
/// (backlog#1834) — before injection the label reads "unset".
|
||||
pub fn set_internode_server_label(label: impl Into<String>) {
|
||||
let _ = STABLE_SERVER_LABEL.set(label.into());
|
||||
}
|
||||
|
||||
fn current_server_label() -> &'static str {
|
||||
STABLE_SERVER_LABEL.get().map(String::as_str).unwrap_or("unset")
|
||||
static STABLE_SERVER_LABEL: OnceLock<String> = OnceLock::new();
|
||||
static FALLBACK_SERVER_LABEL: LazyLock<String> = LazyLock::new(rustfs_utils::get_local_ip_with_default);
|
||||
|
||||
if let Some(server) = STABLE_SERVER_LABEL.get() {
|
||||
return server.as_str();
|
||||
}
|
||||
|
||||
if let Some(server) = rustfs_common::try_get_global_local_node_name() {
|
||||
let _ = STABLE_SERVER_LABEL.set(server);
|
||||
if let Some(server) = STABLE_SERVER_LABEL.get() {
|
||||
return server.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
FALLBACK_SERVER_LABEL.as_str()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
|
||||
@@ -58,7 +58,7 @@ use std::sync::{
|
||||
/// When `false`, `record_put_object_path` and `record_put_object_stage_duration`
|
||||
/// become no-ops, and callers can skip the `Instant::now()` syscalls entirely.
|
||||
///
|
||||
/// Enabled only through an explicit runtime opt-in.
|
||||
/// Set to `true` during startup when OTEL metric export is enabled.
|
||||
static PUT_STAGE_METRICS_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
static GET_STAGE_METRICS_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -78,7 +78,7 @@ static METRICS_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Enable or disable detailed per-stage PUT metrics.
|
||||
///
|
||||
/// Called once during startup after applying the detailed PUT attribution opt-in.
|
||||
/// Called once during startup, typically gated by `rustfs_obs::observability_metric_enabled()`.
|
||||
pub fn set_put_stage_metrics_enabled(enabled: bool) {
|
||||
PUT_STAGE_METRICS_ENABLED.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
@@ -103,12 +103,6 @@ pub fn put_stage_metrics_enabled() -> bool {
|
||||
PUT_STAGE_METRICS_ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Start a PUT-stage timer only when detailed PUT attribution is enabled.
|
||||
#[inline(always)]
|
||||
pub fn put_stage_timer() -> Option<std::time::Instant> {
|
||||
put_stage_metrics_enabled().then(std::time::Instant::now)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn get_stage_metrics_enabled() -> bool {
|
||||
GET_STAGE_METRICS_ENABLED.load(Ordering::Relaxed)
|
||||
@@ -440,7 +434,7 @@ pub fn record_get_object_request_result(status: &str, duration_secs: f64) {
|
||||
/// Record PutObject request start.
|
||||
#[inline(always)]
|
||||
pub fn record_put_object_request_start(concurrent_requests: usize) {
|
||||
if !metrics_enabled() {
|
||||
if !put_stage_metrics_enabled() {
|
||||
return;
|
||||
}
|
||||
counter!("rustfs_io_put_object_requests_total").increment(1);
|
||||
@@ -450,7 +444,7 @@ pub fn record_put_object_request_start(concurrent_requests: usize) {
|
||||
/// Record PutObject request result.
|
||||
#[inline(always)]
|
||||
pub fn record_put_object_request_result(status: &str, duration_secs: f64) {
|
||||
if !metrics_enabled() {
|
||||
if !put_stage_metrics_enabled() {
|
||||
return;
|
||||
}
|
||||
counter!("rustfs_io_put_object_request_results_total", "status" => status.to_string()).increment(1);
|
||||
@@ -812,17 +806,6 @@ pub fn record_get_object_metadata_fanout_shape(path: &'static str, total: usize,
|
||||
.record(metadata_fanout_count_to_f64(non_valid));
|
||||
}
|
||||
|
||||
/// Record task lifecycle shape for one GetObject metadata fanout.
|
||||
#[inline(always)]
|
||||
pub fn record_get_object_metadata_fanout_lifecycle(path: &'static str, scheduled: usize, completed: usize, cancelled: usize) {
|
||||
if !get_stage_metrics_enabled() {
|
||||
return;
|
||||
}
|
||||
histogram!("rustfs_io_get_object_metadata_fanout_scheduled", "path" => path).record(metadata_fanout_count_to_f64(scheduled));
|
||||
histogram!("rustfs_io_get_object_metadata_fanout_completed", "path" => path).record(metadata_fanout_count_to_f64(completed));
|
||||
histogram!("rustfs_io_get_object_metadata_fanout_cancelled", "path" => path).record(metadata_fanout_count_to_f64(cancelled));
|
||||
}
|
||||
|
||||
/// Record a guarded metadata early-stop hit for GetObject.
|
||||
#[inline(always)]
|
||||
pub fn record_get_object_metadata_early_stop_hit(path: &'static str, reason: &'static str) {
|
||||
@@ -1922,7 +1905,7 @@ pub fn record_get_object(duration_ms: f64, size_bytes: i64) {
|
||||
/// * `zero_copy_eligible` - Whether the request was eligible for a zero-copy path
|
||||
#[inline(always)]
|
||||
pub fn record_put_object(duration_ms: f64, size_bytes: i64, zero_copy_eligible: bool) {
|
||||
if !metrics_enabled() {
|
||||
if !put_stage_metrics_enabled() {
|
||||
return;
|
||||
}
|
||||
counter!("rustfs_s3_put_object_total").increment(1);
|
||||
@@ -2021,13 +2004,6 @@ pub fn record_put_object_stage_duration(stage: &'static str, duration_ms: f64) {
|
||||
histogram!("rustfs_s3_put_object_stage_duration_ms", "stage" => stage).record(duration_ms);
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn record_put_object_stage_duration_from(stage: &'static str, started_at: Option<std::time::Instant>) {
|
||||
if let Some(started_at) = started_at {
|
||||
record_put_object_stage_duration(stage, started_at.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record generic internal operation stage duration (non-PUT paths).
|
||||
/// Use this for metacache walks, listing, lifecycle, and other background
|
||||
/// operations that are NOT part of the PUT object hot path.
|
||||
@@ -2703,17 +2679,12 @@ mod tests {
|
||||
record_get_object_reader_prefetch_wait("codec_streaming", 0.0002);
|
||||
record_get_object_response_handoff("standard", "selected", 8192, 1024, 0.0001);
|
||||
record_get_object_metadata_fanout_duration("legacy_duplex", 0.001);
|
||||
record_get_object_stage_duration("legacy_duplex", "read_version_path_resolve", 0.0001);
|
||||
record_get_object_stage_duration("legacy_duplex", "read_version_path_check", 0.0001);
|
||||
record_get_object_stage_duration("legacy_duplex", "read_version_xlmeta_read", 0.0005);
|
||||
record_get_object_stage_duration("legacy_duplex", "read_version_decode", 0.0002);
|
||||
record_get_object_first_metadata_response_latency("legacy_duplex", 0.001);
|
||||
record_get_object_first_valid_metadata_response_latency("legacy_duplex", 0.001);
|
||||
record_get_object_slowest_metadata_response_latency("legacy_duplex", 0.003);
|
||||
record_get_object_quorum_reached_latency("legacy_duplex", 0.002);
|
||||
record_get_object_metadata_response("legacy_duplex", "valid");
|
||||
record_get_object_metadata_fanout_shape("legacy_duplex", 4, 3, 1, 1);
|
||||
record_get_object_metadata_fanout_lifecycle("legacy_duplex", 4, 3, 1);
|
||||
record_get_object_metadata_early_stop_hit("legacy_duplex", "valid_quorum");
|
||||
record_get_object_metadata_early_stop_miss("legacy_duplex", "insufficient_quorum");
|
||||
record_get_object_metadata_early_stop_saved_responses("legacy_duplex", 1);
|
||||
@@ -2784,38 +2755,6 @@ mod tests {
|
||||
assert!(remote_scheduled >= remote_avoid_potential);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_fanout_lifecycle_records_named_histograms() {
|
||||
let _guard = METRICS_FLAG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
set_get_stage_metrics_enabled(true);
|
||||
record_get_object_metadata_fanout_lifecycle("legacy_duplex", 4, 3, 1);
|
||||
set_get_stage_metrics_enabled(false);
|
||||
});
|
||||
|
||||
let metrics = snapshotter.snapshot().into_vec();
|
||||
for (name, expected) in [
|
||||
("rustfs_io_get_object_metadata_fanout_scheduled", 4.0),
|
||||
("rustfs_io_get_object_metadata_fanout_completed", 3.0),
|
||||
("rustfs_io_get_object_metadata_fanout_cancelled", 1.0),
|
||||
] {
|
||||
let value = metrics.iter().find_map(|(composite, _, _, value)| {
|
||||
let has_path = composite
|
||||
.key()
|
||||
.labels()
|
||||
.any(|label| label.key() == "path" && label.value() == "legacy_duplex");
|
||||
(composite.kind() == MetricKind::Histogram && composite.key().name() == name && has_path).then_some(value)
|
||||
});
|
||||
assert!(
|
||||
matches!(value, Some(DebugValue::Histogram(values)) if values.len() == 1 && values[0].0 == expected),
|
||||
"{name} must record the exact fanout lifecycle sample"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_get_object_fill_metrics() {
|
||||
record_get_object_fill_queued("codec_streaming", "single_inflight", 1);
|
||||
@@ -2880,56 +2819,6 @@ mod tests {
|
||||
assert!(!put_stage_metrics_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_stage_gate_does_not_disable_basic_put_metrics() {
|
||||
let _guard = METRICS_FLAG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
set_metrics_enabled(true);
|
||||
set_put_stage_metrics_enabled(false);
|
||||
record_put_object_request_start(1);
|
||||
record_put_object_request_result("ok", 0.001);
|
||||
record_put_object(1.0, 1024, false);
|
||||
record_put_object_stage_duration("disabled_stage", 0.5);
|
||||
|
||||
set_put_stage_metrics_enabled(true);
|
||||
record_put_object_stage_duration("enabled_stage", 0.5);
|
||||
|
||||
set_put_stage_metrics_enabled(false);
|
||||
set_metrics_enabled(false);
|
||||
});
|
||||
|
||||
let metrics = snapshotter.snapshot().into_vec();
|
||||
assert!(metrics.iter().any(|(composite, _, _, _)| {
|
||||
composite.kind() == MetricKind::Counter && composite.key().name() == "rustfs_s3_put_object_total"
|
||||
}));
|
||||
assert!(metrics.iter().any(|(composite, _, _, _)| {
|
||||
composite.kind() == MetricKind::Counter && composite.key().name() == "rustfs_io_put_object_requests_total"
|
||||
}));
|
||||
|
||||
let stages = metrics
|
||||
.iter()
|
||||
.filter(|(composite, _, _, _)| {
|
||||
composite.kind() == MetricKind::Histogram && composite.key().name() == "rustfs_s3_put_object_stage_duration_ms"
|
||||
})
|
||||
.flat_map(|(composite, _, _, _)| composite.key().labels().map(|label| label.value().to_string()))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(stages, ["enabled_stage"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_put_stage_timer_follows_metrics_switch() {
|
||||
let _guard = METRICS_FLAG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
set_put_stage_metrics_enabled(false);
|
||||
assert!(put_stage_timer().is_none());
|
||||
|
||||
set_put_stage_metrics_enabled(true);
|
||||
assert!(put_stage_timer().is_some());
|
||||
set_put_stage_metrics_enabled(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_get_object_path_and_stage() {
|
||||
let _guard = METRICS_FLAG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
@@ -326,9 +326,8 @@ impl<'de> Deserialize<'de> for VaultKeyData {
|
||||
encrypted_key_material: encrypted_key_material
|
||||
.ok_or_else(|| de::Error::missing_field("encrypted_key_material"))?,
|
||||
baseline_version: baseline_version.unwrap_or(None),
|
||||
// Absent on records written before wrap accounting existed, and
|
||||
// on records an older build rewrote; zero restarts the
|
||||
// reservation rather than blocking a wrap.
|
||||
// Mirrors the struct's #[serde(default)]: absent on records
|
||||
// written before the wrap-budget field existed.
|
||||
wrap_budget_reserved: wrap_budget_reserved.unwrap_or(0),
|
||||
};
|
||||
unknown_fields.record_for_vault_kv2_key();
|
||||
@@ -349,7 +348,6 @@ impl<'de> Deserialize<'de> for VaultKeyData {
|
||||
"rotated_at",
|
||||
"encrypted_key_material",
|
||||
"baseline_version",
|
||||
"wrap_budget_reserved",
|
||||
];
|
||||
deserializer.deserialize_struct("VaultKeyData", FIELDS, VaultKeyDataVisitor)
|
||||
}
|
||||
@@ -3023,54 +3021,6 @@ mod tests {
|
||||
assert_eq!(legacy.version, 1);
|
||||
}
|
||||
|
||||
/// Every declared `VaultKeyData` field must survive a serialize/deserialize
|
||||
/// round trip through the hand-written `Deserialize`.
|
||||
///
|
||||
/// The hand-written impl lists its fields three times (the `Field` enum, the
|
||||
/// match arms, the struct literal), so a field added to the struct alone
|
||||
/// compiles on its own branch and only breaks once both branches merge —
|
||||
/// which is exactly how `wrap_budget_reserved` briefly broke the build.
|
||||
/// Asserting against the serialized key set makes the deserializer's
|
||||
/// coverage a test failure rather than a merge-order accident.
|
||||
#[test]
|
||||
fn vault_key_data_deserializer_covers_every_serialized_field() {
|
||||
let mut key_data = healthy_key_data();
|
||||
key_data.wrap_budget_reserved = 7_000_000;
|
||||
key_data.baseline_version = Some(2);
|
||||
key_data.rotated_at = Some(Zoned::now());
|
||||
key_data.deletion_date = Some(Zoned::now());
|
||||
key_data.description = Some("described".to_string());
|
||||
|
||||
let value = serde_json::to_value(&key_data).expect("serialize key data");
|
||||
let serialized_fields: Vec<String> = value
|
||||
.as_object()
|
||||
.expect("key data serializes to an object")
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Every serialized field must be a known field: an unknown one would be
|
||||
// counted by the unknown-field observer instead of being read back.
|
||||
let recorder = metrics_util::debugging::DebuggingRecorder::new();
|
||||
let restored: VaultKeyData =
|
||||
metrics::with_local_recorder(&recorder, || serde_json::from_value(value).expect("round trip"));
|
||||
assert_eq!(
|
||||
crate::test_support::unknown_field_metric(&recorder, "vault-kv2-key"),
|
||||
0,
|
||||
"a serialized field was not recognized by the deserializer; fields: {serialized_fields:?}"
|
||||
);
|
||||
|
||||
// And every value must survive, not just parse.
|
||||
assert_eq!(restored.wrap_budget_reserved, key_data.wrap_budget_reserved);
|
||||
assert_eq!(restored.baseline_version, key_data.baseline_version);
|
||||
assert_eq!(restored.version, key_data.version);
|
||||
assert_eq!(restored.status, key_data.status);
|
||||
assert_eq!(restored.description, key_data.description);
|
||||
assert_eq!(restored.encrypted_key_material, key_data.encrypted_key_material);
|
||||
assert!(restored.rotated_at.is_some());
|
||||
assert!(restored.deletion_date.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_key_data_unknown_fields_remain_readable_and_are_observed() {
|
||||
// A record written by a newer build carries fields this build does not
|
||||
|
||||
@@ -41,7 +41,6 @@ pub const ENV_KMS_AWS_ENDPOINT_URL: &str = "RUSTFS_KMS_AWS_ENDPOINT_URL";
|
||||
/// unset leaves rotation readiness unreported. Read once when the manager is
|
||||
/// built, by [`crate::manager::KmsManager`].
|
||||
pub const ENV_KMS_ROTATION_MAX_AGE_SECS: &str = "RUSTFS_KMS_ROTATION_MAX_AGE_SECS";
|
||||
pub const ENV_KMS_ROTATION_MAX_WRAPS: &str = "RUSTFS_KMS_ROTATION_MAX_WRAPS";
|
||||
pub const DEFAULT_VAULT_TRANSIT_METADATA_KV_MOUNT: &str = "secret";
|
||||
pub const DEFAULT_VAULT_TRANSIT_METADATA_KEY_PREFIX: &str = "rustfs/kms/transit-metadata";
|
||||
pub const DEFAULT_VAULT_APPROLE_MOUNT: &str = "approle";
|
||||
|
||||
+6
-136
@@ -17,7 +17,7 @@
|
||||
use crate::audit::{KmsAuditOperation, KmsAuditRecord, KmsAuditSink};
|
||||
use crate::backends::KmsBackend;
|
||||
use crate::cache::{KmsCache, KmsCacheStats};
|
||||
use crate::config::{ENV_KMS_ALLOW_IMMEDIATE_DELETION, ENV_KMS_ROTATION_MAX_AGE_SECS, ENV_KMS_ROTATION_MAX_WRAPS, KmsConfig};
|
||||
use crate::config::{ENV_KMS_ALLOW_IMMEDIATE_DELETION, ENV_KMS_ROTATION_MAX_AGE_SECS, KmsConfig};
|
||||
use crate::deletion_worker::DeletionReferenceChecker;
|
||||
use crate::error::{KmsError, Result};
|
||||
use crate::types::{
|
||||
@@ -42,13 +42,6 @@ use tracing::warn;
|
||||
/// after it was rotated, which trains operators to ignore the signal.
|
||||
const MIN_ROTATION_MAX_AGE: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// Smallest wrap budget that can be configured.
|
||||
///
|
||||
/// Wraps are accounted in reserved blocks, so any threshold below one block
|
||||
/// would be crossed by a single reservation and report a key that has barely
|
||||
/// wrapped anything as overdue.
|
||||
const MIN_ROTATION_MAX_WRAPS: u64 = 1_000_000;
|
||||
|
||||
/// Rotation age from the environment, or `None` when the signal is off.
|
||||
///
|
||||
/// Unset leaves it off rather than guessing a policy: how often a deployment
|
||||
@@ -75,33 +68,6 @@ fn parse_rotation_max_age(value: Option<&str>) -> Option<Duration> {
|
||||
Some(Duration::from_secs(seconds).max(MIN_ROTATION_MAX_AGE))
|
||||
}
|
||||
|
||||
/// Wrap budget from the environment, or `None` when the signal is off.
|
||||
///
|
||||
/// Same discipline as the age threshold: unset means unreported rather than a
|
||||
/// guessed policy, and an unparsable value is refused loudly instead of
|
||||
/// falling back to a number the operator did not write. Clamped to
|
||||
/// [`MIN_ROTATION_MAX_WRAPS`] because the backend accounts for wraps in
|
||||
/// reserved blocks, so a threshold below one block would trip on the first
|
||||
/// reservation regardless of how many wraps actually happened.
|
||||
fn configured_rotation_max_wraps() -> Option<u64> {
|
||||
parse_rotation_max_wraps(std::env::var(ENV_KMS_ROTATION_MAX_WRAPS).ok().as_deref())
|
||||
}
|
||||
|
||||
fn parse_rotation_max_wraps(value: Option<&str>) -> Option<u64> {
|
||||
let value = value?;
|
||||
let Ok(wraps) = value.trim().parse::<u64>() else {
|
||||
warn!(
|
||||
variable = ENV_KMS_ROTATION_MAX_WRAPS,
|
||||
"ignoring unparsable KMS rotation wrap budget; rotation readiness stays unreported"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
if wraps == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(wraps.max(MIN_ROTATION_MAX_WRAPS))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct KmsManager {
|
||||
backend: Arc<dyn KmsBackend>,
|
||||
@@ -116,7 +82,6 @@ pub struct KmsManager {
|
||||
/// the verdict unreported. Read once at construction so a listing cannot
|
||||
/// change its answer halfway through.
|
||||
rotation_max_age: Option<Duration>,
|
||||
rotation_max_wraps: Option<u64>,
|
||||
}
|
||||
|
||||
impl KmsManager {
|
||||
@@ -138,7 +103,6 @@ impl KmsManager {
|
||||
allow_immediate_deletion: config.allow_immediate_deletion,
|
||||
reference_checker: None,
|
||||
rotation_max_age: configured_rotation_max_age(),
|
||||
rotation_max_wraps: configured_rotation_max_wraps(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,22 +314,9 @@ impl KmsManager {
|
||||
key.rotation_due_reason = Some(RotationDueReason::Unsupported);
|
||||
return;
|
||||
}
|
||||
key.rotation_due = false;
|
||||
key.rotation_due_reason = None;
|
||||
|
||||
// The wrap budget is checked first: it is the cryptographic bound (the
|
||||
// AES-GCM random-nonce ceiling), whereas the age threshold is a policy
|
||||
// choice, so when both are crossed the reason an operator most needs to
|
||||
// see is the one they cannot negotiate.
|
||||
if let (Some(max_wraps), Some(wraps)) = (self.rotation_max_wraps, key.wrap_budget_reserved)
|
||||
&& wraps >= max_wraps
|
||||
{
|
||||
key.rotation_due = true;
|
||||
key.rotation_due_reason = Some(RotationDueReason::Wraps);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(max_age) = self.rotation_max_age else {
|
||||
key.rotation_due = false;
|
||||
key.rotation_due_reason = None;
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -382,6 +333,9 @@ impl KmsManager {
|
||||
if age >= max_age {
|
||||
key.rotation_due = true;
|
||||
key.rotation_due_reason = Some(reason);
|
||||
} else {
|
||||
key.rotation_due = false;
|
||||
key.rotation_due_reason = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1731,15 +1685,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn readiness_manager(rotation_max_age: Option<Duration>) -> KmsManager {
|
||||
readiness_manager_with(rotation_max_age, None)
|
||||
}
|
||||
|
||||
fn readiness_manager_with(rotation_max_age: Option<Duration>, rotation_max_wraps: Option<u64>) -> KmsManager {
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir");
|
||||
let config = KmsConfig::local(temp_dir.path().to_path_buf()).with_insecure_development_defaults();
|
||||
let mut manager = KmsManager::new(Arc::new(ScriptedBackend::succeeding()), config);
|
||||
manager.rotation_max_age = rotation_max_age;
|
||||
manager.rotation_max_wraps = rotation_max_wraps;
|
||||
manager
|
||||
}
|
||||
|
||||
@@ -1816,85 +1765,6 @@ mod tests {
|
||||
assert!(!key.rotation_due, "clock skew must not manufacture an overdue key");
|
||||
}
|
||||
|
||||
/// The wrap-budget half of the verdict: the cryptographic bound, checked
|
||||
/// independently of the age policy and reported under its own reason.
|
||||
#[test]
|
||||
fn rotation_readiness_reports_an_exhausted_wrap_budget() {
|
||||
let now = Zoned::now();
|
||||
let recently = &now - jiff::Span::new().hours(1);
|
||||
let long_ago = &now - jiff::Span::new().days(400);
|
||||
let day = Duration::from_secs(86_400);
|
||||
let budget = 2_000_000;
|
||||
|
||||
let with_wraps = |manager: &KmsManager, wraps: Option<u64>, rotated_at: Option<Zoned>| {
|
||||
let mut key = aged_key(rotated_at, recently.clone());
|
||||
key.wrap_budget_reserved = wraps;
|
||||
manager.apply_rotation_readiness(&mut key, true, &now);
|
||||
(key.rotation_due, key.rotation_due_reason)
|
||||
};
|
||||
|
||||
// Budget configured and exceeded on a freshly rotated key: due, and the
|
||||
// reason names the wrap budget rather than an age nobody crossed.
|
||||
let manager = readiness_manager_with(Some(day), Some(budget));
|
||||
assert_eq!(
|
||||
with_wraps(&manager, Some(budget), Some(recently.clone())),
|
||||
(true, Some(RotationDueReason::Wraps))
|
||||
);
|
||||
// At the threshold exactly, not only past it: the bound is a ceiling.
|
||||
assert_eq!(
|
||||
with_wraps(&manager, Some(budget + 1), Some(recently.clone())),
|
||||
(true, Some(RotationDueReason::Wraps))
|
||||
);
|
||||
// Under the threshold: no verdict from the wrap half.
|
||||
assert_eq!(with_wraps(&manager, Some(budget - 1), Some(recently.clone())), (false, None));
|
||||
|
||||
// The cryptographic bound outranks the policy one when both are crossed.
|
||||
let mut key = aged_key(Some(long_ago.clone()), long_ago);
|
||||
key.wrap_budget_reserved = Some(budget);
|
||||
manager.apply_rotation_readiness(&mut key, true, &now);
|
||||
assert_eq!(key.rotation_due_reason, Some(RotationDueReason::Wraps));
|
||||
|
||||
// No wrap threshold configured: an enormous count reports nothing, the
|
||||
// same way an unset age threshold does.
|
||||
let age_only = readiness_manager_with(Some(day), None);
|
||||
assert_eq!(with_wraps(&age_only, Some(u64::MAX), Some(recently.clone())), (false, None));
|
||||
|
||||
// Backend reports no count (Transit, AWS, or a pre-accounting record):
|
||||
// the wrap half stays silent instead of guessing, and the age half
|
||||
// still decides.
|
||||
let wraps_only = readiness_manager_with(None, Some(budget));
|
||||
assert_eq!(with_wraps(&wraps_only, None, Some(recently.clone())), (false, None));
|
||||
assert_eq!(
|
||||
with_wraps(&wraps_only, Some(budget), Some(recently.clone())),
|
||||
(true, Some(RotationDueReason::Wraps))
|
||||
);
|
||||
|
||||
// A backend that cannot rotate is never told to, whatever it wrapped.
|
||||
let mut key = aged_key(None, recently);
|
||||
key.wrap_budget_reserved = Some(u64::MAX);
|
||||
wraps_only.apply_rotation_readiness(&mut key, false, &now);
|
||||
assert!(!key.rotation_due);
|
||||
assert_eq!(key.rotation_due_reason, Some(RotationDueReason::Unsupported));
|
||||
}
|
||||
|
||||
/// Threshold parsing matches the age threshold's discipline: unset and
|
||||
/// unparsable both disable the signal rather than inventing a policy.
|
||||
#[test]
|
||||
fn rotation_wrap_threshold_parsing_refuses_to_guess() {
|
||||
assert_eq!(parse_rotation_max_wraps(None), None);
|
||||
assert_eq!(parse_rotation_max_wraps(Some("not-a-number")), None);
|
||||
assert_eq!(parse_rotation_max_wraps(Some("")), None);
|
||||
assert_eq!(parse_rotation_max_wraps(Some("-1")), None);
|
||||
assert_eq!(parse_rotation_max_wraps(Some("0")), None);
|
||||
// Clamped: below one reservation block the first reservation would trip it.
|
||||
assert_eq!(parse_rotation_max_wraps(Some("1")), Some(MIN_ROTATION_MAX_WRAPS));
|
||||
assert_eq!(
|
||||
parse_rotation_max_wraps(Some(" 5000000 ")),
|
||||
Some(5_000_000),
|
||||
"a configured budget above the floor is honored verbatim"
|
||||
);
|
||||
}
|
||||
|
||||
/// The two fields are additive on the wire: a payload written before they
|
||||
/// existed still deserializes, and a key with no verdict serializes exactly
|
||||
/// as it did before.
|
||||
|
||||
@@ -217,12 +217,6 @@ pub enum RotationDueReason {
|
||||
/// The key has never been rotated and has existed longer than the
|
||||
/// configured maximum age.
|
||||
NeverRotated,
|
||||
/// The key has wrapped more data keys than the configured maximum.
|
||||
///
|
||||
/// Counted per key-material version, so a rotation restarts the budget.
|
||||
/// The count is an over-estimate by construction (see the backend's
|
||||
/// reservation accounting), so this verdict errs toward rotating early.
|
||||
Wraps,
|
||||
/// The backend cannot rotate keys at all, so no age makes one due.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@ impl Drop for DistributedLockGuard {
|
||||
#[derive(Debug)]
|
||||
pub struct DistributedLock {
|
||||
/// Lock clients for this namespace
|
||||
clients: Arc<[Arc<dyn LockClient>]>,
|
||||
clients: Vec<Arc<dyn LockClient>>,
|
||||
/// Namespace identifier
|
||||
namespace: Arc<str>,
|
||||
/// Quorum size for exclusive/write operations
|
||||
@@ -496,11 +496,11 @@ struct LockAcquireQuorumResult {
|
||||
impl DistributedLock {
|
||||
/// Create new distributed lock
|
||||
pub fn new(namespace: String, clients: Vec<Arc<dyn LockClient>>, quorum: usize) -> Self {
|
||||
Self::new_shared(namespace.into(), clients.into(), quorum)
|
||||
Self::new_shared(namespace.into(), clients, quorum)
|
||||
}
|
||||
|
||||
/// Create a distributed lock that shares existing namespace and client allocations.
|
||||
pub(crate) fn new_shared(namespace: Arc<str>, clients: Arc<[Arc<dyn LockClient>]>, quorum: usize) -> Self {
|
||||
/// Create a distributed lock that shares an existing namespace allocation.
|
||||
pub(crate) fn new_shared(namespace: Arc<str>, clients: Vec<Arc<dyn LockClient>>, quorum: usize) -> Self {
|
||||
let q = if clients.len() <= 1 {
|
||||
1
|
||||
} else {
|
||||
@@ -777,7 +777,7 @@ impl DistributedLock {
|
||||
|
||||
fn spawn_pending_cleanup(
|
||||
mut pending: JoinSet<LockAcquireTaskResult>,
|
||||
clients: Arc<[Arc<dyn LockClient>]>,
|
||||
clients: Vec<Arc<dyn LockClient>>,
|
||||
fallback_lock_id: LockId,
|
||||
context: &'static str,
|
||||
) {
|
||||
|
||||
@@ -200,13 +200,9 @@ impl NamespaceLock {
|
||||
Self::Distributed(DistributedLock::new(namespace, clients, quorum))
|
||||
}
|
||||
|
||||
/// Create a namespace lock that shares existing namespace and client allocations.
|
||||
pub fn with_clients_and_quorum_shared(
|
||||
namespace: Arc<str>,
|
||||
clients: impl Into<Arc<[Arc<dyn LockClient>]>>,
|
||||
quorum: usize,
|
||||
) -> Self {
|
||||
Self::Distributed(DistributedLock::new_shared(namespace, clients.into(), quorum))
|
||||
/// Create a namespace lock that shares an existing namespace allocation.
|
||||
pub fn with_clients_and_quorum_shared(namespace: Arc<str>, clients: Vec<Arc<dyn LockClient>>, quorum: usize) -> Self {
|
||||
Self::Distributed(DistributedLock::new_shared(namespace, clients, quorum))
|
||||
}
|
||||
|
||||
/// Get namespace identifier
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Audit metrics collector.
|
||||
//!
|
||||
//! Collects audit log metrics including failed messages, queue length,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Cluster config metrics collector.
|
||||
//!
|
||||
//! Collects cluster configuration metrics including storage class
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Cluster erasure set metrics collector.
|
||||
//!
|
||||
//! Collects erasure coding set metrics including parity, quorum,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Cluster health metrics collector.
|
||||
//!
|
||||
//! Collects cluster-wide health metrics including drive counts
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Cluster IAM metrics collector.
|
||||
//!
|
||||
//! Collects IAM (Identity and Access Management) metrics including
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Cluster usage metrics collector.
|
||||
//!
|
||||
//! Collects cluster-wide and per-bucket usage metrics including
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! ILM (Information Lifecycle Management) metrics collector.
|
||||
//!
|
||||
//! Collects ILM metrics including pending tasks, active tasks,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Notification metrics collector.
|
||||
//!
|
||||
//! Collects notification system metrics including events sent,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::metrics::report::PrometheusMetric;
|
||||
use crate::metrics::schema::notification_target::{
|
||||
NOTIFICATION_TARGET_FAILED_MESSAGES_BY_SERVER_MD, NOTIFICATION_TARGET_FAILED_MESSAGES_MD,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user