fix(object-data-cache): make the GET key write-unique and dedup the lookup (#4693)

fix(object-data-cache): make GET body cache key write-unique and dedup lookups

Address four object-data-cache GET-path findings (backlog#1107 batch):

ODC-06 (backlog#1111): the cache key was content-unique, not write-unique.
Extend ObjectDataCacheKey with the resolved version's modification time
(i128 unix nanoseconds, None -> 0), derived once in the shared planner so the
ecstore hook and the usecase layer produce an identical key. An unversioned
overwrite advances mod_time, so a stale node can no longer serve old bytes for
up to the TTL under an MD5 collision; etag + size stay as belt-and-braces.

ODC-16 (backlog#1121): every cacheable GET planned and looked up twice (once in
the ecstore hook, once in the usecase layer), double-counting hits, hit_bytes
and lookups. GetObjectReader now carries a GetObjectBodySource marker
(Unprobed / HookMissed / HookServed); the hook stamps it, and
build_get_object_body_with_cache serves a hook-served body directly and skips
its lookup whenever the hook already probed. One hook-served GET now records
exactly one lookup.

ODC-19 (backlog#1124): ENABLE=true with no explicit mode defaulted to HitOnly,
which never fills and keeps a permanent 0% hit rate. Default to
FillBufferedOnly, log the resolved mode at startup, and warn when HitOnly is
selected explicitly.

ODC-24 (backlog#1129): max_entry_bytes above the in-memory GET fill limits was
silently inert. Clamp the planner's size eligibility to
min(max_entry_bytes, seek-support threshold, 64 MiB buffer cap) so ineligible
sizes plan SkipTooLarge instead of being reported eligible, and warn at startup
when the excess is inert.

Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
houseme
2026-07-11 02:22:20 +08:00
committed by GitHub
parent d0ca14d8df
commit 6780140318
17 changed files with 781 additions and 39 deletions
+110 -8
View File
@@ -44,9 +44,14 @@ pub(crate) struct ObjectDataCacheAdapter {
impl ObjectDataCacheAdapter {
/// Creates an adapter from validated cache configuration.
///
/// ODC-24 (backlog#1129): the engine's size eligibility is clamped to the
/// in-memory GET fill limits so a `max_entry_bytes` above them is not
/// reported as eligible while fill could never materialize the body.
pub(crate) fn new(config: ObjectDataCacheConfig) -> Result<Self, ObjectDataCacheConfigError> {
let fill_ceiling = resolve_fill_ceiling_bytes(config.max_entry_bytes);
Ok(Self {
cache: Arc::new(ObjectDataCache::new(config)?),
cache: Arc::new(ObjectDataCache::new(config)?.with_fill_ceiling_bytes(fill_ceiling)),
})
}
@@ -124,8 +129,37 @@ impl ObjectDataCacheAdapter {
impl ObjectDataCacheAdapter {
fn from_config_or_disabled(config: ObjectDataCacheConfig, source: &str) -> Arc<Self> {
let mode = config.mode;
let max_entry_bytes = config.max_entry_bytes;
// `Self::new` applies the ODC-24 fill-ceiling clamp; this startup path
// only adds the resolved-config logging on top.
match Self::new(config) {
Ok(adapter) => Arc::new(adapter),
Ok(adapter) => {
if !adapter.is_disabled() {
let ceiling = resolve_fill_ceiling_bytes(max_entry_bytes);
// ODC-19: log the resolved mode at startup, and warn when the
// operator explicitly selected the never-filling HitOnly mode.
tracing::info!(source, ?mode, "object data cache enabled");
// ODC-24: warn when the excess above the effective ceiling is inert.
if max_entry_bytes > ceiling {
warn!(
source,
max_entry_bytes,
effective_fill_ceiling = ceiling,
"RUSTFS_OBJECT_DATA_CACHE_MAX_ENTRY_BYTES exceeds the in-memory GET fill limit; \
bodies above the effective cap are planned SkipTooLarge"
);
}
if mode == ObjectDataCacheMode::HitOnly {
warn!(
source,
"object data cache mode 'hit_only' never populates the cache; it only \
serves as a runtime-downgrade target and keeps a permanent 0% hit rate"
);
}
}
Arc::new(adapter)
}
Err(err) => {
warn!(
error = %err,
@@ -138,6 +172,17 @@ impl ObjectDataCacheAdapter {
}
}
/// Resolves the effective fill ceiling from the app-layer in-memory GET fill
/// limits: `min(max_entry_bytes, seek-support threshold, 64 MiB buffer cap)`
/// (backlog#1129 / ODC-24). The concurrency-driven shrink of the fill threshold
/// is dynamic and cannot be captured here, so this static ceiling is the
/// conservative floor.
fn resolve_fill_ceiling_bytes(max_entry_bytes: u64) -> u64 {
let seek_threshold = crate::app::object_usecase::object_seek_support_threshold() as u64;
let hard_cap = u64::try_from(crate::app::object_usecase::MAX_GET_OBJECT_MEMORY_BUFFER_BYTES).unwrap_or(u64::MAX);
max_entry_bytes.min(seek_threshold).min(hard_cap)
}
impl Default for ObjectDataCacheAdapter {
fn default() -> Self {
Self::disabled()
@@ -210,9 +255,13 @@ fn object_data_cache_config_from_values(values: ObjectDataCacheEnvValues) -> Obj
config.mode = ObjectDataCacheMode::Disabled;
}
Some(true) if !mode_explicit => {
// Enable flag without an explicit mode: start at the safest
// enabled stage instead of silently staying disabled.
config.mode = ObjectDataCacheMode::HitOnly;
// Enable flag without an explicit mode: start at the safest stage
// that actually populates the cache. `HitOnly` never fills, so it
// would keep a permanent 0% hit rate and pay per-GET overhead for
// nothing (backlog#1124 / ODC-19). `FillBufferedOnly` fills only
// from bodies the GET path already buffered — no extra
// materialization — so it is both safe and effective.
config.mode = ObjectDataCacheMode::FillBufferedOnly;
}
_ => {}
}
@@ -382,13 +431,66 @@ mod tests {
}
#[test]
fn object_data_cache_enable_true_without_mode_defaults_to_hit_only() {
fn object_data_cache_enable_true_without_mode_defaults_to_fill_buffered_only() {
// ODC-19 (backlog#1124): ENABLE=true with no explicit mode must default
// to a mode that actually populates the cache. HitOnly never fills.
let config = object_data_cache_config_from_values(ObjectDataCacheEnvValues {
enabled: Some(true),
..ObjectDataCacheEnvValues::default()
});
assert_eq!(config.mode, ObjectDataCacheMode::HitOnly);
assert_eq!(config.mode, ObjectDataCacheMode::FillBufferedOnly);
}
#[test]
fn resolve_fill_ceiling_clamps_large_max_entry_bytes() {
// ODC-24 (backlog#1129): a max_entry_bytes above the in-memory GET fill
// limits is clamped down; the ceiling never exceeds the 64 MiB hard cap.
let huge = 512 * 1024 * 1024;
let ceiling = super::resolve_fill_ceiling_bytes(huge);
assert!(ceiling < huge, "a huge max_entry_bytes must be clamped down");
assert!(
ceiling <= u64::try_from(crate::app::object_usecase::MAX_GET_OBJECT_MEMORY_BUFFER_BYTES).unwrap(),
"the ceiling must not exceed the 64 MiB hard cap"
);
}
#[test]
fn resolve_fill_ceiling_leaves_small_max_entry_bytes_untouched() {
// A small max_entry_bytes is already below the fill limits, so it is the
// binding constraint and passes through unchanged.
assert_eq!(super::resolve_fill_ceiling_bytes(4096), 4096);
}
#[test]
fn startup_adapter_clamps_plan_eligibility_to_fill_ceiling() {
// ODC-24: an adapter built on the startup path with a `max_entry_bytes`
// above the in-memory GET fill limits must plan a gap-sized object
// `SkipTooLarge`, not `Cacheable` — otherwise it reports eligible while
// it can never fill.
let adapter = ObjectDataCacheAdapter::from_config_or_disabled(
ObjectDataCacheConfig {
mode: ObjectDataCacheMode::HitOnly,
max_bytes: 200 * 1024 * 1024,
max_memory_percent: 0,
max_entry_bytes: 128 * 1024 * 1024,
..ObjectDataCacheConfig::default()
},
"test",
);
// 32 MiB is within max_entry_bytes (128 MiB) but above the effective
// ceiling (<= 64 MiB hard cap, and the 10 MiB default seek threshold).
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
bucket: "bucket",
object: "object",
version_id: None,
etag: "etag",
size: 32 * 1024 * 1024,
mod_time_unix_nanos: 0,
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
assert_eq!(plan, rustfs_object_data_cache::ObjectDataCacheGetPlan::SkipTooLarge);
}
#[test]
@@ -453,7 +555,7 @@ mod tests {
temp_env::with_vars(vars, || {
let config = object_data_cache_config_from_env().expect("valid numeric env should be applied");
assert_eq!(config.mode, ObjectDataCacheMode::HitOnly);
assert_eq!(config.mode, ObjectDataCacheMode::FillBufferedOnly);
assert_eq!(config.max_entry_bytes, 2_097_152);
});
}
+1
View File
@@ -124,6 +124,7 @@ mod tests {
version_id: None,
etag: "etag",
size: 5,
mod_time_unix_nanos: 0,
body_variant: ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let fill = adapter.cache().fill_body(&plan, Bytes::from_static(b"hello")).await;
@@ -144,6 +144,7 @@ mod tests {
version_id: None,
etag: "etag",
size: 5,
mod_time_unix_nanos: 0,
body_variant: ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let fill = adapter.fill_body(&plan, Bytes::from_static(b"hello")).await;
@@ -170,6 +171,7 @@ mod tests {
version_id: None,
etag: "etag-a",
size: 5,
mod_time_unix_nanos: 0,
body_variant: ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let plan_b = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
@@ -178,6 +180,7 @@ mod tests {
version_id: None,
etag: "etag-b",
size: 5,
mod_time_unix_nanos: 0,
body_variant: ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let _ = adapter.fill_body(&plan_a, Bytes::from_static(b"aaaaa")).await;
@@ -81,12 +81,21 @@ pub(crate) fn build_get_object_body_cache_plan(
.version_id
.filter(|version_id| !version_id.is_nil())
.map(|version_id| version_id.to_string());
// ODC-06 (backlog#1111): carry the resolved version's modification time into
// the key so an unversioned overwrite (which advances mod_time) cannot be
// served the stale body under an MD5 collision. Absent mod_time maps to 0.
let mod_time_unix_nanos = request
.info
.mod_time
.map(|mod_time| mod_time.unix_timestamp_nanos())
.unwrap_or(0);
let engine_request = ObjectDataCacheGetRequest {
bucket: request.bucket,
object: request.key,
version_id,
etag,
size,
mod_time_unix_nanos,
body_variant: ObjectDataCacheBodyVariant::FullObjectPlainV1,
};
@@ -242,6 +251,89 @@ mod tests {
assert!(matches!(plan, GetObjectBodyCachePlan::Skip));
}
fn cacheable_key(plan: &GetObjectBodyCachePlan) -> rustfs_object_data_cache::ObjectDataCacheKey {
match plan {
GetObjectBodyCachePlan::Cacheable(rustfs_object_data_cache::ObjectDataCacheGetPlan::Cacheable { key }) => key.clone(),
other => panic!("expected a cacheable plan, got {other:?}"),
}
}
#[test]
fn hook_and_planner_derive_identical_keys() {
// ODC-16 correctness guard: the ecstore hook and the usecase planner both
// route through build_get_object_body_cache_plan, so for the same object
// they must derive byte-identical keys — otherwise every GET misses. The
// hook builds its request with response_content_length = get_actual_size;
// the usecase builds the same for a plain, non-range GET.
let adapter = enabled_adapter();
let info = crate::storage::storage_api::StorageObjectInfo {
etag: Some("etag".to_string()),
size: 4,
actual_size: 4,
mod_time: Some(time::OffsetDateTime::from_unix_timestamp_nanos(1_234_567_890).unwrap()),
..Default::default()
};
let hook_request = GetObjectBodyCacheRequest {
bucket: "bucket",
key: "object",
info: &info,
response_content_length: info.get_actual_size().expect("actual size"),
has_range: false,
part_number: None,
encryption_applied: false,
};
let usecase_request = GetObjectBodyCacheRequest {
bucket: "bucket",
key: "object",
info: &info,
response_content_length: 4,
has_range: false,
part_number: None,
encryption_applied: false,
};
let hook_key = cacheable_key(&build_get_object_body_cache_plan(&adapter, hook_request));
let usecase_key = cacheable_key(&build_get_object_body_cache_plan(&adapter, usecase_request));
assert_eq!(hook_key, usecase_key, "hook and planner must derive the same key");
// ODC-06: the modification time flows into the key.
assert_eq!(hook_key.mod_time_unix_nanos, 1_234_567_890);
}
#[test]
fn planner_key_changes_with_mod_time() {
// ODC-06 (backlog#1111): an unversioned overwrite advances mod_time, so
// the same etag + size must derive a different key.
let adapter = enabled_adapter();
let mut info = crate::storage::storage_api::StorageObjectInfo {
etag: Some("etag".to_string()),
size: 4,
actual_size: 4,
mod_time: Some(time::OffsetDateTime::from_unix_timestamp_nanos(1_000).unwrap()),
..Default::default()
};
let make_request = |info: &crate::storage::storage_api::StorageObjectInfo| {
build_get_object_body_cache_plan(
&adapter,
GetObjectBodyCacheRequest {
bucket: "bucket",
key: "object",
info,
response_content_length: 4,
has_range: false,
part_number: None,
encryption_applied: false,
},
)
};
let old_key = cacheable_key(&make_request(&info));
info.mod_time = Some(time::OffsetDateTime::from_unix_timestamp_nanos(2_000).unwrap());
let new_key = cacheable_key(&make_request(&info));
assert_ne!(old_key, new_key, "keys differing only by mod_time must not collide");
}
#[test]
fn plan_is_cacheable_for_plain_full_object() {
let adapter = enabled_adapter();
+214 -11
View File
@@ -206,7 +206,7 @@ use crate::app::object_data_cache::{
type S3StdError = Box<dyn std::error::Error + Send + Sync + 'static>;
const ACCEPT_RANGES_BYTES: &str = "bytes";
const MAX_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 64 * 1024 * 1024;
pub(crate) const MAX_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 64 * 1024 * 1024;
const MEDIUM_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 8 * 1024 * 1024;
const HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 4 * 1024 * 1024;
const VERY_HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 1024 * 1024;
@@ -337,6 +337,12 @@ struct GetObjectReadSetup {
info: ObjectInfo,
final_stream: DynReader,
buffered_body: Option<Bytes>,
/// ODC-16: `buffered_body` is the body the ecstore cache hook served, so the
/// app layer serves it as the object-data-cache source without a re-lookup.
cache_hook_served: bool,
/// ODC-16: the cache hook probed this read (served or missed), so the app
/// layer must skip its own lookup.
cache_hook_probed: bool,
rs: Option<HTTPRangeSpec>,
content_type: Option<ContentType>,
last_modified: Option<Timestamp>,
@@ -1497,7 +1503,7 @@ where
Ok(ChunkedBytesReader::new(chunks))
}
fn object_seek_support_threshold() -> usize {
pub(crate) fn object_seek_support_threshold() -> usize {
static OBJECT_SEEK_SUPPORT_THRESHOLD: OnceLock<usize> = OnceLock::new();
*OBJECT_SEEK_SUPPORT_THRESHOLD.get_or_init(|| {
rustfs_utils::get_env_usize(
@@ -2730,6 +2736,11 @@ impl DefaultObjectUsecase {
);
}
// ODC-16: capture whether the ecstore cache hook already probed this
// read, so the app layer does not repeat the lookup it ran after fresh
// metadata resolution.
let cache_hook_served = reader.is_cache_hook_served();
let cache_hook_probed = reader.cache_hook_probed();
let info = reader.object_info;
let stream = reader.stream;
let buffered_body = reader.buffered_body;
@@ -2843,6 +2854,8 @@ impl DefaultObjectUsecase {
info,
final_stream,
buffered_body,
cache_hook_served,
cache_hook_probed,
rs,
content_type,
last_modified,
@@ -3140,6 +3153,8 @@ impl DefaultObjectUsecase {
has_range: bool,
encryption_applied: bool,
buffered_body: Option<Bytes>,
cache_hook_served: bool,
cache_hook_probed: bool,
bucket: &str,
key: &str,
mut lifecycle: GetObjectBodyLifecycle,
@@ -3158,16 +3173,35 @@ impl DefaultObjectUsecase {
};
let cache_plan = build_get_object_body_cache_plan(cache_adapter, cache_request);
match lookup_get_object_body_cache_hit(cache_adapter, &cache_plan).await {
GetObjectBodyCacheLookup::Hit(bytes) => {
return Ok(Self::build_memory_bytes_blob(
bytes,
response_content_length,
GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE,
lifecycle,
));
// ODC-16 (backlog#1121): when the ecstore hook already served this body
// from the cache, serve it straight through as the object-data-cache
// source. Re-running the lookup here would record a second hit, double
// the hit_bytes, and do redundant moka work for one hook-served GET.
if cache_hook_served && let Some(bytes) = buffered_body.clone() {
return Ok(Self::build_memory_bytes_blob(
bytes,
response_content_length,
GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE,
lifecycle,
));
}
// ODC-16: only look up when the hook did not probe this read. When it did
// probe (a served body handled above, or a miss), its result is
// authoritative because it ran after fresh metadata resolution, so the
// app layer skips its own lookup and only uses the plan to fill.
if !cache_hook_probed {
match lookup_get_object_body_cache_hit(cache_adapter, &cache_plan).await {
GetObjectBodyCacheLookup::Hit(bytes) => {
return Ok(Self::build_memory_bytes_blob(
bytes,
response_content_length,
GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE,
lifecycle,
));
}
GetObjectBodyCacheLookup::Disabled | GetObjectBodyCacheLookup::Skip | GetObjectBodyCacheLookup::Miss => {}
}
GetObjectBodyCacheLookup::Disabled | GetObjectBodyCacheLookup::Skip | GetObjectBodyCacheLookup::Miss => {}
}
if let Some(buffered_body) = buffered_body {
@@ -4059,6 +4093,8 @@ impl DefaultObjectUsecase {
event_info: Option<ObjectInfo>,
final_stream: DynReader,
buffered_body: Option<Bytes>,
cache_hook_served: bool,
cache_hook_probed: bool,
rs: Option<HTTPRangeSpec>,
content_type: Option<ContentType>,
last_modified: Option<Timestamp>,
@@ -4111,6 +4147,8 @@ impl DefaultObjectUsecase {
rs.is_some(),
encryption_applied,
buffered_body,
cache_hook_served,
cache_hook_probed,
bucket,
key,
lifecycle,
@@ -4277,6 +4315,8 @@ impl DefaultObjectUsecase {
info,
final_stream,
buffered_body,
cache_hook_served,
cache_hook_probed,
rs,
content_type,
last_modified,
@@ -4309,6 +4349,8 @@ impl DefaultObjectUsecase {
event_info,
final_stream,
buffered_body,
cache_hook_served,
cache_hook_probed,
rs,
content_type,
last_modified,
@@ -7124,6 +7166,7 @@ mod tests {
version_id: None,
etag,
size,
mod_time_unix_nanos: 0,
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
for _ in 0..400 {
@@ -7534,6 +7577,7 @@ mod tests {
version_id: None,
etag: "etag",
size: 5,
mod_time_unix_nanos: 0,
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let fill = adapter.cache().fill_body(&plan, Bytes::from_static(b"hello")).await;
@@ -7552,6 +7596,8 @@ mod tests {
false,
false,
None,
false,
false,
"test-bucket",
"cached-object",
GetObjectBodyLifecycle::disabled(),
@@ -7592,6 +7638,7 @@ mod tests {
version_id: None,
etag: "etag",
size: 5,
mod_time_unix_nanos: 0,
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let fill = adapter.cache().fill_body(&plan, Bytes::from_static(b"oops")).await;
@@ -7608,6 +7655,8 @@ mod tests {
false,
false,
None,
false,
false,
"test-bucket",
"cached-object",
GetObjectBodyLifecycle::disabled(),
@@ -7665,6 +7714,8 @@ mod tests {
false,
false,
Some(Bytes::from_static(b"hello")),
false,
false,
"test-bucket",
"cached-object",
GetObjectBodyLifecycle::disabled(),
@@ -7688,6 +7739,8 @@ mod tests {
false,
false,
None,
false,
false,
"test-bucket",
"cached-object",
GetObjectBodyLifecycle::disabled(),
@@ -7733,6 +7786,7 @@ mod tests {
version_id: None,
etag: "etag",
size: 5,
mod_time_unix_nanos: 0,
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
@@ -7748,6 +7802,8 @@ mod tests {
false,
false,
Some(Bytes::from_static(b"oops")),
false,
false,
"test-bucket",
"cached-object",
GetObjectBodyLifecycle::disabled(),
@@ -7767,6 +7823,143 @@ mod tests {
);
}
#[tokio::test]
async fn build_get_object_body_with_cache_hook_served_records_no_second_lookup() {
// ODC-16 (backlog#1121): a hook-served GET must record exactly one
// lookup — the ecstore hook's. The app layer, handed the cache body as
// buffered_body with cache_hook_served=true, must serve it directly
// without a second lookup (which would double the hits and hit_bytes).
let reads = Arc::new(AtomicUsize::new(0));
let reader = ReadProbeReader {
reads: Arc::clone(&reads),
};
let info = ObjectInfo {
size: 5,
etag: Some("etag".to_string()),
..Default::default()
};
let adapter =
crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly,
max_bytes: 8_388_608,
min_free_memory_percent: 0,
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
})
.expect("fill-enabled cache adapter should initialize");
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
bucket: "test-bucket",
object: "hook-served",
version_id: None,
etag: "etag",
size: 5,
mod_time_unix_nanos: 0,
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let hit_body = Bytes::from_static(b"hello");
assert_eq!(
adapter.cache().fill_body(&plan, hit_body.clone()).await,
rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted
);
// Simulate the ecstore hook: it performs exactly one lookup after fresh
// metadata resolution, hits, and hands the body forward as buffered_body.
assert!(matches!(
adapter.lookup_body(&plan).await,
rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_)
));
let lookups_after_hook = adapter.cache().stats().lookups;
assert_eq!(lookups_after_hook, 1, "the hook performs exactly one lookup");
let _body = DefaultObjectUsecase::build_get_object_body_with_cache(
&adapter,
reader,
&info,
5,
128 * 1024,
false,
1,
None,
false,
false,
Some(hit_body),
/* cache_hook_served */ true,
/* cache_hook_probed */ true,
"test-bucket",
"hook-served",
GetObjectBodyLifecycle::disabled(),
)
.await
.expect("hook-served body handoff should succeed");
assert_eq!(
adapter.cache().stats().lookups,
lookups_after_hook,
"a hook-served GET must not record a second lookup in the app layer"
);
assert_eq!(
reads.load(AtomicOrdering::Relaxed),
0,
"hook-served body handoff must not read from the fallback reader"
);
}
#[tokio::test]
async fn build_get_object_body_with_cache_hook_miss_skips_app_lookup() {
// ODC-16: when the hook probed and missed, its miss is authoritative
// (it ran after fresh metadata resolution), so the app layer must not
// run a second lookup — it only fills from the buffered body.
let reads = Arc::new(AtomicUsize::new(0));
let reader = ReadProbeReader {
reads: Arc::clone(&reads),
};
let info = ObjectInfo {
size: 5,
etag: Some("etag".to_string()),
..Default::default()
};
let adapter =
crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly,
max_bytes: 8_388_608,
min_free_memory_percent: 0,
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
})
.expect("fill-enabled cache adapter should initialize");
let lookups_before = adapter.cache().stats().lookups;
let _body = DefaultObjectUsecase::build_get_object_body_with_cache(
&adapter,
reader,
&info,
5,
128 * 1024,
false,
1,
None,
false,
false,
Some(Bytes::from_static(b"hello")),
/* cache_hook_served */ false,
/* cache_hook_probed */ true,
"test-bucket",
"hook-missed",
GetObjectBodyLifecycle::disabled(),
)
.await
.expect("hook-miss buffered-body handoff should succeed");
assert_eq!(
adapter.cache().stats().lookups,
lookups_before,
"a hook-probed miss must not trigger an app-layer lookup"
);
assert_eq!(
reads.load(AtomicOrdering::Relaxed),
0,
"buffered-body handoff must not read from the fallback reader"
);
}
#[tokio::test]
async fn build_get_object_body_with_cache_materializes_once_and_hits_later() {
let first_reads = Arc::new(AtomicUsize::new(0));
@@ -7805,6 +7998,8 @@ mod tests {
false,
false,
None,
false,
false,
"test-bucket",
"materialized-object",
GetObjectBodyLifecycle::disabled(),
@@ -7828,6 +8023,8 @@ mod tests {
false,
false,
None,
false,
false,
"test-bucket",
"materialized-object",
GetObjectBodyLifecycle::disabled(),
@@ -7885,6 +8082,8 @@ mod tests {
false,
false,
None,
false,
false,
"test-bucket",
"mismatch-object",
GetObjectBodyLifecycle::disabled(),
@@ -7932,6 +8131,8 @@ mod tests {
false,
false,
None,
false,
false,
"test-bucket",
"too-large-object",
GetObjectBodyLifecycle::disabled(),
@@ -8679,6 +8880,8 @@ mod tests {
Some(info),
wrap_reader(tokio::io::empty()),
None,
false,
false,
None,
None,
None,