perf(ecstore): add guarded encrypted multipart range seeks (#5145)

* perf(ecstore): seek encrypted multipart Range GETs to the covering part boundary on the Legacy rio v1 backend

Phase A of https://github.com/rustfs/backlog/issues/1316. Encrypted Range GETs on the default (non rio-v2) backend previously planned storage_offset=0, storage_length=oi.size and discarded the decrypted prefix, so a small Range on a large SSE object read, erasure-decoded, and decrypted the whole object. Eligible multipart objects now seek to the covering part boundary using only the per-part size/actual_size metadata facts; the multipart decrypt reader already handles streams starting at any part boundary, so rio and the on-disk format are untouched. Single-part objects, compressed payloads, defective parts tables, and zero-length ranges keep the previous full read, and RUSTFS_ENCRYPTED_RANGE_SEEK=false restores it globally. A new histogram rustfs_get_encrypted_range_read_amplification plus a full|part_seek path counter record the physical/plaintext amplification at the ReadPlan decision point.

* fix(ecstore): guard encrypted multipart range seeks

* fix(io-metrics): align encrypted range metric names with the rustfs_io_ prefix

Every other metric in rustfs-io-metrics uses the rustfs_io_ prefix; the
two encrypted-range-seek metrics were the only exception. Rename before
first release so dashboards never see the unprefixed names.
This commit is contained in:
Zhengchao An
2026-07-23 16:33:54 +08:00
committed by GitHub
parent 6bab9e421b
commit 8e214104f3
6 changed files with 2262 additions and 120 deletions
+31
View File
@@ -51,6 +51,37 @@ use uuid::Uuid;
pub const ERASURE_ALGORITHM: &str = "rs-vandermonde";
pub const BLOCK_SIZE_V2: usize = 1024 * 1024; // 1M
pub(crate) const ENCRYPTED_PART_LAYOUT_CANDIDATE_SUFFIX: &str = "encrypted-part-layout-quorum-candidate-v1";
pub(crate) const ENCRYPTED_PART_LAYOUT_QUORUM_SUFFIX: &str = "encrypted-part-layout-quorum-v1";
pub(crate) const ENV_RUSTFS_ENCRYPTED_RANGE_SEEK: &str = "RUSTFS_ENCRYPTED_RANGE_SEEK";
pub(crate) const DEFAULT_RUSTFS_ENCRYPTED_RANGE_SEEK: bool = false;
pub(crate) fn has_encrypted_part_layout_marker(metadata: &HashMap<String, String>, suffix: &str, expected: &str) -> bool {
let mut value = None;
for (key, candidate) in metadata {
if !rustfs_utils::http::has_internal_suffix(key, suffix) {
continue;
}
if candidate.is_empty() || value.is_some_and(|current| current != candidate) {
return false;
}
value = Some(candidate);
}
value.is_some_and(|value| value == expected)
}
pub(crate) fn legacy_encrypted_range_seek_enabled() -> bool {
// RUSTFS_COMPAT_TODO(backlog-1316): mixed-version MPUs need opt-in. Remove after all servers use candidate markers and uploadId locks.
#[cfg(test)]
{
rustfs_utils::get_env_bool(ENV_RUSTFS_ENCRYPTED_RANGE_SEEK, DEFAULT_RUSTFS_ENCRYPTED_RANGE_SEEK)
}
#[cfg(not(test))]
{
static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*CACHED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_ENCRYPTED_RANGE_SEEK, DEFAULT_RUSTFS_ENCRYPTED_RANGE_SEEK))
}
}
mod body_cache_hook;
mod hook_slot;
File diff suppressed because it is too large Load Diff
@@ -602,8 +602,7 @@ pub(in crate::set_disk) fn resolve_read_part_from_responses(
responses: &[Option<Vec<ObjectPartInfo>>],
read_quorum: usize,
) -> disk::error::Result<ObjectPartInfo> {
let mut etag_quorum = HashMap::new();
let mut part_infos = Vec::new();
let mut part_quorum: HashMap<(&str, usize, usize, i64), (usize, &ObjectPartInfo)> = HashMap::new();
let mut present_count = 0usize;
let mut missing_count = 0usize;
let mut transient_error_count = 0usize;
@@ -621,8 +620,10 @@ pub(in crate::set_disk) fn resolve_read_part_from_responses(
if !parts[part_idx].etag.is_empty() {
present_count += 1;
*etag_quorum.entry(parts[part_idx].etag.clone()).or_insert(0) += 1;
part_infos.push(parts[part_idx].clone());
let part = &parts[part_idx];
let key = (part.etag.as_str(), part.number, part.size, part.actual_size);
let (count, _) = part_quorum.entry(key).or_insert((0, part));
*count += 1;
continue;
}
@@ -633,31 +634,12 @@ pub(in crate::set_disk) fn resolve_read_part_from_responses(
}
}
let mut max_etag_quorum = 0;
let mut max_etag = None;
for (etag, quorum) in etag_quorum.iter() {
if quorum > &max_etag_quorum {
max_etag_quorum = *quorum;
max_etag = Some(etag);
}
}
let max_quorum = max_etag_quorum.max(missing_count);
let mut found = None;
for info in part_infos.iter() {
if let Some(etag) = max_etag
&& info.etag == *etag
{
found = Some(info.clone());
break;
}
}
if let (Some(found), Some(max_etag)) = (found, max_etag)
&& !found.etag.is_empty()
&& etag_quorum.get(max_etag).unwrap_or(&0) >= &read_quorum
let max_part = part_quorum.values().max_by_key(|(count, _)| count);
let max_quorum = max_part.map_or(0, |(count, _)| *count).max(missing_count);
if let Some((count, part)) = max_part
&& *count >= read_quorum
{
return Ok(found);
return Ok((*part).clone());
}
if missing_count >= read_quorum {
@@ -5134,17 +5116,34 @@ mod tests {
}
#[test]
fn resolve_read_part_returns_part_when_etag_reaches_quorum() {
let responses = vec![
Some(vec![read_part_test_part(1, "winner")]),
Some(vec![read_part_test_part(1, "loser")]),
Some(vec![read_part_test_part(1, "winner")]),
];
fn resolve_read_part_requires_layout_fields_to_reach_quorum() {
let mut valid = read_part_test_part(1, "winner");
valid.size = 100;
valid.actual_size = 90;
let mut wrong_etag = valid.clone();
wrong_etag.etag = "loser".to_string();
let mut wrong_number = valid.clone();
wrong_number.number = 2;
let mut wrong_size = valid.clone();
wrong_size.size = 50;
let mut wrong_actual_size = valid.clone();
wrong_actual_size.actual_size = 40;
let part = resolve_read_part_from_responses("bucket", "upload/part.1.meta", 1, 0, 1, &responses, 2)
.expect("etag quorum should resolve the present part");
for (field, corrupted) in [
("etag", wrong_etag),
("number", wrong_number),
("size", wrong_size),
("actual_size", wrong_actual_size),
] {
let responses = vec![Some(vec![corrupted]), Some(vec![valid.clone()]), Some(vec![valid.clone()])];
let part = resolve_read_part_from_responses("bucket", "upload/part.1.meta", 1, 0, 1, &responses, 2)
.unwrap_or_else(|err| panic!("{field} mismatch must not defeat layout quorum: {err}"));
assert_eq!(part.etag, "winner");
assert_eq!(part.etag, "winner", "{field}");
assert_eq!(part.number, 1, "{field}");
assert_eq!(part.size, 100, "{field}");
assert_eq!(part.actual_size, 90, "{field}");
}
}
#[test]
File diff suppressed because it is too large Load Diff
+13
View File
@@ -878,6 +878,19 @@ pub fn record_get_object_codec_streaming_fallback(reason: &'static str) {
counter!("rustfs_io_get_object_codec_streaming_fallback_total", "reason" => reason).increment(1);
}
/// Record the read path chosen for one encrypted Range GET on the Legacy (rio v1) backend
/// together with its read amplification — physical ciphertext bytes scheduled for the
/// erasure layer divided by the plaintext bytes the client requested. Observed at the
/// ReadPlan decision point (https://github.com/rustfs/backlog/issues/1316 Phase A).
#[inline(always)]
pub fn record_get_encrypted_range_read_amplification(path: &'static str, amplification: f64) {
if !get_stage_metrics_enabled() {
return;
}
counter!("rustfs_io_get_encrypted_range_read_path_total", "path" => path).increment(1);
histogram!("rustfs_io_get_encrypted_range_read_amplification", "path" => path).record(amplification);
}
/// Record the final codec-streaming rollout decision for a GET request.
#[inline(always)]
pub fn record_get_object_codec_streaming_decision(outcome: &'static str, object_class: &'static str, reason: &'static str) {
@@ -15,6 +15,7 @@ for later deletion.
- `#4648` walk-dir stream completion capability: old clients can append fallback output to an already-used metacache writer after a terminal body error, so servers emit terminal walk errors only to clients that sign the `walk_dir_stream_completion=error-v1` query capability and its request-body digest. Remove the legacy clean-EOF path after the minimum supported RustFS peer version always advertises this capability.
- `heal-rpc-auth-v2` internode gRPC authentication: servers temporarily accept legacy prefix signatures so old peers remain available during rolling upgrades. Remove the legacy fallback after the minimum supported RustFS peer version sends v2 authentication on every internode gRPC request.
- `heal-status-rpc-v1` node heal status capability: new peers treat an unimplemented BackgroundHealStatus RPC as an explicitly incomplete rolling-upgrade response. Remove the fallback after the minimum supported RustFS peer version implements BackgroundHealStatus.
- `backlog-1316` legacy encrypted multipart range seek: the feature remains opt-in until every server that can initiate, write, or complete multipart uploads supports the candidate-to-final marker protocol and uploadId commit lock, and pre-upgrade multipart uploads have drained. Remove the RUSTFS_ENCRYPTED_RANGE_SEEK switch after the minimum supported release does so; keep the quorum marker and malformed-layout full-read guards permanently.
## Review Checklist