// 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`. 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 = 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) { 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", ); } /// Unregister the process-wide object mutation hook. /// /// Config reloads use this when body caching becomes disabled so the previous /// adapter and its cached plaintext bodies are not retained until their TTL. pub fn unregister_object_mutation_hook() { OBJECT_MUTATION_HOOK.clear(); } /// The registered hook, if any. fn object_mutation_hook() -> Option> { 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; } } #[cfg(test)] mod tests { use super::*; use std::sync::Mutex; struct RecordingHook { calls: Arc>>, } #[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() { unregister_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())]); unregister_object_mutation_hook(); } #[tokio::test] #[serial_test::serial(object_mutation_hook)] async fn unregister_prevents_later_notifications() { unregister_object_mutation_hook(); let calls = Arc::new(Mutex::new(Vec::new())); register_object_mutation_hook(Arc::new(RecordingHook { calls: Arc::clone(&calls), })); unregister_object_mutation_hook(); notify_object_mutation("bucket", "object").await; assert!(calls.lock().unwrap().is_empty(), "an unregistered hook must receive no mutation callback"); } #[test] #[serial_test::serial(object_mutation_hook)] fn unregister_releases_the_previous_hook() { unregister_object_mutation_hook(); let calls = Arc::new(Mutex::new(Vec::new())); let hook = Arc::new(RecordingHook { calls }); let weak = Arc::downgrade(&hook); register_object_mutation_hook(hook); assert!(weak.upgrade().is_some()); unregister_object_mutation_hook(); assert!(weak.upgrade().is_none()); } }