mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-09 06:39:25 +00:00
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:
@@ -21,8 +21,9 @@
|
||||
//! (after the reader is built) means a hit no longer saves any disk I/O.
|
||||
|
||||
use crate::object_api::ObjectInfo;
|
||||
use crate::object_api::hook_slot::HookSlot;
|
||||
use bytes::Bytes;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Serves full-object GET bodies from a cache keyed by object identity.
|
||||
///
|
||||
@@ -35,14 +36,15 @@ pub trait GetObjectBodyCacheHook: Send + Sync + 'static {
|
||||
async fn lookup(&self, bucket: &str, object: &str, info: &ObjectInfo) -> Option<Bytes>;
|
||||
}
|
||||
|
||||
// `RwLock<Option<Arc<dyn ...>>>` rather than `ArcSwapOption`: arc-swap's
|
||||
// `RefCnt` is implemented only for the sized `Arc<T>` (it stores a thin
|
||||
// `*mut T`), so it cannot hold an `Arc<dyn GetObjectBodyCacheHook>` without a
|
||||
// sized newtype wrapper. The probe reads this slot once per full-object GET,
|
||||
// but the read guard only clones an `Arc`, which is negligible next to the
|
||||
// metadata quorum fan-out already completed before the probe. Registration is
|
||||
// a startup / config-reload event, so writer contention is a non-issue.
|
||||
static GET_OBJECT_BODY_CACHE_HOOK: RwLock<Option<Arc<dyn GetObjectBodyCacheHook>>> = RwLock::new(None);
|
||||
// A `HookSlot` (RwLock<Option<Arc<dyn ...>>>) rather than `ArcSwapOption`:
|
||||
// arc-swap's `RefCnt` is implemented only for the sized `Arc<T>` (it stores a
|
||||
// thin `*mut T`), so it cannot hold an `Arc<dyn GetObjectBodyCacheHook>`
|
||||
// without a sized newtype wrapper. The probe reads this slot once per
|
||||
// full-object GET, but the read guard only clones an `Arc`, which is negligible
|
||||
// next to the metadata quorum fan-out already completed before the probe.
|
||||
// Registration is a startup / config-reload event, so writer contention is a
|
||||
// non-issue.
|
||||
static GET_OBJECT_BODY_CACHE_HOOK: HookSlot<dyn GetObjectBodyCacheHook> = HookSlot::new();
|
||||
|
||||
/// Register (or re-register) the process-wide GET body cache hook.
|
||||
///
|
||||
@@ -52,31 +54,22 @@ static GET_OBJECT_BODY_CACHE_HOOK: RwLock<Option<Arc<dyn GetObjectBodyCacheHook>
|
||||
/// to the original adapter while every usecase-layer fill and invalidation
|
||||
/// targeted the replacement, silently degrading the feature to a 0% hit rate
|
||||
/// and stranding entries in the unreachable cache until their TTL (backlog#1126).
|
||||
///
|
||||
/// Replacing a *different* hook instance is logged at WARN: in production the
|
||||
/// hook is installed exactly once per process, so a swap to a distinct instance
|
||||
/// signals an unexpected re-init and orphans the previous adapter's cache.
|
||||
pub fn register_get_object_body_cache_hook(hook: Arc<dyn GetObjectBodyCacheHook>) {
|
||||
let mut slot = GET_OBJECT_BODY_CACHE_HOOK.write().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(previous) = slot.as_ref()
|
||||
&& !Arc::ptr_eq(previous, &hook)
|
||||
{
|
||||
tracing::warn!(
|
||||
"GET object body cache hook re-registered with a different instance; \
|
||||
the previous adapter's cache is now unreachable by ecstore's GET probe"
|
||||
);
|
||||
}
|
||||
*slot = Some(hook);
|
||||
GET_OBJECT_BODY_CACHE_HOOK.register(
|
||||
hook,
|
||||
"GET object body cache hook re-registered with a different instance; \
|
||||
the previous adapter's cache is now unreachable by ecstore's GET probe",
|
||||
);
|
||||
}
|
||||
|
||||
/// The registered hook, if any.
|
||||
pub(crate) fn get_object_body_cache_hook() -> Option<Arc<dyn GetObjectBodyCacheHook>> {
|
||||
GET_OBJECT_BODY_CACHE_HOOK.read().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
GET_OBJECT_BODY_CACHE_HOOK.get()
|
||||
}
|
||||
|
||||
/// Test-only: unregister the hook so tests can register and clear the slot
|
||||
/// deterministically without leaking a hook into unrelated tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn clear_get_object_body_cache_hook() {
|
||||
*GET_OBJECT_BODY_CACHE_HOOK.write().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
GET_OBJECT_BODY_CACHE_HOOK.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// 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.
|
||||
|
||||
//! A process-wide, re-registrable slot for a single `Arc` hook.
|
||||
//!
|
||||
//! The GET body cache hook and the object mutation hook both need the same
|
||||
//! shape: one global slot that starts empty, is (re-)registered from the app
|
||||
//! layer at startup, is read on the hot/delete paths, and warns when a
|
||||
//! re-registration swaps in a *different* instance (an unexpected re-init that
|
||||
//! orphans the previous adapter's cache). This type holds that logic once so
|
||||
//! the two callers cannot drift apart.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// A global slot holding at most one `Arc<T>` hook.
|
||||
///
|
||||
/// Registration atomically swaps the stored hook. Poisoned locks recover in
|
||||
/// place: a panicked writer cannot leave an `Option<Arc<_>>` in an unsound
|
||||
/// state, so there is nothing to salvage.
|
||||
pub(crate) struct HookSlot<T: ?Sized> {
|
||||
inner: RwLock<Option<Arc<T>>>,
|
||||
}
|
||||
|
||||
impl<T: ?Sized> HookSlot<T> {
|
||||
/// Creates an empty slot. `const` so it can initialize a `static`.
|
||||
pub(crate) const fn new() -> Self {
|
||||
Self {
|
||||
inner: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers (or re-registers) the hook.
|
||||
///
|
||||
/// Replacing a *different* instance logs `warn_on_replace` at WARN: in
|
||||
/// production a hook is installed exactly once per process, so a swap to a
|
||||
/// distinct instance signals an unexpected re-init that leaves the previous
|
||||
/// adapter's cache unreachable.
|
||||
pub(crate) fn register(&self, hook: Arc<T>, warn_on_replace: &str) {
|
||||
let mut slot = self.inner.write().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some(previous) = slot.as_ref()
|
||||
&& !Arc::ptr_eq(previous, &hook)
|
||||
{
|
||||
tracing::warn!("{warn_on_replace}");
|
||||
}
|
||||
*slot = Some(hook);
|
||||
}
|
||||
|
||||
/// Returns the registered hook, if any.
|
||||
pub(crate) fn get(&self) -> Option<Arc<T>> {
|
||||
self.inner.read().unwrap_or_else(|poisoned| poisoned.into_inner()).clone()
|
||||
}
|
||||
|
||||
/// Clears the slot. Test-only: production never unregisters a hook.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn clear(&self) {
|
||||
*self.inner.write().unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::HookSlot;
|
||||
use std::sync::Arc;
|
||||
|
||||
trait Marker: Send + Sync {
|
||||
fn id(&self) -> u32;
|
||||
}
|
||||
struct Impl(u32);
|
||||
impl Marker for Impl {
|
||||
fn id(&self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_slot_reads_none() {
|
||||
let slot: HookSlot<dyn Marker> = HookSlot::new();
|
||||
assert!(slot.get().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_then_get_returns_the_hook() {
|
||||
let slot: HookSlot<dyn Marker> = HookSlot::new();
|
||||
slot.register(Arc::new(Impl(7)), "unused");
|
||||
assert_eq!(slot.get().map(|h| h.id()), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_registration_swaps_to_the_latest_instance() {
|
||||
// The load-bearing #1126 guarantee: the newest registration wins, so a
|
||||
// rebuilt AppContext leaves ecstore pointed at the current adapter
|
||||
// rather than a stranded first-wins one.
|
||||
let slot: HookSlot<dyn Marker> = HookSlot::new();
|
||||
slot.register(Arc::new(Impl(1)), "unused");
|
||||
slot.register(Arc::new(Impl(2)), "unused");
|
||||
assert_eq!(slot.get().map(|h| h.id()), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_registering_the_same_arc_is_idempotent() {
|
||||
let slot: HookSlot<dyn Marker> = HookSlot::new();
|
||||
let hook: Arc<dyn Marker> = Arc::new(Impl(9));
|
||||
slot.register(Arc::clone(&hook), "unused");
|
||||
slot.register(hook, "unused");
|
||||
assert_eq!(slot.get().map(|h| h.id()), Some(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_empties_the_slot() {
|
||||
let slot: HookSlot<dyn Marker> = HookSlot::new();
|
||||
slot.register(Arc::new(Impl(3)), "unused");
|
||||
slot.clear();
|
||||
assert!(slot.get().is_none());
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,8 @@ pub const ERASURE_ALGORITHM: &str = "rs-vandermonde";
|
||||
pub const BLOCK_SIZE_V2: usize = 1024 * 1024; // 1M
|
||||
|
||||
mod body_cache_hook;
|
||||
mod hook_slot;
|
||||
mod object_mutation_hook;
|
||||
mod readers;
|
||||
mod types;
|
||||
|
||||
@@ -60,5 +62,7 @@ mod types;
|
||||
pub(crate) use body_cache_hook::clear_get_object_body_cache_hook;
|
||||
pub(crate) use body_cache_hook::get_object_body_cache_hook;
|
||||
pub use body_cache_hook::{GetObjectBodyCacheHook, register_get_object_body_cache_hook};
|
||||
pub(crate) use object_mutation_hook::notify_object_mutation;
|
||||
pub use object_mutation_hook::{ObjectMutationHook, register_object_mutation_hook};
|
||||
pub use readers::*;
|
||||
pub use types::*;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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.
|
||||
|
||||
//! Write-side counterpart to [`super::body_cache_hook`].
|
||||
//!
|
||||
//! ecstore terminates object bodies from paths that never pass through the
|
||||
//! app-layer usecases: lifecycle/scanner expiry, non-current version cleanup,
|
||||
//! and restored-copy expiry all delete objects directly on the store. The GET
|
||||
//! body cache is keyed on `(bucket, object, versionId, etag, size, variant)`
|
||||
//! and every lookup follows a fresh metadata quorum, so a stale entry can never
|
||||
//! be *served* after its object is gone (the GET fails at metadata resolution
|
||||
//! first). What lingers is dead body bytes resident until TTL, which evict live
|
||||
//! hot entries — a hygiene and capacity problem, not a correctness one.
|
||||
//!
|
||||
//! This hook lets the app-layer cache adapter drop those bodies from its index
|
||||
//! the moment ecstore removes the object (ODC-26, backlog#1131).
|
||||
|
||||
use crate::object_api::hook_slot::HookSlot;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Invalidates cached object bodies after an ecstore-internal mutation removed
|
||||
/// them. Keyed on `(bucket, object)`; the implementation invalidates every
|
||||
/// cached version/etag under that identity.
|
||||
#[async_trait::async_trait]
|
||||
pub trait ObjectMutationHook: Send + Sync + 'static {
|
||||
async fn after_object_mutation(&self, bucket: &str, object: &str);
|
||||
}
|
||||
|
||||
// A `HookSlot`, for the same reason as the GET hook slot: arc-swap cannot hold
|
||||
// an unsized `Arc<dyn ObjectMutationHook>`. Registration is a startup event and
|
||||
// each delete-path invocation only clones an `Arc` under the read guard,
|
||||
// negligible next to the delete it accompanies.
|
||||
static OBJECT_MUTATION_HOOK: HookSlot<dyn ObjectMutationHook> = HookSlot::new();
|
||||
|
||||
/// Register (or re-register) the process-wide object mutation hook.
|
||||
///
|
||||
/// Re-registration atomically swaps to `hook`, mirroring the GET body hook so a
|
||||
/// rebuilt `AppContext` leaves ecstore's delete paths pointed at the newest
|
||||
/// adapter.
|
||||
pub fn register_object_mutation_hook(hook: Arc<dyn ObjectMutationHook>) {
|
||||
OBJECT_MUTATION_HOOK.register(
|
||||
hook,
|
||||
"object mutation cache hook re-registered with a different instance; \
|
||||
the previous adapter's cache is now unreachable by ecstore's delete paths",
|
||||
);
|
||||
}
|
||||
|
||||
/// The registered hook, if any.
|
||||
fn object_mutation_hook() -> Option<Arc<dyn ObjectMutationHook>> {
|
||||
OBJECT_MUTATION_HOOK.get()
|
||||
}
|
||||
|
||||
/// Invoke the registered hook for `(bucket, object)`, if one is installed.
|
||||
///
|
||||
/// A single `None` branch when the cache feature is off, so the ecstore delete
|
||||
/// paths pay nothing beyond one relaxed lock read when unconfigured.
|
||||
pub(crate) async fn notify_object_mutation(bucket: &str, object: &str) {
|
||||
if let Some(hook) = object_mutation_hook() {
|
||||
hook.after_object_mutation(bucket, object).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only: unregister the hook so tests can register and clear the slot
|
||||
/// deterministically without leaking a hook into unrelated tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn clear_object_mutation_hook() {
|
||||
OBJECT_MUTATION_HOOK.clear();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct RecordingHook {
|
||||
calls: Arc<Mutex<Vec<(String, String)>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ObjectMutationHook for RecordingHook {
|
||||
async fn after_object_mutation(&self, bucket: &str, object: &str) {
|
||||
self.calls.lock().unwrap().push((bucket.to_string(), object.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(object_mutation_hook)]
|
||||
async fn notify_invokes_registered_hook_with_identity() {
|
||||
clear_object_mutation_hook();
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
register_object_mutation_hook(Arc::new(RecordingHook {
|
||||
calls: Arc::clone(&calls),
|
||||
}));
|
||||
|
||||
notify_object_mutation("bucket", "photos/a.jpg").await;
|
||||
|
||||
assert_eq!(&*calls.lock().unwrap(), &[("bucket".to_string(), "photos/a.jpg".to_string())]);
|
||||
clear_object_mutation_hook();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(object_mutation_hook)]
|
||||
async fn notify_without_registered_hook_is_noop() {
|
||||
clear_object_mutation_hook();
|
||||
// Must not panic when no hook is installed (the cache feature is off).
|
||||
notify_object_mutation("bucket", "object").await;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user