Files
rustfs/crates/object-data-cache/src/cache.rs
T
houseme eebd16d8a4 feat(cache): add object data cache engine and app flow (#4187)
* feat(cache): add object data cache engine

* feat(cache): wire app-layer object cache flow

* refactor(cache): streamline app-layer cache flow

* refactor(cache): tighten cache flow internals

* refactor: address final clippy cleanup

* chore(deps): update quick-xml to 0.41.0

* feat(cache): wire object data cache env config

* fix(cache): gate materialize fill by cache plan

* chore(cache): add object data cache benchmark gate

* fix(cache): guard object cache fill size mismatches

* refactor(cache): streamline object cache body planning

* fix(cache): align object cache rollout config

* test(cache): cover buffered object cache benchmark

* test(cache): isolate object cache benchmark metrics

* test(cache): mark materialize rollout experimental

* test(cache): tighten object cache benchmark gate

* fix(cache): address review findings for object data cache

- singleflight: clean up leader entry on cancellation (Drop impl) so a dropped GET future can no longer wedge all subsequent fills for the same key; switch the fill map to a std Mutex and add a regression test

- adapter: honor RUSTFS_OBJECT_DATA_CACHE_ENABLE=true by defaulting to hit_only when no explicit mode is set (explicit mode still wins)

- planner: treat nil version UUIDs as "no value" per repo convention so unversioned objects key under the canonical "null" instead of fragmenting the key space

- multipart: invalidate the object cache on the quota-exceeded rollback delete after complete-multipart, closing a stale-cache window

- layering: move the disabled-cache fallback into app::context and drop the new infra->app layer-dependency baseline entry

* fix(cache): close invalidation races and drop full-cache scan on writes

- index: make identity-index insert/remove/prune atomic via starshard compute_if_present/compute_if_absent so concurrent fills can no longer drop each other's keys (lost keys made entries unreachable to invalidation until TTL); add a concurrency regression test

- fill: register the key in the identity index before the entry becomes visible in the cache and re-check the index afterwards, undoing the fill when an invalidation raced in between (new skipped_invalidation_race fill result)

- invalidate: with the index now authoritative, remove the full-cache iter() fallback that made every PUT/DELETE of a never-cached object O(total cache entries) (two scans per PUT, 2N per batch delete)

- materialize-fill: fail the GET instead of falling back to the partially consumed stream after a mid-read error (the fallback would send a body missing its prefix under a full-length Content-Length), and log the same size-mismatch warning as the sibling buffering paths

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

* test(storage): fix media-dependent buffer clamp expectation

test_concurrency_manager_multi_factor_strategy_buffer_clamp asserted media_cap.min(MI_B), but the implementation's final safety clamp is [32KiB, media_cap.max(MI_B)] — deliberately so a media cap above 1MiB (NVMe's 2MiB default) stays effective. The test only passed on machines detected as SSD/Unknown (cap == 1MiB) and failed on NVMe-backed CI runners with 2MiB != 1MiB. Assert the media cap itself, which is what the strategy actually guarantees on every environment.

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

* test(storage): format buffer clamp assertion

* chore(logging): update tier guardrail path

---------

Signed-off-by: houseme <housemecn@gmail.com>
Co-authored-by: cxymds <cxymds@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-03 18:11:14 +08:00

375 lines
14 KiB
Rust

// 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.
use crate::backend::ObjectDataCacheBackendKind;
use crate::config::ObjectDataCacheConfig;
use crate::error::ObjectDataCacheConfigError;
use crate::key::{ObjectDataCacheBodyVariant, ObjectDataCacheIdentity, ObjectDataCacheKey};
use crate::metrics::{
describe_metrics_once, publish_cache_state, record_fill_result, record_hit_bytes, record_invalidation,
record_request_decision,
};
use crate::moka_backend::MokaBackend;
use crate::noop::NoopBackend;
use crate::stats::{ObjectDataCacheStats, ObjectDataCacheStatsSnapshot};
use bytes::Bytes;
use std::sync::Arc;
use std::time::Instant;
/// Protocol-neutral cache facade for object body reuse.
#[derive(Debug)]
pub struct ObjectDataCache {
backend: ObjectDataCacheBackendKind,
config: Arc<ObjectDataCacheConfig>,
stats: Arc<ObjectDataCacheStats>,
}
impl ObjectDataCache {
/// Creates a disabled cache facade without requiring configuration parsing.
pub fn disabled() -> Self {
let config = Arc::new(ObjectDataCacheConfig::default());
let stats = Arc::new(ObjectDataCacheStats::default());
Self {
backend: ObjectDataCacheBackendKind::Noop(NoopBackend),
config,
stats,
}
}
/// Creates a new cache facade.
pub fn new(config: ObjectDataCacheConfig) -> Result<Self, ObjectDataCacheConfigError> {
describe_metrics_once();
config.validate()?;
let stats = Arc::new(ObjectDataCacheStats::default());
let backend = if config.is_disabled() {
ObjectDataCacheBackendKind::Noop(NoopBackend)
} else {
ObjectDataCacheBackendKind::Moka(Box::new(MokaBackend::new(&config, Arc::clone(&stats))?))
};
Ok(Self {
backend,
config: Arc::new(config),
stats,
})
}
/// Produces a lightweight GET plan from request metadata.
pub fn plan_get(&self, request: ObjectDataCacheGetRequest<'_>) -> ObjectDataCacheGetPlan {
if self.config.is_disabled() {
record_request_decision(
self.backend.as_metric_label(),
self.config.mode,
"disabled",
"mode_disabled",
request.size,
);
return ObjectDataCacheGetPlan::Disabled;
}
if request.size > self.config.max_entry_bytes {
record_request_decision(self.backend.as_metric_label(), self.config.mode, "skip", "too_large", request.size);
return ObjectDataCacheGetPlan::SkipTooLarge;
}
record_request_decision(self.backend.as_metric_label(), self.config.mode, "cacheable", "eligible", request.size);
ObjectDataCacheGetPlan::Cacheable {
key: ObjectDataCacheKey::new(
request.bucket,
request.object,
request.version_id.as_deref(),
request.etag,
request.size,
request.body_variant,
),
}
}
/// Looks up an object body from the configured backend.
pub async fn lookup_body(&self, plan: &ObjectDataCacheGetPlan) -> ObjectDataCacheLookup {
let lookup = match &self.backend {
ObjectDataCacheBackendKind::Noop(backend) => backend.lookup_body(plan).await,
ObjectDataCacheBackendKind::Moka(backend) => backend.lookup_body(plan).await,
};
self.stats.record_lookup(matches!(lookup, ObjectDataCacheLookup::Hit(_)));
self.refresh_entry_count();
match &lookup {
ObjectDataCacheLookup::Hit(bytes) => {
let size_bytes = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
record_request_decision(self.backend.as_metric_label(), self.config.mode, "hit", "cache_hit", size_bytes);
record_hit_bytes(self.backend.as_metric_label(), self.config.mode, size_bytes);
}
ObjectDataCacheLookup::Miss => {
let size_bytes = match plan {
ObjectDataCacheGetPlan::Cacheable { key } => key.size,
_ => 0,
};
record_request_decision(self.backend.as_metric_label(), self.config.mode, "miss", "cache_miss", size_bytes);
}
ObjectDataCacheLookup::SkipDisabled => {
record_request_decision(self.backend.as_metric_label(), self.config.mode, "skip", "lookup_disabled", 0);
}
ObjectDataCacheLookup::SkipNotCacheable => {
record_request_decision(self.backend.as_metric_label(), self.config.mode, "skip", "lookup_not_cacheable", 0);
}
}
lookup
}
/// Attempts to fill the cache body for the current plan.
pub async fn fill_body(&self, plan: &ObjectDataCacheGetPlan, bytes: Bytes) -> ObjectDataCacheFillResult {
let fill_bytes = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
if !self.config.fill_enabled() {
record_fill_result(self.backend.as_metric_label(), self.config.mode, "skipped_by_mode", fill_bytes, 0.0);
return ObjectDataCacheFillResult::SkippedByMode;
}
if let ObjectDataCacheGetPlan::Cacheable { key } = plan
&& fill_bytes != key.size
{
let result = ObjectDataCacheFillResult::SkippedSizeMismatch;
record_fill_result(
self.backend.as_metric_label(),
self.config.mode,
result.as_metric_label(),
fill_bytes,
0.0,
);
return result;
}
let fill_start = Instant::now();
let result = match &self.backend {
ObjectDataCacheBackendKind::Noop(backend) => backend.fill_body(plan).await,
ObjectDataCacheBackendKind::Moka(backend) => backend.fill_body(plan, bytes).await,
};
if matches!(result, ObjectDataCacheFillResult::Inserted) {
self.stats.record_fill();
}
self.refresh_entry_count();
record_fill_result(
self.backend.as_metric_label(),
self.config.mode,
result.as_metric_label(),
fill_bytes,
fill_start.elapsed().as_secs_f64(),
);
result
}
/// Invalidates all cache entries associated with the object identity.
pub async fn invalidate_object(
&self,
_identity: ObjectDataCacheIdentity,
_reason: ObjectDataCacheInvalidationReason,
) -> ObjectDataCacheInvalidationResult {
let result = match &self.backend {
ObjectDataCacheBackendKind::Noop(backend) => backend.invalidate_object().await,
ObjectDataCacheBackendKind::Moka(backend) => backend.invalidate_object(&_identity).await,
};
self.stats.record_invalidation();
self.refresh_entry_count();
record_invalidation(self.backend.as_metric_label(), _reason.as_metric_label());
result
}
/// Returns the current stats snapshot.
pub fn stats(&self) -> ObjectDataCacheStatsSnapshot {
self.stats.snapshot()
}
/// Returns true when the cache facade is fully disabled.
pub fn is_disabled(&self) -> bool {
self.config.is_disabled()
}
/// Returns true when the cache mode allows materialize fill.
pub fn materialize_fill_enabled(&self) -> bool {
matches!(self.config.mode, crate::config::ObjectDataCacheMode::FillMaterializeEnabled)
}
fn refresh_entry_count(&self) {
let (entries, weighted_bytes) = match &self.backend {
ObjectDataCacheBackendKind::Noop(_) => (0, 0),
ObjectDataCacheBackendKind::Moka(backend) => (backend.entry_count(), backend.weighted_size()),
};
self.stats.set_entries(entries);
publish_cache_state(self.backend.as_metric_label(), entries, weighted_bytes);
}
}
/// Protocol-neutral GET request metadata for cache planning.
#[derive(Debug, Clone)]
pub struct ObjectDataCacheGetRequest<'a> {
/// Bucket name.
pub bucket: &'a str,
/// Object key.
pub object: &'a str,
/// Optional version id.
pub version_id: Option<String>,
/// Object ETag.
pub etag: &'a str,
/// Object size in bytes.
pub size: u64,
/// Supported response body variant.
pub body_variant: ObjectDataCacheBodyVariant,
}
/// Planning result for a cache-aware GET.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ObjectDataCacheGetPlan {
/// Cache is globally disabled.
Disabled,
/// Object body exceeds the configured cacheable entry size.
SkipTooLarge,
/// Request is eligible for cache lookup and fill.
Cacheable {
/// Stable cache key for the request.
key: ObjectDataCacheKey,
},
}
/// Result of a cache lookup attempt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ObjectDataCacheLookup {
/// Cache is disabled.
SkipDisabled,
/// Request was not cacheable under the current plan.
SkipNotCacheable,
/// Cache did not contain a matching object body.
Miss,
/// Cache returned a reusable object body.
Hit(Bytes),
}
/// Result of a cache fill attempt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ObjectDataCacheFillResult {
/// Cache is disabled.
SkippedDisabled,
/// Cache mode does not currently allow fill.
SkippedByMode,
/// Request was not cacheable under the current plan.
SkippedNotCacheable,
/// Fill was skipped because the local memory gate rejected it.
SkippedMemoryPressure,
/// Fill was skipped because the per-identity key budget overflowed and was conservatively cleared.
SkippedIdentityOverflow,
/// Fill was skipped because the provided body length did not match the cache key identity.
SkippedSizeMismatch,
/// Fill waiters were released without a published leader result.
SkippedSingleflightClosed,
/// Fill was undone because an invalidation raced with the insert.
SkippedInvalidationRace,
/// The cache entry was inserted successfully.
Inserted,
}
impl ObjectDataCacheFillResult {
pub(crate) const fn as_metric_label(&self) -> &'static str {
match self {
Self::SkippedDisabled => "skipped_disabled",
Self::SkippedByMode => "skipped_by_mode",
Self::SkippedNotCacheable => "skipped_not_cacheable",
Self::SkippedMemoryPressure => "skipped_memory_pressure",
Self::SkippedIdentityOverflow => "skipped_identity_overflow",
Self::SkippedSizeMismatch => "skipped_size_mismatch",
Self::SkippedSingleflightClosed => "skipped_singleflight_closed",
Self::SkippedInvalidationRace => "skipped_invalidation_race",
Self::Inserted => "inserted",
}
}
}
/// Invalidation reason placeholder for the skeleton.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectDataCacheInvalidationReason {
/// Conservative invalidation before a mutating write or delete begins.
BeforeMutation,
/// Invalidation after a successful PutObject write.
AfterPutSuccess,
/// Invalidation after a successful delete.
AfterDeleteSuccess,
/// Invalidation after a successful copy destination write.
AfterCopySuccess,
/// Invalidation after a successful complete multipart upload.
AfterCompleteMultipartSuccess,
/// Manual invalidation requested by the caller.
Manual,
}
impl ObjectDataCacheInvalidationReason {
pub(crate) const fn as_metric_label(self) -> &'static str {
match self {
Self::BeforeMutation => "before_mutation",
Self::AfterPutSuccess => "after_put_success",
Self::AfterDeleteSuccess => "after_delete_success",
Self::AfterCopySuccess => "after_copy_success",
Self::AfterCompleteMultipartSuccess => "after_complete_multipart_success",
Self::Manual => "manual",
}
}
}
/// Result of an invalidation request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectDataCacheInvalidationResult {
/// Invalidation completed successfully.
Success,
}
#[cfg(test)]
mod tests {
use super::{ObjectDataCache, ObjectDataCacheFillResult, ObjectDataCacheGetRequest, ObjectDataCacheLookup};
use crate::config::{ObjectDataCacheConfig, ObjectDataCacheMode};
use crate::key::ObjectDataCacheBodyVariant;
use bytes::Bytes;
fn fill_enabled_cache() -> ObjectDataCache {
let config = ObjectDataCacheConfig {
mode: ObjectDataCacheMode::FillBufferedOnly,
max_bytes: 8_388_608,
..ObjectDataCacheConfig::default()
};
ObjectDataCache::new(config).expect("fill-enabled cache config should initialize")
}
#[tokio::test]
async fn fill_body_rejects_size_mismatch() {
let cache = fill_enabled_cache();
let plan = cache.plan_get(ObjectDataCacheGetRequest {
bucket: "bucket",
object: "object",
version_id: None,
etag: "etag",
size: 5,
body_variant: ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let fill = cache.fill_body(&plan, Bytes::from_static(b"oops")).await;
let lookup = cache.lookup_body(&plan).await;
assert_eq!(fill, ObjectDataCacheFillResult::SkippedSizeMismatch);
assert!(matches!(lookup, ObjectDataCacheLookup::Miss));
}
}