// 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::index::{ ObjectDataCacheGeneration, ObjectDataCacheGenerationalInsertResult, ObjectDataCacheIndexInsertResult, ObjectDataCacheKeySet, ObjectDataCacheKeyToken, }; use crate::key::{ObjectDataCacheIdentity, ObjectDataCacheKey}; use starshard::{AsyncShardedHashMap, DEFAULT_SHARDS, SnapshotMode}; use std::collections::hash_map::RandomState; use std::sync::Arc; /// Async starshard-backed identity -> keys index. #[derive(Clone)] pub struct StarshardIdentityIndex { by_object: Arc>, max_keys_per_identity: usize, } impl std::fmt::Debug for StarshardIdentityIndex { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("StarshardIdentityIndex") .field("max_keys_per_identity", &self.max_keys_per_identity) .finish() } } impl StarshardIdentityIndex { /// Creates a new identity index. pub fn new(max_keys_per_identity: usize) -> Self { Self { by_object: Arc::new(AsyncShardedHashMap::with_shards_and_hasher_and_snapshot_mode( DEFAULT_SHARDS, RandomState::new(), SnapshotMode::Cached, )), max_keys_per_identity, } } /// Inserts a key into the identity index. /// /// Runs the read-modify-write under the shard write lock so concurrent /// fills for the same identity cannot drop each other's keys. pub async fn insert( &self, identity: ObjectDataCacheIdentity, key: ObjectDataCacheKey, token: ObjectDataCacheKeyToken, ) -> ObjectDataCacheIndexInsertResult { let generation = u64::try_from(token).unwrap_or(u64::MAX); match self.insert_generation(identity, key, generation).await { ObjectDataCacheGenerationalInsertResult::Inserted { evicted } => ObjectDataCacheIndexInsertResult::Inserted { evicted_keys: evicted.into_iter().map(|entry| entry.key).collect(), }, ObjectDataCacheGenerationalInsertResult::Duplicate => ObjectDataCacheIndexInsertResult::Duplicate, } } pub(crate) async fn insert_generation( &self, identity: ObjectDataCacheIdentity, key: ObjectDataCacheKey, generation: ObjectDataCacheGeneration, ) -> ObjectDataCacheGenerationalInsertResult { let max_keys = self.max_keys_per_identity; loop { let mut outcome = None; { let outcome = &mut outcome; let key = key.clone(); let _ = self .by_object .compute_if_present(&identity, move |mut key_set| { let result = key_set.insert_generation(key, generation, max_keys); // Insert never empties the set (it either dedups or // bounded-evicts and adds the new key), but keep the // guard so an already-empty entry is not republished. *outcome = Some(result); (!key_set.is_empty()).then_some(key_set) }) .await; } if let Some(result) = outcome { return result; } // Identity not tracked yet: publish a fresh single-key set. let mut fresh = ObjectDataCacheKeySet::default(); let result = fresh.insert_generation(key.clone(), generation, max_keys); let final_set = self.by_object.compute_if_absent(identity.clone(), move || fresh).await; if final_set.contains(&key) { return result; } // Lost the race to a concurrent insert; retry against the now // present entry. } } /// Removes all keys tracked for an identity. pub async fn remove_identity(&self, identity: &ObjectDataCacheIdentity) -> Vec { self.by_object .remove(identity) .await .map_or_else(Vec::new, |set| set.cloned()) } /// Removes every tracked identity whose key matches `predicate`, returning /// all keys that were dropped so the caller can evict them from the cache. /// /// This performs a **full shard scan** (`keys()` snapshots every identity, /// then each match is removed under its shard write lock). It is therefore /// restricted to the rare prefix-delete, bucket-delete, and admin /// `clear()`/flush paths — it must never run on the GET or fill hot path. /// A `true`-returning predicate clears the whole index (used by `clear()`). /// /// The snapshot/remove split leaves a small window: an identity inserted /// after the snapshot but before removal is not visited, and a key added to /// a matched identity between snapshot and its `remove` is still dropped /// from the index (via `remove`) but returned for cache eviction, so no /// tracked body is stranded. This is acceptable hygiene slack on these rare /// paths (backlog#1132/#1133/#1143). pub async fn remove_matching(&self, predicate: F) -> Vec where F: Fn(&ObjectDataCacheIdentity) -> bool, { let identities: Vec = self .by_object .keys() .await .into_iter() .filter(|identity| predicate(identity)) .collect(); let mut removed = Vec::new(); for identity in identities { if let Some(key_set) = self.by_object.remove(&identity).await { removed.extend(key_set.cloned()); } } removed } /// Removes a single evicted key tracked under an identity, but only when the /// evicted entry's generation token still matches the tracked one. /// /// This lets the cache eviction listener prune keys of genuinely evicted /// entries while leaving a key that was refilled under a new generation /// intact, so a stale (inline `Expired` upsert or deferred `Size`) eviction /// notification cannot silently break the invalidation contract. pub async fn remove_evicted_key( &self, identity: &ObjectDataCacheIdentity, key: &ObjectDataCacheKey, token: ObjectDataCacheKeyToken, ) -> bool { self.remove_generation(identity, key, u64::try_from(token).unwrap_or(u64::MAX)) .await } pub(crate) async fn remove_generation( &self, identity: &ObjectDataCacheIdentity, key: &ObjectDataCacheKey, generation: ObjectDataCacheGeneration, ) -> bool { let mut removed = false; { let removed = &mut removed; let _ = self .by_object .compute_if_present(identity, move |mut key_set| { *removed = key_set.remove_generation(key, generation); (!key_set.is_empty()).then_some(key_set) }) .await; } removed } /// Returns whether the identity currently tracks the supplied key. pub async fn contains_key(&self, identity: &ObjectDataCacheIdentity, key: &ObjectDataCacheKey) -> bool { self.by_object .get(identity) .await .is_some_and(|key_set| key_set.contains(key)) } pub(crate) async fn contains_generation( &self, identity: &ObjectDataCacheIdentity, key: &ObjectDataCacheKey, generation: ObjectDataCacheGeneration, ) -> bool { self.by_object .get(identity) .await .is_some_and(|key_set| key_set.contains_generation(key, generation)) } /// Returns the number of tracked identities. #[cfg(test)] pub(crate) async fn identity_count(&self) -> usize { self.by_object.len().await } /// Removes index keys that no longer exist in the cache. pub async fn prune_missing(&self, identity: &ObjectDataCacheIdentity, mut key_exists: F) where F: FnMut(&ObjectDataCacheKey) -> bool, { let _ = self .by_object .compute_if_present(identity, move |mut key_set| { key_set.retain(|key| key_exists(key)); (!key_set.is_empty()).then_some(key_set) }) .await; } } #[cfg(test)] mod tests { use super::StarshardIdentityIndex; use crate::index::ObjectDataCacheIndexInsertResult; use crate::key::{ObjectDataCacheBodyVariant, ObjectDataCacheIdentity, ObjectDataCacheKey}; fn identity() -> ObjectDataCacheIdentity { ObjectDataCacheIdentity::new("bucket", "object") } fn key(id: &str) -> ObjectDataCacheKey { ObjectDataCacheKey::new("bucket", "object", Some(id), "etag", 1, ObjectDataCacheBodyVariant::FullObjectPlainV1) } #[tokio::test] async fn identity_index_removes_all_keys_for_identity() { let index = StarshardIdentityIndex::new(4); let identity = identity(); let key_a = key("v1"); let key_b = key("v2"); let _ = index.insert(identity.clone(), key_a.clone(), 1).await; let _ = index.insert(identity.clone(), key_b.clone(), 2).await; let removed = index.remove_identity(&identity).await; assert_eq!(removed, vec![key_a, key_b]); } #[tokio::test] async fn identity_index_prunes_stale_keys() { let index = StarshardIdentityIndex::new(4); let identity = identity(); let key_a = key("v1"); let key_b = key("v2"); let _ = index.insert(identity.clone(), key_a.clone(), 1).await; let _ = index.insert(identity.clone(), key_b.clone(), 2).await; index.prune_missing(&identity, |candidate| candidate == &key_b).await; let removed = index.remove_identity(&identity).await; assert_eq!(removed, vec![key_b]); } #[tokio::test] async fn identity_index_concurrent_inserts_keep_all_keys() { let index = StarshardIdentityIndex::new(64); let identity = identity(); let mut handles = Vec::new(); for i in 0..32 { let index = index.clone(); let identity = identity.clone(); handles.push(tokio::spawn( async move { index.insert(identity, key(&format!("v{i}")), i as usize).await }, )); } for handle in handles { handle.await.expect("insert task should complete"); } let removed = index.remove_identity(&identity).await; assert_eq!(removed.len(), 32, "no concurrent insert may drop another fill's key"); } #[tokio::test] async fn identity_index_bounded_eviction_evicts_oldest() { let index = StarshardIdentityIndex::new(1); let identity = identity(); let key_a = key("v1"); let key_b = key("v2"); let _ = index.insert(identity.clone(), key_a.clone(), 1).await; let result = index.insert(identity.clone(), key_b.clone(), 2).await; let removed = index.remove_identity(&identity).await; assert!(matches!( result, ObjectDataCacheIndexInsertResult::Inserted { evicted_keys } if evicted_keys == vec![key_a] )); // The newest key is retained rather than the identity being cleared. assert_eq!(removed, vec![key_b]); } #[tokio::test] async fn identity_index_stale_eviction_preserves_refreshed_key() { // Manifestation of ODC-12(2): a deferred eviction notification for an old // generation must not break a later invalidate_object for the same key. let index = StarshardIdentityIndex::new(4); let identity = identity(); let key_a = key("v1"); // First fill registers token 1; a refill (same key, new body) refreshes // the token to 2. let _ = index.insert(identity.clone(), key_a.clone(), 1).await; let _ = index.insert(identity.clone(), key_a.clone(), 2).await; // A stale eviction notification for the old generation (token 1) arrives. let removed = index.remove_evicted_key(&identity, &key_a, 1).await; assert!(!removed, "stale-generation eviction must not remove the refreshed key"); // A later invalidate_object still finds and removes the key. let invalidated = index.remove_identity(&identity).await; assert_eq!(invalidated, vec![key_a]); } fn bucketed_key(bucket: &str, object: &str) -> ObjectDataCacheKey { ObjectDataCacheKey::new(bucket, object, Some("v1"), "etag", 1, ObjectDataCacheBodyVariant::FullObjectPlainV1) } #[tokio::test] async fn remove_matching_returns_only_predicate_matches() { let index = StarshardIdentityIndex::new(4); let id_photos = ObjectDataCacheIdentity::new("bucket", "photos/a.jpg"); let id_photos2 = ObjectDataCacheIdentity::new("bucket", "photos/b.jpg"); let id_videos = ObjectDataCacheIdentity::new("bucket", "videos/c.mp4"); let _ = index .insert(id_photos.clone(), bucketed_key("bucket", "photos/a.jpg"), 1) .await; let _ = index .insert(id_photos2.clone(), bucketed_key("bucket", "photos/b.jpg"), 2) .await; let _ = index .insert(id_videos.clone(), bucketed_key("bucket", "videos/c.mp4"), 3) .await; let removed = index .remove_matching(|identity| identity.bucket.as_ref() == "bucket" && identity.object.starts_with("photos/")) .await; assert_eq!(removed.len(), 2, "only the two photos/ identities match the prefix"); // The unmatched identity is still tracked; the matched ones are gone. assert!(index.remove_identity(&id_videos).await.len() == 1); assert!(index.remove_identity(&id_photos).await.is_empty()); } #[tokio::test] async fn remove_matching_true_predicate_clears_index() { let index = StarshardIdentityIndex::new(4); let _ = index .insert(ObjectDataCacheIdentity::new("b1", "o1"), bucketed_key("b1", "o1"), 1) .await; let _ = index .insert(ObjectDataCacheIdentity::new("b2", "o2"), bucketed_key("b2", "o2"), 2) .await; let removed = index.remove_matching(|_| true).await; assert_eq!(removed.len(), 2); assert_eq!(index.identity_count().await, 0, "a true predicate clears every identity"); } #[tokio::test] async fn identity_index_matching_eviction_removes_key() { let index = StarshardIdentityIndex::new(4); let identity = identity(); let key_a = key("v1"); let _ = index.insert(identity.clone(), key_a.clone(), 7).await; let removed = index.remove_evicted_key(&identity, &key_a, 7).await; assert!(removed, "an eviction carrying the tracked token must remove the key"); assert!(index.remove_identity(&identity).await.is_empty()); } }