feat(object-data-cache): close write-side invalidation gaps and add an admin surface (#4694)

* feat(object-data-cache): close write/delete-side invalidation gaps

The object data cache exposed only a single per-(bucket,object)
invalidation primitive and no write-side ecstore hook, so several
delete paths left dead bodies resident until TTL (hygiene/capacity, not
stale-serving: lookups follow a fresh metadata quorum and cannot serve a
gone object). This adds the missing primitives and wires them in.

ODC-26 (backlog#1131): add an `ObjectMutationHook` trait beside the GET
body hook, registered next to it at startup, and call it from the
ecstore-internal delete paths (`apply_expiry_on_non_transitioned_objects`,
`expire_transitioned_object` including the restored-copy branch, and
`delete_object_versions`). The app impl is one `invalidate_object` call
under a new `AfterLifecycleExpiry` reason.

ODC-27 (backlog#1132): force prefix delete now invalidates the whole
prefix, not just the prefix string. `store.delete_object(delete_prefix)`
returns no deleted-name list, so this uses a new prefix primitive rather
than the batch path.

ODC-28 (backlog#1133): DeleteBucket now flushes the bucket via a new
bucket-scope primitive (covers force and non-force, which share the
delete_bucket call).

ODC-C2 (backlog#1143): add `ObjectDataCache::clear()` and two admin
handlers (GET stats, POST flush) routed through admin runtime_sources.

The starshard identity index gains a single `remove_matching` full-scan
API backing prefix/bucket/clear; it is documented as admin/delete-path
only and never runs on the GET or fill hot path. New invalidation
reasons and metric labels added; outcome (removed/noop) labelling kept
correct for every new primitive.

Also fixes a pre-existing broken intra-doc link in memory.rs.

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

* refactor(ecstore): extract the shared HookSlot behind both cache hooks

This PR introduced object_mutation_hook.rs by mirroring body_cache_hook.rs,
which left two process-global registration slots whose register/get/clear
bodies were line-for-line identical except the trait type and the WARN string:
a RwLock<Option<Arc<dyn _>>>, an Arc::ptr_eq "different instance" warning, the
poison-recovery closure, and the same read-lock-and-clone read. Two copies of
the same swap-vs-warn logic can drift apart under maintenance.

Hoist it into a generic HookSlot<T: ?Sized> that owns the logic once. Each hook
module keeps its `static HOOK: HookSlot<dyn XxxHook>` and its thin, unchanged
public wrappers (register_/get_/clear_), so the crate's public surface and
every call site are untouched — this is an internal consolidation, not a
contract change.

The load-bearing #1126 guarantee (newest registration wins, so a rebuilt
AppContext is never stranded on a first-wins slot) previously had no direct
test — the hook tests only covered register-then-notify. HookSlot now has its
own unit tests including re_registration_swaps_to_the_latest_instance;
mutation-testing confirms a first-wins regression fails exactly that test.

No behavior change: the two hooks' existing tests, the P0 body_cache_hook_e2e
regressions, and the app-layer mutation-hook tests all pass unchanged.

Refs: backlog#1126, backlog#1131

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

* fix(admin): register the object-data-cache routes in the policy inventory

This PR added GET /object-data-cache/stats and POST /object-data-cache/flush
but did not list them in the two registries that must account for every
admin route: the route-policy inventory (route_policy.rs) and the route
matrix (route_registration_test.rs). Their coverage tests —
route_policy_inventory_covers_registered_routes and
test_admin_route_matrix_matches_registered_routes — failed on CI because a
registered route had no policy/matrix entry.

These two tests are not part of `make pre-commit` (which runs fmt + arch +
quick-check, not the full suite), so the gap passed local pre-commit and
only surfaced in the CI Test-and-Lint lane.

stats is a read (ServerInfoAdminAction, Sensitive); flush mutates
(ConfigUpdateAdminAction, High) — matching the actions the handlers already
enforce. The MinIO-alias matrix test is unaffected: these are native rustfs
endpoints with no MinIO equivalent.

Refs: backlog#1143

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
houseme
2026-07-11 04:04:05 +08:00
committed by GitHub
parent f9874b591a
commit 85fd824581
28 changed files with 1173 additions and 53 deletions
+1
View File
@@ -36,6 +36,7 @@ pub mod kms_management;
pub mod metrics;
pub mod module_switch;
mod notify_runtime_access;
pub mod object_data_cache;
pub mod object_zip_download;
pub mod oidc;
pub mod plugins_catalog;
@@ -0,0 +1,219 @@
// 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.
//! Admin surface for the object data cache (ODC-C2, backlog#1143).
//!
//! `GET {ADMIN_PREFIX}/v3/object-data-cache/stats` returns the current stats
//! snapshot plus mode, the only production window into the cache short of a
//! metrics scrape. `POST {ADMIN_PREFIX}/v3/object-data-cache/flush` drops
//! cached bodies; with no query it clears everything, with `bucket` it flushes
//! that bucket, and with `bucket`+`object` it flushes that one identity — the
//! only remediation for a poisoned entry short of a node restart.
use crate::admin::auth::validate_admin_request;
use crate::admin::router::{AdminOperation, Operation, S3Router};
use crate::admin::runtime_sources::current_object_data_cache;
use crate::app::object_data_cache::ObjectDataCacheAdapter;
use crate::auth::{check_key_valid, get_session_token};
use crate::server::{ADMIN_PREFIX, RemoteAddr};
use http::{HeaderMap, HeaderValue};
use hyper::{Method, StatusCode};
use matchit::Params;
use rustfs_object_data_cache::{ObjectDataCacheIdentity, ObjectDataCacheInvalidationReason, ObjectDataCacheInvalidationResult};
use rustfs_policy::policy::action::{Action, AdminAction};
use s3s::header::CONTENT_TYPE;
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
use serde::Serialize;
use std::sync::Arc;
const JSON_CONTENT_TYPE: &str = "application/json";
#[derive(Debug, Serialize)]
struct ObjectDataCacheStatsResponse {
mode: &'static str,
disabled: bool,
entries: u64,
lookups: u64,
hits: u64,
fills: u64,
invalidations: u64,
inflight_fills: u64,
singleflight_joins: u64,
memory_pressure_events: u64,
}
#[derive(Debug, Serialize)]
struct ObjectDataCacheFlushResponse {
scope: &'static str,
bucket: Option<String>,
object: Option<String>,
outcome: &'static str,
removed_keys: usize,
}
pub fn register_object_data_cache_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
r.insert(
Method::GET,
format!("{ADMIN_PREFIX}/v3/object-data-cache/stats").as_str(),
AdminOperation(&ObjectDataCacheStatsHandler {}),
)?;
r.insert(
Method::POST,
format!("{ADMIN_PREFIX}/v3/object-data-cache/flush").as_str(),
AdminOperation(&ObjectDataCacheFlushHandler {}),
)?;
Ok(())
}
async fn authorize(req: &S3Request<Body>, action: AdminAction) -> S3Result<()> {
let Some(input_cred) = req.credentials.as_ref() else {
return Err(s3_error!(InvalidRequest, "missing credentials"));
};
let (cred, owner) =
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?;
let remote_addr = req
.extensions
.get::<Option<RemoteAddr>>()
.and_then(|opt| opt.map(|addr| addr.0));
validate_admin_request(&req.headers, &cred, owner, false, vec![Action::AdminAction(action)], remote_addr).await
}
fn json_response<T: Serialize>(body: &T) -> S3Result<S3Response<(StatusCode, Body)>> {
let data = serde_json::to_vec(body)
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to encode response: {err}")))?;
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static(JSON_CONTENT_TYPE));
Ok(S3Response::with_headers((StatusCode::OK, Body::from(data)), headers))
}
fn query_value(req: &S3Request<Body>, key: &str) -> Option<String> {
req.uri.query().and_then(|query| {
url::form_urlencoded::parse(query.as_bytes())
.find(|(k, _)| k == key)
.map(|(_, v)| v.into_owned())
.filter(|v| !v.is_empty())
})
}
fn invalidation_outcome(result: &ObjectDataCacheInvalidationResult) -> (&'static str, usize) {
match result {
ObjectDataCacheInvalidationResult::Removed { keys } => ("removed", *keys),
ObjectDataCacheInvalidationResult::NoOp => ("noop", 0),
}
}
pub struct ObjectDataCacheStatsHandler {}
#[async_trait::async_trait]
impl Operation for ObjectDataCacheStatsHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
authorize(&req, AdminAction::ServerInfoAdminAction).await?;
let response = match current_object_data_cache() {
Some(adapter) => {
let snapshot = adapter.stats();
ObjectDataCacheStatsResponse {
mode: adapter.mode().as_str(),
disabled: adapter.is_disabled(),
entries: snapshot.entries,
lookups: snapshot.lookups,
hits: snapshot.hits,
fills: snapshot.fills,
invalidations: snapshot.invalidations,
inflight_fills: snapshot.inflight_fills,
singleflight_joins: snapshot.singleflight_joins,
memory_pressure_events: snapshot.memory_pressure_events,
}
}
None => ObjectDataCacheStatsResponse {
mode: "disabled",
disabled: true,
entries: 0,
lookups: 0,
hits: 0,
fills: 0,
invalidations: 0,
inflight_fills: 0,
singleflight_joins: 0,
memory_pressure_events: 0,
},
};
json_response(&response)
}
}
pub struct ObjectDataCacheFlushHandler {}
#[async_trait::async_trait]
impl Operation for ObjectDataCacheFlushHandler {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
authorize(&req, AdminAction::ConfigUpdateAdminAction).await?;
let bucket = query_value(&req, "bucket");
let object = query_value(&req, "object");
if object.is_some() && bucket.is_none() {
return Err(s3_error!(InvalidRequest, "object flush requires a bucket query parameter"));
}
let adapter: Arc<ObjectDataCacheAdapter> =
current_object_data_cache().ok_or_else(|| s3_error!(InternalError, "object data cache is not initialized"))?;
// Manual is the existing-but-uncalled reason reserved for operator-driven
// invalidation; every flush scope reports under it.
let reason = ObjectDataCacheInvalidationReason::Manual;
let (scope, result) = match (bucket.as_deref(), object.as_deref()) {
(Some(bucket), Some(object)) => (
"object",
adapter
.invalidate_object(ObjectDataCacheIdentity::new(bucket, object), reason)
.await,
),
(Some(bucket), None) => ("bucket", adapter.invalidate_bucket(bucket, reason).await),
(None, _) => ("all", adapter.clear(reason).await),
};
let (outcome, removed_keys) = invalidation_outcome(&result);
json_response(&ObjectDataCacheFlushResponse {
scope,
bucket,
object,
outcome,
removed_keys,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flush_outcome_maps_removed_and_noop() {
assert_eq!(
invalidation_outcome(&ObjectDataCacheInvalidationResult::Removed { keys: 3 }),
("removed", 3)
);
assert_eq!(invalidation_outcome(&ObjectDataCacheInvalidationResult::NoOp), ("noop", 0));
}
#[test]
fn stats_handler_requires_server_info_action() {
// Guard the auth contract: the stats endpoint is a read, the flush
// endpoint mutates, so they must not share one action.
let src = include_str!("object_data_cache.rs");
assert!(src.contains("authorize(&req, AdminAction::ServerInfoAdminAction).await?;"));
assert!(src.contains("authorize(&req, AdminAction::ConfigUpdateAdminAction).await?;"));
}
}
+4 -3
View File
@@ -34,9 +34,9 @@ mod route_registration_test;
use handlers::{
audit, batch_job, bucket_meta, cluster_snapshot, config_admin, diagnostics, durability as durability_handler, extensions,
heal, health, idp_compat, kms, module_switch, object_zip_download, oidc, plugins_catalog, plugins_instances, pools,
profile_admin, quota as quota_handler, rebalance, replication as replication_handler, scanner, site_replication, sts, system,
table_catalog, tier, tls_debug, user,
heal, health, idp_compat, kms, module_switch, object_data_cache, object_zip_download, oidc, plugins_catalog,
plugins_instances, pools, profile_admin, quota as quota_handler, rebalance, replication as replication_handler, scanner,
site_replication, sts, system, table_catalog, tier, tls_debug, user,
};
use router::{AdminOperation, S3Router};
use s3s::route::S3Route;
@@ -76,6 +76,7 @@ fn register_admin_routes(r: &mut S3Router<AdminOperation>) -> std::io::Result<()
bucket_meta::register_bucket_meta_route(r)?;
config_admin::register_config_route(r)?;
scanner::register_scanner_route(r)?;
object_data_cache::register_object_data_cache_route(r)?;
audit::register_audit_target_route(r)?;
module_switch::register_module_switch_route(r)?;
cluster_snapshot::register_cluster_snapshot_route(r)?;
+12
View File
@@ -291,6 +291,18 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
admin(HttpMethod::Get, "/rustfs/admin/v3/info", SERVER_INFO, RouteRiskLevel::Sensitive),
admin(HttpMethod::Get, "/rustfs/admin/v3/storageinfo", STORAGE_INFO, RouteRiskLevel::Sensitive),
admin(HttpMethod::Get, "/rustfs/admin/v3/metrics", GET_METRICS, RouteRiskLevel::Sensitive),
admin(
HttpMethod::Get,
"/rustfs/admin/v3/object-data-cache/stats",
SERVER_INFO,
RouteRiskLevel::Sensitive,
),
admin(
HttpMethod::Post,
"/rustfs/admin/v3/object-data-cache/flush",
CONFIG_UPDATE,
RouteRiskLevel::High,
),
admin(
HttpMethod::Post,
"/rustfs/admin/v3/pools/decommission",
@@ -172,6 +172,8 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
admin_route(Method::GET, "/v3/storageinfo"),
admin_route(Method::GET, "/v3/datausageinfo"),
admin_route(Method::GET, "/v3/metrics"),
admin_route(Method::GET, "/v3/object-data-cache/stats"),
admin_route(Method::POST, "/v3/object-data-cache/flush"),
admin_route(Method::GET, "/v3/pools/list"),
admin_route(Method::GET, "/v3/pools/status"),
admin_route(Method::GET, "/v3/decommission/status"),
+13 -3
View File
@@ -18,14 +18,16 @@ use crate::admin::storage_api::runtime_sources::{
pub(crate) use crate::app::admin_usecase::{
AdminPoolStatus, DefaultAdminUsecase, QueryPoolStatusRequest, QueryServerInfoRequest,
};
use crate::app::object_data_cache::ObjectDataCacheAdapter;
use crate::app::object_usecase::DefaultObjectUsecase;
use crate::runtime_sources as root_runtime_sources;
pub(crate) use crate::runtime_sources::{
AppContext, ServerContextSlot, current_action_credentials, current_boot_time, current_bucket_metadata_handle,
current_bucket_monitor_handle, current_deployment_id, current_endpoints_handle, current_iam_handle,
current_kms_runtime_service_manager, current_notification_system_for_context, current_object_store_handle_for_context,
current_oidc_handle, current_ready_iam_handle, current_region, current_replication_pool_handle,
current_replication_stats_handle, current_server_config_for_context, current_token_signing_key,
current_kms_runtime_service_manager, current_notification_system_for_context, current_object_data_cache_handle_for_context,
current_object_store_handle_for_context, current_oidc_handle, current_ready_iam_handle, current_region,
current_replication_pool_handle, current_replication_stats_handle, current_server_config_for_context,
current_token_signing_key,
};
use rustfs_config::server_config::Config;
use rustfs_kms::KmsServiceManager;
@@ -52,6 +54,14 @@ pub(crate) fn current_object_store_handle() -> Option<Arc<ECStore>> {
current_object_store_handle_for_context(context.as_deref())
}
/// Resolve the object data cache adapter for an admin request through the
/// process AppContext. `None` when no context is initialised (the admin
/// stats/flush handlers then report the cache as unavailable).
pub(crate) fn current_object_data_cache() -> Option<Arc<ObjectDataCacheAdapter>> {
let context = current_app_context();
current_object_data_cache_handle_for_context(context.as_deref())
}
/// Resolve the object store for an admin request through the server's context
/// slot injected at router dispatch (backlog#1052 S2). Falls back to the
/// ambient process context when no slot was injected (direct handler tests,
+9 -1
View File
@@ -63,9 +63,10 @@ use super::storage_api::bucket_usecase::{
use crate::admin::handlers::site_replication::{
site_replication_bucket_meta_hook, site_replication_delete_bucket_hook, site_replication_make_bucket_hook,
};
use crate::app::object_data_cache::invalidate_object_data_cache_bucket_after_delete;
use crate::app::runtime_sources::{
AppContext, current_app_context, current_encryption_service, current_notification_system,
current_notify_interface_for_context, current_object_store_handle_for_context,
current_notify_interface_for_context, current_object_data_cache_for_context, current_object_store_handle_for_context,
};
use crate::auth::get_condition_values_with_client_info;
use crate::error::ApiError;
@@ -1193,6 +1194,13 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
// Drop every cached object body for the now-deleted bucket so dead
// bytes do not sit resident until TTL. Covers both the normal and the
// force-delete path, which share this single delete_bucket call
// (ODC-28, backlog#1133).
let cache_adapter = current_object_data_cache_for_context(self.context.as_deref());
let _ = invalidate_object_data_cache_bucket_after_delete(&cache_adapter, &input.bucket).await;
// Invalidate bucket validation cache
crate::storage::invalidate_bucket_validation_cache(&input.bucket);
+1 -1
View File
@@ -131,7 +131,7 @@ pub fn resolve_object_store_handle_for_context(context: Option<&AppContext>) ->
/// Resolve object data cache adapter using AppContext-first precedence.
#[expect(
dead_code,
reason = "ST-05 exposes the global resolver; app use sites start in later cache phases"
reason = "admin/app read sites resolve through the _for_context variant re-exported by runtime_sources"
)]
pub(crate) fn resolve_object_data_cache_handle() -> Option<Arc<ObjectDataCacheAdapter>> {
let context = get_global_app_context();
+5
View File
@@ -82,6 +82,11 @@ impl AppContext {
// Let ecstore probe this cache inside get_object_reader, after
// metadata resolution but before the erasure data read (backlog#802).
crate::app::object_data_cache::register_object_data_cache_body_hook(Arc::clone(&object_data_cache));
// Let ecstore's internal delete paths (lifecycle/scanner expiry,
// noncurrent-version cleanup, restored-copy expiry) drop the removed
// object's cached body instead of leaving it resident until TTL
// (ODC-26, backlog#1131).
crate::app::object_data_cache::register_object_data_cache_mutation_hook(Arc::clone(&object_data_cache));
Self {
object_store,
+35 -1
View File
@@ -16,7 +16,7 @@ use bytes::Bytes;
use rustfs_object_data_cache::{
ObjectDataCache, ObjectDataCacheConfig, ObjectDataCacheConfigError, ObjectDataCacheFillResult, ObjectDataCacheGetPlan,
ObjectDataCacheGetRequest, ObjectDataCacheIdentity, ObjectDataCacheInvalidationReason, ObjectDataCacheInvalidationResult,
ObjectDataCacheLookup, ObjectDataCacheMode,
ObjectDataCacheLookup, ObjectDataCacheMode, ObjectDataCacheStatsSnapshot,
};
use std::{sync::Arc, time::Duration};
use tracing::warn;
@@ -125,6 +125,40 @@ impl ObjectDataCacheAdapter {
) -> ObjectDataCacheInvalidationResult {
self.cache.invalidate_object(identity, reason).await
}
/// Executes an engine-level prefix invalidation (ODC-27).
pub(crate) async fn invalidate_prefix(
&self,
bucket: &str,
prefix: &str,
reason: ObjectDataCacheInvalidationReason,
) -> ObjectDataCacheInvalidationResult {
self.cache.invalidate_prefix(bucket, prefix, reason).await
}
/// Executes an engine-level bucket invalidation (ODC-28).
pub(crate) async fn invalidate_bucket(
&self,
bucket: &str,
reason: ObjectDataCacheInvalidationReason,
) -> ObjectDataCacheInvalidationResult {
self.cache.invalidate_bucket(bucket, reason).await
}
/// Drops every cached body and resets the identity index (ODC-C2 flush).
pub(crate) async fn clear(&self, reason: ObjectDataCacheInvalidationReason) -> ObjectDataCacheInvalidationResult {
self.cache.clear(reason).await
}
/// Current cache statistics snapshot (ODC-C2 admin stats).
pub(crate) fn stats(&self) -> ObjectDataCacheStatsSnapshot {
self.cache.stats()
}
/// Configured runtime mode (ODC-C2 admin stats).
pub(crate) fn mode(&self) -> ObjectDataCacheMode {
self.cache.mode()
}
}
impl ObjectDataCacheAdapter {
@@ -63,6 +63,42 @@ pub(crate) async fn invalidate_object_data_cache_after_complete_multipart_succes
.await
}
/// Invalidates every cached body under a prefix before a force-prefix delete
/// begins (ODC-27). The exact-key `..._before_mutation` helper only covers the
/// prefix string itself, leaving every object beneath it cached.
pub(crate) async fn invalidate_object_data_cache_prefix_before_mutation(
adapter: &ObjectDataCacheAdapter,
bucket: &str,
prefix: &str,
) -> ObjectDataCacheInvalidationResult {
adapter
.invalidate_prefix(bucket, prefix, ObjectDataCacheInvalidationReason::BeforeMutation)
.await
}
/// Invalidates every cached body under a prefix after a successful force-prefix
/// delete (ODC-27).
pub(crate) async fn invalidate_object_data_cache_prefix_after_delete(
adapter: &ObjectDataCacheAdapter,
bucket: &str,
prefix: &str,
) -> ObjectDataCacheInvalidationResult {
adapter
.invalidate_prefix(bucket, prefix, ObjectDataCacheInvalidationReason::AfterPrefixDelete)
.await
}
/// Invalidates every cached body in a bucket after the bucket is deleted
/// (ODC-28).
pub(crate) async fn invalidate_object_data_cache_bucket_after_delete(
adapter: &ObjectDataCacheAdapter,
bucket: &str,
) -> ObjectDataCacheInvalidationResult {
adapter
.invalidate_bucket(bucket, ObjectDataCacheInvalidationReason::AfterBucketDelete)
.await
}
/// Invalidates a single object identity through the app-layer cache adapter.
pub(crate) async fn invalidate_object_data_cache_object(
adapter: &ObjectDataCacheAdapter,
+5 -2
View File
@@ -18,6 +18,7 @@ mod adapter;
mod body;
mod hook;
mod invalidation;
mod mutation_hook;
mod planner;
pub(crate) use adapter::ObjectDataCacheAdapter;
@@ -29,7 +30,9 @@ pub(crate) use hook::register_object_data_cache_body_hook;
pub(crate) use invalidation::{
invalidate_object_data_cache_after_complete_multipart_success, invalidate_object_data_cache_after_copy_success,
invalidate_object_data_cache_after_delete_success, invalidate_object_data_cache_after_put_success,
invalidate_object_data_cache_before_mutation, invalidate_object_data_cache_objects_after_delete_success,
invalidate_object_data_cache_objects_before_mutation,
invalidate_object_data_cache_before_mutation, invalidate_object_data_cache_bucket_after_delete,
invalidate_object_data_cache_objects_after_delete_success, invalidate_object_data_cache_objects_before_mutation,
invalidate_object_data_cache_prefix_after_delete, invalidate_object_data_cache_prefix_before_mutation,
};
pub(crate) use mutation_hook::register_object_data_cache_mutation_hook;
pub(crate) use planner::{GetObjectBodyCachePlan, GetObjectBodyCacheRequest, build_get_object_body_cache_plan};
@@ -0,0 +1,104 @@
// 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.
//! ecstore-facing write-side cache hook.
//!
//! Registered into ecstore so its internal delete paths (lifecycle/scanner
//! expiry, noncurrent-version cleanup, restored-copy expiry) can drop the
//! object's cached body the moment the object is removed, instead of leaving
//! dead bytes resident until TTL (ODC-26, backlog#1131).
use crate::app::object_data_cache::ObjectDataCacheAdapter;
use crate::storage::storage_api::ecstore_object::{ObjectMutationHook, register_object_mutation_hook};
use rustfs_object_data_cache::{ObjectDataCacheIdentity, ObjectDataCacheInvalidationReason};
use std::sync::Arc;
/// Adapter-backed implementation of ecstore's object mutation hook.
pub(crate) struct ObjectDataCacheMutationHook {
adapter: Arc<ObjectDataCacheAdapter>,
}
/// Registers the mutation hook into ecstore. No-op for a disabled cache so the
/// delete paths keep a single `None` branch when the feature is off, matching
/// [`register_object_data_cache_body_hook`](super::register_object_data_cache_body_hook).
pub(crate) fn register_object_data_cache_mutation_hook(adapter: Arc<ObjectDataCacheAdapter>) {
if adapter.is_disabled() {
return;
}
register_object_mutation_hook(Arc::new(ObjectDataCacheMutationHook { adapter }));
}
#[async_trait::async_trait]
impl ObjectMutationHook for ObjectDataCacheMutationHook {
async fn after_object_mutation(&self, bucket: &str, object: &str) {
let _ = self
.adapter
.invalidate_object(
ObjectDataCacheIdentity::new(bucket, object),
ObjectDataCacheInvalidationReason::AfterLifecycleExpiry,
)
.await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use rustfs_object_data_cache::{
ObjectDataCacheBodyVariant, ObjectDataCacheConfig, ObjectDataCacheFillResult, ObjectDataCacheGetRequest,
ObjectDataCacheLookup, ObjectDataCacheMode,
};
fn fill_enabled_adapter() -> Arc<ObjectDataCacheAdapter> {
Arc::new(
ObjectDataCacheAdapter::new(ObjectDataCacheConfig {
mode: ObjectDataCacheMode::FillMaterializeEnabled,
max_bytes: 8_388_608,
// Fill must not depend on the live memory reading (host vs container).
min_free_memory_percent: 0,
..ObjectDataCacheConfig::default()
})
.expect("adapter"),
)
}
fn request<'a>(bucket: &'a str, object: &'a str) -> ObjectDataCacheGetRequest<'a> {
ObjectDataCacheGetRequest {
bucket,
object,
version_id: None,
etag: "etag",
size: 5,
body_variant: ObjectDataCacheBodyVariant::FullObjectPlainV1,
}
}
#[tokio::test]
async fn mutation_hook_invalidates_cached_body() {
let adapter = fill_enabled_adapter();
let plan = adapter.plan_get(request("b", "k"));
assert_eq!(
adapter.fill_body(&plan, Bytes::from_static(b"hello")).await,
ObjectDataCacheFillResult::Inserted
);
let hook = ObjectDataCacheMutationHook {
adapter: Arc::clone(&adapter),
};
hook.after_object_mutation("b", "k").await;
assert!(matches!(adapter.lookup_body(&plan).await, ObjectDataCacheLookup::Miss));
}
}
+15 -3
View File
@@ -200,7 +200,8 @@ use crate::app::object_data_cache::{
fill_get_object_body_cache_from_materialized_body, invalidate_object_data_cache_after_copy_success,
invalidate_object_data_cache_after_delete_success, invalidate_object_data_cache_after_put_success,
invalidate_object_data_cache_before_mutation, invalidate_object_data_cache_objects_after_delete_success,
invalidate_object_data_cache_objects_before_mutation, lookup_get_object_body_cache_hit,
invalidate_object_data_cache_objects_before_mutation, invalidate_object_data_cache_prefix_after_delete,
invalidate_object_data_cache_prefix_before_mutation, lookup_get_object_body_cache_hit,
};
type S3StdError = Box<dyn std::error::Error + Send + Sync + 'static>;
@@ -5543,7 +5544,14 @@ impl DefaultObjectUsecase {
};
let cache_adapter = self.object_data_cache();
let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await;
// A force (delete_prefix) delete removes every object under `key` as a
// prefix, so invalidating only the exact key would strand every cached
// body beneath it. Use the prefix primitive in that branch (ODC-27).
if force_delete {
let _ = invalidate_object_data_cache_prefix_before_mutation(&cache_adapter, &bucket, &key).await;
} else {
let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await;
}
let obj_info = {
match store.delete_object(&bucket, &key, opts.clone()).await {
@@ -5574,7 +5582,11 @@ impl DefaultObjectUsecase {
"failed to persist transitioned object cleanup journal"
);
}
let _ = invalidate_object_data_cache_after_delete_success(&cache_adapter, &bucket, &key).await;
if force_delete {
let _ = invalidate_object_data_cache_prefix_after_delete(&cache_adapter, &bucket, &key).await;
} else {
let _ = invalidate_object_data_cache_after_delete_success(&cache_adapter, &bucket, &key).await;
}
// Fast in-memory update for immediate quota and admin usage consistency
if delete_creates_delete_marker(&opts) {
+3 -1
View File
@@ -441,7 +441,9 @@ pub(crate) mod ecstore_rpc {
}
pub(crate) mod ecstore_object {
pub(crate) use rustfs_ecstore::api::object::{GetObjectBodyCacheHook, register_get_object_body_cache_hook};
pub(crate) use rustfs_ecstore::api::object::{
GetObjectBodyCacheHook, ObjectMutationHook, register_get_object_body_cache_hook, register_object_mutation_hook,
};
}
pub(crate) mod ecstore_set_disk {