mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-12 16:16:55 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user