feat(cache): add object data cache engine and app flow (#4187)

* feat(cache): add object data cache engine

* feat(cache): wire app-layer object cache flow

* refactor(cache): streamline app-layer cache flow

* refactor(cache): tighten cache flow internals

* refactor: address final clippy cleanup

* chore(deps): update quick-xml to 0.41.0

* feat(cache): wire object data cache env config

* fix(cache): gate materialize fill by cache plan

* chore(cache): add object data cache benchmark gate

* fix(cache): guard object cache fill size mismatches

* refactor(cache): streamline object cache body planning

* fix(cache): align object cache rollout config

* test(cache): cover buffered object cache benchmark

* test(cache): isolate object cache benchmark metrics

* test(cache): mark materialize rollout experimental

* test(cache): tighten object cache benchmark gate

* fix(cache): address review findings for object data cache

- singleflight: clean up leader entry on cancellation (Drop impl) so a dropped GET future can no longer wedge all subsequent fills for the same key; switch the fill map to a std Mutex and add a regression test

- adapter: honor RUSTFS_OBJECT_DATA_CACHE_ENABLE=true by defaulting to hit_only when no explicit mode is set (explicit mode still wins)

- planner: treat nil version UUIDs as "no value" per repo convention so unversioned objects key under the canonical "null" instead of fragmenting the key space

- multipart: invalidate the object cache on the quota-exceeded rollback delete after complete-multipart, closing a stale-cache window

- layering: move the disabled-cache fallback into app::context and drop the new infra->app layer-dependency baseline entry

* fix(cache): close invalidation races and drop full-cache scan on writes

- index: make identity-index insert/remove/prune atomic via starshard compute_if_present/compute_if_absent so concurrent fills can no longer drop each other's keys (lost keys made entries unreachable to invalidation until TTL); add a concurrency regression test

- fill: register the key in the identity index before the entry becomes visible in the cache and re-check the index afterwards, undoing the fill when an invalidation raced in between (new skipped_invalidation_race fill result)

- invalidate: with the index now authoritative, remove the full-cache iter() fallback that made every PUT/DELETE of a never-cached object O(total cache entries) (two scans per PUT, 2N per batch delete)

- materialize-fill: fail the GET instead of falling back to the partially consumed stream after a mid-read error (the fallback would send a body missing its prefix under a full-length Content-Length), and log the same size-mismatch warning as the sibling buffering paths

Co-Authored-By: heihutu <heihutu@gmail.com>

* test(storage): fix media-dependent buffer clamp expectation

test_concurrency_manager_multi_factor_strategy_buffer_clamp asserted media_cap.min(MI_B), but the implementation's final safety clamp is [32KiB, media_cap.max(MI_B)] — deliberately so a media cap above 1MiB (NVMe's 2MiB default) stays effective. The test only passed on machines detected as SSD/Unknown (cap == 1MiB) and failed on NVMe-backed CI runners with 2MiB != 1MiB. Assert the media cap itself, which is what the strategy actually guarantees on every environment.

Co-Authored-By: heihutu <heihutu@gmail.com>

* test(storage): format buffer clamp assertion

* chore(logging): update tier guardrail path

---------

Signed-off-by: houseme <housemecn@gmail.com>
Co-authored-by: cxymds <cxymds@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
houseme
2026-07-03 18:11:14 +08:00
committed by GitHub
parent 25d80d7c60
commit eebd16d8a4
37 changed files with 5701 additions and 14 deletions
+1
View File
@@ -102,6 +102,7 @@ rustfs-zip = { workspace = true }
rustfs-io-core = { workspace = true }
rustfs-io-metrics = { workspace = true }
rustfs-object-capacity = { workspace = true }
rustfs-object-data-cache = { workspace = true }
rustfs-concurrency = { workspace = true }
rustfs-scanner = { workspace = true }
tempfile = { workspace = true }
+16
View File
@@ -32,6 +32,7 @@ use super::storage_api::context::runtime::{
ScannerMetricsReport, StorageClassConfig, TierConfigMgr, TransitionState,
};
use super::storage_api::context::{ECStore, EndpointServerPools};
use crate::app::object_data_cache::ObjectDataCacheAdapter;
use crate::config::RustFSBufferConfig;
use rustfs_config::server_config::Config;
use rustfs_credentials::Credentials;
@@ -125,6 +126,21 @@ pub fn resolve_object_store_handle_for_context(context: Option<&AppContext>) ->
context.map(|context| context.object_store())
}
/// Resolve object data cache adapter using AppContext-first precedence.
#[expect(
dead_code,
reason = "ST-05 exposes the global resolver; app use sites start in later cache phases"
)]
pub(crate) fn resolve_object_data_cache_handle() -> Option<Arc<ObjectDataCacheAdapter>> {
let context = get_global_app_context();
resolve_object_data_cache_handle_for_context(context.as_deref())
}
/// Resolve object data cache adapter using an explicit AppContext.
pub(crate) fn resolve_object_data_cache_handle_for_context(context: Option<&AppContext>) -> Option<Arc<ObjectDataCacheAdapter>> {
context.map(|context| context.object_data_cache())
}
/// Resolve notify interface using AppContext-first precedence.
pub fn resolve_notify_interface() -> Option<Arc<dyn NotifyInterface>> {
let context = get_global_app_context();
+10
View File
@@ -33,6 +33,7 @@ use super::interfaces::{
ReplicationPoolInterface, ReplicationStatsInterface, RuntimePortInterface, S3SelectDbInterface, ScannerMetricsInterface,
ServerConfigInterface, StorageClassInterface, TierConfigInterface, TransitionStateInterface,
};
use crate::app::object_data_cache::ObjectDataCacheAdapter;
use rustfs_iam::{oidc::OidcSys, store::object::ObjectStore, sys::IamSys};
use rustfs_kms::KmsServiceManager;
use std::sync::{Arc, OnceLock};
@@ -72,10 +73,13 @@ pub struct AppContext {
server_config: Arc<dyn ServerConfigInterface>,
storage_class: Arc<dyn StorageClassInterface>,
buffer_config: Arc<dyn BufferConfigInterface>,
object_data_cache: Arc<ObjectDataCacheAdapter>,
}
impl AppContext {
pub fn new(object_store: Arc<ECStore>, iam: Arc<dyn IamInterface>, kms: Arc<dyn KmsInterface>) -> Self {
let object_data_cache = ObjectDataCacheAdapter::from_env_or_disabled();
Self {
object_store,
iam,
@@ -108,6 +112,7 @@ impl AppContext {
server_config: default_server_config_interface(),
storage_class: default_storage_class_interface(),
buffer_config: default_buffer_config_interface(),
object_data_cache,
}
}
@@ -249,6 +254,10 @@ impl AppContext {
pub fn buffer_config(&self) -> Arc<dyn BufferConfigInterface> {
self.buffer_config.clone()
}
pub(crate) fn object_data_cache(&self) -> Arc<ObjectDataCacheAdapter> {
Arc::clone(&self.object_data_cache)
}
}
#[cfg(test)]
@@ -320,6 +329,7 @@ impl AppContext {
server_config: interfaces.server_config,
storage_class: interfaces.storage_class,
buffer_config: interfaces.buffer_config,
object_data_cache: ObjectDataCacheAdapter::disabled_arc(),
}
}
}
+4
View File
@@ -432,6 +432,10 @@ pub fn default_notify_interface() -> Arc<dyn NotifyInterface> {
Arc::new(NotifyHandle)
}
pub(crate) fn default_object_data_cache_handle() -> Arc<crate::app::object_data_cache::ObjectDataCacheAdapter> {
crate::app::object_data_cache::ObjectDataCacheAdapter::disabled_arc()
}
pub fn default_notification_system_interface() -> Arc<dyn NotificationSystemInterface> {
Arc::new(NotificationSystemHandle)
}
+1
View File
@@ -19,6 +19,7 @@ pub mod admin_usecase;
pub mod bucket_usecase;
pub mod context;
pub mod multipart_usecase;
pub(crate) mod object_data_cache;
pub mod object_usecase;
pub(crate) mod runtime_sources;
mod select_object;
+15 -1
View File
@@ -55,8 +55,14 @@ use super::storage_api::multipart_usecase::sse::{
mark_encrypted_multipart_metadata, sse_decryption, sse_prepare_encryption,
};
use super::storage_api::multipart_usecase::{StorageObjectOptions as ObjectOptions, StoragePutObjReader as PutObjReader};
use crate::app::object_data_cache::{
ObjectDataCacheAdapter, invalidate_object_data_cache_after_complete_multipart_success,
invalidate_object_data_cache_after_delete_success, invalidate_object_data_cache_before_mutation,
};
use crate::app::object_usecase::{build_put_like_object_lock_metadata, validate_existing_object_lock_for_write};
use crate::app::runtime_sources::{AppContext, current_app_context, current_object_store_handle_for_context};
use crate::app::runtime_sources::{
AppContext, current_app_context, current_object_data_cache_for_context, current_object_store_handle_for_context,
};
use crate::capacity::record_capacity_write;
use crate::error::ApiError;
use crate::table_catalog;
@@ -297,6 +303,10 @@ impl DefaultMultipartUsecase {
current_object_store_handle_for_context(self.context.as_deref())
}
fn object_data_cache(&self) -> Arc<ObjectDataCacheAdapter> {
current_object_data_cache_for_context(self.context.as_deref())
}
#[instrument(level = "debug", skip(self))]
pub async fn execute_abort_multipart_upload(
&self,
@@ -447,6 +457,8 @@ impl DefaultMultipartUsecase {
.get_multipart_info(&bucket, &key, &upload_id, &ObjectOptions::default())
.await
.map_err(ApiError::from)?;
let cache_adapter = self.object_data_cache();
let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await;
let server_side_encryption = multipart_info
.user_defined
@@ -466,6 +478,7 @@ impl DefaultMultipartUsecase {
.complete_multipart_upload(&bucket, &key, &upload_id, uploaded_parts, &opts)
.await
.map_err(ApiError::from)?;
let _ = invalidate_object_data_cache_after_complete_multipart_success(&cache_adapter, &bucket, &key).await;
record_capacity_write(Some(capacity_scope_token)).await;
// check quota after completing multipart upload
@@ -480,6 +493,7 @@ impl DefaultMultipartUsecase {
if !check_result.allowed {
// Quota exceeded, delete the completed object
let _ = store.delete_object(&bucket, &key, ObjectOptions::default()).await;
let _ = invalidate_object_data_cache_after_delete_success(&cache_adapter, &bucket, &key).await;
return Err(S3Error::with_message(
S3ErrorCode::InvalidRequest,
format!(
+345
View File
@@ -0,0 +1,345 @@
// 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 bytes::Bytes;
use rustfs_object_data_cache::{
ObjectDataCache, ObjectDataCacheConfig, ObjectDataCacheConfigError, ObjectDataCacheFillResult, ObjectDataCacheGetPlan,
ObjectDataCacheGetRequest, ObjectDataCacheIdentity, ObjectDataCacheInvalidationReason, ObjectDataCacheInvalidationResult,
ObjectDataCacheLookup, ObjectDataCacheMode,
};
use std::{sync::Arc, time::Duration};
use tracing::warn;
#[derive(Debug, Default)]
struct ObjectDataCacheEnvValues {
enabled: Option<bool>,
mode: Option<String>,
max_bytes: Option<u64>,
max_memory_percent: Option<u8>,
max_entry_bytes: Option<u64>,
ttl_secs: Option<u64>,
time_to_idle_secs: Option<u64>,
min_free_memory_percent: Option<u8>,
fill_concurrency_per_cpu: Option<u16>,
fill_concurrency_max: Option<u16>,
identity_keys_max: Option<u16>,
}
/// App-layer wrapper around the engine-only object data cache.
#[derive(Debug, Clone)]
pub(crate) struct ObjectDataCacheAdapter {
cache: Arc<ObjectDataCache>,
}
impl ObjectDataCacheAdapter {
/// Creates an adapter from validated cache configuration.
pub(crate) fn new(config: ObjectDataCacheConfig) -> Result<Self, ObjectDataCacheConfigError> {
Ok(Self {
cache: Arc::new(ObjectDataCache::new(config)?),
})
}
/// Creates an adapter from runtime environment variables, falling back to
/// no-op on invalid input so startup behavior stays conservative.
pub(crate) fn from_env_or_disabled() -> Arc<Self> {
Self::from_config_or_disabled(object_data_cache_config_from_env(), "env")
}
/// Creates a disabled no-op adapter.
pub(crate) fn disabled() -> Self {
Self {
cache: Arc::new(ObjectDataCache::disabled()),
}
}
/// Creates a shared disabled no-op adapter.
pub(crate) fn disabled_arc() -> Arc<Self> {
Arc::new(Self::disabled())
}
/// Returns the underlying shared cache handle.
#[cfg(test)]
pub(crate) fn cache(&self) -> Arc<ObjectDataCache> {
Arc::clone(&self.cache)
}
/// Returns true when the adapter is wired to a disabled cache.
pub(crate) fn is_disabled(&self) -> bool {
self.cache.is_disabled()
}
/// Returns true when the adapter allows materialize fill.
pub(crate) fn materialize_fill_enabled(&self) -> bool {
self.cache.materialize_fill_enabled()
}
/// Builds an engine-level GET plan.
pub(crate) fn plan_get(&self, request: ObjectDataCacheGetRequest<'_>) -> ObjectDataCacheGetPlan {
self.cache.plan_get(request)
}
/// Executes an engine-level cache lookup.
pub(crate) async fn lookup_body(&self, plan: &ObjectDataCacheGetPlan) -> ObjectDataCacheLookup {
self.cache.lookup_body(plan).await
}
/// Executes an engine-level cache fill.
pub(crate) async fn fill_body(&self, plan: &ObjectDataCacheGetPlan, bytes: Bytes) -> ObjectDataCacheFillResult {
self.cache.fill_body(plan, bytes).await
}
/// Executes an engine-level object invalidation.
pub(crate) async fn invalidate_object(
&self,
identity: ObjectDataCacheIdentity,
reason: ObjectDataCacheInvalidationReason,
) -> ObjectDataCacheInvalidationResult {
self.cache.invalidate_object(identity, reason).await
}
}
impl ObjectDataCacheAdapter {
fn from_config_or_disabled(config: ObjectDataCacheConfig, source: &str) -> Arc<Self> {
match Self::new(config) {
Ok(adapter) => Arc::new(adapter),
Err(err) => {
warn!(
error = %err,
source,
"object data cache disabled because configuration is invalid"
);
Self::disabled_arc()
}
}
}
}
impl Default for ObjectDataCacheAdapter {
fn default() -> Self {
Self::disabled()
}
}
fn object_data_cache_config_from_env() -> ObjectDataCacheConfig {
object_data_cache_config_from_values(ObjectDataCacheEnvValues {
enabled: rustfs_utils::get_env_opt_bool(rustfs_config::ENV_OBJECT_DATA_CACHE_ENABLE),
mode: rustfs_utils::get_env_opt_str(rustfs_config::ENV_OBJECT_DATA_CACHE_MODE),
max_bytes: rustfs_utils::get_env_opt_u64(rustfs_config::ENV_OBJECT_DATA_CACHE_MAX_BYTES),
max_memory_percent: rustfs_utils::get_env_opt_u8(rustfs_config::ENV_OBJECT_DATA_CACHE_MAX_MEMORY_PERCENT),
max_entry_bytes: rustfs_utils::get_env_opt_u64(rustfs_config::ENV_OBJECT_DATA_CACHE_MAX_ENTRY_BYTES),
ttl_secs: rustfs_utils::get_env_opt_u64(rustfs_config::ENV_OBJECT_DATA_CACHE_TTL_SECS),
time_to_idle_secs: rustfs_utils::get_env_opt_u64(rustfs_config::ENV_OBJECT_DATA_CACHE_TIME_TO_IDLE_SECS),
min_free_memory_percent: rustfs_utils::get_env_opt_u8(rustfs_config::ENV_OBJECT_DATA_CACHE_MIN_FREE_MEMORY_PERCENT),
fill_concurrency_per_cpu: rustfs_utils::get_env_opt_u16(rustfs_config::ENV_OBJECT_DATA_CACHE_FILL_CONCURRENCY_PER_CPU),
fill_concurrency_max: rustfs_utils::get_env_opt_u16(rustfs_config::ENV_OBJECT_DATA_CACHE_FILL_CONCURRENCY_MAX),
identity_keys_max: rustfs_utils::get_env_opt_u16(rustfs_config::ENV_OBJECT_DATA_CACHE_IDENTITY_KEYS_MAX),
})
}
fn object_data_cache_config_from_values(values: ObjectDataCacheEnvValues) -> ObjectDataCacheConfig {
let mut config = ObjectDataCacheConfig::default();
let mode_explicit = values.mode.is_some();
if let Some(mode) = values.mode {
config.mode = parse_object_data_cache_mode(&mode).unwrap_or_else(|| {
warn!(
value = %mode,
supported = "disabled,hit_only,fill_buffered_only,fill_materialize_enabled",
"invalid object data cache mode; cache remains disabled"
);
ObjectDataCacheMode::Disabled
});
}
match values.enabled {
Some(false) => {
config.mode = ObjectDataCacheMode::Disabled;
}
Some(true) if !mode_explicit => {
// Enable flag without an explicit mode: start at the safest
// enabled stage instead of silently staying disabled.
config.mode = ObjectDataCacheMode::HitOnly;
}
_ => {}
}
if let Some(max_bytes) = values.max_bytes {
config.max_bytes = max_bytes;
}
if let Some(max_memory_percent) = values.max_memory_percent {
config.max_memory_percent = max_memory_percent;
}
if let Some(max_entry_bytes) = values.max_entry_bytes {
config.max_entry_bytes = max_entry_bytes;
}
if let Some(ttl_secs) = values.ttl_secs {
config.ttl = Duration::from_secs(ttl_secs);
}
if let Some(time_to_idle_secs) = values.time_to_idle_secs {
config.time_to_idle = Duration::from_secs(time_to_idle_secs);
}
if let Some(min_free_memory_percent) = values.min_free_memory_percent {
config.min_free_memory_percent = min_free_memory_percent;
}
if let Some(fill_concurrency_per_cpu) = values.fill_concurrency_per_cpu {
config.fill_concurrency_per_cpu = fill_concurrency_per_cpu;
}
if let Some(fill_concurrency_max) = values.fill_concurrency_max {
config.fill_concurrency_max = fill_concurrency_max;
}
if let Some(identity_keys_max) = values.identity_keys_max {
config.identity_keys_max = identity_keys_max;
}
config
}
fn parse_object_data_cache_mode(value: &str) -> Option<ObjectDataCacheMode> {
match value.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"disabled" | "off" | "none" => Some(ObjectDataCacheMode::Disabled),
"hit_only" => Some(ObjectDataCacheMode::HitOnly),
"fill_buffered_only" => Some(ObjectDataCacheMode::FillBufferedOnly),
"fill_materialize_enabled" => Some(ObjectDataCacheMode::FillMaterializeEnabled),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{
ObjectDataCacheAdapter, ObjectDataCacheEnvValues, object_data_cache_config_from_values, parse_object_data_cache_mode,
};
use rustfs_object_data_cache::{ObjectDataCacheConfig, ObjectDataCacheMode};
use std::time::Duration;
#[test]
fn disabled_adapter_exposes_disabled_engine() {
let adapter = ObjectDataCacheAdapter::disabled();
assert!(adapter.is_disabled());
assert!(adapter.cache().is_disabled());
}
#[test]
fn adapter_new_accepts_default_disabled_config() {
let adapter = ObjectDataCacheAdapter::new(ObjectDataCacheConfig::default()).expect("disabled config should initialize");
assert!(adapter.is_disabled());
}
#[test]
fn object_data_cache_mode_parser_accepts_supported_modes() {
assert_eq!(parse_object_data_cache_mode("disabled"), Some(ObjectDataCacheMode::Disabled));
assert_eq!(parse_object_data_cache_mode("hit-only"), Some(ObjectDataCacheMode::HitOnly));
assert_eq!(
parse_object_data_cache_mode("fill_buffered_only"),
Some(ObjectDataCacheMode::FillBufferedOnly)
);
assert_eq!(
parse_object_data_cache_mode("fill-materialize-enabled"),
Some(ObjectDataCacheMode::FillMaterializeEnabled)
);
}
#[test]
fn object_data_cache_env_defaults_stay_disabled() {
let config = object_data_cache_config_from_values(ObjectDataCacheEnvValues::default());
assert_eq!(config, ObjectDataCacheConfig::default());
}
#[test]
fn object_data_cache_env_values_override_defaults() {
let config = object_data_cache_config_from_values(ObjectDataCacheEnvValues {
enabled: Some(true),
mode: Some("fill_materialize_enabled".to_string()),
max_bytes: Some(8_388_608),
max_memory_percent: Some(10),
max_entry_bytes: Some(2_097_152),
ttl_secs: Some(120),
time_to_idle_secs: Some(45),
min_free_memory_percent: Some(30),
fill_concurrency_per_cpu: Some(2),
fill_concurrency_max: Some(64),
identity_keys_max: Some(32),
});
assert_eq!(config.mode, ObjectDataCacheMode::FillMaterializeEnabled);
assert_eq!(config.max_bytes, 8_388_608);
assert_eq!(config.max_memory_percent, 10);
assert_eq!(config.max_entry_bytes, 2_097_152);
assert_eq!(config.ttl, Duration::from_secs(120));
assert_eq!(config.time_to_idle, Duration::from_secs(45));
assert_eq!(config.min_free_memory_percent, 30);
assert_eq!(config.fill_concurrency_per_cpu, 2);
assert_eq!(config.fill_concurrency_max, 64);
assert_eq!(config.identity_keys_max, 32);
}
#[test]
fn invalid_object_data_cache_mode_falls_back_to_disabled() {
let config = object_data_cache_config_from_values(ObjectDataCacheEnvValues {
mode: Some("enabled".to_string()),
..ObjectDataCacheEnvValues::default()
});
assert_eq!(config.mode, ObjectDataCacheMode::Disabled);
}
#[test]
fn object_data_cache_enable_false_overrides_mode() {
let config = object_data_cache_config_from_values(ObjectDataCacheEnvValues {
enabled: Some(false),
mode: Some("fill_materialize_enabled".to_string()),
..ObjectDataCacheEnvValues::default()
});
assert_eq!(config.mode, ObjectDataCacheMode::Disabled);
}
#[test]
fn object_data_cache_enable_true_without_mode_defaults_to_hit_only() {
let config = object_data_cache_config_from_values(ObjectDataCacheEnvValues {
enabled: Some(true),
..ObjectDataCacheEnvValues::default()
});
assert_eq!(config.mode, ObjectDataCacheMode::HitOnly);
}
#[test]
fn object_data_cache_explicit_mode_wins_over_enable_true() {
let config = object_data_cache_config_from_values(ObjectDataCacheEnvValues {
enabled: Some(true),
mode: Some("disabled".to_string()),
..ObjectDataCacheEnvValues::default()
});
assert_eq!(config.mode, ObjectDataCacheMode::Disabled);
}
#[test]
fn invalid_object_data_cache_config_builds_disabled_adapter() {
let adapter = ObjectDataCacheAdapter::from_config_or_disabled(
ObjectDataCacheConfig {
mode: ObjectDataCacheMode::FillBufferedOnly,
fill_concurrency_per_cpu: 2,
fill_concurrency_max: 1,
..ObjectDataCacheConfig::default()
},
"test",
);
assert!(adapter.is_disabled());
}
}
+235
View File
@@ -0,0 +1,235 @@
// 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.
//! Body handoff glue for the object data cache adapter.
use crate::app::object_data_cache::ObjectDataCacheAdapter;
use crate::app::object_data_cache::planner::GetObjectBodyCachePlan;
use bytes::Bytes;
use rustfs_object_data_cache::{ObjectDataCacheFillResult, ObjectDataCacheLookup};
/// Result of an app-layer GET body cache lookup.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum GetObjectBodyCacheLookup {
Disabled,
Skip,
Miss,
Hit(Bytes),
}
/// Attempts a conservative cache lookup for the GET response body.
pub(crate) async fn lookup_get_object_body_cache_hit(
adapter: &ObjectDataCacheAdapter,
plan: &GetObjectBodyCachePlan,
) -> GetObjectBodyCacheLookup {
match plan {
GetObjectBodyCachePlan::Disabled => GetObjectBodyCacheLookup::Disabled,
GetObjectBodyCachePlan::Skip => GetObjectBodyCacheLookup::Skip,
GetObjectBodyCachePlan::Cacheable(plan) => match adapter.lookup_body(plan).await {
ObjectDataCacheLookup::Hit(bytes) => GetObjectBodyCacheLookup::Hit(bytes),
ObjectDataCacheLookup::Miss => GetObjectBodyCacheLookup::Miss,
ObjectDataCacheLookup::SkipDisabled => GetObjectBodyCacheLookup::Disabled,
ObjectDataCacheLookup::SkipNotCacheable => GetObjectBodyCacheLookup::Skip,
},
}
}
/// Attempts a conservative cache fill from an already buffered full object body.
pub(crate) async fn fill_get_object_body_cache_from_buffered_body(
adapter: &ObjectDataCacheAdapter,
plan: &GetObjectBodyCachePlan,
buffered_body: &Bytes,
) -> ObjectDataCacheFillResult {
fill_get_object_body_cache_from_bytes(adapter, plan, buffered_body).await
}
/// Attempts a conservative cache fill from an already materialized full object body.
pub(crate) async fn fill_get_object_body_cache_from_materialized_body(
adapter: &ObjectDataCacheAdapter,
plan: &GetObjectBodyCachePlan,
materialized_body: &Bytes,
) -> ObjectDataCacheFillResult {
fill_get_object_body_cache_from_bytes(adapter, plan, materialized_body).await
}
async fn fill_get_object_body_cache_from_bytes(
adapter: &ObjectDataCacheAdapter,
plan: &GetObjectBodyCachePlan,
body: &Bytes,
) -> ObjectDataCacheFillResult {
match plan {
GetObjectBodyCachePlan::Disabled => ObjectDataCacheFillResult::SkippedDisabled,
GetObjectBodyCachePlan::Skip => ObjectDataCacheFillResult::SkippedNotCacheable,
GetObjectBodyCachePlan::Cacheable(plan) => adapter.fill_body(plan, body.clone()).await,
}
}
#[cfg(test)]
mod tests {
use super::{
GetObjectBodyCacheLookup, fill_get_object_body_cache_from_buffered_body,
fill_get_object_body_cache_from_materialized_body, lookup_get_object_body_cache_hit,
};
use crate::app::object_data_cache::ObjectDataCacheAdapter;
use crate::app::object_data_cache::planner::{GetObjectBodyCacheRequest, build_get_object_body_cache_plan};
use bytes::Bytes;
use rustfs_object_data_cache::{
ObjectDataCacheBodyVariant, ObjectDataCacheConfig, ObjectDataCacheFillResult, ObjectDataCacheMode,
};
fn enabled_fill_adapter() -> ObjectDataCacheAdapter {
let config = ObjectDataCacheConfig {
mode: ObjectDataCacheMode::FillBufferedOnly,
max_bytes: 8_388_608,
..ObjectDataCacheConfig::default()
};
ObjectDataCacheAdapter::new(config).expect("fill-enabled config should build adapter")
}
#[tokio::test]
async fn body_lookup_returns_hit_when_cache_contains_matching_body() {
let adapter = enabled_fill_adapter();
let info = crate::storage::storage_api::StorageObjectInfo {
etag: Some("etag".to_string()),
size: 5,
..Default::default()
};
let request = GetObjectBodyCacheRequest {
bucket: "bucket",
key: "object",
info: &info,
response_content_length: 5,
has_range: false,
part_number: None,
encryption_applied: false,
};
let cache_plan = build_get_object_body_cache_plan(&adapter, request);
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
bucket: "bucket",
object: "object",
version_id: None,
etag: "etag",
size: 5,
body_variant: ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let fill = adapter.cache().fill_body(&plan, Bytes::from_static(b"hello")).await;
let lookup = lookup_get_object_body_cache_hit(&adapter, &cache_plan).await;
assert_eq!(fill, ObjectDataCacheFillResult::Inserted);
assert!(matches!(lookup, GetObjectBodyCacheLookup::Hit(ref bytes) if bytes.as_ref() == b"hello"));
}
#[tokio::test]
async fn body_lookup_returns_skip_for_part_requests() {
let adapter = enabled_fill_adapter();
let info = crate::storage::storage_api::StorageObjectInfo {
etag: Some("etag".to_string()),
size: 5,
..Default::default()
};
let request = GetObjectBodyCacheRequest {
bucket: "bucket",
key: "object",
info: &info,
response_content_length: 5,
has_range: false,
part_number: Some(1),
encryption_applied: false,
};
let cache_plan = build_get_object_body_cache_plan(&adapter, request);
let lookup = lookup_get_object_body_cache_hit(&adapter, &cache_plan).await;
assert!(matches!(lookup, GetObjectBodyCacheLookup::Skip));
}
#[tokio::test]
async fn buffered_body_fill_populates_cache_for_later_hit() {
let adapter = enabled_fill_adapter();
let info = crate::storage::storage_api::StorageObjectInfo {
etag: Some("etag".to_string()),
size: 5,
..Default::default()
};
let request = GetObjectBodyCacheRequest {
bucket: "bucket",
key: "object",
info: &info,
response_content_length: 5,
has_range: false,
part_number: None,
encryption_applied: false,
};
let cache_plan = build_get_object_body_cache_plan(&adapter, request);
let fill = fill_get_object_body_cache_from_buffered_body(&adapter, &cache_plan, &Bytes::from_static(b"hello")).await;
let lookup = lookup_get_object_body_cache_hit(&adapter, &cache_plan).await;
assert_eq!(fill, ObjectDataCacheFillResult::Inserted);
assert!(matches!(lookup, GetObjectBodyCacheLookup::Hit(ref bytes) if bytes.as_ref() == b"hello"));
}
#[tokio::test]
async fn buffered_body_fill_rejects_size_mismatch() {
let adapter = enabled_fill_adapter();
let info = crate::storage::storage_api::StorageObjectInfo {
etag: Some("etag".to_string()),
size: 5,
..Default::default()
};
let request = GetObjectBodyCacheRequest {
bucket: "bucket",
key: "object",
info: &info,
response_content_length: 5,
has_range: false,
part_number: None,
encryption_applied: false,
};
let cache_plan = build_get_object_body_cache_plan(&adapter, request);
let fill = fill_get_object_body_cache_from_buffered_body(&adapter, &cache_plan, &Bytes::from_static(b"oops")).await;
let lookup = lookup_get_object_body_cache_hit(&adapter, &cache_plan).await;
assert_eq!(fill, ObjectDataCacheFillResult::SkippedSizeMismatch);
assert!(matches!(lookup, GetObjectBodyCacheLookup::Miss));
}
#[tokio::test]
async fn materialized_body_fill_populates_cache_for_later_hit() {
let adapter = enabled_fill_adapter();
let info = crate::storage::storage_api::StorageObjectInfo {
etag: Some("etag".to_string()),
size: 5,
..Default::default()
};
let request = GetObjectBodyCacheRequest {
bucket: "bucket",
key: "object",
info: &info,
response_content_length: 5,
has_range: false,
part_number: None,
encryption_applied: false,
};
let cache_plan = build_get_object_body_cache_plan(&adapter, request);
let fill = fill_get_object_body_cache_from_materialized_body(&adapter, &cache_plan, &Bytes::from_static(b"hello")).await;
let lookup = lookup_get_object_body_cache_hit(&adapter, &cache_plan).await;
assert_eq!(fill, ObjectDataCacheFillResult::Inserted);
assert!(matches!(lookup, GetObjectBodyCacheLookup::Hit(ref bytes) if bytes.as_ref() == b"hello"));
}
}
@@ -0,0 +1,185 @@
// 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.
//! Invalidation glue for the object data cache adapter.
use crate::app::object_data_cache::ObjectDataCacheAdapter;
use rustfs_object_data_cache::{ObjectDataCacheIdentity, ObjectDataCacheInvalidationReason, ObjectDataCacheInvalidationResult};
/// Invalidates a single object before a mutating operation begins.
pub(crate) async fn invalidate_object_data_cache_before_mutation(
adapter: &ObjectDataCacheAdapter,
bucket: &str,
key: &str,
) -> ObjectDataCacheInvalidationResult {
invalidate_object_data_cache_object(adapter, bucket, key, ObjectDataCacheInvalidationReason::BeforeMutation).await
}
/// Invalidates a single object after a successful `PutObject`.
pub(crate) async fn invalidate_object_data_cache_after_put_success(
adapter: &ObjectDataCacheAdapter,
bucket: &str,
key: &str,
) -> ObjectDataCacheInvalidationResult {
invalidate_object_data_cache_object(adapter, bucket, key, ObjectDataCacheInvalidationReason::AfterPutSuccess).await
}
/// Invalidates a single object after a successful delete.
pub(crate) async fn invalidate_object_data_cache_after_delete_success(
adapter: &ObjectDataCacheAdapter,
bucket: &str,
key: &str,
) -> ObjectDataCacheInvalidationResult {
invalidate_object_data_cache_object(adapter, bucket, key, ObjectDataCacheInvalidationReason::AfterDeleteSuccess).await
}
/// Invalidates a single object after a successful copy destination write.
pub(crate) async fn invalidate_object_data_cache_after_copy_success(
adapter: &ObjectDataCacheAdapter,
bucket: &str,
key: &str,
) -> ObjectDataCacheInvalidationResult {
invalidate_object_data_cache_object(adapter, bucket, key, ObjectDataCacheInvalidationReason::AfterCopySuccess).await
}
/// Invalidates a single object after a successful multipart completion.
pub(crate) async fn invalidate_object_data_cache_after_complete_multipart_success(
adapter: &ObjectDataCacheAdapter,
bucket: &str,
key: &str,
) -> ObjectDataCacheInvalidationResult {
invalidate_object_data_cache_object(adapter, bucket, key, ObjectDataCacheInvalidationReason::AfterCompleteMultipartSuccess)
.await
}
/// Invalidates a single object identity through the app-layer cache adapter.
pub(crate) async fn invalidate_object_data_cache_object(
adapter: &ObjectDataCacheAdapter,
bucket: &str,
key: &str,
reason: ObjectDataCacheInvalidationReason,
) -> ObjectDataCacheInvalidationResult {
adapter
.invalidate_object(ObjectDataCacheIdentity::new(bucket, key), reason)
.await
}
/// Invalidates many object identities before a mutating operation begins.
pub(crate) async fn invalidate_object_data_cache_objects_before_mutation<'a, I>(
adapter: &ObjectDataCacheAdapter,
bucket: &str,
keys: I,
) where
I: IntoIterator<Item = &'a String>,
{
invalidate_object_data_cache_objects(adapter, bucket, keys, ObjectDataCacheInvalidationReason::BeforeMutation).await;
}
/// Invalidates many object identities after successful deletes.
pub(crate) async fn invalidate_object_data_cache_objects_after_delete_success<'a, I>(
adapter: &ObjectDataCacheAdapter,
bucket: &str,
keys: I,
) where
I: IntoIterator<Item = &'a String>,
{
invalidate_object_data_cache_objects(adapter, bucket, keys, ObjectDataCacheInvalidationReason::AfterDeleteSuccess).await;
}
/// Invalidates many object identities through the app-layer cache adapter.
pub(crate) async fn invalidate_object_data_cache_objects<'a, I>(
adapter: &ObjectDataCacheAdapter,
bucket: &str,
keys: I,
reason: ObjectDataCacheInvalidationReason,
) where
I: IntoIterator<Item = &'a String>,
{
for key in keys {
let _ = invalidate_object_data_cache_object(adapter, bucket, key.as_str(), reason).await;
}
}
#[cfg(test)]
mod tests {
use super::{
invalidate_object_data_cache_after_delete_success, invalidate_object_data_cache_before_mutation,
invalidate_object_data_cache_objects_before_mutation,
};
use crate::app::object_data_cache::ObjectDataCacheAdapter;
use bytes::Bytes;
use rustfs_object_data_cache::{
ObjectDataCacheBodyVariant, ObjectDataCacheConfig, ObjectDataCacheFillResult, ObjectDataCacheInvalidationResult,
ObjectDataCacheLookup, ObjectDataCacheMode,
};
fn enabled_adapter() -> ObjectDataCacheAdapter {
let config = ObjectDataCacheConfig {
mode: ObjectDataCacheMode::FillMaterializeEnabled,
max_bytes: 8_388_608,
..ObjectDataCacheConfig::default()
};
ObjectDataCacheAdapter::new(config).expect("enabled config should build adapter")
}
#[tokio::test]
async fn single_object_invalidation_removes_cached_body() {
let adapter = enabled_adapter();
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
bucket: "bucket",
object: "object",
version_id: None,
etag: "etag",
size: 5,
body_variant: ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let fill = adapter.fill_body(&plan, Bytes::from_static(b"hello")).await;
let _ = invalidate_object_data_cache_before_mutation(&adapter, "bucket", "object").await;
let invalidation = invalidate_object_data_cache_after_delete_success(&adapter, "bucket", "object").await;
let lookup = adapter.lookup_body(&plan).await;
assert_eq!(fill, ObjectDataCacheFillResult::Inserted);
assert_eq!(invalidation, ObjectDataCacheInvalidationResult::Success);
assert!(matches!(lookup, ObjectDataCacheLookup::Miss));
}
#[tokio::test]
async fn batch_invalidation_removes_each_requested_identity() {
let adapter = enabled_adapter();
let plan_a = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
bucket: "bucket",
object: "a",
version_id: None,
etag: "etag-a",
size: 5,
body_variant: ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let plan_b = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
bucket: "bucket",
object: "b",
version_id: None,
etag: "etag-b",
size: 5,
body_variant: ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let _ = adapter.fill_body(&plan_a, Bytes::from_static(b"aaaaa")).await;
let _ = adapter.fill_body(&plan_b, Bytes::from_static(b"bbbbb")).await;
let keys = ["a".to_string(), "b".to_string()];
invalidate_object_data_cache_objects_before_mutation(&adapter, "bucket", keys.iter()).await;
assert!(matches!(adapter.lookup_body(&plan_a).await, ObjectDataCacheLookup::Miss));
assert!(matches!(adapter.lookup_body(&plan_b).await, ObjectDataCacheLookup::Miss));
}
}
+33
View File
@@ -0,0 +1,33 @@
// 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.
//! App-layer object data cache adapter boundary.
mod adapter;
mod body;
mod invalidation;
mod planner;
pub(crate) use adapter::ObjectDataCacheAdapter;
pub(crate) use body::{
GetObjectBodyCacheLookup, fill_get_object_body_cache_from_buffered_body, fill_get_object_body_cache_from_materialized_body,
lookup_get_object_body_cache_hit,
};
pub(crate) use invalidation::{
invalidate_object_data_cache_after_complete_multipart_success, invalidate_object_data_cache_after_copy_success,
invalidate_object_data_cache_after_delete_success, invalidate_object_data_cache_after_put_success,
invalidate_object_data_cache_before_mutation, invalidate_object_data_cache_objects_after_delete_success,
invalidate_object_data_cache_objects_before_mutation,
};
pub(crate) use planner::{GetObjectBodyCachePlan, GetObjectBodyCacheRequest, build_get_object_body_cache_plan};
+205
View File
@@ -0,0 +1,205 @@
// 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.
//! GET planning glue for the object data cache adapter.
use crate::app::object_data_cache::ObjectDataCacheAdapter;
use crate::app::storage_api::object_usecase::StorageObjectInfo;
use rustfs_object_data_cache::{ObjectDataCacheBodyVariant, ObjectDataCacheGetPlan, ObjectDataCacheGetRequest};
/// App-layer GET request snapshot used for cache planning.
#[derive(Clone, Copy)]
pub(crate) struct GetObjectBodyCacheRequest<'a> {
pub(crate) bucket: &'a str,
pub(crate) key: &'a str,
pub(crate) info: &'a StorageObjectInfo,
pub(crate) response_content_length: i64,
pub(crate) has_range: bool,
pub(crate) part_number: Option<usize>,
pub(crate) encryption_applied: bool,
}
/// Planning result for app-layer GET cache lookup.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum GetObjectBodyCachePlan {
Disabled,
Skip,
Cacheable(ObjectDataCacheGetPlan),
}
/// Builds a conservative body-cache plan for a GET request.
pub(crate) fn build_get_object_body_cache_plan(
adapter: &ObjectDataCacheAdapter,
request: GetObjectBodyCacheRequest<'_>,
) -> GetObjectBodyCachePlan {
if adapter.is_disabled() {
return GetObjectBodyCachePlan::Disabled;
}
if request.has_range
|| request.part_number.is_some()
|| request.encryption_applied
|| request.info.delete_marker
|| request.info.version_only
|| request.info.metadata_only
|| request.response_content_length < 0
{
return GetObjectBodyCachePlan::Skip;
}
let Some(etag) = request.info.etag.as_deref() else {
return GetObjectBodyCachePlan::Skip;
};
let Ok(size) = u64::try_from(request.response_content_length) else {
return GetObjectBodyCachePlan::Skip;
};
// Nil version ids mean "no value" (see CLAUDE.md); map them to None so the
// engine canonicalizes to the same "null" key as unversioned reads.
let version_id = request
.info
.version_id
.filter(|version_id| !version_id.is_nil())
.map(|version_id| version_id.to_string());
let engine_request = ObjectDataCacheGetRequest {
bucket: request.bucket,
object: request.key,
version_id,
etag,
size,
body_variant: ObjectDataCacheBodyVariant::FullObjectPlainV1,
};
match adapter.plan_get(engine_request) {
ObjectDataCacheGetPlan::Disabled | ObjectDataCacheGetPlan::SkipTooLarge => GetObjectBodyCachePlan::Skip,
plan @ ObjectDataCacheGetPlan::Cacheable { .. } => GetObjectBodyCachePlan::Cacheable(plan),
}
}
#[cfg(test)]
mod tests {
use super::{GetObjectBodyCachePlan, GetObjectBodyCacheRequest, build_get_object_body_cache_plan};
use crate::app::object_data_cache::ObjectDataCacheAdapter;
use rustfs_object_data_cache::{ObjectDataCacheConfig, ObjectDataCacheMode};
fn enabled_adapter() -> ObjectDataCacheAdapter {
let config = ObjectDataCacheConfig {
mode: ObjectDataCacheMode::HitOnly,
max_bytes: 8_388_608,
..ObjectDataCacheConfig::default()
};
ObjectDataCacheAdapter::new(config).expect("hit-only config should build adapter")
}
#[test]
fn plan_is_disabled_when_adapter_is_disabled() {
let adapter = ObjectDataCacheAdapter::disabled();
let info = crate::storage::storage_api::StorageObjectInfo {
etag: Some("etag".to_string()),
size: 4,
..Default::default()
};
let plan = build_get_object_body_cache_plan(
&adapter,
GetObjectBodyCacheRequest {
bucket: "bucket",
key: "object",
info: &info,
response_content_length: 4,
has_range: false,
part_number: None,
encryption_applied: false,
},
);
assert!(matches!(plan, GetObjectBodyCachePlan::Disabled));
}
#[test]
fn plan_skips_range_requests() {
let adapter = enabled_adapter();
let info = crate::storage::storage_api::StorageObjectInfo {
etag: Some("etag".to_string()),
size: 4,
..Default::default()
};
let plan = build_get_object_body_cache_plan(
&adapter,
GetObjectBodyCacheRequest {
bucket: "bucket",
key: "object",
info: &info,
response_content_length: 4,
has_range: true,
part_number: None,
encryption_applied: false,
},
);
assert!(matches!(plan, GetObjectBodyCachePlan::Skip));
}
#[test]
fn plan_skips_when_etag_is_missing() {
let adapter = enabled_adapter();
let info = crate::storage::storage_api::StorageObjectInfo {
etag: None,
size: 4,
..Default::default()
};
let plan = build_get_object_body_cache_plan(
&adapter,
GetObjectBodyCacheRequest {
bucket: "bucket",
key: "object",
info: &info,
response_content_length: 4,
has_range: false,
part_number: None,
encryption_applied: false,
},
);
assert!(matches!(plan, GetObjectBodyCachePlan::Skip));
}
#[test]
fn plan_is_cacheable_for_plain_full_object() {
let adapter = enabled_adapter();
let info = crate::storage::storage_api::StorageObjectInfo {
etag: Some("etag".to_string()),
size: 4,
..Default::default()
};
let plan = build_get_object_body_cache_plan(
&adapter,
GetObjectBodyCacheRequest {
bucket: "bucket",
key: "object",
info: &info,
response_content_length: 4,
has_range: false,
part_number: None,
encryption_applied: false,
},
);
assert!(matches!(plan, GetObjectBodyCachePlan::Cacheable(_)));
}
}
+600 -2
View File
@@ -94,7 +94,7 @@ use super::storage_api::object_usecase::{
};
use crate::app::runtime_sources::{
AppContext, current_app_context, current_expiry_state_handle, current_notify_interface_for_context,
current_object_store_handle_for_context,
current_object_data_cache_for_context, current_object_store_handle_for_context,
};
use crate::config::RustFSBufferConfig;
use crate::delete_tail_activity::{DeleteTailActivityGuard, DeleteTailStage};
@@ -189,6 +189,14 @@ use super::storage_api::object_usecase::{
StorageDeletedObject, StorageObjectInfo as ObjectInfo, StorageObjectOptions as ObjectOptions,
StorageObjectToDelete as ObjectToDelete, StoragePutObjReader as PutObjReader,
};
use crate::app::object_data_cache::{
GetObjectBodyCacheLookup, GetObjectBodyCachePlan, GetObjectBodyCacheRequest, ObjectDataCacheAdapter,
build_get_object_body_cache_plan, fill_get_object_body_cache_from_buffered_body,
fill_get_object_body_cache_from_materialized_body, invalidate_object_data_cache_after_copy_success,
invalidate_object_data_cache_after_delete_success, invalidate_object_data_cache_after_put_success,
invalidate_object_data_cache_before_mutation, invalidate_object_data_cache_objects_after_delete_success,
invalidate_object_data_cache_objects_before_mutation, lookup_get_object_body_cache_hit,
};
type S3StdError = Box<dyn std::error::Error + Send + Sync + 'static>;
@@ -393,8 +401,11 @@ const GET_READER_STREAM_POLL_READY_DATA: &str = "ready_data";
const GET_READER_STREAM_POLL_READY_EMPTY: &str = "ready_empty";
const GET_READER_STREAM_POLL_READY_ERROR: &str = "ready_error";
const GET_MEMORY_BODY_SOURCE_BUFFERED_BODY: &str = "buffered_body";
const GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE: &str = "object_data_cache";
const GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE_MATERIALIZED: &str = "object_data_cache_materialized";
const GET_MEMORY_BODY_SOURCE_SEEK_BUFFER: &str = "seek_buffer";
const GET_MEMORY_BODY_SOURCE_ENCRYPTED_BUFFER: &str = "encrypted_buffer";
const GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ: &str = "body_cache_materialize_read";
fn get_reader_stream_buffer_size_override() -> Option<usize> {
static GET_READER_STREAM_BUFFER_SIZE_OVERRIDE: OnceLock<Option<usize>> = OnceLock::new();
@@ -1286,6 +1297,25 @@ fn should_buffer_get_object_in_memory(
)
}
fn should_materialize_get_object_body_for_cache(
info: &ObjectInfo,
response_content_length: i64,
part_number: Option<usize>,
has_range: bool,
concurrent_requests: usize,
) -> bool {
let configured_threshold = object_seek_support_threshold() as i64;
should_buffer_get_object_in_memory_with_threshold(
info,
response_content_length,
part_number,
has_range,
configured_threshold,
concurrent_requests,
true,
)
}
fn should_buffer_get_object_in_memory_with_threshold(
_info: &ObjectInfo,
response_content_length: i64,
@@ -2045,6 +2075,10 @@ impl DefaultObjectUsecase {
current_object_store_handle_for_context(self.context.as_deref())
}
fn object_data_cache(&self) -> Arc<ObjectDataCacheAdapter> {
current_object_data_cache_for_context(self.context.as_deref())
}
fn base_buffer_size(&self) -> usize {
self.context
.clone()
@@ -2788,6 +2822,147 @@ impl DefaultObjectUsecase {
))
}
#[allow(clippy::too_many_arguments)]
async fn build_get_object_body_with_cache<R>(
cache_adapter: &ObjectDataCacheAdapter,
mut final_stream: R,
info: &ObjectInfo,
response_content_length: i64,
optimal_buffer_size: usize,
enable_readahead: bool,
concurrent_requests: usize,
part_number: Option<usize>,
has_range: bool,
encryption_applied: bool,
buffered_body: Option<Bytes>,
bucket: &str,
key: &str,
) -> S3Result<Option<StreamingBlob>>
where
R: AsyncRead + Send + Sync + Unpin + 'static,
{
let cache_request = GetObjectBodyCacheRequest {
bucket,
key,
info,
response_content_length,
has_range,
part_number,
encryption_applied,
};
let cache_plan = build_get_object_body_cache_plan(cache_adapter, cache_request);
match lookup_get_object_body_cache_hit(cache_adapter, &cache_plan).await {
GetObjectBodyCacheLookup::Hit(bytes) => {
return Ok(Self::build_memory_bytes_blob(
bytes,
response_content_length,
optimal_buffer_size,
GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE,
));
}
GetObjectBodyCacheLookup::Disabled | GetObjectBodyCacheLookup::Skip | GetObjectBodyCacheLookup::Miss => {}
}
if let Some(buffered_body) = buffered_body {
let _fill_result = fill_get_object_body_cache_from_buffered_body(cache_adapter, &cache_plan, &buffered_body).await;
return Ok(Self::build_memory_bytes_blob(
buffered_body,
response_content_length,
optimal_buffer_size,
GET_MEMORY_BODY_SOURCE_BUFFERED_BODY,
));
}
let should_materialize_for_cache = cache_adapter.materialize_fill_enabled()
&& matches!(cache_plan, GetObjectBodyCachePlan::Cacheable(_))
&& should_materialize_get_object_body_for_cache(
info,
response_content_length,
part_number,
has_range,
concurrent_requests,
);
if should_materialize_for_cache {
let Ok(materialized_capacity) = usize::try_from(response_content_length) else {
warn!(
expected = response_content_length,
"GetObject materialize-fill skipped because content length is not representable"
);
return Self::build_get_object_body(
final_stream,
info,
response_content_length,
optimal_buffer_size,
enable_readahead,
concurrent_requests,
part_number,
has_range,
encryption_applied,
None,
bucket,
key,
)
.await;
};
let mut buf = Vec::with_capacity(materialized_capacity);
let buffer_read_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
let read_result = tokio::io::AsyncReadExt::read_to_end(&mut final_stream, &mut buf).await;
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ, buffer_read_start);
match read_result {
Ok(_) => {
if buf.len() != materialized_capacity {
warn!(
expected = response_content_length,
actual = buf.len(),
"Object size mismatch during materialize-fill read"
);
}
let bytes = Bytes::from(buf);
let _fill_result =
fill_get_object_body_cache_from_materialized_body(cache_adapter, &cache_plan, &bytes).await;
return Ok(Self::build_memory_bytes_blob(
bytes,
response_content_length,
optimal_buffer_size,
GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE_MATERIALIZED,
));
}
Err(e) => {
error!(error = %e, "GetObject materialize-fill buffering failed");
// The stream is partially consumed; falling back to the
// streaming path would send a body missing its prefix, so
// fail the request like the encrypted-buffer path does.
return Err(ApiError::from(StorageError::other(format!(
"Failed to read object body for cache materialization: {e}"
)))
.into());
}
}
}
Self::build_get_object_body(
final_stream,
info,
response_content_length,
optimal_buffer_size,
enable_readahead,
concurrent_requests,
part_number,
has_range,
encryption_applied,
None,
bucket,
key,
)
.await
}
fn put_object_execution_context(req: &S3Request<PutObjectInput>) -> (EventName, QuotaOperation, &'static str) {
if req.extensions.get::<PostObjectRequestMarker>().is_some() {
(put_event_name_for_post_object(true), QuotaOperation::PostObject, "POST")
@@ -3199,6 +3374,9 @@ impl DefaultObjectUsecase {
);
}
let cache_adapter = self.object_data_cache();
let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await;
let store_put_watchdog = tokio_util::sync::CancellationToken::new();
spawn_traced({
let store_put_watchdog = store_put_watchdog.clone();
@@ -3277,6 +3455,7 @@ impl DefaultObjectUsecase {
};
maybe_enqueue_transition_immediate(&obj_info, LcEventSrc::S3PutObject).await;
let _ = invalidate_object_data_cache_after_put_success(&cache_adapter, &bucket, &key).await;
let put_versioned = BucketVersioningSys::prefix_enabled(&bucket, &key).await;
// Fast in-memory update for immediate quota and admin usage consistency
@@ -3513,9 +3692,11 @@ impl DefaultObjectUsecase {
optimal_buffer_size,
enable_readahead,
} = strategy;
let cache_adapter = self.object_data_cache();
let body_build_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
let body = Self::build_get_object_body(
let body = Self::build_get_object_body_with_cache(
&cache_adapter,
final_stream,
&info,
response_content_length,
@@ -4285,6 +4466,8 @@ impl DefaultObjectUsecase {
self.check_bucket_quota(&bucket, QuotaOperation::CopyObject, src_info.size as u64)
.await?;
let has_bucket_metadata = self.bucket_metadata_sys().is_some();
let cache_adapter = self.object_data_cache();
let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await;
let oi = store
.copy_object(&src_bucket, &src_key, &bucket, &key, &mut src_info, &src_opts, &dst_opts)
@@ -4292,6 +4475,7 @@ impl DefaultObjectUsecase {
.map_err(ApiError::from)?;
maybe_enqueue_transition_immediate(&oi, LcEventSrc::S3CopyObject).await;
let _ = invalidate_object_data_cache_after_copy_success(&cache_adapter, &bucket, &key).await;
let dest_versioned = BucketVersioningSys::prefix_enabled(&bucket, &key).await;
// Update quota tracking after successful copy
@@ -4514,6 +4698,13 @@ impl DefaultObjectUsecase {
existing_object_infos.push(gerr.is_none().then_some(goi));
}
let cache_adapter = self.object_data_cache();
let cache_keys_before_delete = object_to_delete
.iter()
.map(|object| object.object_name.clone())
.collect::<Vec<_>>();
invalidate_object_data_cache_objects_before_mutation(&cache_adapter, &bucket, cache_keys_before_delete.iter()).await;
let (mut dobjs, errs) = store
.delete_objects(
&bucket,
@@ -4616,6 +4807,11 @@ impl DefaultObjectUsecase {
},
})
.collect();
let deleted_cache_keys = delete_results
.iter()
.filter_map(|result| result.delete_object.as_ref().map(|deleted| deleted.object_name.clone()))
.collect::<Vec<_>>();
invalidate_object_data_cache_objects_after_delete_success(&cache_adapter, &bucket, deleted_cache_keys.iter()).await;
let errors = delete_results
.iter()
@@ -4787,6 +4983,9 @@ impl DefaultObjectUsecase {
}
};
let cache_adapter = self.object_data_cache();
let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await;
let obj_info = {
match store.delete_object(&bucket, &key, opts.clone()).await {
Ok(obj) => obj,
@@ -4816,6 +5015,7 @@ impl DefaultObjectUsecase {
"failed to persist transitioned object cleanup journal"
);
}
let _ = invalidate_object_data_cache_after_delete_success(&cache_adapter, &bucket, &key).await;
// Fast in-memory update for immediate quota and admin usage consistency
if delete_creates_delete_marker(&opts) {
@@ -5829,6 +6029,8 @@ impl DefaultObjectUsecase {
hrd = write_plan.apply(hrd, actual_size).map_err(ApiError::from)?;
opts.user_defined.extend(metadata);
let mut reader = PutObjReader::new(hrd);
let cache_adapter = self.object_data_cache();
let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &fpath).await;
let obj_info = match store.put_object(&bucket, &fpath, &mut reader, &opts).await {
Ok(info) => info,
@@ -5840,6 +6042,7 @@ impl DefaultObjectUsecase {
return Err(ApiError::from(e).into());
}
};
let _ = invalidate_object_data_cache_after_put_success(&cache_adapter, &bucket, &fpath).await;
if !wrote_any_entry {
rustfs_scanner::record_dirty_usage_bucket(&bucket);
wrote_any_entry = true;
@@ -6355,6 +6558,33 @@ mod tests {
}
}
struct DataProbeReader {
reads: Arc<AtomicUsize>,
data: std::io::Cursor<Vec<u8>>,
}
impl AsyncRead for DataProbeReader {
fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
self.reads.fetch_add(1, AtomicOrdering::Relaxed);
let remaining = buf.remaining();
if remaining == 0 {
return Poll::Ready(Ok(()));
}
let position = usize::try_from(self.data.position()).unwrap_or(usize::MAX);
let source = self.data.get_ref();
if position >= source.len() {
return Poll::Ready(Ok(()));
}
let end = position.saturating_add(remaining).min(source.len());
buf.put_slice(&source[position..end]);
self.data.set_position(u64::try_from(end).unwrap_or(u64::MAX));
Poll::Ready(Ok(()))
}
}
struct PendingReader;
impl AsyncRead for PendingReader {
@@ -6501,6 +6731,374 @@ mod tests {
);
}
#[tokio::test]
async fn build_get_object_body_with_cache_uses_cached_body_without_reader_preread() {
let reads = Arc::new(AtomicUsize::new(0));
let reader = ReadProbeReader {
reads: Arc::clone(&reads),
};
let info = ObjectInfo {
size: 5,
etag: Some("etag".to_string()),
..Default::default()
};
let adapter =
crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly,
max_bytes: 8_388_608,
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
})
.expect("fill-enabled cache adapter should initialize");
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
bucket: "test-bucket",
object: "cached-object",
version_id: None,
etag: "etag",
size: 5,
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let fill = adapter.cache().fill_body(&plan, Bytes::from_static(b"hello")).await;
assert_eq!(fill, rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted);
let body = DefaultObjectUsecase::build_get_object_body_with_cache(
&adapter,
reader,
&info,
5,
128 * 1024,
false,
1,
None,
false,
false,
None,
"test-bucket",
"cached-object",
)
.await
.expect("cache hit body handoff should succeed");
assert!(body.is_some());
assert_eq!(
reads.load(AtomicOrdering::Relaxed),
0,
"cache hit body handoff must not read from the fallback reader"
);
}
#[tokio::test]
async fn build_get_object_body_with_cache_rejects_size_mismatch_fill() {
let reads = Arc::new(AtomicUsize::new(0));
let reader = ReadProbeReader {
reads: Arc::clone(&reads),
};
let info = ObjectInfo {
size: 5,
etag: Some("etag".to_string()),
..Default::default()
};
let adapter =
crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly,
max_bytes: 8_388_608,
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
})
.expect("fill-enabled cache adapter should initialize");
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
bucket: "test-bucket",
object: "cached-object",
version_id: None,
etag: "etag",
size: 5,
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let fill = adapter.cache().fill_body(&plan, Bytes::from_static(b"oops")).await;
let body = DefaultObjectUsecase::build_get_object_body_with_cache(
&adapter,
reader,
&info,
5,
128 * 1024,
false,
1,
None,
false,
false,
None,
"test-bucket",
"cached-object",
)
.await
.expect("size-mismatched direct fill should not create a cache hit");
let lookup_after_mismatch = adapter.lookup_body(&plan).await;
assert_eq!(fill, rustfs_object_data_cache::ObjectDataCacheFillResult::SkippedSizeMismatch);
assert!(body.is_some());
assert_eq!(
reads.load(AtomicOrdering::Relaxed),
0,
"size-mismatched rejected fill should construct the fallback stream without pre-reading"
);
assert!(
matches!(lookup_after_mismatch, rustfs_object_data_cache::ObjectDataCacheLookup::Miss),
"size-mismatched fill must not leave a reusable cache entry"
);
}
#[tokio::test]
async fn build_get_object_body_with_cache_fills_from_buffered_body_without_reader_preread() {
let first_reads = Arc::new(AtomicUsize::new(0));
let first_reader = ReadProbeReader {
reads: Arc::clone(&first_reads),
};
let second_reads = Arc::new(AtomicUsize::new(0));
let second_reader = ReadProbeReader {
reads: Arc::clone(&second_reads),
};
let info = ObjectInfo {
size: 5,
etag: Some("etag".to_string()),
..Default::default()
};
let adapter =
crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly,
max_bytes: 8_388_608,
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
})
.expect("fill-enabled cache adapter should initialize");
let first_body = DefaultObjectUsecase::build_get_object_body_with_cache(
&adapter,
first_reader,
&info,
5,
128 * 1024,
false,
1,
None,
false,
false,
Some(Bytes::from_static(b"hello")),
"test-bucket",
"cached-object",
)
.await
.expect("buffered-body handoff should succeed");
let second_body = DefaultObjectUsecase::build_get_object_body_with_cache(
&adapter,
second_reader,
&info,
5,
128 * 1024,
false,
1,
None,
false,
false,
None,
"test-bucket",
"cached-object",
)
.await
.expect("follow-up cache hit should succeed");
assert!(first_body.is_some());
assert!(second_body.is_some());
assert_eq!(
first_reads.load(AtomicOrdering::Relaxed),
0,
"buffered-body fill path must not read from the fallback reader"
);
assert_eq!(
second_reads.load(AtomicOrdering::Relaxed),
0,
"cache hit after buffered-body fill must not read from the fallback reader"
);
}
#[tokio::test]
async fn build_get_object_body_with_cache_skips_buffered_fill_on_size_mismatch() {
let reads = Arc::new(AtomicUsize::new(0));
let reader = ReadProbeReader {
reads: Arc::clone(&reads),
};
let info = ObjectInfo {
size: 5,
etag: Some("etag".to_string()),
..Default::default()
};
let adapter =
crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillBufferedOnly,
max_bytes: 8_388_608,
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
})
.expect("fill-enabled cache adapter should initialize");
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
bucket: "test-bucket",
object: "cached-object",
version_id: None,
etag: "etag",
size: 5,
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
});
let body = DefaultObjectUsecase::build_get_object_body_with_cache(
&adapter,
reader,
&info,
5,
128 * 1024,
false,
1,
None,
false,
false,
Some(Bytes::from_static(b"oops")),
"test-bucket",
"cached-object",
)
.await
.expect("size-mismatched buffered-body handoff should still return a response body");
let lookup = adapter.lookup_body(&plan).await;
assert!(body.is_some());
assert_eq!(
reads.load(AtomicOrdering::Relaxed),
0,
"buffered-body handoff must not read from the fallback reader"
);
assert!(
matches!(lookup, rustfs_object_data_cache::ObjectDataCacheLookup::Miss),
"size-mismatched buffered body must not be filled into cache"
);
}
#[tokio::test]
async fn build_get_object_body_with_cache_materializes_once_and_hits_later() {
let first_reads = Arc::new(AtomicUsize::new(0));
let first_reader = DataProbeReader {
reads: Arc::clone(&first_reads),
data: std::io::Cursor::new(b"hello".to_vec()),
};
let second_reads = Arc::new(AtomicUsize::new(0));
let second_reader = ReadProbeReader {
reads: Arc::clone(&second_reads),
};
let info = ObjectInfo {
size: 5,
etag: Some("etag".to_string()),
..Default::default()
};
let adapter =
crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled,
max_bytes: 8_388_608,
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
})
.expect("materialize-fill cache adapter should initialize");
let first_body = DefaultObjectUsecase::build_get_object_body_with_cache(
&adapter,
first_reader,
&info,
5,
128 * 1024,
false,
1,
None,
false,
false,
None,
"test-bucket",
"materialized-object",
)
.await
.expect("materialize-fill handoff should succeed");
let second_body = DefaultObjectUsecase::build_get_object_body_with_cache(
&adapter,
second_reader,
&info,
5,
128 * 1024,
false,
1,
None,
false,
false,
None,
"test-bucket",
"materialized-object",
)
.await
.expect("follow-up cache hit should succeed");
assert!(first_body.is_some());
assert!(second_body.is_some());
assert_eq!(
first_reads.load(AtomicOrdering::Relaxed),
2,
"materialize-fill path should read the source stream once to data and once for EOF"
);
assert_eq!(
second_reads.load(AtomicOrdering::Relaxed),
0,
"cache hit after materialize-fill must not read from the fallback reader"
);
}
#[tokio::test]
async fn build_get_object_body_with_cache_skips_materialize_when_too_large_for_cache() {
let reads = Arc::new(AtomicUsize::new(0));
let reader = DataProbeReader {
reads: Arc::clone(&reads),
data: std::io::Cursor::new(b"hello".to_vec()),
};
let info = ObjectInfo {
size: 5,
etag: Some("etag".to_string()),
..Default::default()
};
let adapter =
crate::app::object_data_cache::ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled,
max_bytes: 8_388_608,
max_entry_bytes: 4,
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
})
.expect("materialize-fill cache adapter should initialize");
let body = DefaultObjectUsecase::build_get_object_body_with_cache(
&adapter,
reader,
&info,
5,
128 * 1024,
false,
1,
None,
false,
false,
None,
"test-bucket",
"too-large-object",
)
.await
.expect("too-large cache candidate should use streaming fallback");
assert!(body.is_some());
assert_eq!(
reads.load(AtomicOrdering::Relaxed),
0,
"too-large materialize-fill candidate must not pre-read the fallback reader"
);
}
#[tokio::test]
async fn build_get_object_body_keeps_small_plain_objects_on_streaming_path_by_default() {
let reads = Arc::new(AtomicUsize::new(0));
+7 -1
View File
@@ -12,13 +12,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::app::object_data_cache::ObjectDataCacheAdapter;
use crate::app::storage_api::runtime_sources::ExpiryState;
#[cfg(test)]
use crate::app::storage_api::runtime_sources::TierConfigMgr;
use crate::runtime_sources as root_runtime_sources;
pub(crate) use crate::runtime_sources::{
AppContext, current_encryption_service, current_endpoints_handle, current_notification_system,
current_object_store_handle_for_context,
current_object_data_cache_handle_for_context, current_object_store_handle_for_context,
};
use rustfs_s3select_api::{QueryResult, server::dbms::DatabaseManagerSystem};
use s3s::dto::SelectObjectContentInput;
@@ -36,6 +37,11 @@ pub(crate) fn current_notify_interface_for_context(
.unwrap_or_else(root_runtime_sources::fallback_notify_interface)
}
pub(crate) fn current_object_data_cache_for_context(app_context: Option<&AppContext>) -> Arc<ObjectDataCacheAdapter> {
current_object_data_cache_handle_for_context(app_context)
.unwrap_or_else(root_runtime_sources::fallback_object_data_cache_handle)
}
pub(crate) async fn current_s3select_db(
input: SelectObjectContentInput,
enable_debug: bool,
+2
View File
@@ -17,6 +17,7 @@ use std::sync::Arc;
pub(crate) use context::{
AppContext, NotifyInterface, default_notify_interface as fallback_notify_interface,
default_object_data_cache_handle as fallback_object_data_cache_handle,
default_outbound_tls_runtime_interface as fallback_outbound_tls_runtime_interface,
default_s3select_db_interface as fallback_s3select_db_interface,
default_scanner_metrics_interface as fallback_scanner_metrics_interface,
@@ -35,6 +36,7 @@ pub(crate) use context::{
resolve_notification_system_for_context as current_notification_system_for_context,
resolve_notify_interface as current_notify_interface,
resolve_notify_interface_for_context as current_notify_interface_for_context,
resolve_object_data_cache_handle_for_context as current_object_data_cache_handle_for_context,
resolve_object_store_handle as current_object_store_handle,
resolve_object_store_handle_for_context as current_object_store_handle_for_context,
resolve_oidc_handle as current_oidc_handle,
+5 -3
View File
@@ -988,10 +988,12 @@ mod integration_tests {
StorageMedia::Hdd => config.hdd_buffer_cap,
StorageMedia::Unknown => config.ssd_buffer_cap,
};
let expected_max = media_cap;
// Large base buffer should be constrained by the active storage media cap.
assert_eq!(strategy.buffer_size, expected_max, "Buffer should be capped by the active media profile");
// Large base buffer should be constrained by the storage media cap.
// The final safety clamp is [32KiB, media_cap.max(MI_B)], so it never
// lowers the result below the media cap (e.g. NVMe's 2MiB cap stays
// effective even though the global floor clamp is 1MiB).
assert_eq!(strategy.buffer_size, media_cap, "Buffer should be capped by the storage media profile");
}
#[tokio::test]