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
@@ -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;
}
}