fix(cache): stop the GET body-cache hook from bypassing ReadPlan (#4654)

* fix(cache): gate GET body-cache hook to preserve ReadPlan output

The ecstore GET body-cache hook serves cached full-object plaintext
directly, bypassing ReadPlan/ReadTransform. That is only sound when the
normal read path returns that same plaintext byte-for-byte. Two probe
conditions were missing, plus two usecase-layer planner gaps.

ODC-01 (backlog#1108): raw/data-movement reads. ReadPlan::build returns
the STORED representation for raw_data_movement_read (e.g. compressed
bytes, length = oi.size), but the cache holds the post-decompression
body. Decommission (raw_data_movement_read: true) would receive
decompressed plaintext where raw compressed bytes are required, silently
corrupting the destination pool.

ODC-02 (backlog#1109): compressed objects. ReadTransform::Compressed
rewrites object_info.size to the decompressed length; on a hook hit
object_info is returned unchanged, so object_info.size is the compressed
size while the stream carries the decompressed body. UploadPartCopy then
uses src_info.size as the copy length and truncates the part.

Fix: gate the hook probe with should_probe_body_cache_hook, refusing
raw_data_movement_read, data_movement, and compressed objects, mirroring
the conditions get_small_object_direct_memory_decision already applies.

ODC-33 (backlog#1138): build_get_object_body_cache_plan lacked the
is_remote() exclusion the ecstore hook enforces; add it so transitioned
(remote-tier) objects are excluded uniformly.

ODC-C1 (backlog#1142): zero-length bodies save no I/O (ecstore returns an
empty body before the hook probe) yet the planner admitted them; change
the guard to response_content_length <= 0 so they plan Skip, mirroring
should_buffer_get_object_in_memory_with_threshold.

Tests: body_cache_hook_gate_tests (4) cover plain-probe plus
raw/data-movement/compressed skips; planner gains
plan_skips_remote_transitioned_objects and plan_skips_zero_length_objects.

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(cache): allow compressed bodies via a fail-closed read allow-list

The body-cache hook probe was gated by a deny-list that refused compressed
objects outright, which cost the cache every compressed body — a growing
share of stored data. Replace it with an allow-list that returns the exact
plaintext length a hit may serve, or None.

full_object_plaintext_len() answers a single question: would the normal
ReadPlan produce this object's complete plaintext, and under which size?
Compressed objects now qualify, and the hit site publishes the returned
length as object_info.size, reproducing the contract ReadTransform::
Compressed establishes. A hit whose body length disagrees is refused and
falls through to the erasure read.

This also closes a gate the deny-list only covered by accident: a restore
read forces ReadPlan down the Plain branch, so a compressed object yields
STORED bytes under its compressed size. Refusing compressed objects hid
that; admitting them exposes it, so restore reads are refused explicitly.

Being fail-closed, a newly added ReadPlan branch bypasses the cache by
default rather than silently serving the wrong representation — the
structural defect behind both backlog#1108 and backlog#1109.

Refs: backlog#1108, backlog#1109

Co-Authored-By: heihutu <heihutu@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
houseme
2026-07-10 16:01:10 +08:00
committed by GitHub
parent a269f8df05
commit 8c76efead2
3 changed files with 242 additions and 4 deletions
+65 -1
View File
@@ -16,6 +16,7 @@
use crate::app::object_data_cache::ObjectDataCacheAdapter;
use crate::app::storage_api::object_usecase::StorageObjectInfo;
use crate::storage::storage_api::ecstore_bucket::lifecycle::bucket_lifecycle_ops::LifecycleOps as _;
use rustfs_object_data_cache::{ObjectDataCacheBodyVariant, ObjectDataCacheGetPlan, ObjectDataCacheGetRequest};
/// App-layer GET request snapshot used for cache planning.
@@ -53,7 +54,14 @@ pub(crate) fn build_get_object_body_cache_plan(
|| request.info.delete_marker
|| request.info.version_only
|| request.info.metadata_only
|| request.response_content_length < 0
// Remote (transitioned) objects are served from the warm tier; the
// ecstore hook already refuses them (hook.rs is_remote()), so the
// usecase-layer planner must exclude them too for a uniform contract.
|| request.info.is_remote()
// Zero-length bodies save no I/O — ecstore returns an empty body before
// the hook probe — so admitting them only creates useless entries and
// inflates hit metrics. Mirrors should_buffer_get_object_in_memory_with_threshold.
|| request.response_content_length <= 0
{
return GetObjectBodyCachePlan::Skip;
}
@@ -178,6 +186,62 @@ mod tests {
assert!(matches!(plan, GetObjectBodyCachePlan::Skip));
}
#[test]
fn plan_skips_remote_transitioned_objects() {
// backlog#1138: transitioned (remote-tier) objects are served from the
// warm backend; the ecstore hook already refuses them, so the
// usecase-layer planner must exclude them too for a uniform contract.
let adapter = enabled_adapter();
let mut info = crate::storage::storage_api::StorageObjectInfo {
etag: Some("etag".to_string()),
size: 4,
..Default::default()
};
info.transitioned_object.status = "complete".to_string();
let plan = build_get_object_body_cache_plan(
&adapter,
GetObjectBodyCacheRequest {
bucket: "bucket",
key: "object",
info: &info,
response_content_length: 4,
has_range: false,
part_number: None,
encryption_applied: false,
},
);
assert!(matches!(plan, GetObjectBodyCachePlan::Skip));
}
#[test]
fn plan_skips_zero_length_objects() {
// backlog#1142: ecstore returns an empty body before the hook probe, so
// a zero-length GET saves no I/O; admitting it only inflates hit metrics.
let adapter = enabled_adapter();
let info = crate::storage::storage_api::StorageObjectInfo {
etag: Some("etag".to_string()),
size: 0,
..Default::default()
};
let plan = build_get_object_body_cache_plan(
&adapter,
GetObjectBodyCacheRequest {
bucket: "bucket",
key: "object",
info: &info,
response_content_length: 0,
has_range: false,
part_number: None,
encryption_applied: false,
},
);
assert!(matches!(plan, GetObjectBodyCachePlan::Skip));
}
#[test]
fn plan_is_cacheable_for_plain_full_object() {
let adapter = enabled_adapter();