mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-08 14:23:13 +00:00
85fd824581
* 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>
358 lines
14 KiB
Rust
358 lines
14 KiB
Rust
// 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::{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<AsyncShardedHashMap<ObjectDataCacheIdentity, ObjectDataCacheKeySet, RandomState>>,
|
|
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 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(key, token, 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(key.clone(), token, 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<ObjectDataCacheKey> {
|
|
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<F>(&self, predicate: F) -> Vec<ObjectDataCacheKey>
|
|
where
|
|
F: Fn(&ObjectDataCacheIdentity) -> bool,
|
|
{
|
|
let identities: Vec<ObjectDataCacheIdentity> = 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 {
|
|
let mut removed = false;
|
|
{
|
|
let removed = &mut removed;
|
|
let _ = self
|
|
.by_object
|
|
.compute_if_present(identity, move |mut key_set| {
|
|
*removed = key_set.remove_evicted_key(key, token);
|
|
(!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))
|
|
}
|
|
|
|
/// 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<F>(&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());
|
|
}
|
|
}
|