mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-06 03:59:14 +00:00
dd368f0f5b
* fix(odm): fence backfill checkpoints by bucket incarnation * fix(odm): bind source work to the bucket incarnation * fix(odm): retain checkpoint fences through owned commit tails * docs(odm): explain application service and incarnation boundaries * test(odm): probe lifecycle fence after checkpoint waiter aborts * fix(odm): defer source identity errors past local reads * docs(metadata): clarify MinIO target recovery limits * fix(odm): keep source-free reads independent of capture errors * fix(odm): retain one source policy snapshot across lookup * test(odm): name recorded metadata hook snapshots
10660 lines
425 KiB
Rust
10660 lines
425 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.
|
|
|
|
//! GetObject / GetObjectAttributes read path: cold fill, resume, stream tuning.
|
|
|
|
use super::*;
|
|
use crate::on_demand_migration::WriteBackBody;
|
|
use crate::on_demand_migration::{
|
|
BucketOdmState, OdmLookup, OdmOp, OdmOutcome, OnDemandMigrationSys, PullError, PullLeader, PullOutcome, PullReason, PullSlot,
|
|
RangeGetPolicy, SourceBody, SourceClient, SourceError, SourceGet, SourceHead, commit_inline, idle_guarded_body,
|
|
};
|
|
use rustfs_rio::{TeeOptions, TeePrimary, tee_reader_with_options};
|
|
use tokio_stream::wrappers::ReceiverStream;
|
|
|
|
struct ColdFillDiskPermitMetric {
|
|
owner: ColdFillDiskPermitOwner,
|
|
metric_recorded: bool,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
static COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST: AtomicU64 = AtomicU64::new(0);
|
|
|
|
#[cfg(test)]
|
|
struct ColdFillPublicationBarrier {
|
|
reached: tokio::sync::Semaphore,
|
|
release: tokio::sync::Semaphore,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
type ColdFillPublicationBarrierState = Option<(rustfs_object_data_cache::ObjectDataCacheKey, Arc<ColdFillPublicationBarrier>)>;
|
|
|
|
#[cfg(test)]
|
|
static COLD_FILL_PUBLICATION_BARRIER: OnceLock<Mutex<ColdFillPublicationBarrierState>> = OnceLock::new();
|
|
|
|
#[cfg(test)]
|
|
type ColdFillReaderOpenProbeState = Option<(rustfs_object_data_cache::ObjectDataCacheKey, Arc<AtomicU64>)>;
|
|
|
|
#[cfg(test)]
|
|
static COLD_FILL_READER_OPEN_PROBE: OnceLock<Mutex<ColdFillReaderOpenProbeState>> = OnceLock::new();
|
|
|
|
fn adjust_cold_fill_disk_permit_metric(owner: ColdFillDiskPermitOwner, acquired: bool) {
|
|
macro_rules! adjust_gauge {
|
|
($name:literal) => {{
|
|
#[cfg(not(test))]
|
|
let gauge = {
|
|
static HANDLE: std::sync::LazyLock<metrics::Gauge> = std::sync::LazyLock::new(|| metrics::gauge!($name));
|
|
&*HANDLE
|
|
};
|
|
#[cfg(test)]
|
|
let gauge = metrics::gauge!($name);
|
|
if acquired {
|
|
gauge.increment(1.0);
|
|
} else {
|
|
gauge.decrement(1.0);
|
|
}
|
|
}};
|
|
}
|
|
|
|
match owner {
|
|
ColdFillDiskPermitOwner::Producer => {
|
|
adjust_gauge!("rustfs_object_data_cache_cold_fill_producer_disk_permits");
|
|
}
|
|
ColdFillDiskPermitOwner::Follower => {
|
|
adjust_gauge!("rustfs_object_data_cache_cold_fill_follower_disk_permits");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
async fn wait_cold_fill_publication_barrier(plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan) {
|
|
let Some(key) = plan.key() else {
|
|
return;
|
|
};
|
|
let barrier = COLD_FILL_PUBLICATION_BARRIER
|
|
.get_or_init(|| Mutex::new(None))
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
.as_ref()
|
|
.filter(|(barrier_key, _)| barrier_key == key)
|
|
.map(|(_, barrier)| Arc::clone(barrier));
|
|
if let Some(barrier) = barrier {
|
|
barrier.reached.add_permits(1);
|
|
if let Ok(permit) = barrier.release.acquire().await {
|
|
permit.forget();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn record_cold_fill_reader_open_for_test(plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan) {
|
|
let Some(key) = plan.key() else {
|
|
return;
|
|
};
|
|
let probe = COLD_FILL_READER_OPEN_PROBE
|
|
.get_or_init(|| Mutex::new(None))
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
.as_ref()
|
|
.filter(|(probe_key, _)| probe_key == key)
|
|
.map(|(_, count)| Arc::clone(count));
|
|
if let Some(count) = probe {
|
|
count.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
impl ColdFillDiskPermitMetric {
|
|
fn new(owner: ColdFillDiskPermitOwner) -> Self {
|
|
let metric_recorded = rustfs_io_metrics::metrics_enabled();
|
|
if metric_recorded {
|
|
adjust_cold_fill_disk_permit_metric(owner, true);
|
|
}
|
|
#[cfg(test)]
|
|
if matches!(owner, ColdFillDiskPermitOwner::Follower) {
|
|
COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
Self { owner, metric_recorded }
|
|
}
|
|
}
|
|
|
|
impl Drop for ColdFillDiskPermitMetric {
|
|
fn drop(&mut self) {
|
|
if self.metric_recorded {
|
|
adjust_cold_fill_disk_permit_metric(self.owner, false);
|
|
}
|
|
#[cfg(test)]
|
|
if matches!(self.owner, ColdFillDiskPermitOwner::Follower) {
|
|
COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.fetch_sub(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
}
|
|
|
|
struct GetObjectDiskPermit {
|
|
permit: Option<OwnedSemaphorePermit>,
|
|
metric: Option<ColdFillDiskPermitMetric>,
|
|
}
|
|
|
|
impl GetObjectDiskPermit {
|
|
fn new(permit: OwnedSemaphorePermit) -> Self {
|
|
Self {
|
|
permit: Some(permit),
|
|
metric: current_cold_fill_disk_permit_owner().map(ColdFillDiskPermitMetric::new),
|
|
}
|
|
}
|
|
|
|
fn release(&mut self) {
|
|
self.permit.take();
|
|
self.metric.take();
|
|
}
|
|
}
|
|
|
|
impl From<OwnedSemaphorePermit> for GetObjectDiskPermit {
|
|
fn from(permit: OwnedSemaphorePermit) -> Self {
|
|
Self::new(permit)
|
|
}
|
|
}
|
|
|
|
impl Drop for GetObjectDiskPermit {
|
|
fn drop(&mut self) {
|
|
self.release();
|
|
}
|
|
}
|
|
|
|
fn release_disk_read_permit_if_buffered(disk_permit: &mut Option<GetObjectDiskPermit>, buffered_body: Option<&Bytes>) {
|
|
if buffered_body.is_some() {
|
|
disk_permit.take();
|
|
}
|
|
}
|
|
|
|
const COLD_FILL_HARD_MAX_DURATION: Duration = Duration::from_secs(10 * 60);
|
|
|
|
pub(crate) const MAX_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 64 * 1024 * 1024;
|
|
|
|
const MEDIUM_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 8 * 1024 * 1024;
|
|
|
|
const HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 4 * 1024 * 1024;
|
|
|
|
const VERY_HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES: i64 = 1024 * 1024;
|
|
|
|
const EVENT_GET_OBJECT_STREAM_BODY: &str = "get_object_stream_body";
|
|
|
|
const GET_OBJECT_STAGE_PATH_S3_HANDLER: &str = "s3_handler";
|
|
|
|
const GET_OBJECT_STAGE_REQUEST_INGRESS_TO_CONTEXT: &str = "request_ingress_to_context";
|
|
|
|
const GET_OBJECT_STAGE_REQUEST_SHAPE: &str = "request_shape";
|
|
|
|
const GET_OBJECT_STAGE_REQUEST_VALIDATION: &str = "request_validation";
|
|
|
|
const GET_OBJECT_STAGE_BUCKET_VALIDATION: &str = "bucket_validation";
|
|
|
|
const GET_OBJECT_STAGE_RESPONSE_FINALIZE: &str = "response_finalize";
|
|
|
|
const GET_OBJECT_STAGE_OUTPUT_STRATEGY: &str = "output_strategy";
|
|
|
|
const GET_OBJECT_STAGE_BODY_BUILD: &str = "body_build";
|
|
|
|
const GET_OBJECT_STAGE_BODY_ENCRYPTED_BUFFER_READ: &str = "body_encrypted_buffer_read";
|
|
|
|
const GET_OBJECT_STAGE_BODY_MEMORY_BLOB: &str = "body_memory_blob";
|
|
|
|
const GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ: &str = "body_seek_buffer_read";
|
|
|
|
const GET_OBJECT_STAGE_BODY_STREAM_STRATEGY: &str = "body_stream_strategy";
|
|
|
|
const GET_OBJECT_STAGE_BODY_STREAMING_BLOB: &str = "body_streaming_blob";
|
|
|
|
const GET_OBJECT_STAGE_CHECKSUM_HEADERS: &str = "checksum_headers";
|
|
|
|
const GET_OBJECT_STAGE_LIFECYCLE_EXPIRATION: &str = "lifecycle_expiration";
|
|
|
|
const GET_OBJECT_STAGE_METADATA_FILTER: &str = "metadata_filter";
|
|
|
|
const GET_OBJECT_STREAM_WARN_THRESHOLD: Duration = Duration::from_secs(5);
|
|
|
|
static GET_OBJECT_BUFFER_THRESHOLD_WARNED: AtomicBool = AtomicBool::new(false);
|
|
|
|
fn record_get_object_s3_handler_stage_duration(stage: &'static str, start: Option<std::time::Instant>) {
|
|
if let Some(start) = start {
|
|
rustfs_io_metrics::record_get_object_stage_duration(
|
|
GET_OBJECT_STAGE_PATH_S3_HANDLER,
|
|
stage,
|
|
start.elapsed().as_secs_f64(),
|
|
);
|
|
}
|
|
}
|
|
|
|
struct GetObjectBootstrap {
|
|
timeout_config: GetObjectTimeoutPolicy,
|
|
wrapper: RequestTimeoutWrapper,
|
|
request_start: std::time::Instant,
|
|
request_guard: GetObjectGuard,
|
|
_deadlock_request_guard: Option<DeadlockRequestGuard>,
|
|
concurrent_requests: usize,
|
|
}
|
|
|
|
struct GetObjectIoPlanning {
|
|
/// `None` when inline fast path skips disk I/O semaphore.
|
|
disk_permit: Option<GetObjectDiskPermit>,
|
|
permit_wait_duration: Duration,
|
|
queue_status: concurrency::IoQueueStatus,
|
|
queue_utilization: f64,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct GetObjectRequestTimeout<'a> {
|
|
wrapper: &'a RequestTimeoutWrapper,
|
|
policy: &'a GetObjectTimeoutPolicy,
|
|
}
|
|
|
|
struct GetObjectRequestContext {
|
|
bucket: String,
|
|
key: String,
|
|
version_id_for_event: String,
|
|
part_number: Option<usize>,
|
|
rs: Option<HTTPRangeSpec>,
|
|
opts: ObjectOptions,
|
|
}
|
|
|
|
/// Request fields that passed the cheap GET validations, ready for the
|
|
/// bucket-metadata work in [`DefaultObjectUsecase::prepare_get_object_request_context`].
|
|
struct GetObjectValidatedRequest {
|
|
bucket: String,
|
|
key: String,
|
|
version_id: Option<String>,
|
|
part_number: Option<usize>,
|
|
rs: Option<HTTPRangeSpec>,
|
|
}
|
|
|
|
struct GetObjectReadSetup {
|
|
info: ObjectInfo,
|
|
final_stream: DynReader,
|
|
buffered_body: Option<Bytes>,
|
|
/// ODC-16: `buffered_body` is the body the ecstore cache hook served, so the
|
|
/// app layer serves it as the object-data-cache source without a re-lookup.
|
|
cache_hook_served: bool,
|
|
/// ODC-16: the cache hook probed this read (served or missed), so the app
|
|
/// layer must skip its own lookup.
|
|
cache_hook_probed: bool,
|
|
cache_fill_allowed: bool,
|
|
rs: Option<HTTPRangeSpec>,
|
|
content_type: Option<ContentType>,
|
|
last_modified: Option<Timestamp>,
|
|
response_content_length: i64,
|
|
content_range: Option<String>,
|
|
server_side_encryption: Option<ServerSideEncryption>,
|
|
sse_customer_algorithm: Option<SSECustomerAlgorithm>,
|
|
sse_customer_key_md5: Option<SSECustomerKeyMD5>,
|
|
ssekms_key_id: Option<SSEKMSKeyId>,
|
|
encryption_applied: bool,
|
|
/// Resolved plaintext start offset of the committed response body
|
|
/// (`get_offset_length` output; 0 for a full-object read). Feeds the
|
|
/// mid-stream resume offset.
|
|
resume_range_start: i64,
|
|
/// Resolved inclusive plaintext end offset of the committed response body;
|
|
/// -1 when the committed body runs to the end of the object.
|
|
resume_range_end: i64,
|
|
}
|
|
|
|
struct GetObjectPreparedRead {
|
|
io_planning: GetObjectIoPlanning,
|
|
read_setup: GetObjectReadSetup,
|
|
}
|
|
|
|
struct GetObjectStrategyContext {
|
|
#[allow(dead_code, reason = "written but never read back (backlog#1823)")]
|
|
io_strategy: concurrency::IoStrategy,
|
|
optimal_buffer_size: usize,
|
|
enable_readahead: bool,
|
|
}
|
|
|
|
struct GetObjectOutputContext {
|
|
output: GetObjectOutput,
|
|
event_info: Option<ObjectInfo>,
|
|
response_content_length: i64,
|
|
optimal_buffer_size: usize,
|
|
extra_checksum_headers: Vec<(&'static str, String)>,
|
|
}
|
|
|
|
enum GetObjectTimeoutStage {
|
|
BeforeProcessing,
|
|
DiskPermitWait { permit_wait_duration: Duration },
|
|
BeforeRead,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum GetObjectStreamStrategy {
|
|
Standard,
|
|
LargeSequentialReadahead,
|
|
}
|
|
|
|
impl GetObjectStreamStrategy {
|
|
fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Standard => "standard",
|
|
Self::LargeSequentialReadahead => "large_sequential_readahead",
|
|
}
|
|
}
|
|
}
|
|
|
|
const LARGE_SEQUENTIAL_GET_THRESHOLD_BYTES: i64 = 1024 * 1024 * 1024;
|
|
|
|
const LARGE_SEQUENTIAL_GET_STREAM_BUFFER_CAP_BYTES: usize = 4 * MI_B;
|
|
|
|
const LARGE_SEQUENTIAL_GET_READAHEAD_MULTIPLIER: usize = 2;
|
|
|
|
const LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES: usize = MI_B;
|
|
|
|
const LARGE_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES: i64 = 4 * MI_B as i64;
|
|
|
|
const MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES: usize = 512 * 1024;
|
|
|
|
const MID_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES: i64 = MI_B as i64;
|
|
|
|
const ENV_RUSTFS_GET_SEEK_BUFFER_ENABLE: &str = "RUSTFS_GET_SEEK_BUFFER_ENABLE";
|
|
|
|
const ENV_RUSTFS_GET_READER_STREAM_BUFFER_SIZE: &str = "RUSTFS_GET_READER_STREAM_BUFFER_SIZE";
|
|
|
|
const ENV_RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE: &str = "RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE";
|
|
|
|
const ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE: &str = "RUSTFS_GET_SMALL_BODY_ONCE_ENABLE";
|
|
|
|
const GET_READER_STREAM_BUFFER_SOURCE_SELECTED: &str = "selected";
|
|
|
|
const GET_READER_STREAM_BUFFER_SOURCE_ENV_OVERRIDE: &str = "env_override";
|
|
|
|
const GET_READER_STREAM_POLL_PENDING: &str = "pending";
|
|
|
|
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_STREAMING_BODY_FAILURE_STAGE_READER_STREAM: &str = "reader_stream";
|
|
|
|
const GET_STREAMING_BODY_FAILURE_REASON_READER_ERROR: &str = "reader_error";
|
|
|
|
const GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF: &str = "short_eof";
|
|
|
|
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();
|
|
*GET_READER_STREAM_BUFFER_SIZE_OVERRIDE.get_or_init(|| {
|
|
std::env::var(ENV_RUSTFS_GET_READER_STREAM_BUFFER_SIZE)
|
|
.ok()
|
|
.and_then(|value| value.parse::<usize>().ok())
|
|
.filter(|value| *value > 0)
|
|
})
|
|
}
|
|
|
|
fn is_get_output_handoff_attribution_enabled() -> bool {
|
|
static ENABLED: OnceLock<bool> = OnceLock::new();
|
|
*ENABLED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_GET_OUTPUT_HANDOFF_ATTRIBUTION_ENABLE, false))
|
|
}
|
|
|
|
fn is_get_small_body_once_enabled() -> bool {
|
|
#[cfg(test)]
|
|
{
|
|
rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, false)
|
|
}
|
|
#[cfg(not(test))]
|
|
{
|
|
static ENABLED: OnceLock<bool> = OnceLock::new();
|
|
*ENABLED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, false))
|
|
}
|
|
}
|
|
|
|
fn is_get_seek_buffer_enabled() -> bool {
|
|
static ENABLED: OnceLock<bool> = OnceLock::new();
|
|
*ENABLED.get_or_init(|| rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SEEK_BUFFER_ENABLE, false))
|
|
}
|
|
|
|
fn resolve_reader_stream_buffer_size(selected_size: usize, override_size: Option<usize>) -> (usize, &'static str) {
|
|
if let Some(override_size) = override_size.filter(|value| *value > 0) {
|
|
return (override_size, GET_READER_STREAM_BUFFER_SOURCE_ENV_OVERRIDE);
|
|
}
|
|
|
|
(selected_size.max(1), GET_READER_STREAM_BUFFER_SOURCE_SELECTED)
|
|
}
|
|
|
|
fn tune_reader_stream_buffer_size(
|
|
selected_size: usize,
|
|
response_content_length: i64,
|
|
stream_strategy: GetObjectStreamStrategy,
|
|
) -> usize {
|
|
if stream_strategy == GetObjectStreamStrategy::Standard
|
|
&& response_content_length >= LARGE_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES
|
|
{
|
|
return selected_size.max(LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES);
|
|
}
|
|
|
|
if stream_strategy == GetObjectStreamStrategy::Standard
|
|
&& response_content_length >= MID_BODY_READER_STREAM_BUFFER_THRESHOLD_BYTES
|
|
{
|
|
return selected_size.max(MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES);
|
|
}
|
|
|
|
selected_size
|
|
}
|
|
|
|
fn get_object_stream_size_bucket(expected: usize) -> &'static str {
|
|
rustfs_io_metrics::get_object_size_bucket(i64::try_from(expected).unwrap_or(i64::MAX))
|
|
}
|
|
|
|
fn classify_get_object_stream_read_error(err: &std::io::Error) -> &'static str {
|
|
if let Some(inner) = err.get_ref() {
|
|
if inner.is::<rustfs_rio::IncompleteBody>() {
|
|
return "short_eof";
|
|
}
|
|
|
|
if inner.is::<rustfs_rio::ChecksumMismatch>() {
|
|
return "bitrot";
|
|
}
|
|
|
|
let error_msg = inner.to_string().to_lowercase();
|
|
if error_msg.contains("bitrot") {
|
|
return "bitrot";
|
|
}
|
|
if error_msg.contains("read quorum") || error_msg.contains("insufficient read quorum") || error_msg.contains("erasure") {
|
|
return "read_quorum";
|
|
}
|
|
}
|
|
|
|
match err.kind() {
|
|
std::io::ErrorKind::UnexpectedEof => "short_eof",
|
|
std::io::ErrorKind::TimedOut => "timeout",
|
|
std::io::ErrorKind::InvalidInput | std::io::ErrorKind::InvalidData => "range_or_length_invalid",
|
|
_ => "io",
|
|
}
|
|
}
|
|
|
|
fn get_object_stream_failure_reason(error_class: &'static str) -> &'static str {
|
|
if error_class == "short_eof" {
|
|
GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF
|
|
} else {
|
|
GET_STREAMING_BODY_FAILURE_REASON_READER_ERROR
|
|
}
|
|
}
|
|
|
|
fn record_get_object_reader_stream_failure(
|
|
reason: &'static str,
|
|
error_class: &'static str,
|
|
strategy: &'static str,
|
|
buffer_source: &'static str,
|
|
expected: usize,
|
|
emitted: usize,
|
|
remaining: usize,
|
|
) {
|
|
rustfs_io_metrics::record_get_object_streaming_body_failure(rustfs_io_metrics::GetObjectStreamingBodyFailure {
|
|
stage: GET_STREAMING_BODY_FAILURE_STAGE_READER_STREAM,
|
|
reason,
|
|
error_class,
|
|
strategy,
|
|
buffer_source,
|
|
size_bucket: get_object_stream_size_bucket(expected),
|
|
emitted_bytes: emitted,
|
|
remaining_bytes: remaining,
|
|
});
|
|
}
|
|
|
|
struct MemoryTrackedBytesStream {
|
|
bytes: Option<Bytes>,
|
|
emitted: bool,
|
|
completed: bool,
|
|
expected: usize,
|
|
/// Set when the materialized buffer length disagrees with the declared
|
|
/// content length. Such a body would be truncated (short) or over-long
|
|
/// relative to the already-committed `Content-Length`, so the stream must
|
|
/// surface an error instead of a clean short/over-long body. See #1324.
|
|
length_mismatch: bool,
|
|
started: std::time::Instant,
|
|
source: &'static str,
|
|
_guard: Option<rustfs_io_metrics::MemoryGaugeGuard>,
|
|
lifecycle: GetObjectBodyLifecycle,
|
|
}
|
|
|
|
struct MemoryOnceBodyOwner {
|
|
bytes: Bytes,
|
|
_guard: Option<rustfs_io_metrics::MemoryGaugeGuard>,
|
|
// Body::Once has no poll hook, so this opt-in path only holds the request
|
|
// guard until the bytes are dropped; the result status remains unknown.
|
|
_lifecycle: GetObjectBodyLifecycle,
|
|
}
|
|
|
|
impl MemoryOnceBodyOwner {
|
|
fn new(bytes: Bytes, guard: Option<rustfs_io_metrics::MemoryGaugeGuard>, lifecycle: GetObjectBodyLifecycle) -> Self {
|
|
Self {
|
|
bytes,
|
|
_guard: guard,
|
|
_lifecycle: lifecycle,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AsRef<[u8]> for MemoryOnceBodyOwner {
|
|
fn as_ref(&self) -> &[u8] {
|
|
self.bytes.as_ref()
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct GetObjectBodyLifecycle {
|
|
request_guard: Option<GetObjectGuard>,
|
|
}
|
|
|
|
impl GetObjectBodyLifecycle {
|
|
fn tracked(request_guard: GetObjectGuard) -> Self {
|
|
Self {
|
|
request_guard: Some(request_guard),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn disabled() -> Self {
|
|
Self { request_guard: None }
|
|
}
|
|
|
|
fn is_finished(&self) -> bool {
|
|
self.request_guard.is_none()
|
|
}
|
|
|
|
fn finish_ok(&mut self) {
|
|
if let Some(mut request_guard) = self.request_guard.take() {
|
|
request_guard.finish_ok();
|
|
}
|
|
}
|
|
|
|
fn finish_err(&mut self) {
|
|
if let Some(mut request_guard) = self.request_guard.take() {
|
|
request_guard.finish_err();
|
|
}
|
|
}
|
|
}
|
|
|
|
pin_project! {
|
|
// Keep the disk-read admission permit tied to the response body. This is
|
|
// intentionally conservative backpressure: a streaming GET should occupy a
|
|
// read slot until the client drains or drops the body.
|
|
struct DiskReadPermitReader<R> {
|
|
#[pin]
|
|
inner: R,
|
|
disk_permit: Option<GetObjectDiskPermit>,
|
|
}
|
|
}
|
|
|
|
impl<R> DiskReadPermitReader<R> {
|
|
fn new(inner: R, disk_permit: GetObjectDiskPermit) -> Self {
|
|
Self {
|
|
inner,
|
|
disk_permit: Some(disk_permit),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<R> AsyncRead for DiskReadPermitReader<R>
|
|
where
|
|
R: AsyncRead,
|
|
{
|
|
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
|
let this = self.project();
|
|
let had_capacity = buf.remaining() > 0;
|
|
let filled_before = buf.filled().len();
|
|
let poll = this.inner.poll_read(cx, buf);
|
|
// EOF: no more disk reads can happen through this stream, so release
|
|
// the permit instead of holding it until the client drops the body.
|
|
if had_capacity
|
|
&& matches!(poll, Poll::Ready(Ok(())))
|
|
&& buf.filled().len() == filled_before
|
|
&& let Some(mut disk_permit) = this.disk_permit.take()
|
|
{
|
|
disk_permit.release();
|
|
}
|
|
poll
|
|
}
|
|
}
|
|
|
|
pin_project! {
|
|
struct GetObjectReaderStream<R> {
|
|
#[pin]
|
|
reader: Option<R>,
|
|
capacity: usize,
|
|
strategy: &'static str,
|
|
buffer_source: &'static str,
|
|
remaining: usize,
|
|
emitted: usize,
|
|
expected: usize,
|
|
// Diagnostic-only identity for the body this stream is serving. Unset in
|
|
// unit tests that drive the stream over a bare reader; every production
|
|
// body carries it via `with_diagnostics`.
|
|
diagnostics: GetObjectReaderStreamDiagnostics,
|
|
}
|
|
}
|
|
|
|
/// Object identity carried alongside a streaming GET body purely so a
|
|
/// mid-stream failure names the object it happened on.
|
|
#[derive(Clone, Default)]
|
|
struct GetObjectReaderStreamDiagnostics {
|
|
bucket: String,
|
|
object: String,
|
|
request_id: String,
|
|
}
|
|
|
|
impl MemoryTrackedBytesStream {
|
|
fn new(
|
|
bytes: Bytes,
|
|
expected: usize,
|
|
source: &'static str,
|
|
guard: Option<rustfs_io_metrics::MemoryGaugeGuard>,
|
|
lifecycle: GetObjectBodyLifecycle,
|
|
) -> Self {
|
|
let length_mismatch = bytes.len() != expected;
|
|
Self {
|
|
bytes: Some(bytes),
|
|
emitted: false,
|
|
completed: !length_mismatch && expected == 0,
|
|
expected,
|
|
length_mismatch,
|
|
started: std::time::Instant::now(),
|
|
source,
|
|
_guard: guard,
|
|
lifecycle,
|
|
}
|
|
}
|
|
|
|
fn finish_ok(&mut self) {
|
|
self.completed = true;
|
|
self.lifecycle.finish_ok();
|
|
}
|
|
|
|
fn finish_err(&mut self) {
|
|
self.lifecycle.finish_err();
|
|
}
|
|
}
|
|
|
|
impl<R> GetObjectReaderStream<R>
|
|
where
|
|
R: AsyncRead,
|
|
{
|
|
fn new(reader: R, capacity: usize, remaining: usize, strategy: &'static str, buffer_source: &'static str) -> Self {
|
|
if is_get_output_handoff_attribution_enabled() {
|
|
rustfs_io_metrics::record_get_object_reader_stream_buffer_size(strategy, buffer_source, capacity);
|
|
}
|
|
Self {
|
|
reader: Some(reader),
|
|
capacity,
|
|
strategy,
|
|
buffer_source,
|
|
remaining,
|
|
emitted: 0,
|
|
expected: remaining,
|
|
diagnostics: GetObjectReaderStreamDiagnostics::default(),
|
|
}
|
|
}
|
|
|
|
/// Attach the object identity a failed body should be reported against.
|
|
fn with_diagnostics(mut self, bucket: &str, object: &str, request_id: &str) -> Self {
|
|
self.diagnostics = GetObjectReaderStreamDiagnostics {
|
|
bucket: bucket.to_string(),
|
|
object: object.to_string(),
|
|
request_id: request_id.to_string(),
|
|
};
|
|
self
|
|
}
|
|
}
|
|
|
|
impl futures::Stream for MemoryTrackedBytesStream {
|
|
type Item = Result<Bytes, S3StdError>;
|
|
|
|
fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
|
let this = self.get_mut();
|
|
let poll_start = is_get_output_handoff_attribution_enabled().then(std::time::Instant::now);
|
|
if this.emitted {
|
|
if let Some(poll_start) = poll_start {
|
|
rustfs_io_metrics::record_get_object_memory_body_stream_poll(
|
|
this.source,
|
|
GET_READER_STREAM_POLL_READY_EMPTY,
|
|
0,
|
|
poll_start.elapsed().as_secs_f64(),
|
|
);
|
|
}
|
|
return Poll::Ready(None);
|
|
}
|
|
|
|
// Strict materialization guard (#1324): a body whose length disagrees
|
|
// with the declared content length must fail the transfer rather than be
|
|
// delivered as a clean short body (truncation) or an over-long body
|
|
// (protocol violation). The HTTP layer has already committed to
|
|
// `Content-Length == expected`, so there is no safe way to serve a
|
|
// differently sized body. This is a defense-in-depth backstop; the
|
|
// buffered/cache callers reject the mismatch before headers are sent.
|
|
if this.length_mismatch {
|
|
let actual = this.bytes.as_ref().map_or(0, Bytes::len);
|
|
this.emitted = true;
|
|
this.finish_err();
|
|
return Poll::Ready(Some(Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidData,
|
|
format!("materialized GET body length mismatch: expected {}, got {}", this.expected, actual),
|
|
)
|
|
.into())));
|
|
}
|
|
|
|
let Some(bytes) = this.bytes.take() else {
|
|
return Poll::Ready(None);
|
|
};
|
|
let bytes_len = bytes.len();
|
|
let first_byte_elapsed = (!bytes.is_empty()).then(|| this.started.elapsed());
|
|
this.emitted = true;
|
|
if let Some(elapsed) = first_byte_elapsed {
|
|
rustfs_io_metrics::record_get_object_first_byte_latency(GET_OBJECT_STAGE_PATH_S3_HANDLER, elapsed.as_secs_f64());
|
|
}
|
|
if bytes_len >= this.expected {
|
|
this.finish_ok();
|
|
}
|
|
if let Some(poll_start) = poll_start {
|
|
rustfs_io_metrics::record_get_object_memory_body_stream_poll(
|
|
this.source,
|
|
GET_READER_STREAM_POLL_READY_DATA,
|
|
bytes_len,
|
|
poll_start.elapsed().as_secs_f64(),
|
|
);
|
|
}
|
|
Poll::Ready(Some(Ok(bytes)))
|
|
}
|
|
}
|
|
|
|
impl ByteStream for MemoryTrackedBytesStream {
|
|
fn remaining_length(&self) -> RemainingLength {
|
|
if self.emitted || self.bytes.is_none() {
|
|
RemainingLength::new_exact(0)
|
|
} else {
|
|
RemainingLength::new_exact(self.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for MemoryTrackedBytesStream {
|
|
fn drop(&mut self) {
|
|
if self.lifecycle.is_finished() {
|
|
return;
|
|
}
|
|
|
|
if self.completed {
|
|
self.finish_ok();
|
|
} else {
|
|
self.finish_err();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Failure modes of strictly materializing an object body into memory (#1324).
|
|
#[derive(Debug)]
|
|
enum StrictMaterializeError {
|
|
/// The reader produced a different number of bytes than the declared content
|
|
/// length (short or over-long). The response has already committed to
|
|
/// `Content-Length == expected`, so any other length is an unrecoverable,
|
|
/// broken HTTP response and must fail before headers are sent.
|
|
LengthMismatch { expected: usize, actual: usize },
|
|
/// A read error occurred after `consumed` bytes were already drained from the
|
|
/// reader. The caller MUST NOT fall back to streaming the same reader: the
|
|
/// drained prefix is gone, so streaming would ship a body missing its prefix
|
|
/// (the seek-buffer prefix-misalignment bug this issue closes).
|
|
Read { consumed: usize, source: std::io::Error },
|
|
}
|
|
|
|
impl std::fmt::Display for StrictMaterializeError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::LengthMismatch { expected, actual, .. } => {
|
|
write!(f, "materialized length mismatch: expected {expected}, got {actual}")
|
|
}
|
|
Self::Read { consumed, source } => {
|
|
write!(f, "read failed after {consumed} bytes: {source}")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl StrictMaterializeError {
|
|
fn into_storage_error(self) -> StorageError {
|
|
match self {
|
|
Self::LengthMismatch { expected, actual, .. } if actual < expected => StorageError::LessData,
|
|
Self::LengthMismatch { .. } => StorageError::MoreData,
|
|
Self::Read { source, .. } if source.kind() == std::io::ErrorKind::TimedOut => StorageError::Timeout,
|
|
Self::Read { source, .. } => StorageError::Io(std::io::Error::new(source.kind(), "object body read failed")),
|
|
}
|
|
}
|
|
|
|
fn into_s3_error(self, _response_content_length: i64) -> S3Error {
|
|
ApiError::from(self.into_storage_error()).into()
|
|
}
|
|
}
|
|
|
|
/// Strictly materialize an object body into memory, enforcing an exact-length
|
|
/// contract (#1324).
|
|
///
|
|
/// Reads at most `expected + 1` bytes so an over-long stream is detected without
|
|
/// buffering it unbounded, then requires `bytes_read == expected`. A short read
|
|
/// (clean EOF before `expected`), an over-long read, or a mid-stream read error
|
|
/// all return an error; only an exact-length read yields the buffer. Because the
|
|
/// HTTP response commits to `Content-Length == expected` before the body is
|
|
/// produced, this mirrors the streaming path (which already fails a short read
|
|
/// with `UnexpectedEof`) and the ODC materialize-fill path, closing the
|
|
/// warn-and-serve holes in the encrypted, seek, and cache memory branches.
|
|
///
|
|
/// On error the reader has already been (partially) consumed, so callers must
|
|
/// propagate the error rather than fall back to streaming the same reader.
|
|
async fn strict_materialize_object_body<R>(
|
|
reader: R,
|
|
expected: usize,
|
|
stage: &'static str,
|
|
) -> Result<Vec<u8>, StrictMaterializeError>
|
|
where
|
|
R: AsyncRead + Unpin,
|
|
{
|
|
// Stop filling before the Vec reaches capacity. Calling `read_to_end` on a
|
|
// bounded reader can still reserve beyond `expected` before observing EOF.
|
|
// The over-long probe below stays outside this Vec so the admitted body
|
|
// allocation remains exactly `expected` bytes.
|
|
let mut buf = Vec::with_capacity(expected);
|
|
let mut reader = reader;
|
|
let read_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
|
|
let read_result = loop {
|
|
if buf.len() == expected {
|
|
break Ok(());
|
|
}
|
|
match tokio::io::AsyncReadExt::read_buf(&mut reader, &mut buf).await {
|
|
Ok(0) => break Ok(()),
|
|
Ok(_) => {}
|
|
Err(source) => break Err(source),
|
|
}
|
|
};
|
|
let actual = buf.len();
|
|
let probe_result = if read_result.is_ok() && actual == expected {
|
|
let mut probe = [0_u8; 1];
|
|
tokio::io::AsyncReadExt::read(&mut reader, &mut probe).await
|
|
} else {
|
|
Ok(0)
|
|
};
|
|
record_get_object_s3_handler_stage_duration(stage, read_start);
|
|
match (read_result, probe_result) {
|
|
(Ok(_), Ok(extra)) => {
|
|
let actual = actual.saturating_add(extra);
|
|
if actual == expected {
|
|
Ok(buf)
|
|
} else {
|
|
Err(StrictMaterializeError::LengthMismatch { expected, actual })
|
|
}
|
|
}
|
|
(Err(source), _) | (_, Err(source)) => Err(StrictMaterializeError::Read {
|
|
consumed: actual,
|
|
source,
|
|
}),
|
|
}
|
|
}
|
|
|
|
struct ColdFillProducerExecution {
|
|
expected: usize,
|
|
deadline: Option<tokio::time::Instant>,
|
|
adapter: Arc<ObjectDataCacheAdapter>,
|
|
engine_plan: rustfs_object_data_cache::ObjectDataCacheGetPlan,
|
|
}
|
|
|
|
enum ColdFillStartupWaitError {
|
|
Cancelled,
|
|
DeadlineExceeded,
|
|
}
|
|
|
|
async fn await_cold_fill_startup<F>(
|
|
future: F,
|
|
cancellation: &tokio_util::sync::CancellationToken,
|
|
deadline: Option<tokio::time::Instant>,
|
|
) -> Result<F::Output, ColdFillStartupWaitError>
|
|
where
|
|
F: Future,
|
|
{
|
|
tokio::pin!(future);
|
|
match deadline {
|
|
Some(deadline) => {
|
|
tokio::select! {
|
|
biased;
|
|
_ = cancellation.cancelled() => Err(ColdFillStartupWaitError::Cancelled),
|
|
result = tokio::time::timeout_at(deadline, &mut future) => {
|
|
result.map_err(|_| ColdFillStartupWaitError::DeadlineExceeded)
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
tokio::select! {
|
|
biased;
|
|
_ = cancellation.cancelled() => Err(ColdFillStartupWaitError::Cancelled),
|
|
result = &mut future => Ok(result),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn start_cold_fill_producer<AcquireIo, AcquireIoFuture, OpenReader, OpenReaderFuture>(
|
|
producer: ColdFillProducer,
|
|
reservation: Option<rustfs_object_data_cache::ObjectDataCacheBodyReservation>,
|
|
acquire_io: AcquireIo,
|
|
open_reader: OpenReader,
|
|
execution: ColdFillProducerExecution,
|
|
) where
|
|
AcquireIo: FnOnce() -> AcquireIoFuture,
|
|
AcquireIoFuture: Future<Output = Result<GetObjectIoPlanning, ColdFillError>>,
|
|
OpenReader: FnOnce() -> OpenReaderFuture,
|
|
OpenReaderFuture: Future<Output = Result<GetObjectReader, StorageError>>,
|
|
{
|
|
let ColdFillProducerExecution {
|
|
expected,
|
|
deadline,
|
|
adapter,
|
|
engine_plan,
|
|
} = execution;
|
|
let hard_deadline = tokio::time::Instant::now() + COLD_FILL_HARD_MAX_DURATION;
|
|
let deadline = deadline.map_or(hard_deadline, |request_deadline| request_deadline.min(hard_deadline));
|
|
let cancellation = producer.cancellation_token();
|
|
let Some(reservation) = reservation else {
|
|
producer.bypass();
|
|
return;
|
|
};
|
|
let acquire = acquire_io();
|
|
tokio::pin!(acquire);
|
|
let producer_io = tokio::select! {
|
|
_ = cancellation.cancelled() => {
|
|
producer.finish(Err(StorageError::OperationCanceled));
|
|
return;
|
|
}
|
|
result = tokio::time::timeout_at(deadline, &mut acquire) => match result {
|
|
Ok(result) => result,
|
|
Err(_) => {
|
|
producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout));
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
let producer_io = match producer_io {
|
|
Ok(io) => io,
|
|
Err(err) => {
|
|
producer.relinquish_or_finish(err);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let open = open_reader();
|
|
tokio::pin!(open);
|
|
let reader = match tokio::select! {
|
|
_ = cancellation.cancelled() => Err(StorageError::OperationCanceled),
|
|
result = tokio::time::timeout_at(deadline, &mut open) => {
|
|
result.unwrap_or(Err(StorageError::Timeout))
|
|
}
|
|
} {
|
|
Ok(reader) => reader,
|
|
Err(err) => {
|
|
producer.relinquish_or_finish(ColdFillError::Storage(err));
|
|
return;
|
|
}
|
|
};
|
|
producer.mark_reader_started();
|
|
let materialize = async move {
|
|
let GetObjectReader {
|
|
stream, buffered_body, ..
|
|
} = reader;
|
|
let body = if let Some(body) = buffered_body {
|
|
if body.len() == expected {
|
|
body
|
|
} else {
|
|
return Err(StorageError::other(format!(
|
|
"cold-fill buffered body length mismatch: expected {expected}, got {}",
|
|
body.len()
|
|
)));
|
|
}
|
|
} else {
|
|
let stream = if let Some(permit) = producer_io.disk_permit {
|
|
wrap_reader(DiskReadPermitReader::new(stream, permit))
|
|
} else {
|
|
stream
|
|
};
|
|
Bytes::from(
|
|
strict_materialize_object_body(stream, expected, GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ)
|
|
.await
|
|
.map_err(StrictMaterializeError::into_storage_error)?,
|
|
)
|
|
};
|
|
Ok::<_, StorageError>((body, reservation))
|
|
};
|
|
let materialized = tokio::select! {
|
|
_ = cancellation.cancelled() => Err(StorageError::OperationCanceled),
|
|
result = tokio::time::timeout_at(deadline, materialize) => {
|
|
result.unwrap_or(Err(StorageError::Timeout))
|
|
}
|
|
};
|
|
let result = match materialized {
|
|
Ok((body, reservation)) => {
|
|
if cancellation.is_cancelled() {
|
|
producer.finish(Err(StorageError::OperationCanceled));
|
|
return;
|
|
}
|
|
if deadline <= tokio::time::Instant::now() {
|
|
producer.finish(Err(StorageError::Timeout));
|
|
return;
|
|
}
|
|
let reserved = reservation.wrap_bytes(body);
|
|
let shared = reserved.bytes();
|
|
let publish = async {
|
|
#[cfg(test)]
|
|
wait_cold_fill_publication_barrier(&engine_plan).await;
|
|
adapter.fill_reserved_body(&engine_plan, reserved).await
|
|
};
|
|
tokio::pin!(publish);
|
|
tokio::select! {
|
|
_ = cancellation.cancelled() => Err(StorageError::OperationCanceled),
|
|
_ = tokio::time::sleep_until(deadline) => {
|
|
Err(StorageError::Timeout)
|
|
}
|
|
_ = &mut publish => Ok(shared),
|
|
}
|
|
}
|
|
Err(err) => Err(err),
|
|
};
|
|
producer.finish(result);
|
|
}
|
|
|
|
fn cold_fill_deadline(
|
|
wrapper: &RequestTimeoutWrapper,
|
|
timeout_config: &GetObjectTimeoutPolicy,
|
|
response_size: u64,
|
|
) -> Option<tokio::time::Instant> {
|
|
if !timeout_config.is_timeout_enabled() {
|
|
return None;
|
|
}
|
|
Some(tokio::time::Instant::now() + wrapper.remaining_time_for_size(Some(response_size)).unwrap_or(Duration::ZERO))
|
|
}
|
|
|
|
fn cold_fill_producer_deadline(timeout_config: &GetObjectTimeoutPolicy, response_size: u64) -> tokio::time::Instant {
|
|
let now = tokio::time::Instant::now();
|
|
let hard_deadline = now + COLD_FILL_HARD_MAX_DURATION;
|
|
if timeout_config.is_timeout_enabled() {
|
|
(now + timeout_config.calculate_timeout_for_size(response_size)).min(hard_deadline)
|
|
} else {
|
|
hard_deadline
|
|
}
|
|
}
|
|
|
|
async fn lookup_cold_fill_second_chance(
|
|
adapter: &ObjectDataCacheAdapter,
|
|
plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan,
|
|
) -> Option<Bytes> {
|
|
match adapter.peek_body_untracked(plan).await {
|
|
rustfs_object_data_cache::ObjectDataCacheLookup::Hit(body) => Some(body),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn retain_cold_fill_producer_for_matching_plan(
|
|
producer: ColdFillProducer,
|
|
current: &GetObjectBodyCachePlan,
|
|
expected: &rustfs_object_data_cache::ObjectDataCacheGetPlan,
|
|
) -> Option<ColdFillProducer> {
|
|
if current == &GetObjectBodyCachePlan::Cacheable(expected.clone()) {
|
|
Some(producer)
|
|
} else {
|
|
producer.bypass();
|
|
None
|
|
}
|
|
}
|
|
|
|
impl<R> futures::Stream for GetObjectReaderStream<R>
|
|
where
|
|
R: AsyncRead,
|
|
{
|
|
type Item = Result<Bytes, S3StdError>;
|
|
|
|
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
|
let mut this = self.project();
|
|
if *this.remaining == 0 {
|
|
return Poll::Ready(None);
|
|
}
|
|
|
|
let remaining_before = *this.remaining;
|
|
let attribution_enabled = is_get_output_handoff_attribution_enabled();
|
|
let poll_start = attribution_enabled.then(std::time::Instant::now);
|
|
let reader = match this.reader.as_mut().as_pin_mut() {
|
|
Some(reader) => reader,
|
|
None => return Poll::Ready(None),
|
|
};
|
|
let read_capacity = (*this.capacity).min(*this.remaining);
|
|
let mut buf = BytesMut::with_capacity(read_capacity);
|
|
let poll_read = poll_read_buf(reader, cx, &mut buf);
|
|
|
|
let result: Poll<Option<Self::Item>> = match poll_read {
|
|
Poll::Ready(Ok(bytes_read)) if bytes_read > 0 => {
|
|
let bytes = buf.freeze();
|
|
*this.remaining -= bytes.len();
|
|
*this.emitted += bytes.len();
|
|
#[cfg(feature = "tracing-chunk-debug")]
|
|
{
|
|
tracing::debug!(
|
|
emitted = *this.emitted,
|
|
expected = *this.expected,
|
|
chunk_len = bytes.len(),
|
|
"GetObject ReaderStream emitted bytes"
|
|
);
|
|
}
|
|
if bytes.is_empty() {
|
|
Poll::Ready(None)
|
|
} else {
|
|
Poll::Ready(Some(Ok(bytes)))
|
|
}
|
|
}
|
|
Poll::Ready(Ok(_)) => {
|
|
this.reader.set(None);
|
|
let remaining = i64::try_from(*this.remaining).unwrap_or(i64::MAX);
|
|
let err = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, rustfs_rio::IncompleteBody { remaining });
|
|
record_get_object_reader_stream_failure(
|
|
GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF,
|
|
"short_eof",
|
|
this.strategy,
|
|
this.buffer_source,
|
|
*this.expected,
|
|
*this.emitted,
|
|
*this.remaining,
|
|
);
|
|
// The inner GetObjectStreamingReader is what normally reports a
|
|
// short body, so reaching this arm means the reader signalled a
|
|
// clean EOF while this layer still owed bytes against an
|
|
// already-committed Content-Length. That disagreement is a data
|
|
// plane fault, not chunk noise: log it unconditionally so the
|
|
// truncated object is named in the operator's log rather than
|
|
// only in a metric counter (issue #4784).
|
|
error!(
|
|
event = EVENT_GET_OBJECT_STREAM_BODY,
|
|
component = LOG_COMPONENT_APP,
|
|
subsystem = LOG_SUBSYSTEM_OBJECT,
|
|
bucket = %this.diagnostics.bucket,
|
|
object = %this.diagnostics.object,
|
|
request_id = %this.diagnostics.request_id,
|
|
size_bucket = get_object_stream_size_bucket(*this.expected),
|
|
expected = *this.expected,
|
|
emitted = *this.emitted,
|
|
remaining = *this.remaining,
|
|
strategy = this.strategy,
|
|
buffer_source = this.buffer_source,
|
|
state = "reader_stream_short_eof",
|
|
error = %err,
|
|
"GetObject reader stream ended before the committed content length"
|
|
);
|
|
Poll::Ready(Some(Err(Box::new(err) as S3StdError)))
|
|
}
|
|
Poll::Ready(Err(err)) => {
|
|
this.reader.set(None);
|
|
let error_class = classify_get_object_stream_read_error(&err);
|
|
record_get_object_reader_stream_failure(
|
|
get_object_stream_failure_reason(error_class),
|
|
error_class,
|
|
this.strategy,
|
|
this.buffer_source,
|
|
*this.expected,
|
|
*this.emitted,
|
|
*this.remaining,
|
|
);
|
|
// Deliberately not logged at warn here: every production body
|
|
// wraps a GetObjectStreamingReader, and that layer already
|
|
// reports this same error once with `state = "read_failed"` and
|
|
// the object identity. A second unconditional line per failed
|
|
// GET would read as two distinct faults. The chunk-debug build
|
|
// still gets this layer's view of the same error.
|
|
#[cfg(feature = "tracing-chunk-debug")]
|
|
tracing::error!(
|
|
emitted = *this.emitted,
|
|
expected = *this.expected,
|
|
error_class = error_class,
|
|
error = %err,
|
|
"GetObject ReaderStream returned error"
|
|
);
|
|
Poll::Ready(Some(Err(Box::new(err) as S3StdError)))
|
|
}
|
|
Poll::Pending => Poll::Pending,
|
|
};
|
|
|
|
let emitted_bytes = match &result {
|
|
Poll::Ready(Some(Ok(bytes))) => bytes.len(),
|
|
_ => 0,
|
|
};
|
|
let outcome = match &result {
|
|
Poll::Ready(Some(Ok(bytes))) if !bytes.is_empty() => GET_READER_STREAM_POLL_READY_DATA,
|
|
Poll::Ready(Some(Ok(_))) | Poll::Ready(None) => GET_READER_STREAM_POLL_READY_EMPTY,
|
|
Poll::Ready(Some(Err(_))) => GET_READER_STREAM_POLL_READY_ERROR,
|
|
Poll::Pending => GET_READER_STREAM_POLL_PENDING,
|
|
};
|
|
if attribution_enabled {
|
|
rustfs_io_metrics::record_get_object_reader_stream_poll(
|
|
this.strategy,
|
|
this.buffer_source,
|
|
outcome,
|
|
remaining_before,
|
|
emitted_bytes,
|
|
poll_start.map_or(0.0, |start| start.elapsed().as_secs_f64()),
|
|
);
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
fn size_hint(&self) -> (usize, Option<usize>) {
|
|
if self.remaining == 0 || self.reader.is_none() {
|
|
(0, Some(0))
|
|
} else {
|
|
(1, None)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<R> ByteStream for GetObjectReaderStream<R>
|
|
where
|
|
R: AsyncRead,
|
|
{
|
|
fn remaining_length(&self) -> RemainingLength {
|
|
RemainingLength::new_exact(self.remaining)
|
|
}
|
|
}
|
|
|
|
struct GetObjectStreamingReader<R> {
|
|
inner: Option<R>,
|
|
// bucket/object + request_id + optional content_range are only used for diagnostic
|
|
// correlation and failure bucketing; they do not alter stream behavior. The object
|
|
// identity is what turns a mid-stream failure into an actionable report: a request_id
|
|
// alone cannot tell an operator which object reads short (issue #4784).
|
|
bucket: String,
|
|
object: String,
|
|
request_id: String,
|
|
content_range: Option<String>,
|
|
expected: usize,
|
|
emitted: usize,
|
|
timeout: Duration,
|
|
timer: Option<Pin<Box<tokio::time::Sleep>>>,
|
|
started: std::time::Instant,
|
|
first_byte_reported: bool,
|
|
completed: bool,
|
|
lifecycle: GetObjectBodyLifecycle,
|
|
resume: Option<GetObjectResumeControl<R>>,
|
|
_foreground_read_guard: rustfs_scanner::ForegroundReadGuard,
|
|
}
|
|
|
|
impl<R> GetObjectStreamingReader<R> {
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn new(
|
|
inner: R,
|
|
bucket: &str,
|
|
key: &str,
|
|
request_id: &str,
|
|
content_range: Option<String>,
|
|
expected: usize,
|
|
timeout: Duration,
|
|
lifecycle: GetObjectBodyLifecycle,
|
|
resume: Option<GetObjectResumeControl<R>>,
|
|
) -> Self {
|
|
Self {
|
|
inner: Some(inner),
|
|
bucket: bucket.to_string(),
|
|
object: key.to_string(),
|
|
request_id: request_id.to_string(),
|
|
content_range,
|
|
expected,
|
|
emitted: 0,
|
|
timeout,
|
|
timer: None,
|
|
started: std::time::Instant::now(),
|
|
first_byte_reported: false,
|
|
completed: expected == 0,
|
|
lifecycle,
|
|
resume,
|
|
_foreground_read_guard: rustfs_scanner::ForegroundReadGuard::new(),
|
|
}
|
|
}
|
|
|
|
fn elapsed(&self) -> Duration {
|
|
self.started.elapsed()
|
|
}
|
|
|
|
// Classify transport/read failures before logging so operators can quickly
|
|
// distinguish truncated upstream bodies, corruption, quorum issues, and
|
|
// genuine downstream-close disconnects.
|
|
fn classify_read_error(err: &std::io::Error) -> &'static str {
|
|
classify_get_object_stream_read_error(err)
|
|
}
|
|
|
|
fn finish_ok(&mut self) {
|
|
self.completed = true;
|
|
self.lifecycle.finish_ok();
|
|
}
|
|
|
|
fn finish_err(&mut self) {
|
|
self.lifecycle.finish_err();
|
|
}
|
|
|
|
fn resume_in_flight(&self) -> bool {
|
|
matches!(
|
|
self.resume.as_ref().map(|resume| &resume.stage),
|
|
Some(GetObjectResumeStage::Backoff | GetObjectResumeStage::Reopening(_))
|
|
)
|
|
}
|
|
|
|
fn begin_resume(&mut self, error: std::io::Error) {
|
|
let Some(resume) = self.resume.as_mut() else {
|
|
return;
|
|
};
|
|
self.inner.take();
|
|
resume.begin(error);
|
|
}
|
|
|
|
// Drive the armed resume flow: backoff ticks gate each reopen attempt, and
|
|
// a successful reopen swaps the failed stream out for the replacement.
|
|
fn poll_resume(&mut self, cx: &mut Context<'_>) -> GetObjectResumePoll {
|
|
let Some(mut resume) = self.resume.take() else {
|
|
// resume_in_flight guards every call site.
|
|
unreachable!("poll_resume requires an armed resume control");
|
|
};
|
|
let outcome = loop {
|
|
let stage = std::mem::replace(&mut resume.stage, GetObjectResumeStage::Idle);
|
|
match stage {
|
|
GetObjectResumeStage::Idle => unreachable!("resume control is only polled while armed"),
|
|
GetObjectResumeStage::Backoff => match Pin::new(&mut resume.timer).poll_next(cx) {
|
|
Poll::Ready(Some(())) => {
|
|
resume.attempts += 1;
|
|
resume.stage = GetObjectResumeStage::Reopening(Mutex::new((resume.reopen)(self.emitted)));
|
|
}
|
|
Poll::Ready(None) => {
|
|
let error = resume.take_trigger_error();
|
|
break GetObjectResumePoll::Failed {
|
|
error,
|
|
attempts: resume.attempts,
|
|
};
|
|
}
|
|
Poll::Pending => {
|
|
resume.stage = GetObjectResumeStage::Backoff;
|
|
break GetObjectResumePoll::Pending;
|
|
}
|
|
},
|
|
GetObjectResumeStage::Reopening(reopening) => {
|
|
let poll = match reopening.try_lock() {
|
|
Ok(mut reopening) => reopening.as_mut().poll(cx),
|
|
// Only reachable when a poll of the reopen future
|
|
// panicked and poisoned the mutex: fail closed with the
|
|
// original trigger error instead of polling it again.
|
|
Err(_) => {
|
|
let error = resume.take_trigger_error();
|
|
break GetObjectResumePoll::Failed {
|
|
error,
|
|
attempts: resume.attempts,
|
|
};
|
|
}
|
|
};
|
|
match poll {
|
|
Poll::Ready(Ok(reader)) => {
|
|
self.inner = Some(reader);
|
|
break GetObjectResumePoll::Resumed {
|
|
attempts: resume.attempts,
|
|
};
|
|
}
|
|
Poll::Ready(Err(GetObjectResumeFailure::Retryable)) => {
|
|
resume.stage = GetObjectResumeStage::Backoff;
|
|
}
|
|
Poll::Ready(Err(GetObjectResumeFailure::Fatal)) => {
|
|
let error = resume.take_trigger_error();
|
|
break GetObjectResumePoll::Failed {
|
|
error,
|
|
attempts: resume.attempts,
|
|
};
|
|
}
|
|
Poll::Pending => {
|
|
resume.stage = GetObjectResumeStage::Reopening(reopening);
|
|
break GetObjectResumePoll::Pending;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
if matches!(outcome, GetObjectResumePoll::Resumed { .. } | GetObjectResumePoll::Pending) {
|
|
self.resume = Some(resume);
|
|
}
|
|
outcome
|
|
}
|
|
|
|
fn poll_stall_timeout(&mut self, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
|
if self.timeout.is_zero() {
|
|
return Poll::Pending;
|
|
}
|
|
|
|
if self.timer.is_none() {
|
|
self.timer = Some(Box::pin(tokio::time::sleep(self.timeout)));
|
|
}
|
|
|
|
if let Some(timer) = self.timer.as_mut()
|
|
&& std::future::Future::poll(timer.as_mut(), cx).is_ready()
|
|
{
|
|
self.timer = None;
|
|
warn!(
|
|
event = EVENT_GET_OBJECT_STREAM_BODY,
|
|
component = LOG_COMPONENT_APP,
|
|
subsystem = LOG_SUBSYSTEM_OBJECT,
|
|
bucket = %self.bucket,
|
|
object = %self.object,
|
|
request_id = %self.request_id,
|
|
range = %self.content_range.as_deref().unwrap_or("full"),
|
|
size_bucket = get_object_stream_size_bucket(self.expected),
|
|
expected = self.expected,
|
|
emitted = self.emitted,
|
|
elapsed_ms = self.elapsed().as_millis(),
|
|
timeout_ms = self.timeout.as_millis(),
|
|
state = "stall_timeout",
|
|
"GetObject streaming body stalled"
|
|
);
|
|
self.finish_err();
|
|
return Poll::Ready(Err(std::io::Error::new(
|
|
std::io::ErrorKind::TimedOut,
|
|
"get object streaming body stall timeout",
|
|
)));
|
|
}
|
|
|
|
Poll::Pending
|
|
}
|
|
}
|
|
|
|
impl<R: AsyncRead + Unpin> AsyncRead for GetObjectStreamingReader<R> {
|
|
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
|
let filled_before = buf.filled().len();
|
|
|
|
loop {
|
|
// An armed resume owns the reader until it swaps in a reopened
|
|
// stream or exhausts its budget; the failed inner stream is never
|
|
// polled again.
|
|
if self.resume_in_flight() {
|
|
match self.poll_resume(cx) {
|
|
GetObjectResumePoll::Resumed { attempts } => {
|
|
debug!(
|
|
event = EVENT_GET_OBJECT_STREAM_BODY,
|
|
component = LOG_COMPONENT_APP,
|
|
subsystem = LOG_SUBSYSTEM_OBJECT,
|
|
bucket = %self.bucket,
|
|
object = %self.object,
|
|
request_id = %self.request_id,
|
|
range = %self.content_range.as_deref().unwrap_or("full"),
|
|
size_bucket = get_object_stream_size_bucket(self.expected),
|
|
expected = self.expected,
|
|
emitted = self.emitted,
|
|
resume_attempts = attempts,
|
|
state = "resumed",
|
|
"GetObject streaming body resumed from a reopened object read"
|
|
);
|
|
// The replacement stream starts a fresh stall window.
|
|
self.timer = None;
|
|
continue;
|
|
}
|
|
GetObjectResumePoll::Pending => return self.poll_stall_timeout(cx),
|
|
GetObjectResumePoll::Failed { error, attempts } => {
|
|
self.timer = None;
|
|
let failure_reason = Self::classify_read_error(&error);
|
|
self.finish_err();
|
|
error!(
|
|
event = EVENT_GET_OBJECT_STREAM_BODY,
|
|
component = LOG_COMPONENT_APP,
|
|
subsystem = LOG_SUBSYSTEM_OBJECT,
|
|
bucket = %self.bucket,
|
|
object = %self.object,
|
|
request_id = %self.request_id,
|
|
range = %self.content_range.as_deref().unwrap_or("full"),
|
|
size_bucket = get_object_stream_size_bucket(self.expected),
|
|
expected = self.expected,
|
|
emitted = self.emitted,
|
|
elapsed_ms = self.elapsed().as_millis(),
|
|
state = "read_failed",
|
|
failure_reason = failure_reason,
|
|
resume_attempts = attempts,
|
|
error = %error,
|
|
"GetObject streaming body read failed; mid-stream resume did not recover"
|
|
);
|
|
return Poll::Ready(Err(error));
|
|
}
|
|
}
|
|
}
|
|
|
|
let Some(inner) = self.inner.as_mut() else {
|
|
self.finish_err();
|
|
return Poll::Ready(Err(std::io::Error::other(
|
|
"get object streaming reader lost its active read outside resume",
|
|
)));
|
|
};
|
|
match Pin::new(inner).poll_read(cx, buf) {
|
|
Poll::Ready(Ok(())) => {
|
|
self.timer = None;
|
|
let produced = buf.filled().len().saturating_sub(filled_before);
|
|
if produced > 0 {
|
|
self.emitted = self.emitted.saturating_add(produced);
|
|
if !self.first_byte_reported {
|
|
self.first_byte_reported = true;
|
|
let elapsed = self.elapsed();
|
|
rustfs_io_metrics::record_get_object_first_byte_latency(
|
|
GET_OBJECT_STAGE_PATH_S3_HANDLER,
|
|
elapsed.as_secs_f64(),
|
|
);
|
|
if elapsed >= GET_OBJECT_STREAM_WARN_THRESHOLD {
|
|
warn!(
|
|
event = EVENT_GET_OBJECT_STREAM_BODY,
|
|
component = LOG_COMPONENT_APP,
|
|
subsystem = LOG_SUBSYSTEM_OBJECT,
|
|
bucket = %self.bucket,
|
|
object = %self.object,
|
|
request_id = %self.request_id,
|
|
range = %self.content_range.as_deref().unwrap_or("full"),
|
|
size_bucket = get_object_stream_size_bucket(self.expected),
|
|
expected = self.expected,
|
|
emitted = self.emitted,
|
|
elapsed_ms = elapsed.as_millis(),
|
|
state = "first_byte_slow",
|
|
"GetObject streaming body first byte was slow"
|
|
);
|
|
}
|
|
}
|
|
if self.emitted >= self.expected {
|
|
self.completed = true;
|
|
self.finish_ok();
|
|
}
|
|
return Poll::Ready(Ok(()));
|
|
}
|
|
|
|
if self.emitted < self.expected {
|
|
// The inner reader signalled a clean EOF before delivering the full
|
|
// Content-Length. Returning Ok here would hand the client a truncated body
|
|
// under a full Content-Length: the peer treats the short body as complete
|
|
// (e.g. `mc mirror` writes a short file and considers it done — the
|
|
// "incomplete data mirroring" in issue #2955). Surface an error instead so
|
|
// the transfer fails loudly and the client retries rather than persisting
|
|
// truncated data.
|
|
let error = std::io::Error::new(
|
|
std::io::ErrorKind::UnexpectedEof,
|
|
rustfs_rio::IncompleteBody {
|
|
remaining: self.expected.saturating_sub(self.emitted) as i64,
|
|
},
|
|
);
|
|
// A premature EOF is also how the legacy duplex read path
|
|
// surfaces the object data vanishing mid-stream (typed
|
|
// errors do not survive that pump), so arm the resume
|
|
// flow before failing loudly when one is attached.
|
|
if self.resume.is_some() {
|
|
self.begin_resume(error);
|
|
continue;
|
|
}
|
|
error!(
|
|
event = EVENT_GET_OBJECT_STREAM_BODY,
|
|
component = LOG_COMPONENT_APP,
|
|
subsystem = LOG_SUBSYSTEM_OBJECT,
|
|
bucket = %self.bucket,
|
|
object = %self.object,
|
|
request_id = %self.request_id,
|
|
range = %self.content_range.as_deref().unwrap_or("full"),
|
|
size_bucket = get_object_stream_size_bucket(self.expected),
|
|
expected = self.expected,
|
|
emitted = self.emitted,
|
|
elapsed_ms = self.elapsed().as_millis(),
|
|
state = "short_eof",
|
|
"GetObject streaming body ended before expected length"
|
|
);
|
|
self.finish_err();
|
|
return Poll::Ready(Err(error));
|
|
}
|
|
|
|
self.completed = true;
|
|
self.finish_ok();
|
|
return Poll::Ready(Ok(()));
|
|
}
|
|
Poll::Ready(Err(err)) => {
|
|
// Typed relocation errors (the codec read path delivers them
|
|
// in-band) mean rebalance/decommission removed the pinned
|
|
// object data mid-stream: reopen and continue instead of
|
|
// failing the download. The error is only intercepted before
|
|
// the committed body length has been fully delivered.
|
|
if self.emitted < self.expected && is_object_relocation_error(&err) && self.resume.is_some() {
|
|
self.begin_resume(err);
|
|
continue;
|
|
}
|
|
let failure_reason = Self::classify_read_error(&err);
|
|
self.timer = None;
|
|
self.finish_err();
|
|
error!(
|
|
event = EVENT_GET_OBJECT_STREAM_BODY,
|
|
component = LOG_COMPONENT_APP,
|
|
subsystem = LOG_SUBSYSTEM_OBJECT,
|
|
bucket = %self.bucket,
|
|
object = %self.object,
|
|
request_id = %self.request_id,
|
|
range = %self.content_range.as_deref().unwrap_or("full"),
|
|
size_bucket = get_object_stream_size_bucket(self.expected),
|
|
expected = self.expected,
|
|
emitted = self.emitted,
|
|
elapsed_ms = self.elapsed().as_millis(),
|
|
state = "read_failed",
|
|
failure_reason = failure_reason,
|
|
error = %err,
|
|
"GetObject streaming body read failed"
|
|
);
|
|
return Poll::Ready(Err(err));
|
|
}
|
|
Poll::Pending => return self.poll_stall_timeout(cx),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<R> Drop for GetObjectStreamingReader<R> {
|
|
fn drop(&mut self) {
|
|
if self.lifecycle.is_finished() {
|
|
return;
|
|
}
|
|
|
|
if self.expected == 0 || self.completed || self.emitted >= self.expected {
|
|
self.finish_ok();
|
|
return;
|
|
}
|
|
|
|
self.finish_err();
|
|
warn!(
|
|
event = EVENT_GET_OBJECT_STREAM_BODY,
|
|
component = LOG_COMPONENT_APP,
|
|
subsystem = LOG_SUBSYSTEM_OBJECT,
|
|
bucket = %self.bucket,
|
|
object = %self.object,
|
|
request_id = %self.request_id,
|
|
range = %self.content_range.as_deref().unwrap_or("full"),
|
|
size_bucket = get_object_stream_size_bucket(self.expected),
|
|
expected = self.expected,
|
|
emitted = self.emitted,
|
|
elapsed_ms = self.elapsed().as_millis(),
|
|
state = "dropped_incomplete",
|
|
"GetObject streaming body dropped before expected length"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Reopen budget for a single GetObject body. Three attempts against the
|
|
/// jittered 200ms/400ms RetryTimer schedule (~600ms worst case) bound the
|
|
/// metadata fan-out a storm of relocated downloads can multiply.
|
|
const GET_OBJECT_RESUME_MAX_ATTEMPTS: i64 = 3;
|
|
|
|
type GetObjectResumeFuture<R> = Pin<Box<dyn std::future::Future<Output = Result<R, GetObjectResumeFailure>> + Send>>;
|
|
|
|
type GetObjectReopen<R> = Box<dyn FnMut(usize) -> GetObjectResumeFuture<R> + Send + Sync>;
|
|
|
|
enum GetObjectResumePoll {
|
|
Resumed { attempts: usize },
|
|
Pending,
|
|
Failed { error: std::io::Error, attempts: usize },
|
|
}
|
|
|
|
/// Why a single resume attempt did not produce a replacement stream.
|
|
#[derive(Debug)]
|
|
enum GetObjectResumeFailure {
|
|
/// Reopen/admission failure that may clear on the next attempt.
|
|
Retryable,
|
|
/// The reopened object is not the version this response committed to (or
|
|
/// admission is permanently unavailable): continuing would splice two
|
|
/// versions into one 200 response, so fail with the original error.
|
|
Fatal,
|
|
}
|
|
|
|
enum GetObjectResumeStage<R> {
|
|
Idle,
|
|
Backoff,
|
|
// The store's boxed read futures are Send but not Sync, while the
|
|
// streaming body requires the reader to be Sync, so the in-flight reopen
|
|
// future is stored behind a mutex. It is only ever locked under `&mut
|
|
// self` in `poll_resume`, so the lock never contends.
|
|
Reopening(Mutex<GetObjectResumeFuture<R>>),
|
|
}
|
|
|
|
/// Mid-stream resume machinery for [`GetObjectStreamingReader`]: when the
|
|
/// pinned object data vanishes mid-body (rebalance/decommission copies the
|
|
/// version elsewhere, then deletes the source), reopen the object at the
|
|
/// emitted offset and continue instead of failing the download.
|
|
struct GetObjectResumeControl<R> {
|
|
reopen: GetObjectReopen<R>,
|
|
timer: RetryTimer,
|
|
stage: GetObjectResumeStage<R>,
|
|
original_error: Option<std::io::Error>,
|
|
attempts: usize,
|
|
}
|
|
|
|
impl<R> GetObjectResumeControl<R> {
|
|
fn new(reopen: GetObjectReopen<R>, timer: RetryTimer) -> Self {
|
|
Self {
|
|
reopen,
|
|
timer,
|
|
stage: GetObjectResumeStage::Idle,
|
|
original_error: None,
|
|
attempts: 0,
|
|
}
|
|
}
|
|
|
|
fn begin(&mut self, error: std::io::Error) {
|
|
self.original_error = Some(error);
|
|
self.stage = GetObjectResumeStage::Backoff;
|
|
}
|
|
|
|
// The trigger error is always recorded by `begin`; the fallback is a
|
|
// fail-closed internal error, never a fabricated success.
|
|
fn take_trigger_error(&mut self) -> std::io::Error {
|
|
self.original_error
|
|
.take()
|
|
.unwrap_or_else(|| std::io::Error::other("get object resume lost its trigger error"))
|
|
}
|
|
}
|
|
|
|
/// Object-version identity captured when the response committed to a body. A
|
|
/// resumed read must serve exactly this version; `data_dir` is deliberately
|
|
/// excluded because rebalance regenerates it for the same version.
|
|
struct GetObjectResumeIdentity {
|
|
version_id: Option<Uuid>,
|
|
mod_time: Option<OffsetDateTime>,
|
|
size: i64,
|
|
etag: Option<String>,
|
|
// The store rewrites a read's `object_info.size` to the per-read delivered
|
|
// length for encrypted and compressed objects (readers.rs Encrypted /
|
|
// Compressed transforms), so a reopened subrange reports `size - emitted`
|
|
// while a plain read reports the range-invariant `oi.size`. The flag only
|
|
// chooses the comparison arithmetic; a transform change that no longer
|
|
// matches it fails the identity check, which is the closed direction.
|
|
range_dependent_size: bool,
|
|
}
|
|
|
|
impl GetObjectResumeIdentity {
|
|
fn matches(&self, info: &ObjectInfo, emitted: usize) -> bool {
|
|
let expected_size = if self.range_dependent_size {
|
|
self.size - emitted as i64
|
|
} else {
|
|
self.size
|
|
};
|
|
self.version_id == info.version_id
|
|
&& self.mod_time == info.mod_time
|
|
&& expected_size == info.size
|
|
&& self.etag == info.etag
|
|
}
|
|
}
|
|
|
|
/// Reopen parameters for a mid-stream resume. Only the SSE-C headers the store
|
|
/// read path consumes are retained: the store-level `get_object_reader` spans
|
|
/// record their header argument at debug level, so retaining the full request
|
|
/// headers would re-log credentials on every attempt.
|
|
struct GetObjectResumeContext {
|
|
store: Arc<ECStore>,
|
|
bucket: String,
|
|
key: String,
|
|
opts: ObjectOptions,
|
|
ssec_headers: HeaderMap,
|
|
// Resolved plaintext offsets of the committed response body, captured
|
|
// after `HTTPRangeSpec::get_offset_length`: suffix ranges and partNumber
|
|
// GETs are already resolved to absolute offsets at that point, so the
|
|
// resume offset is `range_start + emitted` regardless of request shape.
|
|
range_start: i64,
|
|
range_end: i64,
|
|
identity: GetObjectResumeIdentity,
|
|
}
|
|
|
|
impl GetObjectResumeContext {
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn new(
|
|
store: Arc<ECStore>,
|
|
bucket: &str,
|
|
key: &str,
|
|
mut opts: ObjectOptions,
|
|
request_headers: &HeaderMap,
|
|
info: &ObjectInfo,
|
|
range_start: i64,
|
|
range_end: i64,
|
|
) -> Self {
|
|
if opts.version_id.is_none()
|
|
&& let Some(version_id) = info.version_id
|
|
{
|
|
opts.version_id = Some(version_id.to_string());
|
|
}
|
|
// Store spans record their header argument at debug level. Retain only
|
|
// the SSE-C inputs needed to reopen the reader and keep them redacted.
|
|
let ssec_headers = project_ssec_transport_headers(request_headers);
|
|
Self {
|
|
store,
|
|
bucket: bucket.to_string(),
|
|
key: key.to_string(),
|
|
opts,
|
|
ssec_headers,
|
|
range_start,
|
|
range_end,
|
|
identity: GetObjectResumeIdentity {
|
|
version_id: info.version_id,
|
|
mod_time: info.mod_time,
|
|
size: info.size,
|
|
etag: info.etag.clone(),
|
|
range_dependent_size: info.is_encrypted() || info.is_compressed(),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn resume_range(range_start: i64, range_end: i64, emitted: usize) -> Option<HTTPRangeSpec> {
|
|
let start = range_start + emitted as i64;
|
|
if start == 0 && range_end < 0 {
|
|
// Nothing was emitted from a full-object read: reopen without a
|
|
// range so the replacement stream keeps the codec fast path
|
|
// instead of the duplex fallback a synthesized range forces.
|
|
return None;
|
|
}
|
|
Some(HTTPRangeSpec {
|
|
is_suffix_length: false,
|
|
start,
|
|
end: range_end,
|
|
})
|
|
}
|
|
|
|
async fn reopen(&self, emitted: usize) -> Result<DynReader, GetObjectResumeFailure> {
|
|
#[cfg(test)]
|
|
GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.fetch_add(1, Ordering::Relaxed);
|
|
|
|
// A resumed read must hold disk-read admission just like the initial
|
|
// read; otherwise recovery reads bypass the concurrency caps exactly
|
|
// while rebalance is stressing the pool.
|
|
let disk_permit = DefaultObjectUsecase::admit_get_object_disk_read(get_concurrency_manager(), &self.bucket, &self.key)
|
|
.await
|
|
.map_err(|err| {
|
|
if err.code() == &S3ErrorCode::SlowDown {
|
|
GetObjectResumeFailure::Retryable
|
|
} else {
|
|
GetObjectResumeFailure::Fatal
|
|
}
|
|
})?;
|
|
let range = Self::resume_range(self.range_start, self.range_end, emitted);
|
|
let reader = self
|
|
.store
|
|
.get_object_reader(&self.bucket, &self.key, range, self.ssec_headers.clone(), &self.opts)
|
|
.await
|
|
.map_err(|err| {
|
|
debug!(
|
|
bucket = %self.bucket,
|
|
object = %self.key,
|
|
error = %err,
|
|
"GetObject mid-stream resume reopen failed"
|
|
);
|
|
GetObjectResumeFailure::Retryable
|
|
})?;
|
|
if !self.identity.matches(&reader.object_info, emitted) {
|
|
warn!(
|
|
bucket = %self.bucket,
|
|
object = %self.key,
|
|
"GetObject mid-stream resume resolved a different object version; refusing to splice content"
|
|
);
|
|
return Err(GetObjectResumeFailure::Fatal);
|
|
}
|
|
let stream = wrap_reader(reader.stream);
|
|
Ok(match disk_permit {
|
|
Some(disk_permit) => wrap_reader(DiskReadPermitReader::new(stream, disk_permit)),
|
|
None => stream,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
static GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
fn get_object_resume_control(ctx: GetObjectResumeContext) -> GetObjectResumeControl<DynReader> {
|
|
use rand::RngExt as _;
|
|
let ctx = Arc::new(ctx);
|
|
let reopen: GetObjectReopen<DynReader> = Box::new(move |emitted| {
|
|
let ctx = Arc::clone(&ctx);
|
|
Box::pin(async move { ctx.reopen(emitted).await })
|
|
});
|
|
GetObjectResumeControl::new(
|
|
reopen,
|
|
RetryTimer::new(
|
|
GET_OBJECT_RESUME_MAX_ATTEMPTS,
|
|
DEFAULT_RETRY_UNIT,
|
|
DEFAULT_RETRY_CAP,
|
|
MAX_JITTER,
|
|
rand::rng().random_range(10..=50),
|
|
),
|
|
)
|
|
}
|
|
|
|
/// Mid-stream errors that mean the pinned object data is gone (rebalance or
|
|
/// decommission removed it after copying the version elsewhere). Only typed
|
|
/// `StorageError`s qualify; generic I/O errors and string-matched "not enough
|
|
/// disks" failures keep the existing fail-loud behavior.
|
|
fn is_object_relocation_error(err: &std::io::Error) -> bool {
|
|
let Some(inner) = err.get_ref() else { return false };
|
|
match inner.downcast_ref::<StorageError>() {
|
|
Some(StorageError::FileNotFound | StorageError::ObjectNotFound(..) | StorageError::InsufficientReadQuorum(..)) => true,
|
|
Some(StorageError::Io(source)) => source.kind() == std::io::ErrorKind::NotFound,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn object_seek_support_threshold() -> usize {
|
|
static OBJECT_SEEK_SUPPORT_THRESHOLD: OnceLock<usize> = OnceLock::new();
|
|
*OBJECT_SEEK_SUPPORT_THRESHOLD.get_or_init(|| {
|
|
rustfs_utils::get_env_usize(
|
|
rustfs_config::ENV_OBJECT_SEEK_SUPPORT_THRESHOLD,
|
|
rustfs_config::DEFAULT_OBJECT_SEEK_SUPPORT_THRESHOLD,
|
|
)
|
|
})
|
|
}
|
|
|
|
fn object_seek_support_concurrency_thresholds() -> (usize, usize) {
|
|
static OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS: OnceLock<(usize, usize)> = OnceLock::new();
|
|
*OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS.get_or_init(|| {
|
|
let medium = rustfs_utils::get_env_usize(
|
|
rustfs_config::ENV_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD,
|
|
rustfs_config::DEFAULT_OBJECT_MEDIUM_CONCURRENCY_THRESHOLD,
|
|
)
|
|
.max(1);
|
|
let high = rustfs_utils::get_env_usize(
|
|
rustfs_config::ENV_OBJECT_HIGH_CONCURRENCY_THRESHOLD,
|
|
rustfs_config::DEFAULT_OBJECT_HIGH_CONCURRENCY_THRESHOLD,
|
|
)
|
|
.max(medium + 1);
|
|
(medium, high)
|
|
})
|
|
}
|
|
|
|
fn concurrency_aware_seek_support_threshold(configured_threshold: i64, concurrent_requests: usize) -> i64 {
|
|
let (medium_threshold, high_threshold) = object_seek_support_concurrency_thresholds();
|
|
let effective_threshold = configured_threshold.min(MAX_GET_OBJECT_MEMORY_BUFFER_BYTES);
|
|
|
|
if concurrent_requests >= high_threshold.saturating_mul(2) {
|
|
return effective_threshold.min(VERY_HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES);
|
|
}
|
|
if concurrent_requests >= high_threshold {
|
|
return effective_threshold.min(HIGH_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES);
|
|
}
|
|
if concurrent_requests >= medium_threshold {
|
|
return effective_threshold.min(MEDIUM_CONCURRENCY_GET_OBJECT_MEMORY_BUFFER_BYTES);
|
|
}
|
|
|
|
effective_threshold
|
|
}
|
|
|
|
fn should_buffer_get_object_in_memory(
|
|
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,
|
|
is_get_seek_buffer_enabled(),
|
|
)
|
|
}
|
|
|
|
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,
|
|
part_number: Option<usize>,
|
|
has_range: bool,
|
|
configured_threshold: i64,
|
|
concurrent_requests: usize,
|
|
seek_buffer_enabled: bool,
|
|
) -> bool {
|
|
if !seek_buffer_enabled || part_number.is_some() || has_range || response_content_length <= 0 || configured_threshold <= 0 {
|
|
return false;
|
|
}
|
|
if usize::try_from(response_content_length).is_err() {
|
|
return false;
|
|
}
|
|
|
|
let effective_threshold = concurrency_aware_seek_support_threshold(configured_threshold, concurrent_requests);
|
|
if configured_threshold > MAX_GET_OBJECT_MEMORY_BUFFER_BYTES
|
|
&& GET_OBJECT_BUFFER_THRESHOLD_WARNED
|
|
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
|
|
.is_ok()
|
|
{
|
|
warn!(
|
|
configured_threshold_bytes = configured_threshold,
|
|
hard_limit_bytes = MAX_GET_OBJECT_MEMORY_BUFFER_BYTES,
|
|
"RUSTFS_OBJECT_SEEK_SUPPORT_THRESHOLD exceeds safety cap; using capped in-memory buffer threshold"
|
|
);
|
|
}
|
|
|
|
if response_content_length > effective_threshold {
|
|
return false;
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
impl DefaultObjectUsecase {
|
|
fn build_memory_bytes_blob(
|
|
bytes: Bytes,
|
|
response_content_length: i64,
|
|
source: &'static str,
|
|
lifecycle: GetObjectBodyLifecycle,
|
|
) -> StreamingBlob {
|
|
let get_stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled();
|
|
let memory_blob_start = get_stage_metrics_enabled.then(std::time::Instant::now);
|
|
let handoff_start = get_stage_metrics_enabled.then(std::time::Instant::now);
|
|
let bytes_len = bytes.len();
|
|
let guard = rustfs_io_metrics::track_get_object_buffered_bytes(bytes_len);
|
|
let remaining = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX);
|
|
let blob = if is_get_small_body_once_enabled() && bytes_len == remaining {
|
|
let owner = MemoryOnceBodyOwner::new(bytes, guard, lifecycle);
|
|
StreamingBlob::from_bytes(Bytes::from_owner(owner))
|
|
} else {
|
|
StreamingBlob::new(MemoryTrackedBytesStream::new(bytes, remaining, source, guard, lifecycle))
|
|
};
|
|
if let Some(handoff_start) = handoff_start {
|
|
rustfs_io_metrics::record_get_object_response_handoff(
|
|
"single_chunk",
|
|
source,
|
|
bytes_len,
|
|
response_content_length,
|
|
handoff_start.elapsed().as_secs_f64(),
|
|
);
|
|
}
|
|
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_MEMORY_BLOB, memory_blob_start);
|
|
blob
|
|
}
|
|
|
|
fn build_memory_blob(
|
|
buf: Vec<u8>,
|
|
response_content_length: i64,
|
|
source: &'static str,
|
|
lifecycle: GetObjectBodyLifecycle,
|
|
) -> StreamingBlob {
|
|
Self::build_memory_bytes_blob(Bytes::from(buf), response_content_length, source, lifecycle)
|
|
}
|
|
|
|
fn select_stream_buffer_strategy(
|
|
response_content_length: i64,
|
|
optimal_buffer_size: usize,
|
|
enable_readahead: bool,
|
|
has_range: bool,
|
|
) -> (usize, GetObjectStreamStrategy) {
|
|
if enable_readahead && !has_range && response_content_length >= LARGE_SEQUENTIAL_GET_THRESHOLD_BYTES {
|
|
let expanded_buffer_size = optimal_buffer_size
|
|
.saturating_mul(LARGE_SEQUENTIAL_GET_READAHEAD_MULTIPLIER)
|
|
.min(LARGE_SEQUENTIAL_GET_STREAM_BUFFER_CAP_BYTES)
|
|
.max(optimal_buffer_size);
|
|
return (expanded_buffer_size, GetObjectStreamStrategy::LargeSequentialReadahead);
|
|
}
|
|
|
|
(optimal_buffer_size, GetObjectStreamStrategy::Standard)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_reader_blob<R>(
|
|
reader: R,
|
|
response_content_length: i64,
|
|
request_id: &str,
|
|
content_range: Option<&str>,
|
|
stream_buffer_size: usize,
|
|
stream_strategy: GetObjectStreamStrategy,
|
|
bucket: &str,
|
|
key: &str,
|
|
lifecycle: GetObjectBodyLifecycle,
|
|
resume: Option<GetObjectResumeControl<R>>,
|
|
) -> StreamingBlob
|
|
where
|
|
R: AsyncRead + Send + Sync + Unpin + 'static,
|
|
{
|
|
let streaming_blob_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
|
|
let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX);
|
|
let tuned_stream_buffer_size =
|
|
tune_reader_stream_buffer_size(stream_buffer_size, response_content_length, stream_strategy);
|
|
let (stream_buffer_size, buffer_source) =
|
|
resolve_reader_stream_buffer_size(tuned_stream_buffer_size, get_reader_stream_buffer_size_override());
|
|
let get_stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled();
|
|
if get_stage_metrics_enabled {
|
|
rustfs_io_metrics::record_get_object_stream_strategy(
|
|
stream_strategy.as_str(),
|
|
stream_buffer_size,
|
|
response_content_length,
|
|
);
|
|
}
|
|
let handoff_start = get_stage_metrics_enabled.then(std::time::Instant::now);
|
|
let reader = GetObjectStreamingReader::new(
|
|
reader,
|
|
bucket,
|
|
key,
|
|
request_id,
|
|
content_range.map(|content_range| content_range.to_string()),
|
|
expected,
|
|
get_object_disk_read_timeout(),
|
|
lifecycle,
|
|
resume,
|
|
);
|
|
let stream = GetObjectReaderStream::new(reader, stream_buffer_size, expected, stream_strategy.as_str(), buffer_source)
|
|
.with_diagnostics(bucket, key, request_id);
|
|
let blob = StreamingBlob::new(stream);
|
|
if let Some(handoff_start) = handoff_start {
|
|
rustfs_io_metrics::record_get_object_response_handoff(
|
|
stream_strategy.as_str(),
|
|
buffer_source,
|
|
stream_buffer_size,
|
|
response_content_length,
|
|
handoff_start.elapsed().as_secs_f64(),
|
|
);
|
|
}
|
|
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAMING_BLOB, streaming_blob_start);
|
|
blob
|
|
}
|
|
|
|
fn init_get_object_bootstrap(&self, bucket: &str, key: &str, request_id: &str) -> S3Result<GetObjectBootstrap> {
|
|
#[cfg(test)]
|
|
let timeout_config = self
|
|
.get_object_timeout_policy
|
|
.clone()
|
|
.unwrap_or_else(GetObjectTimeoutPolicy::cached_from_env);
|
|
#[cfg(not(test))]
|
|
let timeout_config = GetObjectTimeoutPolicy::cached_from_env();
|
|
let wrapper = RequestTimeoutWrapper::with_request_id(timeout_config.clone(), request_id.to_string());
|
|
let request_start = std::time::Instant::now();
|
|
let request_guard = ConcurrencyManager::track_request();
|
|
let concurrent_requests = GetObjectGuard::concurrent_requests();
|
|
|
|
let deadlock_detector = deadlock_detector::get_deadlock_detector();
|
|
let deadlock_request_guard = DeadlockRequestGuard::register_if_enabled(deadlock_detector, wrapper.request_id(), || {
|
|
format!("GetObject {bucket}/{key}")
|
|
});
|
|
|
|
Self::ensure_get_object_not_timed_out(&wrapper, &timeout_config, bucket, key, GetObjectTimeoutStage::BeforeProcessing)?;
|
|
|
|
debug!(
|
|
"GetObject request started with {} concurrent requests, timeout={:?}",
|
|
concurrent_requests, timeout_config.get_object_timeout
|
|
);
|
|
|
|
Ok(GetObjectBootstrap {
|
|
timeout_config,
|
|
wrapper,
|
|
request_start,
|
|
request_guard,
|
|
_deadlock_request_guard: deadlock_request_guard,
|
|
concurrent_requests,
|
|
})
|
|
}
|
|
|
|
fn validate_get_object_part_number(part_number: Option<usize>, info: &ObjectInfo) -> S3Result<()> {
|
|
if let Some(part_number) = part_number
|
|
&& part_number > 1
|
|
&& !info.parts.iter().any(|part| part.number == part_number)
|
|
{
|
|
return Err(s3_error!(InvalidPart));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_get_object_before_cold_fill(headers: &HeaderMap, part_number: Option<usize>, info: &ObjectInfo) -> S3Result<()> {
|
|
check_preconditions(headers, info)?;
|
|
Self::validate_get_object_part_number(part_number, info)
|
|
}
|
|
|
|
/// How long a GET waits for a disk read permit before degrading to a
|
|
/// permit-less read. Cached: consulted per GET. Zero disables the bound.
|
|
fn disk_permit_wait_timeout() -> Duration {
|
|
static CACHED: std::sync::OnceLock<Duration> = std::sync::OnceLock::new();
|
|
*CACHED.get_or_init(|| {
|
|
Duration::from_secs(rustfs_utils::get_env_u64(
|
|
rustfs_config::ENV_OBJECT_DISK_PERMIT_WAIT_TIMEOUT,
|
|
rustfs_config::DEFAULT_OBJECT_DISK_PERMIT_WAIT_TIMEOUT,
|
|
))
|
|
})
|
|
}
|
|
|
|
async fn acquire_get_object_io_planning(
|
|
manager: &ConcurrencyManager,
|
|
request_timeout: Option<GetObjectRequestTimeout<'_>>,
|
|
bucket: &str,
|
|
key: &str,
|
|
) -> S3Result<GetObjectIoPlanning> {
|
|
let permit_wait_start = std::time::Instant::now();
|
|
let disk_permit = Self::admit_get_object_disk_read(manager, bucket, key).await?;
|
|
let permit_wait_duration = permit_wait_start.elapsed();
|
|
|
|
if let Some(timeout) = request_timeout {
|
|
Self::ensure_get_object_not_timed_out(
|
|
timeout.wrapper,
|
|
timeout.policy,
|
|
bucket,
|
|
key,
|
|
GetObjectTimeoutStage::DiskPermitWait { permit_wait_duration },
|
|
)?;
|
|
}
|
|
|
|
let queue_status = manager.io_queue_status();
|
|
let queue_snapshot = GetObjectQueueSnapshot::from_available_permits(
|
|
queue_status.total_permits,
|
|
queue_status.total_permits.saturating_sub(queue_status.permits_in_use),
|
|
);
|
|
let queue_utilization = queue_snapshot.utilization_percent();
|
|
|
|
if queue_snapshot.is_congested(80.0) {
|
|
// Metrics count every congested request; only the WARN is rate
|
|
// limited, because under saturation every GET crosses the
|
|
// threshold and per-request WARNs flood the log.
|
|
rustfs_io_metrics::record_io_queue_congestion();
|
|
|
|
if let Some(suppressed_warns) = IO_QUEUE_CONGESTION_WARN_THROTTLE.claim(IoQueueCongestionWarnThrottle::now_ms()) {
|
|
warn!(
|
|
bucket = %bucket,
|
|
key = %key,
|
|
queue_utilization = format!("{:.1}%", queue_utilization),
|
|
permits_in_use = queue_status.permits_in_use,
|
|
total_permits = queue_status.total_permits,
|
|
suppressed_warns,
|
|
"I/O queue congestion detected"
|
|
);
|
|
}
|
|
}
|
|
|
|
if let Some(timeout) = request_timeout {
|
|
Self::ensure_get_object_not_timed_out(
|
|
timeout.wrapper,
|
|
timeout.policy,
|
|
bucket,
|
|
key,
|
|
GetObjectTimeoutStage::BeforeRead,
|
|
)?;
|
|
}
|
|
|
|
Ok(GetObjectIoPlanning {
|
|
disk_permit,
|
|
permit_wait_duration,
|
|
queue_status,
|
|
queue_utilization,
|
|
})
|
|
}
|
|
|
|
// Shared by the initial read path and the mid-stream resume reopen, which
|
|
// must hold the same admission token before touching disks. The permit
|
|
// wait inside is bounded by the primary-pool timeout.
|
|
async fn admit_get_object_disk_read(
|
|
manager: &ConcurrencyManager,
|
|
bucket: &str,
|
|
key: &str,
|
|
) -> S3Result<Option<GetObjectDiskPermit>> {
|
|
let permit_wait_start = std::time::Instant::now();
|
|
let permit_wait_timeout = Self::disk_permit_wait_timeout();
|
|
// Permits are held for the whole body transfer, so slow clients can pin
|
|
// all of them while disks are idle. Bound the wait on the primary pool
|
|
// and, on timeout, admit from a bounded degraded overflow lane. Total
|
|
// concurrent disk-active GETs are hard-capped at
|
|
// `primary_cap + degraded_cap`; once that cap is reached we reject with
|
|
// `SlowDown` instead of reading without any admission token. Never
|
|
// proceed permit-less.
|
|
let disk_permit = match manager
|
|
.admit_disk_read(permit_wait_timeout)
|
|
.await
|
|
.map_err(|_| s3_error!(InternalError, "disk read semaphore closed"))?
|
|
{
|
|
DiskReadAdmission::Primary(permit) => Some(permit),
|
|
// Throttling disabled by config (primary cap 0): proceed without an
|
|
// admission token. Not a saturation bypass.
|
|
DiskReadAdmission::Unbounded => None,
|
|
DiskReadAdmission::Degraded(permit) => {
|
|
metrics::counter!("rustfs.get_object.disk_permit.degraded.total").increment(1);
|
|
warn!(
|
|
bucket = %bucket,
|
|
key = %key,
|
|
wait_ms = permit_wait_start.elapsed().as_millis() as u64,
|
|
"GetObject admitted into bounded degraded disk-read lane after primary pool saturation"
|
|
);
|
|
Some(permit)
|
|
}
|
|
DiskReadAdmission::Rejected => {
|
|
metrics::counter!("rustfs.get_object.disk_permit.hard_reject.total").increment(1);
|
|
warn!(
|
|
bucket = %bucket,
|
|
key = %key,
|
|
wait_ms = permit_wait_start.elapsed().as_millis() as u64,
|
|
"GetObject rejected: disk-read hard concurrency cap reached"
|
|
);
|
|
return Err(s3_error!(
|
|
SlowDown,
|
|
"disk read concurrency limit reached, please reduce your request rate"
|
|
));
|
|
}
|
|
};
|
|
Ok(disk_permit.map(GetObjectDiskPermit::new))
|
|
}
|
|
|
|
async fn acquire_cold_fill_io_planning(
|
|
manager: &'static ConcurrencyManager,
|
|
bucket: &str,
|
|
key: &str,
|
|
) -> Result<GetObjectIoPlanning, ColdFillError> {
|
|
match Self::acquire_get_object_io_planning(manager, None, bucket, key).await {
|
|
Ok(io) => Ok(io),
|
|
Err(err) if err.code() == &S3ErrorCode::SlowDown => Err(ColdFillError::Storage(StorageError::SlowDown)),
|
|
Err(_) => Err(ColdFillError::DiskAdmissionClosed),
|
|
}
|
|
}
|
|
|
|
fn get_object_io_planning_without_disk(manager: &ConcurrencyManager) -> GetObjectIoPlanning {
|
|
let queue_status = manager.io_queue_status();
|
|
let queue_snapshot = GetObjectQueueSnapshot::from_available_permits(
|
|
queue_status.total_permits,
|
|
queue_status.total_permits.saturating_sub(queue_status.permits_in_use),
|
|
);
|
|
GetObjectIoPlanning {
|
|
disk_permit: None,
|
|
permit_wait_duration: Duration::ZERO,
|
|
queue_utilization: queue_snapshot.utilization_percent(),
|
|
queue_status,
|
|
}
|
|
}
|
|
|
|
/// Cheap request-shape validations, run before the bucket-existence store
|
|
/// lookup so invalid requests keep their InvalidArgument precedence.
|
|
fn validate_get_object_request(req: &S3Request<GetObjectInput>) -> S3Result<GetObjectValidatedRequest> {
|
|
// Clone only the fields this path needs instead of the whole input.
|
|
let bucket = req.input.bucket.clone();
|
|
let key = req.input.key.clone();
|
|
let version_id = req.input.version_id.clone();
|
|
let part_number = req.input.part_number;
|
|
let range = req.input.range;
|
|
|
|
validate_object_key(&key, "GET")?;
|
|
|
|
let part_number = parse_part_number_i32_to_usize(part_number, "GET")?;
|
|
|
|
let rs = range.map(range_to_http_range_spec).transpose()?;
|
|
|
|
if rs.is_some() && part_number.is_some() {
|
|
return Err(s3_error!(InvalidArgument, "range and part_number invalid"));
|
|
}
|
|
|
|
Ok(GetObjectValidatedRequest {
|
|
bucket,
|
|
key,
|
|
version_id,
|
|
part_number,
|
|
rs,
|
|
})
|
|
}
|
|
|
|
async fn prepare_get_object_request_context(
|
|
validated: GetObjectValidatedRequest,
|
|
headers: &HeaderMap,
|
|
) -> S3Result<GetObjectRequestContext> {
|
|
let GetObjectValidatedRequest {
|
|
bucket,
|
|
key,
|
|
version_id,
|
|
part_number,
|
|
rs,
|
|
} = validated;
|
|
|
|
let opts: ObjectOptions = get_opts(&bucket, &key, version_id.clone(), part_number, headers)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
|
|
Ok(GetObjectRequestContext {
|
|
version_id_for_event: version_id.unwrap_or_default(),
|
|
bucket,
|
|
key,
|
|
part_number,
|
|
rs,
|
|
opts,
|
|
})
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn prepare_get_object_read_execution(
|
|
&self,
|
|
req: &S3Request<GetObjectInput>,
|
|
manager: &'static ConcurrencyManager,
|
|
store: Arc<ECStore>,
|
|
wrapper: &RequestTimeoutWrapper,
|
|
timeout_config: &GetObjectTimeoutPolicy,
|
|
bucket: &str,
|
|
key: &str,
|
|
rs: Option<HTTPRangeSpec>,
|
|
opts: &ObjectOptions,
|
|
part_number: Option<usize>,
|
|
object_traffic_health: Option<Arc<ObjectTrafficHealth>>,
|
|
) -> S3Result<GetObjectPreparedRead> {
|
|
let read_start = std::time::Instant::now();
|
|
let read_stage_start = rustfs_io_metrics::get_stage_metrics_enabled().then_some(read_start);
|
|
let store_headers = project_ssec_transport_headers(&req.headers);
|
|
let cache_adapter = self.object_data_cache();
|
|
if cache_adapter.is_disabled() || !cache_adapter.materialize_fill_enabled() {
|
|
let io_planning = Self::acquire_get_object_io_planning(
|
|
manager,
|
|
Some(GetObjectRequestTimeout {
|
|
wrapper,
|
|
policy: timeout_config,
|
|
}),
|
|
bucket,
|
|
key,
|
|
)
|
|
.await?;
|
|
let reader = track_object_read_setup(
|
|
object_traffic_health.as_deref(),
|
|
store.get_object_reader(bucket, key, rs.clone(), store_headers, opts),
|
|
)
|
|
.await
|
|
.map_err(map_get_object_reader_error)?;
|
|
let read_setup =
|
|
Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?;
|
|
return Ok(GetObjectPreparedRead { io_planning, read_setup });
|
|
}
|
|
|
|
// Preserve the legacy metadata-fanout bound without making followers
|
|
// hold a body-transfer permit while they wait on the cold-fill session.
|
|
let mut metadata_admission = Some(
|
|
Self::acquire_get_object_io_planning(
|
|
manager,
|
|
Some(GetObjectRequestTimeout {
|
|
wrapper,
|
|
policy: timeout_config,
|
|
}),
|
|
bucket,
|
|
key,
|
|
)
|
|
.await?,
|
|
);
|
|
let mut prepared = Some(
|
|
track_object_read_setup(
|
|
object_traffic_health.as_deref(),
|
|
store.prepare_get_object_reader(bucket, key, rs.clone(), HeaderMap::new(), opts),
|
|
)
|
|
.await
|
|
.map_err(map_get_object_reader_error)?,
|
|
);
|
|
let mut cache_fill_allowed = true;
|
|
let mut legacy_hook_missed = false;
|
|
'snapshot: {
|
|
let info = prepared
|
|
.as_ref()
|
|
.ok_or_else(|| s3_error!(InternalError, "prepared metadata snapshot is unavailable"))?
|
|
.object_info();
|
|
// Preconditions, cache planning, and the authoritative hook lookup all
|
|
// run against one namespace-locked metadata snapshot. Cacheable misses
|
|
// release both the lock and short admission before joining cold fill.
|
|
let Some(response_content_length) = get_object_body_cache_plaintext_len(&rs, opts, info) else {
|
|
break 'snapshot;
|
|
};
|
|
let cache_plan = build_get_object_body_cache_plan(
|
|
&cache_adapter,
|
|
GetObjectBodyCacheRequest {
|
|
bucket,
|
|
key,
|
|
info,
|
|
response_content_length,
|
|
has_range: rs.is_some(),
|
|
part_number,
|
|
encryption_applied: info.is_encrypted(),
|
|
},
|
|
);
|
|
|
|
// The legacy hook is evaluated once, before cold-fill coordination.
|
|
// In-session producer retries never re-enter this snapshot block.
|
|
let legacy_probe = lookup_preplanned_get_object_body_cache_hook(
|
|
Arc::clone(&cache_adapter),
|
|
cache_plan.clone(),
|
|
bucket,
|
|
key,
|
|
&rs,
|
|
opts,
|
|
info,
|
|
)
|
|
.await;
|
|
if matches!(legacy_probe, GetObjectBodyCacheHookLookup::Ineligible) {
|
|
break 'snapshot;
|
|
}
|
|
Self::validate_get_object_before_cold_fill(&req.headers, part_number, info)?;
|
|
if let GetObjectBodyCacheHookLookup::Hit(body) = legacy_probe {
|
|
drop(metadata_admission.take());
|
|
let info = prepared
|
|
.take()
|
|
.ok_or_else(|| s3_error!(InternalError, "prepared cache-hit reader is unavailable"))?
|
|
.into_object_info();
|
|
let reader = GetObjectReader::from_cache_body(info, body).map_err(ApiError::from)?;
|
|
let read_setup =
|
|
Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?;
|
|
return Ok(GetObjectPreparedRead {
|
|
io_planning: Self::get_object_io_planning_without_disk(manager),
|
|
read_setup,
|
|
});
|
|
}
|
|
if matches!(legacy_probe, GetObjectBodyCacheHookLookup::Miss) {
|
|
legacy_hook_missed = true;
|
|
}
|
|
if !legacy_hook_missed
|
|
&& let GetObjectBodyCacheLookup::Hit(body) = lookup_get_object_body_cache_hit(&cache_adapter, &cache_plan).await
|
|
{
|
|
drop(metadata_admission.take());
|
|
let info = prepared
|
|
.take()
|
|
.ok_or_else(|| s3_error!(InternalError, "prepared cache-hit reader is unavailable"))?
|
|
.into_object_info();
|
|
let reader = GetObjectReader::from_cache_body(info, body).map_err(ApiError::from)?;
|
|
let read_setup =
|
|
Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true).await?;
|
|
return Ok(GetObjectPreparedRead {
|
|
io_planning: Self::get_object_io_planning_without_disk(manager),
|
|
read_setup,
|
|
});
|
|
}
|
|
|
|
let GetObjectBodyCachePlan::Cacheable(engine_plan) = &cache_plan else {
|
|
break 'snapshot;
|
|
};
|
|
let Some(cache_key) = cache_plan.key().cloned() else {
|
|
break 'snapshot;
|
|
};
|
|
let expected = usize::try_from(response_content_length)
|
|
.map_err(|_| s3_error!(InternalError, "cold-fill body length is not representable"))?;
|
|
let response_size = u64::try_from(response_content_length)
|
|
.map_err(|_| s3_error!(InternalError, "cold-fill body length is negative"))?;
|
|
let waiter_deadline = cold_fill_deadline(wrapper, timeout_config, response_size);
|
|
let proposed_producer_deadline = cold_fill_producer_deadline(timeout_config, response_size);
|
|
let coordinator = cache_adapter.cold_fill_coordinator();
|
|
let info = prepared
|
|
.take()
|
|
.ok_or_else(|| s3_error!(InternalError, "prepared cold-fill reader is unavailable"))?
|
|
.into_object_info();
|
|
drop(metadata_admission.take());
|
|
let outcome = coordinate_cold_fill(&coordinator, cache_key, waiter_deadline, Some(proposed_producer_deadline), {
|
|
let adapter = &cache_adapter;
|
|
let headers = &store_headers;
|
|
let store = &store;
|
|
let range = &rs;
|
|
let object_traffic_health = &object_traffic_health;
|
|
move |producer| {
|
|
let adapter = Arc::clone(adapter);
|
|
let engine_plan = engine_plan.clone();
|
|
let h = headers.clone();
|
|
let store = Arc::clone(store);
|
|
let range = range.clone();
|
|
let bucket = bucket.to_owned();
|
|
let key = key.to_owned();
|
|
let opts = opts.clone();
|
|
let object_traffic_health = object_traffic_health.as_ref().map(Arc::clone);
|
|
async move {
|
|
let producer_deadline = producer.deadline();
|
|
let cancellation = producer.cancellation_token();
|
|
let second_chance = match await_cold_fill_startup(
|
|
lookup_cold_fill_second_chance(&adapter, &engine_plan),
|
|
&cancellation,
|
|
producer_deadline,
|
|
)
|
|
.await
|
|
{
|
|
Ok(body) => body,
|
|
Err(ColdFillStartupWaitError::Cancelled) => {
|
|
producer.finish(Err(StorageError::OperationCanceled));
|
|
return;
|
|
}
|
|
Err(ColdFillStartupWaitError::DeadlineExceeded) => {
|
|
producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout));
|
|
return;
|
|
}
|
|
};
|
|
if let Some(body) = second_chance {
|
|
producer.finish_shared(Ok(body));
|
|
return;
|
|
}
|
|
|
|
let acquire = Self::acquire_cold_fill_io_planning(manager, &bucket, &key);
|
|
let producer_io = match await_cold_fill_startup(acquire, &cancellation, producer_deadline).await {
|
|
Ok(result) => result,
|
|
Err(ColdFillStartupWaitError::Cancelled) => {
|
|
producer.finish(Err(StorageError::OperationCanceled));
|
|
return;
|
|
}
|
|
Err(ColdFillStartupWaitError::DeadlineExceeded) => {
|
|
producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout));
|
|
return;
|
|
}
|
|
};
|
|
let producer_io = match producer_io {
|
|
Ok(io) => io,
|
|
Err(err) => {
|
|
producer.finish_shared(Err(err));
|
|
return;
|
|
}
|
|
};
|
|
|
|
let prepare = track_object_read_setup(
|
|
object_traffic_health.as_deref(),
|
|
store.prepare_get_object_reader(&bucket, &key, range.clone(), HeaderMap::new(), &opts),
|
|
);
|
|
let prepared = match match await_cold_fill_startup(prepare, &cancellation, producer_deadline).await {
|
|
Ok(result) => result,
|
|
Err(ColdFillStartupWaitError::Cancelled) => {
|
|
producer.finish(Err(StorageError::OperationCanceled));
|
|
return;
|
|
}
|
|
Err(ColdFillStartupWaitError::DeadlineExceeded) => {
|
|
producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout));
|
|
return;
|
|
}
|
|
} {
|
|
Ok(prepared) => prepared,
|
|
Err(err) => {
|
|
producer.relinquish_or_finish(ColdFillError::Storage(err));
|
|
return;
|
|
}
|
|
};
|
|
let current_info = prepared.object_info();
|
|
let current_length = match current_info.get_actual_size() {
|
|
Ok(length) => length,
|
|
Err(err) => {
|
|
let _ = err;
|
|
producer.finish_shared(Err(ColdFillError::Storage(StorageError::FileCorrupt)));
|
|
return;
|
|
}
|
|
};
|
|
let current_plan = build_get_object_body_cache_plan_for_revalidation(
|
|
&adapter,
|
|
GetObjectBodyCacheRequest {
|
|
bucket: &bucket,
|
|
key: &key,
|
|
info: current_info,
|
|
response_content_length: current_length,
|
|
has_range: range.is_some(),
|
|
part_number,
|
|
encryption_applied: current_info.is_encrypted(),
|
|
},
|
|
);
|
|
let Some(producer) = retain_cold_fill_producer_for_matching_plan(producer, ¤t_plan, &engine_plan)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let reservation = adapter.reserve_body(&engine_plan);
|
|
#[cfg(test)]
|
|
let reader_open_plan = engine_plan.clone();
|
|
start_cold_fill_producer(
|
|
producer,
|
|
reservation,
|
|
|| async move { Ok(producer_io) },
|
|
|| {
|
|
#[cfg(test)]
|
|
record_cold_fill_reader_open_for_test(&reader_open_plan);
|
|
let open_reader = prepared.with_headers(h).into_reader();
|
|
async move { track_object_read_setup(object_traffic_health.as_deref(), open_reader).await }
|
|
},
|
|
ColdFillProducerExecution {
|
|
expected,
|
|
deadline: producer_deadline,
|
|
adapter,
|
|
engine_plan,
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
match outcome {
|
|
ColdFillCoordinateOutcome::Ready(result) => {
|
|
let body = match result {
|
|
Ok(body) => body,
|
|
Err(ColdFillError::Storage(err)) => return Err(map_get_object_reader_error(err).into()),
|
|
Err(ColdFillError::DiskAdmissionClosed) => {
|
|
return Err(s3_error!(InternalError, "disk read semaphore closed"));
|
|
}
|
|
};
|
|
let reader = GetObjectReader::from_cache_body(info, body).map_err(ApiError::from)?;
|
|
let read_setup =
|
|
Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, true)
|
|
.await?;
|
|
return Ok(GetObjectPreparedRead {
|
|
io_planning: Self::get_object_io_planning_without_disk(manager),
|
|
read_setup,
|
|
});
|
|
}
|
|
ColdFillCoordinateOutcome::Bypass => {
|
|
cache_fill_allowed = false;
|
|
break 'snapshot;
|
|
}
|
|
ColdFillCoordinateOutcome::Rejected => return Err(ApiError::from(StorageError::SlowDown).into()),
|
|
}
|
|
}
|
|
|
|
let (mut io_planning, reader) = if let Some(prepared) = prepared.take() {
|
|
let io_planning = metadata_admission
|
|
.take()
|
|
.ok_or_else(|| s3_error!(InternalError, "prepared metadata admission is unavailable"))?;
|
|
let reader =
|
|
track_object_read_setup(object_traffic_health.as_deref(), prepared.with_headers(store_headers).into_reader())
|
|
.await
|
|
.map_err(map_get_object_reader_error)?;
|
|
(io_planning, reader)
|
|
} else {
|
|
let io_planning = Self::acquire_get_object_io_planning(
|
|
manager,
|
|
Some(GetObjectRequestTimeout {
|
|
wrapper,
|
|
policy: timeout_config,
|
|
}),
|
|
bucket,
|
|
key,
|
|
)
|
|
.await?;
|
|
let reader = if legacy_hook_missed {
|
|
let prepared = track_object_read_setup(
|
|
object_traffic_health.as_deref(),
|
|
store.prepare_get_object_reader(bucket, key, rs.clone(), HeaderMap::new(), opts),
|
|
)
|
|
.await
|
|
.map_err(map_get_object_reader_error)?;
|
|
track_object_read_setup(object_traffic_health.as_deref(), prepared.with_headers(store_headers).into_reader())
|
|
.await
|
|
.map_err(map_get_object_reader_error)?
|
|
} else {
|
|
track_object_read_setup(
|
|
object_traffic_health.as_deref(),
|
|
store.get_object_reader(bucket, key, rs.clone(), store_headers, opts),
|
|
)
|
|
.await
|
|
.map_err(map_get_object_reader_error)?
|
|
};
|
|
(io_planning, reader)
|
|
};
|
|
let read_setup =
|
|
Self::finish_get_object_read(req, manager, bucket, key, rs, part_number, read_start, reader, cache_fill_allowed)
|
|
.await?;
|
|
// The buffered body has completed storage reads. Release admission
|
|
// before output planning so downstream response work cannot occupy a
|
|
// disk slot; streaming bodies retain the permit below until EOF/drop.
|
|
release_disk_read_permit_if_buffered(&mut io_planning.disk_permit, read_setup.buffered_body.as_ref());
|
|
if let Some(read_stage_start) = read_stage_start {
|
|
rustfs_io_metrics::record_get_object_stage_duration(
|
|
"s3_handler",
|
|
"store_reader_setup",
|
|
read_stage_start.elapsed().as_secs_f64(),
|
|
);
|
|
}
|
|
Ok(GetObjectPreparedRead { io_planning, read_setup })
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn finish_get_object_read(
|
|
req: &S3Request<GetObjectInput>,
|
|
manager: &ConcurrencyManager,
|
|
bucket: &str,
|
|
key: &str,
|
|
mut rs: Option<HTTPRangeSpec>,
|
|
part_number: Option<usize>,
|
|
read_start: std::time::Instant,
|
|
reader: GetObjectReader,
|
|
cache_fill_allowed: bool,
|
|
) -> S3Result<GetObjectReadSetup> {
|
|
// ODC-16: capture whether the ecstore cache hook already probed this
|
|
// read, so the app layer does not repeat the lookup it ran after fresh
|
|
// metadata resolution.
|
|
let cache_hook_served = reader.is_cache_hook_served();
|
|
let cache_hook_probed = reader.cache_hook_probed();
|
|
let info = reader.object_info;
|
|
let stream = reader.stream;
|
|
let buffered_body = reader.buffered_body;
|
|
|
|
let read_duration = read_start.elapsed();
|
|
|
|
// Conditional metrics recording to reduce overhead
|
|
if rustfs_io_metrics::get_stage_metrics_enabled() {
|
|
use rustfs_io_metrics::record_zero_copy_read;
|
|
record_zero_copy_read(info.size as usize, read_duration.as_secs_f64() * 1000.0);
|
|
manager.record_disk_operation(info.size as u64, read_duration, true).await;
|
|
}
|
|
|
|
check_preconditions(&req.headers, &info)?;
|
|
Self::validate_get_object_part_number(part_number, &info)?;
|
|
|
|
debug!(object_size = info.size, part_count = info.parts.len(), "GET object metadata snapshot");
|
|
for part in info.parts.iter() {
|
|
debug!(
|
|
part_number = part.number,
|
|
part_size = part.size,
|
|
part_actual_size = part.actual_size,
|
|
"GET object part details"
|
|
);
|
|
}
|
|
|
|
let content_type = if let Some(content_type) = &info.content_type {
|
|
match ContentType::from_str(content_type) {
|
|
Ok(res) => Some(res),
|
|
Err(err) => {
|
|
error!(content_type, error = ?err, "GET object content-type parse failed");
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let last_modified = info.mod_time.map(Timestamp::from);
|
|
|
|
if let Some(part_number) = part_number
|
|
&& rs.is_none()
|
|
{
|
|
rs = HTTPRangeSpec::from_part_sizes(
|
|
info.size,
|
|
part_number,
|
|
info.parts.iter().map(|part| {
|
|
if part.actual_size > 0 {
|
|
part.actual_size
|
|
} else {
|
|
i64::try_from(part.size).unwrap_or(i64::MAX)
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
validate_sse_headers_for_read(&info.user_defined, &req.headers)?;
|
|
|
|
let mut content_length = info.get_actual_size().map_err(ApiError::from)?;
|
|
let (resume_range_start, resume_range_end, content_range) = if let Some(rs) = &rs {
|
|
let total_size = content_length;
|
|
let (start, length) = rs.get_offset_length(total_size).map_err(ApiError::from)?;
|
|
content_length = length;
|
|
let start = start as i64;
|
|
// Inclusive end of the committed body; may precede `start` when a
|
|
// zero-length range was requested, in which case the body completes
|
|
// immediately and the resume range is never consulted.
|
|
(
|
|
start,
|
|
start + length - 1,
|
|
Some(format!("bytes {}-{}/{}", start, start + length - 1, total_size)),
|
|
)
|
|
} else {
|
|
(0, -1, None)
|
|
};
|
|
|
|
debug!(
|
|
"GET object metadata check: parts={}, provided_sse_key={:?}",
|
|
info.parts.len(),
|
|
req.input.sse_customer_key.is_some()
|
|
);
|
|
|
|
let read_principal = SseKmsPrincipal::from_request(req);
|
|
let decryption_request = DecryptionRequest {
|
|
bucket,
|
|
key,
|
|
metadata: &info.user_defined,
|
|
sse_customer_key: req.input.sse_customer_key.as_ref(),
|
|
sse_customer_key_md5: req.input.sse_customer_key_md5.as_ref(),
|
|
principal: read_principal.as_ref(),
|
|
};
|
|
|
|
let response_content_length = content_length;
|
|
|
|
let (
|
|
server_side_encryption,
|
|
sse_customer_algorithm,
|
|
sse_customer_key_md5,
|
|
ssekms_key_id,
|
|
encryption_applied,
|
|
final_stream,
|
|
buffered_body,
|
|
) = match classify_sse_read_response(decryption_request).await? {
|
|
// The stream is already decrypted by the object layer's encryption
|
|
// resolver; only the response headers, authorization and audit
|
|
// summary are derived here, without a second KMS unwrap.
|
|
Some(headers) => (
|
|
Some(headers.server_side_encryption),
|
|
headers.sse_customer_algorithm,
|
|
headers.sse_customer_key_md5,
|
|
headers.ssekms_key_id,
|
|
true,
|
|
wrap_reader(stream),
|
|
None,
|
|
),
|
|
None => (None, None, None, None, false, wrap_reader(stream), buffered_body),
|
|
};
|
|
|
|
Ok(GetObjectReadSetup {
|
|
info,
|
|
final_stream,
|
|
buffered_body,
|
|
cache_hook_served,
|
|
cache_hook_probed,
|
|
cache_fill_allowed,
|
|
rs,
|
|
content_type,
|
|
last_modified,
|
|
response_content_length,
|
|
content_range,
|
|
server_side_encryption,
|
|
sse_customer_algorithm,
|
|
sse_customer_key_md5,
|
|
ssekms_key_id,
|
|
encryption_applied,
|
|
resume_range_start,
|
|
resume_range_end,
|
|
})
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn finalize_get_object_strategy(
|
|
&self,
|
|
manager: &ConcurrencyManager,
|
|
bucket: &str,
|
|
key: &str,
|
|
info: &ObjectInfo,
|
|
rs: Option<&HTTPRangeSpec>,
|
|
response_content_length: i64,
|
|
permit_wait_duration: Duration,
|
|
queue_utilization: f64,
|
|
queue_status: &concurrency::IoQueueStatus,
|
|
concurrent_requests: usize,
|
|
) -> GetObjectStrategyContext {
|
|
let base_buffer_size = if response_content_length > 0 {
|
|
get_buffer_size_opt_in(response_content_length)
|
|
} else {
|
|
self.base_buffer_size()
|
|
};
|
|
|
|
let is_sequential_hint = if rs.is_none() {
|
|
true
|
|
} else if let Some(range_spec) = rs {
|
|
range_spec.start == 0 && !range_spec.is_suffix_length
|
|
} else {
|
|
false
|
|
};
|
|
|
|
// Conditional metrics recording to reduce overhead
|
|
if rustfs_io_metrics::get_stage_metrics_enabled() {
|
|
if let Some(range_spec) = rs
|
|
&& range_spec.start >= 0
|
|
{
|
|
manager.record_access(range_spec.start as u64, response_content_length as u64);
|
|
}
|
|
|
|
if response_content_length > 0 {
|
|
manager.record_transfer(response_content_length as u64, permit_wait_duration);
|
|
}
|
|
}
|
|
|
|
let io_strategy =
|
|
manager.calculate_io_strategy_with_context(info.size, base_buffer_size, permit_wait_duration, is_sequential_hint);
|
|
|
|
debug!(
|
|
wait_ms = permit_wait_duration.as_millis() as u64,
|
|
load_level = ?io_strategy.load_level,
|
|
buffer_size = io_strategy.buffer_size,
|
|
buffer_multiplier = io_strategy.buffer_multiplier,
|
|
readahead = io_strategy.enable_readahead,
|
|
storage_media = ?io_strategy.storage_media,
|
|
access_pattern = ?io_strategy.access_pattern,
|
|
bandwidth_tier = ?io_strategy.bandwidth_tier,
|
|
concurrent_requests = io_strategy.concurrent_requests,
|
|
file_size = info.size,
|
|
is_sequential = is_sequential_hint,
|
|
"Enhanced multi-factor I/O strategy calculated"
|
|
);
|
|
|
|
let io_priority = manager.get_io_priority(response_content_length);
|
|
|
|
if manager.is_priority_scheduling_enabled() {
|
|
debug!(
|
|
bucket = %bucket,
|
|
key = %key,
|
|
priority = %io_priority,
|
|
request_size = response_content_length,
|
|
"I/O priority assigned (based on actual request size)"
|
|
);
|
|
}
|
|
|
|
rustfs_io_metrics::record_get_object_io_state(
|
|
permit_wait_duration.as_secs_f64(),
|
|
queue_utilization,
|
|
queue_status.permits_in_use,
|
|
queue_status.total_permits.saturating_sub(queue_status.permits_in_use),
|
|
io_strategy.load_level.as_str(),
|
|
io_strategy.buffer_multiplier,
|
|
);
|
|
rustfs_io_metrics::record_io_priority_assignment(io_priority.as_str());
|
|
|
|
debug!(
|
|
actual_request_size = response_content_length,
|
|
priority = %io_priority.as_str(),
|
|
"I/O priority finalized with actual request size"
|
|
);
|
|
|
|
let optimal_buffer_size = if io_strategy.buffer_size > 0 {
|
|
io_strategy.buffer_size
|
|
} else {
|
|
get_concurrency_aware_buffer_size(response_content_length, base_buffer_size)
|
|
};
|
|
|
|
debug!(
|
|
"GetObject buffer sizing: file_size={}, base={}, optimal={}, concurrent_requests={}, io_strategy={:?}",
|
|
response_content_length, base_buffer_size, optimal_buffer_size, concurrent_requests, io_strategy.load_level
|
|
);
|
|
let enable_readahead = io_strategy.enable_readahead;
|
|
|
|
GetObjectStrategyContext {
|
|
io_strategy,
|
|
optimal_buffer_size,
|
|
enable_readahead,
|
|
}
|
|
}
|
|
|
|
fn build_get_object_checksums(
|
|
info: &ObjectInfo,
|
|
headers: &HeaderMap,
|
|
part_number: Option<usize>,
|
|
rs: Option<&HTTPRangeSpec>,
|
|
) -> S3Result<ResponseChecksums> {
|
|
if let Some(checksum_mode) = headers.get(AMZ_CHECKSUM_MODE)
|
|
&& checksum_mode.to_str().unwrap_or_default() == "ENABLED"
|
|
&& rs.is_none()
|
|
{
|
|
let (decrypted_checksums, is_multipart) = info.decrypt_checksums(part_number.unwrap_or(0), headers).map_err(|e| {
|
|
error!(error = %e, "GetObject checksum decryption failed");
|
|
ApiError::from(e)
|
|
})?;
|
|
|
|
return Ok(classify_response_checksums(decrypted_checksums, is_multipart));
|
|
}
|
|
|
|
Ok(ResponseChecksums::default())
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn build_get_object_body<R, F>(
|
|
final_stream: R,
|
|
info: &ObjectInfo,
|
|
response_content_length: i64,
|
|
request_id: &str,
|
|
content_range: Option<&str>,
|
|
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,
|
|
mut lifecycle: GetObjectBodyLifecycle,
|
|
resume: F,
|
|
) -> S3Result<StreamingBlob>
|
|
where
|
|
R: AsyncRead + Send + Sync + Unpin + 'static,
|
|
F: FnOnce(&ObjectInfo) -> Option<GetObjectResumeControl<R>>,
|
|
{
|
|
if encryption_applied {
|
|
let should_buffer_encrypted_object =
|
|
should_buffer_get_object_in_memory(info, response_content_length, part_number, has_range, concurrent_requests);
|
|
|
|
if should_buffer_encrypted_object {
|
|
// Strict materialization (#1324): a decrypted body that is shorter
|
|
// or longer than the declared content length must hard-fail before
|
|
// headers, not warn-and-serve a truncated/over-long body.
|
|
let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX);
|
|
match strict_materialize_object_body(final_stream, expected, GET_OBJECT_STAGE_BODY_ENCRYPTED_BUFFER_READ).await {
|
|
Ok(buf) => {
|
|
return Ok(Self::build_memory_blob(
|
|
buf,
|
|
response_content_length,
|
|
GET_MEMORY_BODY_SOURCE_ENCRYPTED_BUFFER,
|
|
lifecycle,
|
|
));
|
|
}
|
|
Err(e) => {
|
|
lifecycle.finish_err();
|
|
error!(error = %e, "GetObject decrypted object strict materialization failed");
|
|
return Err(e.into_s3_error(response_content_length));
|
|
}
|
|
}
|
|
}
|
|
|
|
debug!(buffer_size = optimal_buffer_size, "Encrypted object uses streaming decrypt path");
|
|
let stream_strategy_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
|
|
let (stream_buffer_size, stream_strategy) =
|
|
Self::select_stream_buffer_strategy(response_content_length, optimal_buffer_size, enable_readahead, has_range);
|
|
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAM_STRATEGY, stream_strategy_start);
|
|
return Ok(Self::build_reader_blob(
|
|
final_stream,
|
|
response_content_length,
|
|
request_id,
|
|
content_range,
|
|
stream_buffer_size,
|
|
stream_strategy,
|
|
bucket,
|
|
key,
|
|
lifecycle,
|
|
resume(info),
|
|
));
|
|
}
|
|
|
|
if let Some(buffered_body) = buffered_body {
|
|
// Strict materialization (#1324): the buffered body is the exact
|
|
// response payload; a length disagreement means an upstream/cache bug
|
|
// and must hard-fail before headers rather than serve a body that does
|
|
// not match its committed Content-Length.
|
|
let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX);
|
|
if buffered_body.len() != expected {
|
|
lifecycle.finish_err();
|
|
error!(
|
|
expected = response_content_length,
|
|
actual = buffered_body.len(),
|
|
"Buffered GetObject body length mismatch"
|
|
);
|
|
return Err(ApiError::from(StorageError::other(format!(
|
|
"Buffered GetObject body length mismatch: expected {response_content_length}, got {}",
|
|
buffered_body.len()
|
|
)))
|
|
.into());
|
|
}
|
|
|
|
return Ok(Self::build_memory_bytes_blob(
|
|
buffered_body,
|
|
response_content_length,
|
|
GET_MEMORY_BODY_SOURCE_BUFFERED_BODY,
|
|
lifecycle,
|
|
));
|
|
}
|
|
|
|
let should_provide_seek_support =
|
|
should_buffer_get_object_in_memory(info, response_content_length, part_number, has_range, concurrent_requests);
|
|
|
|
if should_provide_seek_support {
|
|
// Strict materialization (#1324): the previous implementation only
|
|
// logged a warning on a length mismatch, and — most dangerously — on a read
|
|
// error it fell through to streaming the *same* reader after
|
|
// `read_to_end` had already drained K bytes, shipping a body missing
|
|
// its prefix (prefix-misaligned data). Both are now hard errors: an
|
|
// exact-length read is required, and any read error returns without
|
|
// reusing the partially consumed reader.
|
|
let expected = usize::try_from(response_content_length.max(0)).unwrap_or(usize::MAX);
|
|
match strict_materialize_object_body(final_stream, expected, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await {
|
|
Ok(buf) => {
|
|
return Ok(Self::build_memory_blob(
|
|
buf,
|
|
response_content_length,
|
|
GET_MEMORY_BODY_SOURCE_SEEK_BUFFER,
|
|
lifecycle,
|
|
));
|
|
}
|
|
Err(e) => {
|
|
lifecycle.finish_err();
|
|
error!(
|
|
error = %e,
|
|
"GetObject seek-support strict materialization failed; refusing to reuse the partially consumed reader"
|
|
);
|
|
return Err(e.into_s3_error(response_content_length));
|
|
}
|
|
}
|
|
}
|
|
|
|
let stream_strategy_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
|
|
let (stream_buffer_size, stream_strategy) =
|
|
Self::select_stream_buffer_strategy(response_content_length, optimal_buffer_size, enable_readahead, has_range);
|
|
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAM_STRATEGY, stream_strategy_start);
|
|
Ok(Self::build_reader_blob(
|
|
final_stream,
|
|
response_content_length,
|
|
request_id,
|
|
content_range,
|
|
stream_buffer_size,
|
|
stream_strategy,
|
|
bucket,
|
|
key,
|
|
lifecycle,
|
|
resume(info),
|
|
))
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn build_get_object_body_with_cache<R, F>(
|
|
cache_adapter: &ObjectDataCacheAdapter,
|
|
final_stream: R,
|
|
info: &ObjectInfo,
|
|
response_content_length: i64,
|
|
request_id: &str,
|
|
content_range: Option<&str>,
|
|
optimal_buffer_size: usize,
|
|
enable_readahead: bool,
|
|
concurrent_requests: usize,
|
|
part_number: Option<usize>,
|
|
has_range: bool,
|
|
encryption_applied: bool,
|
|
mut buffered_body: Option<Bytes>,
|
|
cache_hook_served: bool,
|
|
cache_hook_probed: bool,
|
|
cache_fill_allowed: bool,
|
|
bucket: &str,
|
|
key: &str,
|
|
mut lifecycle: GetObjectBodyLifecycle,
|
|
resume: F,
|
|
) -> S3Result<StreamingBlob>
|
|
where
|
|
R: AsyncRead + Send + Sync + Unpin + 'static,
|
|
F: FnOnce(&ObjectInfo) -> Option<GetObjectResumeControl<R>>,
|
|
{
|
|
// ODC-16 (backlog#1121): when the ecstore hook or shared cold fill
|
|
// already supplied this body, the request-level plan was built before
|
|
// the authoritative lookup. Serve it without planning a second time.
|
|
if cache_hook_served && let Some(bytes) = buffered_body.take() {
|
|
return Ok(Self::build_memory_bytes_blob(
|
|
bytes,
|
|
response_content_length,
|
|
GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE,
|
|
lifecycle,
|
|
));
|
|
}
|
|
|
|
if !cache_fill_allowed {
|
|
return Self::build_get_object_body(
|
|
final_stream,
|
|
info,
|
|
response_content_length,
|
|
request_id,
|
|
content_range,
|
|
optimal_buffer_size,
|
|
enable_readahead,
|
|
concurrent_requests,
|
|
part_number,
|
|
has_range,
|
|
encryption_applied,
|
|
buffered_body,
|
|
bucket,
|
|
key,
|
|
lifecycle,
|
|
resume,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
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);
|
|
|
|
// ODC-16: only look up when the hook did not probe this read. When it did
|
|
// probe (a served body handled above, or a miss), its result is
|
|
// authoritative because it ran after fresh metadata resolution, so the
|
|
// app layer skips its own lookup and only uses the plan to fill.
|
|
if !cache_hook_probed {
|
|
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,
|
|
GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE,
|
|
lifecycle,
|
|
));
|
|
}
|
|
GetObjectBodyCacheLookup::Disabled | GetObjectBodyCacheLookup::Skip | GetObjectBodyCacheLookup::Miss => {}
|
|
}
|
|
}
|
|
|
|
if let Some(buffered_body) = buffered_body {
|
|
// ODC-15: the body is already fully in hand, so keep the fill off the
|
|
// response's critical path. For a cacheable plan, run the fill in a
|
|
// detached task (Bytes is a cheap clone) and return immediately. For
|
|
// a non-cacheable plan the fill is a pure metric-only skip with no
|
|
// I/O, so record it inline to preserve observability.
|
|
if cache_fill_allowed && matches!(cache_plan, GetObjectBodyCachePlan::Cacheable(_)) {
|
|
let cache_adapter = cache_adapter.clone();
|
|
let cache_plan = cache_plan.clone();
|
|
let fill_bytes = buffered_body.clone();
|
|
tokio::spawn(async move {
|
|
let _ = fill_get_object_body_cache_from_buffered_body(&cache_adapter, &cache_plan, &fill_bytes).await;
|
|
});
|
|
} else if cache_fill_allowed {
|
|
let _ = 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,
|
|
GET_MEMORY_BODY_SOURCE_BUFFERED_BODY,
|
|
lifecycle,
|
|
));
|
|
}
|
|
|
|
let should_materialize_for_cache = cache_adapter.materialize_fill_enabled()
|
|
&& cache_fill_allowed
|
|
&& 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,
|
|
request_id,
|
|
content_range,
|
|
optimal_buffer_size,
|
|
enable_readahead,
|
|
concurrent_requests,
|
|
part_number,
|
|
has_range,
|
|
encryption_applied,
|
|
None,
|
|
bucket,
|
|
key,
|
|
lifecycle,
|
|
resume,
|
|
)
|
|
.await;
|
|
};
|
|
// ODC-07 / #1324: share the strict exact-length materialization gate
|
|
// with the encrypted and seek memory branches. The helper bounds the
|
|
// read to `capacity + 1` (so an over-long stream is detected without
|
|
// buffering it unbounded), rejects short and over-long reads, and on a
|
|
// partial-read error refuses to reuse the consumed reader.
|
|
match strict_materialize_object_body(
|
|
final_stream,
|
|
materialized_capacity,
|
|
GET_OBJECT_STAGE_BODY_CACHE_MATERIALIZE_READ,
|
|
)
|
|
.await
|
|
{
|
|
Ok(buf) => {
|
|
let bytes = Bytes::from(buf);
|
|
// ODC-15: fill off the response's critical path (see the
|
|
// buffered-body branch above).
|
|
let cache_adapter = cache_adapter.clone();
|
|
let cache_plan = cache_plan.clone();
|
|
let fill_bytes = bytes.clone();
|
|
tokio::spawn(async move {
|
|
let _ = fill_get_object_body_cache_from_materialized_body(&cache_adapter, &cache_plan, &fill_bytes).await;
|
|
});
|
|
|
|
return Ok(Self::build_memory_bytes_blob(
|
|
bytes,
|
|
response_content_length,
|
|
GET_MEMORY_BODY_SOURCE_OBJECT_DATA_CACHE_MATERIALIZED,
|
|
lifecycle,
|
|
));
|
|
}
|
|
Err(e) => {
|
|
lifecycle.finish_err();
|
|
error!(error = %e, "GetObject materialize-fill strict materialization failed");
|
|
// A short/over-long body would ship a truncated or over-long
|
|
// response; a partial-read error leaves the stream consumed so
|
|
// falling back to streaming would send a prefix-misaligned
|
|
// body. Both fail the request.
|
|
return Err(e.into_s3_error(response_content_length));
|
|
}
|
|
}
|
|
}
|
|
|
|
Self::build_get_object_body(
|
|
final_stream,
|
|
info,
|
|
response_content_length,
|
|
request_id,
|
|
content_range,
|
|
optimal_buffer_size,
|
|
enable_readahead,
|
|
concurrent_requests,
|
|
part_number,
|
|
has_range,
|
|
encryption_applied,
|
|
None,
|
|
bucket,
|
|
key,
|
|
lifecycle,
|
|
resume,
|
|
)
|
|
.await
|
|
}
|
|
|
|
fn finalize_get_object_completion(
|
|
wrapper: &RequestTimeoutWrapper,
|
|
timeout_config: &GetObjectTimeoutPolicy,
|
|
total_duration: Duration,
|
|
response_content_length: i64,
|
|
optimal_buffer_size: usize,
|
|
) {
|
|
rustfs_io_metrics::record_get_object_completion(
|
|
total_duration.as_secs_f64(),
|
|
response_content_length,
|
|
optimal_buffer_size,
|
|
);
|
|
|
|
rustfs_io_metrics::record_get_object(total_duration.as_millis() as f64, response_content_length);
|
|
|
|
if wrapper.is_timeout() {
|
|
warn!(
|
|
"GetObject request exceeded timeout: duration={:?} timeout={:?}",
|
|
wrapper.elapsed(),
|
|
timeout_config.get_object_timeout
|
|
);
|
|
rustfs_io_metrics::record_get_object_timeout(None, Some(wrapper.elapsed().as_secs_f64()));
|
|
}
|
|
|
|
debug!(
|
|
"GetObject completed: size={} duration={:?} buffer={}",
|
|
response_content_length, total_duration, optimal_buffer_size
|
|
);
|
|
}
|
|
|
|
fn ensure_get_object_not_timed_out(
|
|
wrapper: &RequestTimeoutWrapper,
|
|
timeout_config: &GetObjectTimeoutPolicy,
|
|
bucket: &str,
|
|
key: &str,
|
|
stage: GetObjectTimeoutStage,
|
|
) -> S3Result<()> {
|
|
if !wrapper.is_timeout() {
|
|
return Ok(());
|
|
}
|
|
|
|
let timeout_secs = timeout_config.get_object_timeout.as_secs();
|
|
let elapsed_ms = wrapper.elapsed().as_millis();
|
|
|
|
match stage {
|
|
GetObjectTimeoutStage::BeforeProcessing => {
|
|
warn!(
|
|
bucket = %bucket,
|
|
key = %key,
|
|
timeout_secs,
|
|
elapsed_ms,
|
|
"GetObject request timed out before processing"
|
|
);
|
|
Err(s3_error!(InternalError, "Request timeout before processing"))
|
|
}
|
|
GetObjectTimeoutStage::DiskPermitWait { permit_wait_duration } => {
|
|
warn!(
|
|
bucket = %bucket,
|
|
key = %key,
|
|
wait_ms = permit_wait_duration.as_millis(),
|
|
timeout_secs,
|
|
elapsed_ms,
|
|
"GetObject request timed out while waiting for disk permit"
|
|
);
|
|
rustfs_io_metrics::record_get_object_timeout(Some("disk_permit"), Some(wrapper.elapsed().as_secs_f64()));
|
|
Err(s3_error!(InternalError, "Request timeout while waiting for disk permit"))
|
|
}
|
|
GetObjectTimeoutStage::BeforeRead => {
|
|
warn!(
|
|
bucket = %bucket,
|
|
key = %key,
|
|
timeout_secs,
|
|
elapsed_ms,
|
|
"GetObject request timed out before reading object"
|
|
);
|
|
rustfs_io_metrics::record_get_object_timeout(Some("before_read"), Some(wrapper.elapsed().as_secs_f64()));
|
|
Err(s3_error!(InternalError, "Request timeout before reading object"))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn finalize_get_object_response(
|
|
helper: OperationHelper,
|
|
bucket: &str,
|
|
method: &hyper::Method,
|
|
headers: &HeaderMap,
|
|
event_info: Option<ObjectInfo>,
|
|
version_id_for_event: String,
|
|
output: GetObjectOutput,
|
|
extra_checksum_headers: Vec<(&'static str, String)>,
|
|
) -> S3Result<S3Response<GetObjectOutput>> {
|
|
let helper = match event_info {
|
|
Some(event_info) => helper.object(event_info),
|
|
None => helper,
|
|
};
|
|
let helper = helper.version_id(version_id_for_event);
|
|
let mut response = wrap_response_with_cors(bucket, method, headers, output).await;
|
|
inject_accept_ranges_header(&mut response.headers);
|
|
// Emit XXHash3/64/128 and SHA-512 checksums that s3s GetObjectOutput cannot
|
|
// carry (#1257). This is the download-side integrity path AWS SDKs verify.
|
|
inject_additional_checksum_headers(&mut response.headers, &extra_checksum_headers);
|
|
let result = Ok(response);
|
|
let _ = helper.complete(&result);
|
|
result
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn build_get_object_output_context<F>(
|
|
&self,
|
|
req: &S3Request<GetObjectInput>,
|
|
manager: &ConcurrencyManager,
|
|
bucket: &str,
|
|
key: &str,
|
|
info: ObjectInfo,
|
|
event_info: Option<ObjectInfo>,
|
|
final_stream: DynReader,
|
|
buffered_body: Option<Bytes>,
|
|
cache_hook_served: bool,
|
|
cache_hook_probed: bool,
|
|
cache_fill_allowed: bool,
|
|
rs: Option<HTTPRangeSpec>,
|
|
content_type: Option<ContentType>,
|
|
last_modified: Option<Timestamp>,
|
|
response_content_length: i64,
|
|
content_range: Option<String>,
|
|
request_id: &str,
|
|
server_side_encryption: Option<ServerSideEncryption>,
|
|
sse_customer_algorithm: Option<SSECustomerAlgorithm>,
|
|
sse_customer_key_md5: Option<SSECustomerKeyMD5>,
|
|
ssekms_key_id: Option<SSEKMSKeyId>,
|
|
encryption_applied: bool,
|
|
permit_wait_duration: Duration,
|
|
queue_utilization: f64,
|
|
queue_status: &concurrency::IoQueueStatus,
|
|
concurrent_requests: usize,
|
|
part_number: Option<usize>,
|
|
versioned: bool,
|
|
lifecycle: GetObjectBodyLifecycle,
|
|
resume: F,
|
|
) -> S3Result<GetObjectOutputContext>
|
|
where
|
|
F: FnOnce(&ObjectInfo) -> Option<GetObjectResumeControl<DynReader>>,
|
|
{
|
|
let strategy_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
|
|
let strategy = self.finalize_get_object_strategy(
|
|
manager,
|
|
bucket,
|
|
key,
|
|
&info,
|
|
rs.as_ref(),
|
|
response_content_length,
|
|
permit_wait_duration,
|
|
queue_utilization,
|
|
queue_status,
|
|
concurrent_requests,
|
|
);
|
|
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_OUTPUT_STRATEGY, strategy_start);
|
|
let GetObjectStrategyContext {
|
|
io_strategy: _,
|
|
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_with_cache(
|
|
&cache_adapter,
|
|
final_stream,
|
|
&info,
|
|
response_content_length,
|
|
request_id,
|
|
content_range.as_deref(),
|
|
optimal_buffer_size,
|
|
enable_readahead,
|
|
concurrent_requests,
|
|
part_number,
|
|
rs.is_some(),
|
|
encryption_applied,
|
|
buffered_body,
|
|
cache_hook_served,
|
|
cache_hook_probed,
|
|
cache_fill_allowed,
|
|
bucket,
|
|
key,
|
|
lifecycle,
|
|
resume,
|
|
)
|
|
.await?;
|
|
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_BUILD, body_build_start);
|
|
|
|
let checksum_headers_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
|
|
let checksums = Self::build_get_object_checksums(&info, &req.headers, part_number, rs.as_ref())?;
|
|
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_CHECKSUM_HEADERS, checksum_headers_start);
|
|
|
|
let output_version_id = if versioned {
|
|
info.version_id.map(|vid| {
|
|
if vid == Uuid::nil() {
|
|
"null".to_string()
|
|
} else {
|
|
vid.to_string()
|
|
}
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// x-amz-restore: extract from object metadata
|
|
let restore = info.user_defined.get(X_AMZ_RESTORE.as_str()).and_then(|v| {
|
|
let rs = parse_restore_obj_status(v).ok()?;
|
|
Some(rs.to_string2())
|
|
});
|
|
|
|
// x-amz-expiration: predict from lifecycle configuration
|
|
let lifecycle_expiration_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
|
|
let expiration = resolve_put_object_expiration(bucket, &info).await;
|
|
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_LIFECYCLE_EXPIRATION, lifecycle_expiration_start);
|
|
let storage_class = response_storage_class(&info, &info.user_defined);
|
|
let cache_control = info.user_defined.get("cache-control").cloned();
|
|
let content_disposition = info.user_defined.get("content-disposition").cloned();
|
|
|
|
let metadata_filter_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
|
|
let metadata = filter_object_metadata(&info.user_defined);
|
|
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_METADATA_FILTER, metadata_filter_start);
|
|
|
|
let output = GetObjectOutput {
|
|
body: Some(body),
|
|
content_length: Some(response_content_length),
|
|
last_modified,
|
|
content_type,
|
|
content_encoding: info.content_encoding.clone(),
|
|
cache_control,
|
|
content_disposition,
|
|
content_range,
|
|
e_tag: info.etag.map(|etag| to_s3s_etag(&etag)),
|
|
metadata,
|
|
server_side_encryption,
|
|
sse_customer_algorithm,
|
|
sse_customer_key_md5,
|
|
ssekms_key_id,
|
|
checksum_crc32: checksums.crc32,
|
|
checksum_crc32c: checksums.crc32c,
|
|
checksum_sha1: checksums.sha1,
|
|
checksum_sha256: checksums.sha256,
|
|
checksum_crc64nvme: checksums.crc64nvme,
|
|
checksum_type: checksums.checksum_type,
|
|
version_id: output_version_id,
|
|
restore,
|
|
expiration,
|
|
storage_class,
|
|
..Default::default()
|
|
};
|
|
|
|
Ok(GetObjectOutputContext {
|
|
output,
|
|
event_info,
|
|
response_content_length,
|
|
optimal_buffer_size,
|
|
extra_checksum_headers: checksums.extra,
|
|
})
|
|
}
|
|
|
|
/// Serve a GET whose local read failed with not-found by proxying to the
|
|
/// bucket's replication targets (MinIO `proxyGetToReplicationTarget`,
|
|
/// backlog#1675 P1-5). Returns None when no target can serve the object;
|
|
/// the caller then returns the original local error.
|
|
async fn proxy_get_object_to_replication_targets(
|
|
req: &S3Request<GetObjectInput>,
|
|
bucket: &str,
|
|
key: &str,
|
|
opts: &ObjectOptions,
|
|
) -> Option<GetObjectOutput> {
|
|
let targets = get_read_proxy_targets(bucket, key, opts).await;
|
|
if targets.is_empty() {
|
|
return None;
|
|
}
|
|
let extra_headers = Self::proxy_read_passthrough_headers(&req.headers);
|
|
let range = req
|
|
.headers
|
|
.get(http::header::RANGE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::to_owned);
|
|
let part_number = req.input.part_number;
|
|
|
|
for target in targets {
|
|
match target
|
|
.get_object(
|
|
&target.bucket,
|
|
key,
|
|
opts.version_id.clone(),
|
|
range.clone(),
|
|
part_number,
|
|
extra_headers.clone(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(remote) => {
|
|
// MinIO-aligned accounting: one total per proxy attempt
|
|
// (targets were available), one failed when no target
|
|
// served it — never per target.
|
|
record_replication_proxy(bucket, "GetObject", false).await;
|
|
return Some(Self::proxy_sdk_get_output_to_s3s(remote));
|
|
}
|
|
Err(err) if Self::proxy_sdk_error_is_not_found(&err) => {
|
|
debug!(bucket, key, arn = %target.arn, "read proxy: target does not have the object");
|
|
}
|
|
Err(err) => {
|
|
warn!(bucket, key, arn = %target.arn, error = %err, "read proxy: GET against replication target failed");
|
|
}
|
|
}
|
|
}
|
|
record_replication_proxy(bucket, "GetObject", true).await;
|
|
None
|
|
}
|
|
|
|
/// Translate a proxied SDK GET response into the s3s output, forwarding
|
|
/// the body as a stream (no buffering, no local persistence).
|
|
fn proxy_sdk_get_output_to_s3s(remote: aws_sdk_s3::operation::get_object::GetObjectOutput) -> GetObjectOutput {
|
|
let body = remote.body;
|
|
let body_stream = tokio_util::io::ReaderStream::with_capacity(body.into_async_read(), 64 * 1024);
|
|
GetObjectOutput {
|
|
body: Some(StreamingBlob::wrap(body_stream)),
|
|
content_length: remote.content_length,
|
|
content_range: remote.content_range,
|
|
content_type: remote.content_type.as_deref().and_then(|v| ContentType::from_str(v).ok()),
|
|
content_encoding: remote.content_encoding,
|
|
content_disposition: remote.content_disposition,
|
|
content_language: remote.content_language,
|
|
cache_control: remote.cache_control,
|
|
e_tag: remote.e_tag.as_deref().and_then(|v| ETag::from_str(v).ok()),
|
|
last_modified: remote
|
|
.last_modified
|
|
.and_then(|dt| OffsetDateTime::from_unix_timestamp_nanos(dt.as_nanos()).ok())
|
|
.map(Timestamp::from),
|
|
metadata: remote.metadata,
|
|
version_id: remote.version_id,
|
|
server_side_encryption: remote
|
|
.server_side_encryption
|
|
.map(|sse| ServerSideEncryption::from(sse.as_str().to_string())),
|
|
sse_customer_algorithm: remote.sse_customer_algorithm,
|
|
sse_customer_key_md5: remote.sse_customer_key_md5,
|
|
ssekms_key_id: remote.ssekms_key_id,
|
|
parts_count: remote.parts_count,
|
|
tag_count: remote.tag_count,
|
|
storage_class: remote.storage_class.map(|sc| StorageClass::from(sc.as_str().to_string())),
|
|
expiration: remote.expiration,
|
|
restore: remote.restore,
|
|
checksum_crc32: remote.checksum_crc32,
|
|
checksum_crc32c: remote.checksum_crc32_c,
|
|
checksum_crc64nvme: remote.checksum_crc64_nvme,
|
|
checksum_sha1: remote.checksum_sha1,
|
|
checksum_sha256: remote.checksum_sha256,
|
|
checksum_type: remote.checksum_type.map(|ct| ChecksumType::from(ct.as_str().to_string())),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// On-demand migration read-through for a GET miss (rustfs/backlog#2156):
|
|
/// consulted only after the local read and the replication proxy both
|
|
/// missed. `None` means the runtime does not intervene and the caller
|
|
/// keeps its original 404.
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn on_demand_migration_get(
|
|
&self,
|
|
req: &S3Request<GetObjectInput>,
|
|
store: &Arc<ECStore>,
|
|
bucket: &str,
|
|
key: &str,
|
|
range: Option<&HTTPRangeSpec>,
|
|
opts: &ObjectOptions,
|
|
part_number: Option<usize>,
|
|
) -> Option<OdmGetOutcome> {
|
|
if !odm_get_may_consult_source(opts, part_number) {
|
|
return None;
|
|
}
|
|
let sys = OnDemandMigrationSys::get();
|
|
if !sys.is_module_enabled() {
|
|
return None;
|
|
}
|
|
let state = sys.state(bucket).filter(|state| state.matches_prefix(key))?;
|
|
let policy = &state.config().policy;
|
|
// The read path reports a latest delete marker as a plain 404, so the
|
|
// marker is classified here, and only where one can exist.
|
|
if policy.respect_local_delete_marker && (opts.versioned || opts.version_suspended) {
|
|
let lookup = store.get_object_info(bucket, key, opts).await;
|
|
match odm_local_miss(lookup.as_ref()) {
|
|
Some(miss) if odm_policy_admits_miss(policy, miss) => {}
|
|
Some(_) => return None,
|
|
// The object appeared meanwhile, or the lookup failed for a
|
|
// reason the source cannot answer: the local read decides.
|
|
None => return Some(OdmGetOutcome::RetryLocal),
|
|
}
|
|
}
|
|
let expected_incarnation = match odm_read_generation(req, bucket) {
|
|
Ok(Some(incarnation)) => incarnation,
|
|
Ok(None) => return None,
|
|
Err(err) => return Some(OdmGetOutcome::Respond(Err(err))),
|
|
};
|
|
match store.bucket_incarnation_id(bucket).await {
|
|
Ok(current) if current == expected_incarnation => {}
|
|
Ok(_) => return None,
|
|
Err(err) => return Some(OdmGetOutcome::Respond(Err(ApiError::from(err).into()))),
|
|
}
|
|
if !sys.is_module_enabled() {
|
|
return None;
|
|
}
|
|
let lookup = state.filter_incarnation(expected_incarnation)?.resolve_key(key)?;
|
|
let (state, client) = match odm_get_verdict(lookup) {
|
|
OdmGetVerdict::Fail(err) => return Some(OdmGetOutcome::Respond(Err(err))),
|
|
OdmGetVerdict::Consult { state, client } => (state, client),
|
|
};
|
|
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
|
|
let reply = odm_get_from_source(&state, client.as_ref(), &req.headers, key, range, request_context).await;
|
|
Some(match reply {
|
|
OdmGetReply::Served { output, backfill } => {
|
|
if let Some(reason) = backfill {
|
|
// Queue outcomes are the queue's own accounting
|
|
// (`queue_full`); the response does not depend on them.
|
|
let _ = state.enqueue_pull(key, reason);
|
|
}
|
|
OdmGetOutcome::Respond(Ok(output))
|
|
}
|
|
OdmGetReply::Error(err) => OdmGetOutcome::Respond(Err(err)),
|
|
OdmGetReply::RetryLocal => OdmGetOutcome::RetryLocal,
|
|
})
|
|
}
|
|
|
|
#[instrument(name = "execute_get_object", level = "trace", skip(self, req))]
|
|
pub async fn execute_get_object(&self, req: S3Request<GetObjectInput>) -> S3Result<S3Response<GetObjectOutput>> {
|
|
self.execute_get_object_boxed(req).await
|
|
}
|
|
|
|
fn execute_get_object_boxed(
|
|
&self,
|
|
req: S3Request<GetObjectInput>,
|
|
) -> impl std::future::Future<Output = S3Result<S3Response<GetObjectOutput>>> + Send + '_ {
|
|
Box::pin(self.execute_get_object_inner(req))
|
|
}
|
|
|
|
fn complete_get_object_error<T>(helper: OperationHelper, err: S3Error) -> S3Result<S3Response<T>> {
|
|
let result = Err(err);
|
|
let _ = helper.complete(&result);
|
|
result
|
|
}
|
|
|
|
async fn execute_get_object_inner(&self, mut req: S3Request<GetObjectInput>) -> S3Result<S3Response<GetObjectOutput>> {
|
|
let helper = OperationHelper::new(&req, EventName::ObjectAccessedGet, S3Operation::GetObject).suppress_event();
|
|
|
|
if let Some(context) = &self.context {
|
|
let _ = context.object_store();
|
|
}
|
|
|
|
let stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled();
|
|
let request_shape_start = stage_metrics_enabled.then(std::time::Instant::now);
|
|
let inbound_request_context = req.extensions.get::<request_context::RequestContext>();
|
|
let request_id = inbound_request_context
|
|
.map(|ctx| ctx.request_id.clone())
|
|
.unwrap_or_else(|| request_context::RequestContext::fallback().request_id);
|
|
if rustfs_io_metrics::get_stage_metrics_enabled()
|
|
&& let Some(context) = inbound_request_context
|
|
{
|
|
rustfs_io_metrics::record_get_object_stage_duration(
|
|
GET_OBJECT_STAGE_PATH_S3_HANDLER,
|
|
GET_OBJECT_STAGE_REQUEST_INGRESS_TO_CONTEXT,
|
|
context.start_time.elapsed().as_secs_f64(),
|
|
);
|
|
}
|
|
let bootstrap = match self.init_get_object_bootstrap(&req.input.bucket, &req.input.key, &request_id) {
|
|
Ok(bootstrap) => bootstrap,
|
|
Err(err) => return Self::complete_get_object_error(helper, err),
|
|
};
|
|
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_REQUEST_SHAPE, request_shape_start);
|
|
let timeout_config = bootstrap.timeout_config;
|
|
let wrapper = bootstrap.wrapper;
|
|
let request_start = bootstrap.request_start;
|
|
let concurrent_requests = bootstrap.concurrent_requests;
|
|
let mut lifecycle = GetObjectBodyLifecycle::tracked(bootstrap.request_guard);
|
|
|
|
// mc get 3
|
|
|
|
// Cheap request-shape validations run first so invalid requests keep
|
|
// their InvalidArgument precedence over bucket existence.
|
|
let request_validation_start = stage_metrics_enabled.then(std::time::Instant::now);
|
|
let validated = match Self::validate_get_object_request(&req) {
|
|
Ok(validated) => validated,
|
|
Err(err) => {
|
|
lifecycle.finish_err();
|
|
return Self::complete_get_object_error(helper, err);
|
|
}
|
|
};
|
|
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_REQUEST_VALIDATION, request_validation_start);
|
|
|
|
// SF05: Store lookup next (5s-TTL bucket-validation cache). Bucket
|
|
// existence is established before any bucket-metadata work, so requests
|
|
// naming nonexistent buckets fail before the versioning lookup in
|
|
// get_opts. The store comes from the request-bound server context
|
|
// (backlog#1052 S6), not the process-global handle.
|
|
let object_traffic_health = self.object_traffic_health();
|
|
let object_metadata_progress = object_traffic_health
|
|
.as_deref()
|
|
.and_then(ObjectTrafficHealth::track_read_metadata);
|
|
let store_lookup_start = stage_metrics_enabled.then(std::time::Instant::now);
|
|
let Some(store) = self.object_store() else {
|
|
lifecycle.finish_err();
|
|
return Self::complete_get_object_error(
|
|
helper,
|
|
S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()),
|
|
);
|
|
};
|
|
if let Some(store_lookup_start) = store_lookup_start {
|
|
rustfs_io_metrics::record_get_object_stage_duration(
|
|
GET_OBJECT_STAGE_PATH_S3_HANDLER,
|
|
"store_lookup",
|
|
store_lookup_start.elapsed().as_secs_f64(),
|
|
);
|
|
}
|
|
let bucket_validation_start = stage_metrics_enabled.then(std::time::Instant::now);
|
|
if let Err(err) = validate_bucket_exists(&store, &req.input.bucket).await {
|
|
lifecycle.finish_err();
|
|
return Self::complete_get_object_error(helper, err);
|
|
}
|
|
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BUCKET_VALIDATION, bucket_validation_start);
|
|
|
|
let request_context_start = stage_metrics_enabled.then(std::time::Instant::now);
|
|
let request_context = match Self::prepare_get_object_request_context(validated, &req.headers).await {
|
|
Ok(request_context) => request_context,
|
|
Err(err) => {
|
|
lifecycle.finish_err();
|
|
return Self::complete_get_object_error(helper, err);
|
|
}
|
|
};
|
|
let bucket = req.input.bucket.clone();
|
|
prepare_odm_read_generation(&store, &mut req, &bucket).await;
|
|
if let Some(request_context_start) = request_context_start {
|
|
rustfs_io_metrics::record_get_object_stage_duration(
|
|
"s3_handler",
|
|
"request_context",
|
|
request_context_start.elapsed().as_secs_f64(),
|
|
);
|
|
}
|
|
let GetObjectRequestContext {
|
|
bucket,
|
|
key,
|
|
version_id_for_event,
|
|
part_number,
|
|
rs,
|
|
opts,
|
|
} = request_context;
|
|
drop(object_metadata_progress);
|
|
|
|
let manager = get_concurrency_manager();
|
|
|
|
let mut prepared_read = self
|
|
.prepare_get_object_read_execution(
|
|
&req,
|
|
manager,
|
|
store.clone(),
|
|
&wrapper,
|
|
&timeout_config,
|
|
&bucket,
|
|
&key,
|
|
rs.clone(),
|
|
&opts,
|
|
part_number,
|
|
object_traffic_health.clone(),
|
|
)
|
|
.await;
|
|
// An object missing locally (and only missing — other errors keep
|
|
// their semantics) may still be served by a remote copy.
|
|
if let Err(err) = &prepared_read
|
|
&& matches!(*err.code(), S3ErrorCode::NoSuchKey | S3ErrorCode::NoSuchVersion)
|
|
{
|
|
// Active-active replication lag window: proxy the GET to a
|
|
// replication target (backlog#1675 P1-5).
|
|
if let Some(output) = Self::proxy_get_object_to_replication_targets(&req, &bucket, &key, &opts).await {
|
|
lifecycle.finish_ok();
|
|
let mut response = wrap_response_with_cors(&bucket, &req.method, &req.headers, output).await;
|
|
inject_accept_ranges_header(&mut response.headers);
|
|
let result = Ok(response);
|
|
let _ = helper.version_id(version_id_for_event).complete(&result);
|
|
return result;
|
|
}
|
|
// On-demand migration source (rustfs/backlog#2156): the
|
|
// authoritative external copy, after the cheaper replication
|
|
// proxy.
|
|
match self
|
|
.on_demand_migration_get(&req, &store, &bucket, &key, rs.as_ref(), &opts, part_number)
|
|
.await
|
|
{
|
|
None => {}
|
|
Some(OdmGetOutcome::Respond(Ok(output))) => {
|
|
lifecycle.finish_ok();
|
|
let mut response = wrap_response_with_cors(&bucket, &req.method, &req.headers, *output).await;
|
|
inject_accept_ranges_header(&mut response.headers);
|
|
mark_on_demand_migration_response(&mut response.headers);
|
|
let result = Ok(response);
|
|
let _ = helper.version_id(version_id_for_event).complete(&result);
|
|
return result;
|
|
}
|
|
Some(OdmGetOutcome::Respond(Err(err))) => {
|
|
lifecycle.finish_err();
|
|
return Self::complete_get_object_error(helper.version_id(version_id_for_event), err);
|
|
}
|
|
Some(OdmGetOutcome::RetryLocal) => {
|
|
prepared_read = self
|
|
.prepare_get_object_read_execution(
|
|
&req,
|
|
manager,
|
|
store.clone(),
|
|
&wrapper,
|
|
&timeout_config,
|
|
&bucket,
|
|
&key,
|
|
rs,
|
|
&opts,
|
|
part_number,
|
|
object_traffic_health,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
let prepared_read = match prepared_read {
|
|
Ok(prepared_read) => prepared_read,
|
|
Err(err) => {
|
|
lifecycle.finish_err();
|
|
return Self::complete_get_object_error(helper.version_id(version_id_for_event), err);
|
|
}
|
|
};
|
|
let GetObjectPreparedRead { io_planning, read_setup } = prepared_read;
|
|
let GetObjectIoPlanning {
|
|
disk_permit,
|
|
permit_wait_duration,
|
|
queue_status,
|
|
queue_utilization,
|
|
} = io_planning;
|
|
|
|
let GetObjectReadSetup {
|
|
info,
|
|
final_stream,
|
|
buffered_body,
|
|
cache_hook_served,
|
|
cache_hook_probed,
|
|
cache_fill_allowed,
|
|
rs,
|
|
content_type,
|
|
last_modified,
|
|
response_content_length,
|
|
content_range,
|
|
server_side_encryption,
|
|
sse_customer_algorithm,
|
|
sse_customer_key_md5,
|
|
ssekms_key_id,
|
|
encryption_applied,
|
|
resume_range_start,
|
|
resume_range_end,
|
|
} = read_setup;
|
|
let final_stream = if let Some(disk_permit) = disk_permit {
|
|
wrap_reader(DiskReadPermitReader::new(final_stream, disk_permit))
|
|
} else {
|
|
final_stream
|
|
};
|
|
|
|
// Clone ObjectInfo for event notification only when an event will
|
|
// actually be built — the clone is expensive for multipart objects.
|
|
let event_info = helper.wants_object_info().then(|| info.clone());
|
|
|
|
let output_build_start = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
|
|
let output_context = self
|
|
.build_get_object_output_context(
|
|
&req,
|
|
manager,
|
|
&bucket,
|
|
&key,
|
|
info,
|
|
event_info,
|
|
final_stream,
|
|
buffered_body,
|
|
cache_hook_served,
|
|
cache_hook_probed,
|
|
cache_fill_allowed,
|
|
rs,
|
|
content_type,
|
|
last_modified,
|
|
response_content_length,
|
|
content_range,
|
|
&request_id,
|
|
server_side_encryption,
|
|
sse_customer_algorithm,
|
|
sse_customer_key_md5,
|
|
ssekms_key_id,
|
|
encryption_applied,
|
|
permit_wait_duration,
|
|
queue_utilization,
|
|
&queue_status,
|
|
concurrent_requests,
|
|
part_number,
|
|
opts.versioned,
|
|
lifecycle,
|
|
|info| {
|
|
Some(get_object_resume_control(GetObjectResumeContext::new(
|
|
store,
|
|
&bucket,
|
|
&key,
|
|
opts,
|
|
&req.headers,
|
|
info,
|
|
resume_range_start,
|
|
resume_range_end,
|
|
)))
|
|
},
|
|
)
|
|
.await;
|
|
let output_context = match output_context {
|
|
Ok(output_context) => output_context,
|
|
Err(err) => return Self::complete_get_object_error(helper.version_id(version_id_for_event), err),
|
|
};
|
|
if let Some(output_build_start) = output_build_start {
|
|
rustfs_io_metrics::record_get_object_stage_duration(
|
|
"s3_handler",
|
|
"output_build",
|
|
output_build_start.elapsed().as_secs_f64(),
|
|
);
|
|
}
|
|
let GetObjectOutputContext {
|
|
output,
|
|
event_info,
|
|
response_content_length,
|
|
optimal_buffer_size,
|
|
extra_checksum_headers,
|
|
} = output_context;
|
|
|
|
let total_duration = request_start.elapsed();
|
|
Self::finalize_get_object_completion(
|
|
&wrapper,
|
|
&timeout_config,
|
|
total_duration,
|
|
response_content_length,
|
|
optimal_buffer_size,
|
|
);
|
|
|
|
let response_finalize_start = stage_metrics_enabled.then(std::time::Instant::now);
|
|
let response = Self::finalize_get_object_response(
|
|
helper,
|
|
&bucket,
|
|
&req.method,
|
|
&req.headers,
|
|
event_info,
|
|
version_id_for_event,
|
|
output,
|
|
extra_checksum_headers,
|
|
)
|
|
.await;
|
|
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_RESPONSE_FINALIZE, response_finalize_start);
|
|
response
|
|
}
|
|
|
|
pub async fn execute_get_object_attributes(
|
|
&self,
|
|
req: S3Request<GetObjectAttributesInput>,
|
|
) -> S3Result<S3Response<GetObjectAttributesOutput>> {
|
|
if let Some(context) = &self.context {
|
|
let _ = context.object_store();
|
|
}
|
|
|
|
let mut helper =
|
|
OperationHelper::new(&req, EventName::ObjectAccessedAttributes, S3Operation::GetObjectAttributes).suppress_event();
|
|
let GetObjectAttributesInput {
|
|
bucket,
|
|
key,
|
|
max_parts,
|
|
object_attributes,
|
|
part_number_marker,
|
|
version_id,
|
|
sse_customer_key,
|
|
sse_customer_key_md5,
|
|
..
|
|
} = req.input;
|
|
|
|
let Some(store) = self.object_store() else {
|
|
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
|
|
};
|
|
|
|
let mut opts: ObjectOptions = get_opts(&bucket, &key, version_id.clone(), None, &req.headers)
|
|
.await
|
|
.map_err(ApiError::from)?;
|
|
opts.include_part_checksums = object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_PARTS);
|
|
|
|
let info = match store.get_object_info(&bucket, &key, &opts).await {
|
|
Ok(info) => info,
|
|
Err(err) => {
|
|
if is_err_object_not_found(&err) || is_err_version_not_found(&err) {
|
|
if is_dir_object(&key) {
|
|
let has_children = match probe_prefix_has_children(store, &bucket, &key, false).await {
|
|
Ok(has_children) => has_children,
|
|
Err(e) => {
|
|
error!(
|
|
"Failed to probe children for object attributes (bucket: {}, key: {}): {}",
|
|
bucket, key, e
|
|
);
|
|
false
|
|
}
|
|
};
|
|
let msg = head_prefix_not_found_message(&bucket, &key, has_children);
|
|
return Err(S3Error::with_message(S3ErrorCode::NoSuchKey, msg));
|
|
}
|
|
return Err(S3Error::new(S3ErrorCode::NoSuchKey));
|
|
}
|
|
return Err(ApiError::from(err).into());
|
|
}
|
|
};
|
|
|
|
if info.delete_marker {
|
|
if opts.version_id.is_none() {
|
|
return Err(S3Error::new(S3ErrorCode::NoSuchKey));
|
|
}
|
|
return Err(S3Error::new(S3ErrorCode::MethodNotAllowed));
|
|
}
|
|
|
|
validate_ssec_for_read(&info.user_defined, sse_customer_key.as_ref(), sse_customer_key_md5.as_ref())?;
|
|
|
|
let metadata_map = info.user_defined.clone();
|
|
debug!(
|
|
"GetObjectAttributes raw object_attributes={:?}",
|
|
object_attributes.iter().map(|value| value.as_str()).collect::<Vec<_>>()
|
|
);
|
|
|
|
let requested = |name: &'static str| -> bool { object_attributes_requested(&object_attributes, name) };
|
|
let storage_class =
|
|
response_storage_class_for_object_attributes(&info, &metadata_map, requested(ObjectAttributes::STORAGE_CLASS));
|
|
|
|
let e_tag = if requested(ObjectAttributes::ETAG) {
|
|
info.etag.as_ref().map(|etag| to_s3s_etag(etag))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let object_size = if requested(ObjectAttributes::OBJECT_SIZE) {
|
|
Some(info.get_actual_size().map_err(ApiError::from)?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let checksum = if requested(ObjectAttributes::CHECKSUM) {
|
|
let (checksums, is_multipart) = info.decrypt_checksums(0, &req.headers).map_err(ApiError::from)?;
|
|
// GetObjectAttributes returns checksums in the XML body, and s3s's Checksum
|
|
// type has no field for the additional algorithms, so `extra` cannot be
|
|
// surfaced here (unlike the header-based GET/HEAD paths) — an s3s limitation
|
|
// tracked for when it gains typed fields.
|
|
let ResponseChecksums {
|
|
crc32: checksum_crc32,
|
|
crc32c: checksum_crc32c,
|
|
sha1: checksum_sha1,
|
|
sha256: checksum_sha256,
|
|
crc64nvme: checksum_crc64nvme,
|
|
checksum_type,
|
|
..
|
|
} = classify_response_checksums(checksums, is_multipart);
|
|
|
|
Some(Checksum {
|
|
checksum_crc32,
|
|
checksum_crc32c,
|
|
checksum_sha1,
|
|
checksum_sha256,
|
|
checksum_crc64nvme,
|
|
checksum_type,
|
|
..Default::default()
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
let object_parts = if requested(ObjectAttributes::OBJECT_PARTS) && info.is_multipart() {
|
|
let params = parse_list_parts_params(part_number_marker, max_parts)?;
|
|
let mut parts = Vec::new();
|
|
let mut marker = params.part_number_marker;
|
|
let max_parts = params.max_parts;
|
|
let mut start_at = 0usize;
|
|
|
|
if let Some(marker_value) = marker {
|
|
if let Some(index) = info.parts.iter().position(|part| part.number == marker_value) {
|
|
start_at = index + 1;
|
|
} else {
|
|
marker = None;
|
|
}
|
|
}
|
|
|
|
let max_parts: i32 = max_parts.try_into().map_err(|_| {
|
|
S3Error::with_message(S3ErrorCode::InvalidArgument, "max-parts value is out of range".to_string())
|
|
})?;
|
|
let end = (start_at + params.max_parts).min(info.parts.len());
|
|
let is_truncated = end < info.parts.len();
|
|
|
|
for part in &info.parts[start_at..end] {
|
|
let (checksums, is_multipart) = info.decrypt_checksums(part.number, &req.headers).map_err(ApiError::from)?;
|
|
// Additional algorithms cannot be surfaced in the ObjectPart XML body
|
|
// (s3s has no field); same limitation as the object-level attributes above.
|
|
let ResponseChecksums {
|
|
crc32: checksum_crc32,
|
|
crc32c: checksum_crc32c,
|
|
sha1: checksum_sha1,
|
|
sha256: checksum_sha256,
|
|
crc64nvme: checksum_crc64nvme,
|
|
..
|
|
} = classify_response_checksums(checksums, is_multipart);
|
|
|
|
let part_size = if part.actual_size > 0 {
|
|
part.actual_size
|
|
} else {
|
|
part.size.try_into().map_err(|_| {
|
|
S3Error::with_message(S3ErrorCode::InvalidArgument, "Part size value is out of range".to_string())
|
|
})?
|
|
};
|
|
|
|
parts.push(ObjectPart {
|
|
checksum_crc32,
|
|
checksum_crc32c,
|
|
checksum_sha1,
|
|
checksum_sha256,
|
|
checksum_crc64nvme,
|
|
part_number: i32::try_from(part.number).ok(),
|
|
size: Some(part_size),
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
let part_number_marker = marker.and_then(|v| i32::try_from(v).ok());
|
|
let next_part_number_marker = parts.last().and_then(|part| part.part_number);
|
|
|
|
Some(GetObjectAttributesParts {
|
|
is_truncated: Some(is_truncated),
|
|
max_parts: Some(max_parts),
|
|
next_part_number_marker,
|
|
part_number_marker,
|
|
parts: Some(parts),
|
|
total_parts_count: Some(i32::try_from(info.parts.len()).map_err(|_| {
|
|
S3Error::with_message(S3ErrorCode::InvalidArgument, "Part count is out of range".to_string())
|
|
})?),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let version_id = if BucketVersioningSys::prefix_enabled(&bucket, &key).await {
|
|
info.version_id.map(|vid| {
|
|
if vid == Uuid::nil() {
|
|
"null".to_string()
|
|
} else {
|
|
vid.to_string()
|
|
}
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let output = GetObjectAttributesOutput {
|
|
checksum,
|
|
delete_marker: if info.delete_marker { Some(true) } else { None },
|
|
e_tag,
|
|
last_modified: info.mod_time.map(Timestamp::from),
|
|
object_parts,
|
|
object_size,
|
|
storage_class,
|
|
version_id: version_id.clone(),
|
|
..Default::default()
|
|
};
|
|
|
|
helper = helper.object(info).version_id(version_id.unwrap_or_default());
|
|
|
|
let result = Ok(S3Response::new(output));
|
|
let _ = helper.complete(&result);
|
|
result
|
|
}
|
|
}
|
|
|
|
fn object_attributes_requested(object_attributes: &[ObjectAttributes], name: &'static str) -> bool {
|
|
object_attributes.iter().any(|value| {
|
|
value.as_str().split(',').any(|part| {
|
|
part.trim_matches(|c: char| c.is_whitespace() || c == '"' || c == '\'')
|
|
.eq_ignore_ascii_case(name)
|
|
})
|
|
})
|
|
}
|
|
|
|
/// Bytes the inline tee may queue for the write-back ahead of the client.
|
|
const ODM_INLINE_TEE_BUFFER_BYTES: usize = 1024 * 1024;
|
|
/// Chunks the client-side pump may run ahead of the response body.
|
|
const ODM_INLINE_CLIENT_CHANNEL_CHUNKS: usize = 8;
|
|
/// Read size of the source body streams handed to the client.
|
|
const ODM_SOURCE_BODY_CHUNK_BYTES: usize = 64 * 1024;
|
|
|
|
/// Source seam for the on-demand migration GET read-through: production
|
|
/// goes through [`SourceClient`], tests script the answers.
|
|
pub(super) trait OdmGetSource {
|
|
async fn head_object(&self, key: &str) -> Result<SourceHead, SourceError>;
|
|
async fn get_object(&self, key: &str, range: Option<&HTTPRangeSpec>) -> Result<SourceGet, SourceError>;
|
|
async fn get_object_tagging(&self, key: &str) -> Result<HashMap<String, String>, SourceError>;
|
|
}
|
|
|
|
impl OdmGetSource for SourceClient {
|
|
async fn head_object(&self, key: &str) -> Result<SourceHead, SourceError> {
|
|
SourceClient::head_object(self, key).await
|
|
}
|
|
|
|
async fn get_object(&self, key: &str, range: Option<&HTTPRangeSpec>) -> Result<SourceGet, SourceError> {
|
|
SourceClient::get_object(self, key, range).await
|
|
}
|
|
|
|
async fn get_object_tagging(&self, key: &str) -> Result<HashMap<String, String>, SourceError> {
|
|
SourceClient::get_object_tagging(self, key).await
|
|
}
|
|
}
|
|
|
|
/// What the on-demand migration runtime decided for a GET miss before any
|
|
/// source traffic.
|
|
pub(super) enum OdmGetVerdict {
|
|
/// Answer with this error without touching the source.
|
|
Fail(S3Error),
|
|
/// Consult the source through `client`.
|
|
Consult {
|
|
state: Arc<BucketOdmState>,
|
|
client: Arc<SourceClient>,
|
|
},
|
|
}
|
|
|
|
/// How the source flow answers a GET miss.
|
|
pub(super) enum OdmGetReply {
|
|
/// Stream this source-backed body to the client; `backfill` names the
|
|
/// background pull to queue once the response is on its way.
|
|
Served {
|
|
output: Box<GetObjectOutput>,
|
|
backfill: Option<PullReason>,
|
|
},
|
|
Error(S3Error),
|
|
/// A concurrent leader committed the object locally; read local again.
|
|
RetryLocal,
|
|
}
|
|
|
|
/// What the GET handler does after the on-demand migration branch.
|
|
pub(super) enum OdmGetOutcome {
|
|
Respond(S3Result<Box<GetObjectOutput>>),
|
|
RetryLocal,
|
|
}
|
|
|
|
/// Request-level gate for the GET read-through, on top of the shared
|
|
/// [`odm_request_may_consult_source`] (version reads and anti-loop marked
|
|
/// requests): a `partNumber` read has no source counterpart, since the
|
|
/// source's part layout is its own, so it keeps the original 404.
|
|
pub(super) fn odm_get_may_consult_source(opts: &ObjectOptions, part_number: Option<usize>) -> bool {
|
|
part_number.is_none() && odm_request_may_consult_source(opts)
|
|
}
|
|
|
|
/// Applies the lookup verdict of [`OnDemandMigrationSys::resolve`] to a GET
|
|
/// miss, recording the outcome for every request that stops here. Unlike
|
|
/// HEAD, an open breaker follows `policy.source_error` (the spec's
|
|
/// "source error / breaker open" row).
|
|
pub(super) fn odm_get_verdict(lookup: OdmLookup) -> OdmGetVerdict {
|
|
let state = Arc::clone(lookup.state());
|
|
let policy = &state.config().policy;
|
|
let stats = state.stats();
|
|
let source_error = |class: &'static str| {
|
|
stats.record_request(OdmOp::Get, OdmOutcome::SourceError);
|
|
OdmGetVerdict::Fail(odm_source_error_response(policy, class))
|
|
};
|
|
match &lookup {
|
|
OdmLookup::NegativeCached { .. } => {
|
|
stats.record_request(OdmOp::Get, OdmOutcome::NegativeCached);
|
|
OdmGetVerdict::Fail(S3Error::new(S3ErrorCode::NoSuchKey))
|
|
}
|
|
OdmLookup::BreakerOpen { .. } => {
|
|
stats.record_request(OdmOp::Get, OdmOutcome::BreakerOpen);
|
|
OdmGetVerdict::Fail(odm_source_error_response(policy, "breaker_open"))
|
|
}
|
|
OdmLookup::Unavailable { error, .. } => source_error(odm_state_error_class(error)),
|
|
OdmLookup::Ready { .. } => match state.client() {
|
|
Ok(client) => OdmGetVerdict::Consult {
|
|
client: Arc::clone(client),
|
|
state: Arc::clone(&state),
|
|
},
|
|
Err(error) => source_error(odm_state_error_class(error)),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Runs one source call and feeds its latency and error into the bucket
|
|
/// runtime (breaker scoring, negative cache, `last_source_error`).
|
|
async fn odm_observe<T>(
|
|
state: &BucketOdmState,
|
|
key: &str,
|
|
call: impl std::future::Future<Output = Result<T, SourceError>>,
|
|
) -> Result<T, SourceError> {
|
|
let started = Instant::now();
|
|
let result = call.await;
|
|
state.observe_source(started.elapsed(), key, result.as_ref().err());
|
|
result
|
|
}
|
|
|
|
/// Client-facing error for a failed source call, recording the GET outcome:
|
|
/// 404 for a source miss, 424 for an unsupported source object (SSE-C),
|
|
/// `policy.source_error` for everything else.
|
|
fn odm_get_source_failure(state: &BucketOdmState, err: &SourceError) -> S3Error {
|
|
let stats = state.stats();
|
|
match err {
|
|
SourceError::NotFound => {
|
|
stats.record_request(OdmOp::Get, OdmOutcome::SourceMiss);
|
|
S3Error::new(S3ErrorCode::NoSuchKey)
|
|
}
|
|
SourceError::Unsupported(_) => {
|
|
stats.record_request(OdmOp::Get, OdmOutcome::Unsupported);
|
|
odm_source_unavailable_error(err.class_label())
|
|
}
|
|
_ => {
|
|
stats.record_request(OdmOp::Get, OdmOutcome::SourceError);
|
|
odm_source_error_response(&state.config().policy, err.class_label())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn odm_content_length(size: u64) -> S3Result<i64> {
|
|
i64::try_from(size)
|
|
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "source object size exceeds the content-length range"))
|
|
}
|
|
|
|
/// Maps a source body onto the s3s output. Only what the source can vouch
|
|
/// for is returned: the ETag and Last-Modified are the source's (the object
|
|
/// is not local yet); no version id, SSE headers, storage class or checksums.
|
|
fn odm_get_output(head: &SourceHead, content_length: i64, content_range: Option<String>, body: StreamingBlob) -> GetObjectOutput {
|
|
GetObjectOutput {
|
|
body: Some(body),
|
|
content_length: Some(content_length),
|
|
content_range,
|
|
content_type: head.content_type.as_deref().and_then(|v| ContentType::from_str(v).ok()),
|
|
content_encoding: head.content_encoding.clone(),
|
|
content_disposition: head.content_disposition.clone(),
|
|
content_language: head.content_language.clone(),
|
|
cache_control: head.cache_control.clone(),
|
|
expires: head.expires.clone(),
|
|
accept_ranges: Some(ACCEPT_RANGES_BYTES.to_string()),
|
|
e_tag: head.etag.as_deref().map(to_s3s_etag),
|
|
last_modified: head.last_modified.map(OffsetDateTime::from).map(Timestamp::from),
|
|
metadata: (!head.user_metadata.is_empty()).then(|| head.user_metadata.clone()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Feeds the tee primary to the client through a channel: the primary owns a
|
|
/// `!Sync` boxed source while the response body must be `Sync`. Dropping the
|
|
/// body ends the pump, which drops the primary and hands the remaining
|
|
/// source bytes to the tee's drain task (`drain_on_primary_drop`).
|
|
fn odm_inline_client_body(primary: TeePrimary) -> StreamingBlob {
|
|
let (tx, rx) = tokio::sync::mpsc::channel::<io::Result<Bytes>>(ODM_INLINE_CLIENT_CHANNEL_CHUNKS);
|
|
spawn_traced(async move {
|
|
let mut chunks = tokio_util::io::ReaderStream::with_capacity(primary, ODM_SOURCE_BODY_CHUNK_BYTES);
|
|
while let Some(chunk) = chunks.next().await {
|
|
let failed = chunk.is_err();
|
|
if tx.send(chunk).await.is_err() || failed {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
StreamingBlob::wrap(ReceiverStream::new(rx))
|
|
}
|
|
|
|
/// Streams one source GET straight to the client (no local persistence);
|
|
/// `backfill` is the background pull the caller queues.
|
|
async fn odm_get_passthrough<S: OdmGetSource>(
|
|
state: &Arc<BucketOdmState>,
|
|
source: &S,
|
|
headers: &HeaderMap,
|
|
key: &str,
|
|
range: Option<&HTTPRangeSpec>,
|
|
backfill: Option<PullReason>,
|
|
) -> OdmGetReply {
|
|
let get = match odm_observe(state, key, source.get_object(key, range)).await {
|
|
Ok(get) => get,
|
|
Err(err) => return OdmGetReply::Error(odm_get_source_failure(state, &err)),
|
|
};
|
|
if let Err(err) = odm_check_source_preconditions(headers, &get.head) {
|
|
return OdmGetReply::Error(err);
|
|
}
|
|
let content_length = match odm_content_length(get.head.size) {
|
|
Ok(length) => length,
|
|
Err(err) => {
|
|
state.stats().record_request(OdmOp::Get, OdmOutcome::SourceError);
|
|
return OdmGetReply::Error(err);
|
|
}
|
|
};
|
|
let body = StreamingBlob::wrap(tokio_util::io::ReaderStream::with_capacity(
|
|
get.body.into_async_read(),
|
|
ODM_SOURCE_BODY_CHUNK_BYTES,
|
|
));
|
|
state.stats().record_request(OdmOp::Get, OdmOutcome::SourceHit);
|
|
OdmGetReply::Served {
|
|
output: Box::new(odm_get_output(&get.head, content_length, get.content_range, body)),
|
|
backfill,
|
|
}
|
|
}
|
|
|
|
/// Leader side of an inline pull: one source GET teed between the client
|
|
/// (primary) and [`commit_inline`] (secondary, in a background task that
|
|
/// survives the request). The write-back result completes the singleflight
|
|
/// slot; a failed write-back never affects the client stream.
|
|
async fn odm_get_inline<S: OdmGetSource>(
|
|
state: &Arc<BucketOdmState>,
|
|
source: &S,
|
|
headers: &HeaderMap,
|
|
key: &str,
|
|
leader: PullLeader,
|
|
request_context: Option<request_context::RequestContext>,
|
|
) -> OdmGetReply {
|
|
let policy = &state.config().policy;
|
|
let tags = if policy.copy_tags {
|
|
match odm_observe(state, key, source.get_object_tagging(key)).await {
|
|
Ok(tags) => Some(tags),
|
|
Err(err) => {
|
|
leader.complete(Err(PullError::from(&err)));
|
|
return OdmGetReply::Error(odm_get_source_failure(state, &err));
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let get = match odm_observe(state, key, source.get_object(key, None)).await {
|
|
Ok(get) => get,
|
|
Err(err) => {
|
|
leader.complete(Err(PullError::from(&err)));
|
|
return OdmGetReply::Error(odm_get_source_failure(state, &err));
|
|
}
|
|
};
|
|
let SourceGet {
|
|
head,
|
|
body,
|
|
content_range,
|
|
} = get;
|
|
// HEAD and GET can observe different source versions. Validate the
|
|
// representation whose body will actually be returned and persisted.
|
|
if let Err(err) = odm_check_source_preconditions(headers, &head) {
|
|
leader.complete(Err(PullError::canceled("source GET did not satisfy request preconditions")));
|
|
return OdmGetReply::Error(err);
|
|
}
|
|
// The object outgrew the inline budget between HEAD and GET: followers
|
|
// stream through on their own and the background pull stores it.
|
|
if head.size > policy.inline_max_bytes {
|
|
leader.complete(Err(PullError::canceled("source object exceeds the inline budget")));
|
|
let content_length = match odm_content_length(head.size) {
|
|
Ok(length) => length,
|
|
Err(err) => {
|
|
state.stats().record_request(OdmOp::Get, OdmOutcome::SourceError);
|
|
return OdmGetReply::Error(err);
|
|
}
|
|
};
|
|
let body = StreamingBlob::wrap(tokio_util::io::ReaderStream::with_capacity(
|
|
body.into_async_read(),
|
|
ODM_SOURCE_BODY_CHUNK_BYTES,
|
|
));
|
|
state.stats().record_request(OdmOp::Get, OdmOutcome::SourceHit);
|
|
return OdmGetReply::Served {
|
|
output: Box::new(odm_get_output(&head, content_length, content_range, body)),
|
|
backfill: Some(PullReason::LargeObject),
|
|
};
|
|
}
|
|
// `inline_max_bytes` is bounded far below `i64::MAX`.
|
|
let content_length = head.size as i64;
|
|
let options = TeeOptions {
|
|
drain_on_primary_drop: true,
|
|
max_drain_bytes: usize::try_from(policy.inline_max_bytes).unwrap_or(usize::MAX),
|
|
};
|
|
// The inline path has no background pump, so `source_timeout.idle_ms` is
|
|
// applied to the teed body here; without it a stalled source would hold
|
|
// both the client stream and the write-back open until the SDK read
|
|
// timeout fires. The guard wraps the source read, upstream of the tee, so
|
|
// a slow client throttles the tee instead of ageing the source's budget.
|
|
let source_body: SourceBody = Box::pin(tokio_util::io::ReaderStream::with_capacity(
|
|
body.into_async_read(),
|
|
ODM_SOURCE_BODY_CHUNK_BYTES,
|
|
));
|
|
let (guarded, idle) = idle_guarded_body(source_body, Duration::from_millis(policy.source_timeout.idle_ms));
|
|
let (primary, secondary) =
|
|
tee_reader_with_options(Box::pin(tokio_util::io::StreamReader::new(guarded)), ODM_INLINE_TEE_BUFFER_BYTES, options);
|
|
let output = Box::new(odm_get_output(&head, content_length, content_range, odm_inline_client_body(primary)));
|
|
let commit_state = Arc::clone(state);
|
|
let commit_key = key.to_string();
|
|
spawn_background_with_context(request_context, async move {
|
|
let body: WriteBackBody = Box::pin(secondary.into_stream());
|
|
let result = commit_inline(&commit_state, &commit_key, head, tags, body, &idle).await;
|
|
leader.complete(result.map(|outcome| PullOutcome {
|
|
etag: outcome.etag,
|
|
size: outcome.size,
|
|
}));
|
|
});
|
|
state.stats().record_request(OdmOp::Get, OdmOutcome::SourceHit);
|
|
OdmGetReply::Served { output, backfill: None }
|
|
}
|
|
|
|
/// One GET miss against the source (rustfs/backlog#2156). Source HEAD first
|
|
/// (size, validators, metadata); conditional headers are evaluated locally
|
|
/// against it, never forwarded. Then one of: passthrough for a Range GET or
|
|
/// an object above `inline_max_bytes` (plus a queued background pull), or
|
|
/// the inline tee for a small object. Concurrent misses of one key share
|
|
/// the singleflight slot: only the leader tees; followers re-read local once
|
|
/// it commits, or stream through (without queueing) after `first_byte_ms`
|
|
/// or when the leader fails.
|
|
pub(super) async fn odm_get_from_source<S: OdmGetSource>(
|
|
state: &Arc<BucketOdmState>,
|
|
source: &S,
|
|
headers: &HeaderMap,
|
|
key: &str,
|
|
range: Option<&HTTPRangeSpec>,
|
|
request_context: Option<request_context::RequestContext>,
|
|
) -> OdmGetReply {
|
|
let head = match odm_observe(state, key, source.head_object(key)).await {
|
|
Ok(head) => head,
|
|
Err(err) => return OdmGetReply::Error(odm_get_source_failure(state, &err)),
|
|
};
|
|
let stats = state.stats();
|
|
if let Err(err) = odm_check_source_preconditions(headers, &head) {
|
|
stats.record_request(OdmOp::Get, OdmOutcome::SourceHit);
|
|
return OdmGetReply::Error(err);
|
|
}
|
|
let policy = &state.config().policy;
|
|
if let Some(range) = range {
|
|
let backfill = (policy.range_get == RangeGetPolicy::ServeAndBackfill).then_some(PullReason::RangeGet);
|
|
return odm_get_passthrough(state, source, headers, key, Some(range), backfill).await;
|
|
}
|
|
if head.size > policy.inline_max_bytes {
|
|
return odm_get_passthrough(state, source, headers, key, None, Some(PullReason::LargeObject)).await;
|
|
}
|
|
let slot = match state.acquire_pull_slot(key).await {
|
|
Ok(slot) => slot,
|
|
// The bucket state was torn down under this request: serve it
|
|
// without queueing anything on the old state.
|
|
Err(_) => return odm_get_passthrough(state, source, headers, key, None, None).await,
|
|
};
|
|
match slot {
|
|
PullSlot::Leader(leader) => odm_get_inline(state, source, headers, key, leader, request_context).await,
|
|
PullSlot::Follower(follower) => {
|
|
let first_byte = Duration::from_millis(policy.source_timeout.first_byte_ms);
|
|
match tokio::time::timeout(first_byte, follower.wait()).await {
|
|
Ok(Ok(_)) => {
|
|
stats.record_request(OdmOp::Get, OdmOutcome::SourceHit);
|
|
OdmGetReply::RetryLocal
|
|
}
|
|
Ok(Err(_)) | Err(_) => odm_get_passthrough(state, source, headers, key, None, None).await,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod on_demand_migration_tests {
|
|
use super::*;
|
|
use crate::on_demand_migration::{
|
|
BREAKER_FAILURE_THRESHOLD, BreakerState, FilterConfig, OdmStateError, OnDemandMigrationConfig, PathStyle, PolicyConfig,
|
|
Provider, SourceConfig, SourceCredentials, SourceErrorPolicy, TlsConfig,
|
|
};
|
|
use crate::on_demand_migration::{
|
|
LocalObject, OdmWriteBack, WriteBackError, WriteBackOutcome, WriteBackPart, WriteBackRequest,
|
|
};
|
|
use async_trait::async_trait;
|
|
use aws_sdk_s3::primitives::ByteStream as AwsByteStream;
|
|
use std::collections::VecDeque;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::time::SystemTime;
|
|
|
|
const KEY: &str = "docs/report.bin";
|
|
|
|
/// A configured, enabled bucket source pointing at an unreachable
|
|
/// endpoint; the client is built (no network) and only the scripted
|
|
/// source below is ever called.
|
|
fn odm_config(policy: PolicyConfig) -> OnDemandMigrationConfig {
|
|
OnDemandMigrationConfig {
|
|
version: 1,
|
|
enabled: true,
|
|
source: SourceConfig {
|
|
provider: Provider::Minio,
|
|
endpoint: Some("https://source.example.invalid:9000".to_string()),
|
|
region: "auto".to_string(),
|
|
bucket: "legacy".to_string(),
|
|
path_style: PathStyle::Auto,
|
|
credentials: Some(SourceCredentials {
|
|
access_key: "AK".to_string(),
|
|
secret_key: "SK".to_string(),
|
|
session_token: None,
|
|
}),
|
|
tls: TlsConfig::default(),
|
|
azure: None,
|
|
gcs: None,
|
|
},
|
|
filter: FilterConfig {
|
|
prefix: None,
|
|
source_prefix: None,
|
|
},
|
|
policy,
|
|
}
|
|
}
|
|
|
|
/// Write-back double: collects every committed body, optionally fails.
|
|
#[derive(Default)]
|
|
struct RecordingWriteBack {
|
|
puts: Mutex<Vec<(WriteBackRequest, Vec<u8>)>>,
|
|
fail_puts: AtomicBool,
|
|
}
|
|
|
|
impl RecordingWriteBack {
|
|
fn puts(&self) -> Vec<(WriteBackRequest, Vec<u8>)> {
|
|
self.puts.lock().expect("write-back lock").clone()
|
|
}
|
|
|
|
async fn wait_for_put(&self) -> (WriteBackRequest, Vec<u8>) {
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
loop {
|
|
if let Some(put) = self.puts().pop() {
|
|
return put;
|
|
}
|
|
assert!(Instant::now() < deadline, "write-back did not commit in time");
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl OdmWriteBack for RecordingWriteBack {
|
|
async fn local_object(&self, _bucket: &str, _key: &str) -> Result<Option<LocalObject>, WriteBackError> {
|
|
Ok(None)
|
|
}
|
|
|
|
async fn put_object(
|
|
&self,
|
|
request: &WriteBackRequest,
|
|
mut body: WriteBackBody,
|
|
) -> Result<WriteBackOutcome, WriteBackError> {
|
|
let mut bytes = Vec::new();
|
|
while let Some(chunk) = body.next().await {
|
|
let chunk = chunk.map_err(|err| WriteBackError::Local(err.to_string()))?;
|
|
bytes.extend_from_slice(&chunk);
|
|
}
|
|
if self.fail_puts.load(Ordering::SeqCst) {
|
|
return Err(WriteBackError::Local("scripted local failure".to_string()));
|
|
}
|
|
let size = bytes.len() as u64;
|
|
self.puts.lock().expect("write-back lock").push((request.clone(), bytes));
|
|
Ok(WriteBackOutcome {
|
|
etag: request.head.etag.clone(),
|
|
size,
|
|
version_id: None,
|
|
})
|
|
}
|
|
|
|
async fn create_multipart_upload(&self, _request: &WriteBackRequest) -> Result<String, WriteBackError> {
|
|
Err(WriteBackError::Local("multipart is not part of the inline path".to_string()))
|
|
}
|
|
|
|
async fn upload_part(
|
|
&self,
|
|
_request: &WriteBackRequest,
|
|
_upload_id: &str,
|
|
_part_number: usize,
|
|
_size: u64,
|
|
_body: WriteBackBody,
|
|
) -> Result<WriteBackPart, WriteBackError> {
|
|
Err(WriteBackError::Local("multipart is not part of the inline path".to_string()))
|
|
}
|
|
|
|
async fn complete_multipart_upload(
|
|
&self,
|
|
_request: &WriteBackRequest,
|
|
_upload_id: &str,
|
|
_parts: Vec<WriteBackPart>,
|
|
) -> Result<WriteBackOutcome, WriteBackError> {
|
|
Err(WriteBackError::Local("multipart is not part of the inline path".to_string()))
|
|
}
|
|
|
|
async fn abort_multipart_upload(&self, _request: &WriteBackRequest, _upload_id: &str) -> Result<(), WriteBackError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct TestRuntime {
|
|
sys: OnDemandMigrationSys,
|
|
write_back: Arc<RecordingWriteBack>,
|
|
}
|
|
|
|
async fn runtime(bucket: &str, policy: PolicyConfig) -> TestRuntime {
|
|
let sys = OnDemandMigrationSys::new();
|
|
sys.set_module_enabled(true);
|
|
let write_back = Arc::new(RecordingWriteBack::default());
|
|
sys.set_write_back(Arc::clone(&write_back) as Arc<dyn OdmWriteBack>);
|
|
sys.apply(bucket, Some(&odm_config(policy))).await;
|
|
TestRuntime { sys, write_back }
|
|
}
|
|
|
|
impl TestRuntime {
|
|
fn state(&self, bucket: &str) -> Arc<BucketOdmState> {
|
|
self.sys.state(bucket).expect("bucket is configured")
|
|
}
|
|
}
|
|
|
|
type ScriptedGet = Result<(SourceHead, Vec<u8>, Option<String>), SourceError>;
|
|
|
|
struct ScriptedSource {
|
|
heads: Mutex<VecDeque<Result<SourceHead, SourceError>>>,
|
|
gets: Mutex<VecDeque<ScriptedGet>>,
|
|
head_calls: AtomicUsize,
|
|
get_calls: AtomicUsize,
|
|
tag_calls: AtomicUsize,
|
|
ranges: Mutex<Vec<Option<(bool, i64, i64)>>>,
|
|
}
|
|
|
|
impl ScriptedSource {
|
|
fn new(heads: Vec<Result<SourceHead, SourceError>>, gets: Vec<ScriptedGet>) -> Self {
|
|
Self {
|
|
heads: Mutex::new(heads.into_iter().collect()),
|
|
gets: Mutex::new(gets.into_iter().collect()),
|
|
head_calls: AtomicUsize::new(0),
|
|
get_calls: AtomicUsize::new(0),
|
|
tag_calls: AtomicUsize::new(0),
|
|
ranges: Mutex::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
fn for_object(body: &[u8]) -> Self {
|
|
let head = source_head(body);
|
|
Self::new(vec![Ok(head.clone())], vec![Ok((head, body.to_vec(), None))])
|
|
}
|
|
|
|
fn head_calls(&self) -> usize {
|
|
self.head_calls.load(Ordering::SeqCst)
|
|
}
|
|
|
|
fn get_calls(&self) -> usize {
|
|
self.get_calls.load(Ordering::SeqCst)
|
|
}
|
|
|
|
fn ranges(&self) -> Vec<Option<(bool, i64, i64)>> {
|
|
self.ranges.lock().expect("ranges lock").clone()
|
|
}
|
|
}
|
|
|
|
impl OdmGetSource for ScriptedSource {
|
|
async fn head_object(&self, _key: &str) -> Result<SourceHead, SourceError> {
|
|
self.head_calls.fetch_add(1, Ordering::SeqCst);
|
|
self.heads
|
|
.lock()
|
|
.expect("heads lock")
|
|
.pop_front()
|
|
.expect("test script must provide a response for every source HEAD")
|
|
}
|
|
|
|
async fn get_object(&self, _key: &str, range: Option<&HTTPRangeSpec>) -> Result<SourceGet, SourceError> {
|
|
self.get_calls.fetch_add(1, Ordering::SeqCst);
|
|
self.ranges
|
|
.lock()
|
|
.expect("ranges lock")
|
|
.push(range.map(|r| (r.is_suffix_length, r.start, r.end)));
|
|
let scripted = self
|
|
.gets
|
|
.lock()
|
|
.expect("gets lock")
|
|
.pop_front()
|
|
.expect("test script must provide a response for every source GET");
|
|
scripted.map(|(head, body, content_range)| SourceGet {
|
|
head,
|
|
body: AwsByteStream::from(body),
|
|
content_range,
|
|
})
|
|
}
|
|
|
|
async fn get_object_tagging(&self, _key: &str) -> Result<HashMap<String, String>, SourceError> {
|
|
self.tag_calls.fetch_add(1, Ordering::SeqCst);
|
|
Ok(HashMap::from([("team".to_string(), "docs".to_string())]))
|
|
}
|
|
}
|
|
|
|
fn payload(len: usize) -> Vec<u8> {
|
|
(0..len).map(|index| (index % 251) as u8).collect()
|
|
}
|
|
|
|
fn source_head(body: &[u8]) -> SourceHead {
|
|
SourceHead {
|
|
etag: Some(Md5::digest(body).iter().map(|byte| format!("{byte:02x}")).collect()),
|
|
size: body.len() as u64,
|
|
last_modified: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_767_225_600)),
|
|
content_type: Some("application/octet-stream".to_string()),
|
|
cache_control: Some("max-age=60".to_string()),
|
|
user_metadata: HashMap::from([("owner".to_string(), "alice".to_string())]),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
async fn collect_body(output: &mut GetObjectOutput) -> Vec<u8> {
|
|
let mut body = output.body.take().expect("source-backed output carries a body");
|
|
let mut bytes = Vec::new();
|
|
while let Some(chunk) = body.next().await {
|
|
bytes.extend_from_slice(&chunk.expect("body chunk"));
|
|
}
|
|
bytes
|
|
}
|
|
|
|
fn get_count(state: &BucketOdmState, outcome: OdmOutcome) -> u64 {
|
|
state.stats().snapshot(state.breaker().state()).requests_total["get"][outcome.as_str()]
|
|
}
|
|
|
|
fn served(reply: OdmGetReply) -> (GetObjectOutput, Option<PullReason>) {
|
|
match reply {
|
|
OdmGetReply::Served { output, backfill } => (*output, backfill),
|
|
OdmGetReply::Error(err) => panic!("expected Served, got {err:?}"),
|
|
OdmGetReply::RetryLocal => panic!("expected Served, got RetryLocal"),
|
|
}
|
|
}
|
|
|
|
fn failed(reply: OdmGetReply) -> S3Error {
|
|
match reply {
|
|
OdmGetReply::Error(err) => err,
|
|
OdmGetReply::Served { .. } => panic!("expected Error, got Served"),
|
|
OdmGetReply::RetryLocal => panic!("expected Error, got RetryLocal"),
|
|
}
|
|
}
|
|
|
|
fn consult(sys: &OnDemandMigrationSys, bucket: &str) -> Arc<BucketOdmState> {
|
|
match odm_get_verdict(sys.resolve(bucket, KEY).expect("bucket is configured")) {
|
|
OdmGetVerdict::Consult { state, .. } => state,
|
|
OdmGetVerdict::Fail(err) => panic!("expected Consult, got {err:?}"),
|
|
}
|
|
}
|
|
|
|
fn fail(sys: &OnDemandMigrationSys, bucket: &str) -> S3Error {
|
|
match odm_get_verdict(sys.resolve(bucket, KEY).expect("bucket is configured")) {
|
|
OdmGetVerdict::Fail(err) => err,
|
|
OdmGetVerdict::Consult { .. } => panic!("expected Fail, got Consult"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn odm_get_gate_rejects_part_reads_and_version_reads() {
|
|
let plain = ObjectOptions::default();
|
|
assert!(odm_get_may_consult_source(&plain, None));
|
|
assert!(!odm_get_may_consult_source(&plain, Some(1)), "a partNumber read keeps its local 404");
|
|
|
|
let versioned_read = ObjectOptions {
|
|
version_id: Some(uuid::Uuid::new_v4().to_string()),
|
|
..Default::default()
|
|
};
|
|
assert!(!odm_get_may_consult_source(&versioned_read, None));
|
|
|
|
let proxy_marked = ObjectOptions {
|
|
proxy_header_set: true,
|
|
..Default::default()
|
|
};
|
|
assert!(!odm_get_may_consult_source(&proxy_marked, None));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_inline_streams_to_client_and_commits_the_same_bytes() {
|
|
let rt = runtime("b", PolicyConfig::default()).await;
|
|
let state = rt.state("b");
|
|
let data = payload(300 * 1024);
|
|
let source = ScriptedSource::for_object(&data);
|
|
|
|
let (mut output, backfill) = served(odm_get_from_source(&state, &source, &HeaderMap::new(), KEY, None, None).await);
|
|
assert_eq!(backfill, None, "inline objects are stored by the tee, not queued");
|
|
assert_eq!(output.content_length, Some(data.len() as i64));
|
|
assert_eq!(output.e_tag, Some(to_s3s_etag(source_head(&data).etag.as_deref().unwrap())));
|
|
assert_eq!(
|
|
output.content_type.as_ref().map(|v| v.to_string()),
|
|
Some("application/octet-stream".to_string())
|
|
);
|
|
assert_eq!(output.cache_control.as_deref(), Some("max-age=60"));
|
|
assert_eq!(output.metadata, Some(HashMap::from([("owner".to_string(), "alice".to_string())])));
|
|
assert_eq!(output.version_id, None, "no x-amz-version-id for a source answer");
|
|
assert_eq!(output.server_side_encryption, None);
|
|
assert_eq!(output.storage_class, None);
|
|
assert_eq!(output.checksum_sha256, None);
|
|
assert_eq!(collect_body(&mut output).await, data, "the client receives the source bytes");
|
|
|
|
let (request, stored) = rt.write_back.wait_for_put().await;
|
|
assert_eq!(stored, data, "the local copy is the source bytes");
|
|
assert_eq!(request.bucket, "b");
|
|
assert_eq!(request.key, KEY);
|
|
assert_eq!(request.head, source_head(&data));
|
|
assert_eq!(request.tags, None, "copy_tags is off by default");
|
|
assert_eq!(source.get_calls(), 1, "exactly one source GET");
|
|
assert_eq!(source.head_calls(), 1);
|
|
assert_eq!(source.tag_calls.load(Ordering::SeqCst), 0);
|
|
|
|
// The leader released the key; the next miss would lead again.
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
while state.inflight_keys() != 0 {
|
|
assert!(Instant::now() < deadline, "leader must release the key after the commit");
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
}
|
|
assert_eq!(get_count(&state, OdmOutcome::SourceHit), 1);
|
|
let snapshot = state.stats().snapshot(state.breaker().state());
|
|
assert_eq!(snapshot.pulled_objects_total["inline"], 1);
|
|
assert_eq!(snapshot.pulled_bytes_total, data.len() as u64);
|
|
assert_eq!(snapshot.source_latency.count, 2, "HEAD and GET are both observed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_inline_copies_tags_when_configured() {
|
|
let rt = runtime(
|
|
"t",
|
|
PolicyConfig {
|
|
copy_tags: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await;
|
|
let state = rt.state("t");
|
|
let data = payload(1024);
|
|
let source = ScriptedSource::for_object(&data);
|
|
|
|
let (mut output, _) = served(odm_get_from_source(&state, &source, &HeaderMap::new(), KEY, None, None).await);
|
|
assert_eq!(collect_body(&mut output).await, data);
|
|
let (request, _) = rt.write_back.wait_for_put().await;
|
|
assert_eq!(request.tags, Some(HashMap::from([("team".to_string(), "docs".to_string())])));
|
|
assert_eq!(source.tag_calls.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_inline_client_disconnect_still_stores_the_whole_object() {
|
|
let rt = runtime("d", PolicyConfig::default()).await;
|
|
let state = rt.state("d");
|
|
let data = payload(4 * 1024 * 1024);
|
|
let source = ScriptedSource::for_object(&data);
|
|
|
|
let (mut output, _) = served(odm_get_from_source(&state, &source, &HeaderMap::new(), KEY, None, None).await);
|
|
let mut body = output.body.take().expect("body");
|
|
let mut received = 0usize;
|
|
while received < data.len() / 10 {
|
|
let chunk = body.next().await.expect("body chunk").expect("body chunk");
|
|
received += chunk.len();
|
|
}
|
|
drop(body);
|
|
|
|
let (_, stored) = rt.write_back.wait_for_put().await;
|
|
assert_eq!(stored, data, "the drain completes the local copy after the client left");
|
|
assert_eq!(source.get_calls(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_inline_write_back_failure_does_not_touch_the_client_stream() {
|
|
let rt = runtime("f", PolicyConfig::default()).await;
|
|
rt.write_back.fail_puts.store(true, Ordering::SeqCst);
|
|
let state = rt.state("f");
|
|
let data = payload(64 * 1024);
|
|
let source = ScriptedSource::for_object(&data);
|
|
|
|
let (mut output, _) = served(odm_get_from_source(&state, &source, &HeaderMap::new(), KEY, None, None).await);
|
|
assert_eq!(collect_body(&mut output).await, data);
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
loop {
|
|
let snapshot = state.stats().snapshot(state.breaker().state());
|
|
if snapshot.pull_failures_total["local_write"] == 1 {
|
|
assert_eq!(snapshot.pulled_objects_total["inline"], 0);
|
|
break;
|
|
}
|
|
assert!(Instant::now() < deadline, "write-back failure must be counted");
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
}
|
|
assert!(rt.write_back.puts().is_empty());
|
|
// No retry: the next miss goes to the source again.
|
|
assert!(matches!(rt.sys.resolve("f", KEY), Some(OdmLookup::Ready { .. })));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_large_object_streams_through_and_queues_a_background_pull() {
|
|
let rt = runtime(
|
|
"l",
|
|
PolicyConfig {
|
|
inline_max_bytes: 1024,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await;
|
|
let state = rt.state("l");
|
|
let data = payload(4096);
|
|
let source = ScriptedSource::for_object(&data);
|
|
|
|
let (mut output, backfill) = served(odm_get_from_source(&state, &source, &HeaderMap::new(), KEY, None, None).await);
|
|
assert_eq!(backfill, Some(PullReason::LargeObject));
|
|
assert_eq!(output.content_length, Some(4096));
|
|
assert_eq!(collect_body(&mut output).await, data);
|
|
assert_eq!(source.ranges(), vec![None], "the whole object is streamed");
|
|
assert_eq!(source.get_calls(), 1);
|
|
assert_eq!(get_count(&state, OdmOutcome::SourceHit), 1);
|
|
assert_eq!(state.inflight_keys(), 0, "passthrough never takes the singleflight slot");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
assert!(rt.write_back.puts().is_empty(), "passthrough writes nothing inline");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_range_streams_206_and_queues_per_policy() {
|
|
let data = payload(10_000);
|
|
let range = HTTPRangeSpec {
|
|
is_suffix_length: false,
|
|
start: 10,
|
|
end: 19,
|
|
};
|
|
let slice = data[10..20].to_vec();
|
|
let mut slice_head = source_head(&data);
|
|
slice_head.size = 10;
|
|
let script = || {
|
|
ScriptedSource::new(
|
|
vec![Ok(source_head(&data))],
|
|
vec![Ok((slice_head.clone(), slice.clone(), Some("bytes 10-19/10000".to_string())))],
|
|
)
|
|
};
|
|
|
|
let rt = runtime("r", PolicyConfig::default()).await;
|
|
let state = rt.state("r");
|
|
let source = script();
|
|
let (mut output, backfill) =
|
|
served(odm_get_from_source(&state, &source, &HeaderMap::new(), KEY, Some(&range), None).await);
|
|
assert_eq!(backfill, Some(PullReason::RangeGet), "serve_and_backfill queues the whole object");
|
|
assert_eq!(output.content_range.as_deref(), Some("bytes 10-19/10000"));
|
|
assert_eq!(output.content_length, Some(10));
|
|
assert_eq!(collect_body(&mut output).await, slice);
|
|
assert_eq!(source.ranges(), vec![Some((false, 10, 19))], "the Range is passed through");
|
|
assert_eq!(source.get_calls(), 1);
|
|
assert_eq!(state.inflight_keys(), 0);
|
|
|
|
let rt = runtime(
|
|
"o",
|
|
PolicyConfig {
|
|
range_get: RangeGetPolicy::ServeOnly,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await;
|
|
let state = rt.state("o");
|
|
let source = script();
|
|
let (_, backfill) = served(odm_get_from_source(&state, &source, &HeaderMap::new(), KEY, Some(&range), None).await);
|
|
assert_eq!(backfill, None, "serve_only never queues");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_conditional_headers_are_answered_from_the_source_head() {
|
|
let rt = runtime("c", PolicyConfig::default()).await;
|
|
let state = rt.state("c");
|
|
let data = payload(512);
|
|
let etag = source_head(&data).etag.expect("etag");
|
|
|
|
let source = ScriptedSource::for_object(&data);
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(http::header::IF_NONE_MATCH, HeaderValue::from_str(&format!("\"{etag}\"")).unwrap());
|
|
let err = failed(odm_get_from_source(&state, &source, &headers, KEY, None, None).await);
|
|
assert_eq!(err.code(), &S3ErrorCode::NotModified);
|
|
assert_eq!(source.head_calls(), 1);
|
|
assert_eq!(source.get_calls(), 0, "a 304 never pulls");
|
|
|
|
let source = ScriptedSource::for_object(&data);
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(http::header::IF_MATCH, HeaderValue::from_static("\"another-etag\""));
|
|
let err = failed(odm_get_from_source(&state, &source, &headers, KEY, None, None).await);
|
|
assert_eq!(err.code(), &S3ErrorCode::PreconditionFailed);
|
|
assert_eq!(source.get_calls(), 0, "a 412 never pulls");
|
|
assert_eq!(get_count(&state, OdmOutcome::SourceHit), 2);
|
|
assert_eq!(state.inflight_keys(), 0);
|
|
assert!(rt.write_back.puts().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_rechecks_conditions_against_the_get_representation() {
|
|
for inline_max_bytes in [0, 1024] {
|
|
for range in [
|
|
None,
|
|
Some(HTTPRangeSpec {
|
|
is_suffix_length: false,
|
|
start: 0,
|
|
end: 2,
|
|
}),
|
|
] {
|
|
let rt = runtime(
|
|
"changed-source",
|
|
PolicyConfig {
|
|
inline_max_bytes,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await;
|
|
let state = rt.state("changed-source");
|
|
let before = source_head(b"before");
|
|
let after = source_head(b"after!");
|
|
let source = ScriptedSource::new(vec![Ok(before.clone())], vec![Ok((after, b"after!".to_vec(), None))]);
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
http::header::IF_MATCH,
|
|
HeaderValue::from_str(&format!("\"{}\"", before.etag.expect("etag"))).expect("header"),
|
|
);
|
|
let error = failed(odm_get_from_source(&state, &source, &headers, KEY, range.as_ref(), None).await);
|
|
assert_eq!(error.code(), &S3ErrorCode::PreconditionFailed);
|
|
assert_eq!(source.get_calls(), 1);
|
|
assert_eq!(state.inflight_keys(), 0);
|
|
assert!(rt.write_back.puts().is_empty(), "a failed condition must not start write-back");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_missing_validators_cannot_bypass_a_condition() {
|
|
for inline_max_bytes in [0, 1024] {
|
|
let rt = runtime(
|
|
"missing-validator",
|
|
PolicyConfig {
|
|
inline_max_bytes,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await;
|
|
let state = rt.state("missing-validator");
|
|
let before = source_head(b"before");
|
|
let after = SourceHead {
|
|
size: 6,
|
|
..Default::default()
|
|
};
|
|
let source = ScriptedSource::new(vec![Ok(before.clone())], vec![Ok((after, b"after!".to_vec(), None))]);
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
http::header::IF_MATCH,
|
|
HeaderValue::from_str(&format!("\"{}\"", before.etag.expect("etag"))).expect("header"),
|
|
);
|
|
let error = failed(odm_get_from_source(&state, &source, &headers, KEY, None, None).await);
|
|
assert_eq!(error.status_code(), Some(StatusCode::FAILED_DEPENDENCY));
|
|
assert_eq!(error.message(), Some("missing_source_validator"));
|
|
assert!(rt.write_back.puts().is_empty());
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_source_not_found_is_404_and_negative_cached() {
|
|
let rt = runtime("n", PolicyConfig::default()).await;
|
|
let state = rt.state("n");
|
|
let source = ScriptedSource::new(vec![Err(SourceError::NotFound)], vec![]);
|
|
|
|
let err = failed(odm_get_from_source(&state, &source, &HeaderMap::new(), KEY, None, None).await);
|
|
assert_eq!(err.code(), &S3ErrorCode::NoSuchKey);
|
|
assert_eq!(get_count(&state, OdmOutcome::SourceMiss), 1);
|
|
|
|
let err = fail(&rt.sys, "n");
|
|
assert_eq!(err.code(), &S3ErrorCode::NoSuchKey);
|
|
assert_eq!(get_count(&state, OdmOutcome::NegativeCached), 1);
|
|
assert_eq!(source.head_calls(), 1, "the negative cache stops the second miss");
|
|
assert!(matches!(rt.sys.resolve("n", "other"), Some(OdmLookup::Ready { .. })));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_unsupported_source_object_is_424() {
|
|
let rt = runtime("s", PolicyConfig::default()).await;
|
|
let state = rt.state("s");
|
|
let source = ScriptedSource::new(
|
|
vec![Err(SourceError::Unsupported(
|
|
"source object is encrypted with SSE-C".to_string(),
|
|
))],
|
|
vec![],
|
|
);
|
|
|
|
let err = failed(odm_get_from_source(&state, &source, &HeaderMap::new(), KEY, None, None).await);
|
|
assert_eq!(err.status_code(), Some(StatusCode::FAILED_DEPENDENCY));
|
|
assert_eq!(err.code(), &S3ErrorCode::Custom(ODM_SOURCE_UNAVAILABLE_CODE.into()));
|
|
assert_eq!(err.message(), Some("unsupported"));
|
|
assert_eq!(get_count(&state, OdmOutcome::Unsupported), 1);
|
|
assert_eq!(source.get_calls(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_source_errors_follow_policy_and_open_the_breaker() {
|
|
let rt = runtime("e", PolicyConfig::default()).await;
|
|
let state = rt.state("e");
|
|
let source = ScriptedSource::new(vec![Err(SourceError::ServerError(503)), Err(SourceError::Timeout)], vec![]);
|
|
let err = failed(odm_get_from_source(&state, &source, &HeaderMap::new(), KEY, None, None).await);
|
|
assert_eq!(err.status_code(), Some(StatusCode::FAILED_DEPENDENCY));
|
|
assert_eq!(err.message(), Some("server_error"));
|
|
let err = failed(odm_get_from_source(&state, &source, &HeaderMap::new(), KEY, None, None).await);
|
|
assert_eq!(err.message(), Some("timeout"));
|
|
assert_eq!(get_count(&state, OdmOutcome::SourceError), 2);
|
|
|
|
// A GET whose body fetch fails is a source error too, and releases the slot.
|
|
let data = payload(64);
|
|
let source = ScriptedSource::new(vec![Ok(source_head(&data))], vec![Err(SourceError::AccessDenied)]);
|
|
let err = failed(odm_get_from_source(&state, &source, &HeaderMap::new(), KEY, None, None).await);
|
|
assert_eq!(err.message(), Some("access_denied"));
|
|
assert_eq!(state.inflight_keys(), 0, "a failed leader releases the key");
|
|
|
|
let hidden = runtime(
|
|
"h",
|
|
PolicyConfig {
|
|
source_error: SourceErrorPolicy::NotFound,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await;
|
|
let hidden_state = hidden.state("h");
|
|
let source = ScriptedSource::new(vec![Err(SourceError::ServerError(503))], vec![]);
|
|
let err = failed(odm_get_from_source(&hidden_state, &source, &HeaderMap::new(), KEY, None, None).await);
|
|
assert_eq!(err.code(), &S3ErrorCode::NoSuchKey);
|
|
|
|
let breaker = runtime("k", PolicyConfig::default()).await;
|
|
let source = ScriptedSource::new(
|
|
(0..BREAKER_FAILURE_THRESHOLD)
|
|
.map(|_| Err(SourceError::ServerError(503)))
|
|
.collect(),
|
|
vec![],
|
|
);
|
|
for _ in 0..BREAKER_FAILURE_THRESHOLD {
|
|
let state = consult(&breaker.sys, "k");
|
|
let _ = failed(odm_get_from_source(&state, &source, &HeaderMap::new(), KEY, None, None).await);
|
|
}
|
|
let state = breaker.state("k");
|
|
assert_eq!(state.breaker().state(), BreakerState::Open);
|
|
let err = fail(&breaker.sys, "k");
|
|
assert_eq!(err.status_code(), Some(StatusCode::FAILED_DEPENDENCY), "breaker open propagates");
|
|
assert_eq!(err.message(), Some("breaker_open"));
|
|
assert_eq!(get_count(&state, OdmOutcome::BreakerOpen), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_verdict_reports_an_unusable_client() {
|
|
let sys = OnDemandMigrationSys::new();
|
|
sys.set_module_enabled(true);
|
|
let mut config = odm_config(PolicyConfig::default());
|
|
config.source.credentials = None;
|
|
sys.apply("a", Some(&config)).await;
|
|
let lookup = sys.resolve("a", KEY).expect("bucket is configured");
|
|
assert!(matches!(
|
|
&lookup,
|
|
OdmLookup::Unavailable {
|
|
error: OdmStateError::AnonymousUnsupported,
|
|
..
|
|
}
|
|
));
|
|
let err = fail(&sys, "a");
|
|
assert_eq!(err.status_code(), Some(StatusCode::FAILED_DEPENDENCY));
|
|
assert_eq!(err.message(), Some("unsupported"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_follower_rereads_local_after_the_leader_commits() {
|
|
let rt = runtime("g", PolicyConfig::default()).await;
|
|
let state = rt.state("g");
|
|
let data = payload(256);
|
|
let leader = match state.acquire_pull_slot(KEY).await.expect("slot") {
|
|
PullSlot::Leader(leader) => leader,
|
|
PullSlot::Follower(_) => panic!("first caller leads"),
|
|
};
|
|
let source = ScriptedSource::for_object(&data);
|
|
let headers = HeaderMap::new();
|
|
|
|
let follower = odm_get_from_source(&state, &source, &headers, KEY, None, None);
|
|
let release = async {
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
leader.complete(Ok(PullOutcome {
|
|
etag: source_head(&data).etag,
|
|
size: data.len() as u64,
|
|
}));
|
|
};
|
|
let (reply, ()) = tokio::join!(follower, release);
|
|
assert!(matches!(reply, OdmGetReply::RetryLocal));
|
|
assert_eq!(source.head_calls(), 1, "the follower still validates the miss against the source HEAD");
|
|
assert_eq!(source.get_calls(), 0, "the follower never pulls");
|
|
assert_eq!(get_count(&state, OdmOutcome::SourceHit), 1);
|
|
assert!(rt.write_back.puts().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_follower_degrades_to_passthrough_on_timeout_or_leader_failure() {
|
|
let mut policy = PolicyConfig::default();
|
|
policy.source_timeout.first_byte_ms = 100;
|
|
let rt = runtime("w", policy).await;
|
|
let state = rt.state("w");
|
|
let data = payload(256);
|
|
let leader = match state.acquire_pull_slot(KEY).await.expect("slot") {
|
|
PullSlot::Leader(leader) => leader,
|
|
PullSlot::Follower(_) => panic!("first caller leads"),
|
|
};
|
|
|
|
let source = ScriptedSource::for_object(&data);
|
|
let (mut output, backfill) = served(odm_get_from_source(&state, &source, &HeaderMap::new(), KEY, None, None).await);
|
|
assert_eq!(backfill, None, "a degraded follower does not queue; the leader stores the object");
|
|
assert_eq!(collect_body(&mut output).await, data);
|
|
assert_eq!(source.get_calls(), 1);
|
|
|
|
// A leader that fails while the follower waits: the follower streams
|
|
// through on its own instead of re-reading local.
|
|
let source = ScriptedSource::for_object(&data);
|
|
let headers = HeaderMap::new();
|
|
let follower = odm_get_from_source(&state, &source, &headers, KEY, None, None);
|
|
let fail_leader = async {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
leader.complete(Err(PullError::canceled("scripted leader failure")));
|
|
};
|
|
let (reply, ()) = tokio::join!(follower, fail_leader);
|
|
let (mut output, backfill) = served(reply);
|
|
assert_eq!(backfill, None);
|
|
assert_eq!(collect_body(&mut output).await, data);
|
|
assert_eq!(source.get_calls(), 1);
|
|
assert!(rt.write_back.puts().is_empty(), "followers never write back");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn odm_get_concurrent_misses_pull_once() {
|
|
let rt = runtime("p", PolicyConfig::default()).await;
|
|
let state = rt.state("p");
|
|
let data = payload(128 * 1024);
|
|
let source = Arc::new(ScriptedSource::new(
|
|
(0..32).map(|_| Ok(source_head(&data))).collect(),
|
|
vec![Ok((source_head(&data), data.clone(), None))],
|
|
));
|
|
|
|
let mut tasks = Vec::new();
|
|
for _ in 0..32 {
|
|
let state = Arc::clone(&state);
|
|
let source = Arc::clone(&source);
|
|
tasks.push(tokio::spawn(async move {
|
|
odm_get_from_source(&state, source.as_ref(), &HeaderMap::new(), KEY, None, None).await
|
|
}));
|
|
}
|
|
let mut leaders = 0;
|
|
let mut followers = 0;
|
|
for task in tasks {
|
|
match task.await.expect("task") {
|
|
OdmGetReply::Served { mut output, backfill } => {
|
|
assert_eq!(backfill, None);
|
|
assert_eq!(collect_body(&mut output).await, data);
|
|
leaders += 1;
|
|
}
|
|
OdmGetReply::RetryLocal => followers += 1,
|
|
OdmGetReply::Error(err) => panic!("unexpected error {err:?}"),
|
|
}
|
|
}
|
|
assert_eq!(leaders, 1, "exactly one caller streams from the source");
|
|
assert_eq!(followers, 31);
|
|
assert_eq!(source.get_calls(), 1);
|
|
let (_, stored) = rt.write_back.wait_for_put().await;
|
|
assert_eq!(stored, data);
|
|
assert_eq!(rt.write_back.puts().len(), 1, "exactly one local commit");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use http::{HeaderMap, HeaderValue, Method};
|
|
use std::pin::Pin;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
|
|
use std::task::{Context, Poll};
|
|
use tokio::io::{AsyncRead, ReadBuf};
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn cold_fill_disk_admission_preserves_slow_down() {
|
|
let manager = Box::leak(Box::new(ConcurrencyManager::with_disk_read_caps_for_test(1, 1)));
|
|
let primary = match manager.admit_disk_read(Duration::from_millis(1)).await.unwrap() {
|
|
DiskReadAdmission::Primary(permit) => permit,
|
|
other => panic!("expected primary admission, got {other:?}"),
|
|
};
|
|
let degraded = match manager.admit_disk_read(Duration::from_millis(1)).await.unwrap() {
|
|
DiskReadAdmission::Degraded(permit) => permit,
|
|
other => panic!("expected degraded admission, got {other:?}"),
|
|
};
|
|
|
|
let result = DefaultObjectUsecase::acquire_cold_fill_io_planning(manager, "bucket", "object").await;
|
|
assert!(matches!(result, Err(ColdFillError::Storage(StorageError::SlowDown))));
|
|
|
|
drop(degraded);
|
|
drop(primary);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_fill_closed_disk_admission_is_not_slow_down() {
|
|
let manager = Box::leak(Box::new(ConcurrencyManager::with_disk_read_caps_for_test(1, 1)));
|
|
manager.close_disk_read_admission_for_test();
|
|
|
|
let result = DefaultObjectUsecase::acquire_cold_fill_io_planning(manager, "bucket", "object").await;
|
|
assert!(matches!(result, Err(ColdFillError::DiskAdmissionClosed)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn finalize_get_object_response_injects_accept_ranges_header() {
|
|
let req = build_request(GetObjectInput::default(), Method::GET);
|
|
let helper = OperationHelper::new(&req, EventName::ObjectAccessedGet, S3Operation::GetObject).suppress_event();
|
|
let response = DefaultObjectUsecase::finalize_get_object_response(
|
|
helper,
|
|
"bucket",
|
|
&req.method,
|
|
&req.headers,
|
|
None,
|
|
String::new(),
|
|
GetObjectOutput::default(),
|
|
Vec::new(),
|
|
)
|
|
.await
|
|
.expect("finalize response");
|
|
|
|
assert_eq!(response.headers.get(http::header::ACCEPT_RANGES).unwrap(), ACCEPT_RANGES_BYTES);
|
|
}
|
|
|
|
#[test]
|
|
fn should_buffer_get_object_in_memory_respects_hard_safety_cap() {
|
|
let info = ObjectInfo::default();
|
|
let configured_threshold = 20_i64 * 1024 * 1024 * 1024;
|
|
let response_len = 80_i64 * 1024 * 1024;
|
|
let should_buffer =
|
|
should_buffer_get_object_in_memory_with_threshold(&info, response_len, None, false, configured_threshold, 1, true);
|
|
|
|
assert!(
|
|
!should_buffer,
|
|
"64MiB hard cap must force streaming when response exceeds cap even if configured threshold is much higher"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn should_buffer_get_object_in_memory_allows_small_non_range_requests() {
|
|
let info = ObjectInfo::default();
|
|
let configured_threshold = 10_i64 * 1024 * 1024;
|
|
|
|
assert!(should_buffer_get_object_in_memory_with_threshold(
|
|
&info,
|
|
1024 * 1024,
|
|
None,
|
|
false,
|
|
configured_threshold,
|
|
1,
|
|
true
|
|
));
|
|
assert!(!should_buffer_get_object_in_memory_with_threshold(
|
|
&info,
|
|
1024 * 1024,
|
|
Some(1),
|
|
false,
|
|
configured_threshold,
|
|
1,
|
|
true
|
|
));
|
|
assert!(!should_buffer_get_object_in_memory_with_threshold(
|
|
&info,
|
|
1024 * 1024,
|
|
None,
|
|
true,
|
|
configured_threshold,
|
|
1,
|
|
true
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn should_buffer_get_object_in_memory_requires_seek_buffer_opt_in() {
|
|
let info = ObjectInfo::default();
|
|
let configured_threshold = 10_i64 * 1024 * 1024;
|
|
|
|
assert!(!should_buffer_get_object_in_memory_with_threshold(
|
|
&info,
|
|
1024,
|
|
None,
|
|
false,
|
|
configured_threshold,
|
|
1,
|
|
false
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn should_buffer_get_object_in_memory_respects_configured_threshold_below_cap() {
|
|
let info = ObjectInfo::default();
|
|
let configured_threshold = 10_i64 * 1024 * 1024;
|
|
|
|
assert!(should_buffer_get_object_in_memory_with_threshold(
|
|
&info,
|
|
configured_threshold,
|
|
None,
|
|
false,
|
|
configured_threshold,
|
|
1,
|
|
true
|
|
));
|
|
assert!(!should_buffer_get_object_in_memory_with_threshold(
|
|
&info,
|
|
configured_threshold + 1,
|
|
None,
|
|
false,
|
|
configured_threshold,
|
|
1,
|
|
true
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn should_buffer_get_object_in_memory_rejects_unknown_lengths_and_disabled_thresholds() {
|
|
let info = ObjectInfo::default();
|
|
let configured_threshold = 10_i64 * 1024 * 1024;
|
|
|
|
assert!(!should_buffer_get_object_in_memory_with_threshold(
|
|
&info,
|
|
0,
|
|
None,
|
|
false,
|
|
configured_threshold,
|
|
1,
|
|
true
|
|
));
|
|
assert!(!should_buffer_get_object_in_memory_with_threshold(
|
|
&info,
|
|
-1,
|
|
None,
|
|
false,
|
|
configured_threshold,
|
|
1,
|
|
true
|
|
));
|
|
assert!(!should_buffer_get_object_in_memory_with_threshold(&info, 1024, None, false, 0, 1, true));
|
|
}
|
|
|
|
#[test]
|
|
fn should_buffer_get_object_in_memory_reduces_threshold_under_concurrency() {
|
|
let info = ObjectInfo::default();
|
|
let configured_threshold = 10_i64 * 1024 * 1024;
|
|
|
|
assert!(should_buffer_get_object_in_memory_with_threshold(
|
|
&info,
|
|
configured_threshold,
|
|
None,
|
|
false,
|
|
configured_threshold,
|
|
1,
|
|
true
|
|
));
|
|
assert!(!should_buffer_get_object_in_memory_with_threshold(
|
|
&info,
|
|
configured_threshold,
|
|
None,
|
|
false,
|
|
configured_threshold,
|
|
32,
|
|
true
|
|
));
|
|
assert!(should_buffer_get_object_in_memory_with_threshold(
|
|
&info,
|
|
4_i64 * 1024 * 1024,
|
|
None,
|
|
false,
|
|
configured_threshold,
|
|
rustfs_config::DEFAULT_OBJECT_HIGH_CONCURRENCY_THRESHOLD,
|
|
true
|
|
));
|
|
}
|
|
|
|
/// Polls the cache until the detached fill (ODC-15) populates the entry, so
|
|
/// a follow-up GET is a deterministic hit rather than racing the fill task.
|
|
async fn wait_for_cache_hit(
|
|
adapter: &crate::app::object_data_cache::ObjectDataCacheAdapter,
|
|
bucket: &str,
|
|
object: &str,
|
|
etag: &str,
|
|
size: u64,
|
|
) {
|
|
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
|
|
bucket,
|
|
object,
|
|
version_id: None,
|
|
etag,
|
|
size,
|
|
data_dir_u128: None,
|
|
mod_time_unix_nanos: 0,
|
|
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
|
|
});
|
|
for _ in 0..400 {
|
|
if matches!(adapter.lookup_body(&plan).await, rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_)) {
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
}
|
|
panic!("detached fill did not populate the cache within the timeout");
|
|
}
|
|
|
|
struct ReadProbeReader {
|
|
reads: Arc<AtomicUsize>,
|
|
}
|
|
|
|
impl AsyncRead for ReadProbeReader {
|
|
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
|
self.reads.fetch_add(1, AtomicOrdering::Relaxed);
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
struct DataProbeReader {
|
|
reads: Arc<AtomicUsize>,
|
|
data: std::io::Cursor<Vec<u8>>,
|
|
}
|
|
|
|
struct ColdFillMatrixReader {
|
|
inner: tokio::io::DuplexStream,
|
|
first_poll_recorded: bool,
|
|
completion_recorded: bool,
|
|
first_polls: Arc<AtomicUsize>,
|
|
completed: Arc<AtomicUsize>,
|
|
bytes_read: Arc<AtomicUsize>,
|
|
}
|
|
|
|
impl AsyncRead for ColdFillMatrixReader {
|
|
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
|
if !self.first_poll_recorded {
|
|
self.first_poll_recorded = true;
|
|
self.first_polls.fetch_add(1, AtomicOrdering::Relaxed);
|
|
}
|
|
let before = buf.filled().len();
|
|
match Pin::new(&mut self.inner).poll_read(cx, buf) {
|
|
Poll::Ready(Ok(())) => {
|
|
let read = buf.filled().len().saturating_sub(before);
|
|
self.bytes_read.fetch_add(read, AtomicOrdering::Relaxed);
|
|
if read == 0 && !self.completion_recorded {
|
|
self.completion_recorded = true;
|
|
self.completed.fetch_add(1, AtomicOrdering::Relaxed);
|
|
}
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
other => other,
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
|
Poll::Pending
|
|
}
|
|
}
|
|
|
|
// Emits `fail_after` bytes from `data`, then returns a hard read error. Used
|
|
// to inject the "read K bytes then Err" partial-read case (#1324).
|
|
struct ErrAfterReader {
|
|
data: std::io::Cursor<Vec<u8>>,
|
|
fail_after: usize,
|
|
emitted: usize,
|
|
}
|
|
|
|
impl AsyncRead for ErrAfterReader {
|
|
fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
|
if self.emitted >= self.fail_after {
|
|
return Poll::Ready(Err(std::io::Error::other("injected mid-stream read error")));
|
|
}
|
|
let remaining = buf.remaining();
|
|
if remaining == 0 {
|
|
return Poll::Ready(Ok(()));
|
|
}
|
|
let want = (self.fail_after - self.emitted).min(remaining);
|
|
let position = usize::try_from(self.data.position()).unwrap_or(usize::MAX);
|
|
let source = self.data.get_ref();
|
|
let end = position.saturating_add(want).min(source.len());
|
|
if end <= position {
|
|
return Poll::Ready(Err(std::io::Error::other("injected mid-stream read error")));
|
|
}
|
|
let chunk_len = end - position;
|
|
buf.put_slice(&source[position..end]);
|
|
self.data.set_position(u64::try_from(end).unwrap_or(u64::MAX));
|
|
self.emitted += chunk_len;
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
fn cursor_reader(bytes: &[u8]) -> std::io::Cursor<Vec<u8>> {
|
|
std::io::Cursor::new(bytes.to_vec())
|
|
}
|
|
|
|
// #1324: the strict materialization helper is the shared exact-length gate
|
|
// for the encrypted, seek, and cache memory branches. For a declared length N
|
|
// only an exact N-byte read succeeds; a short read (N-1), an over-long read
|
|
// (N+1), and a mid-stream read error all hard-fail. This is the reversal
|
|
// guard for every one of those sources at once: restoring WARN-and-serve or a
|
|
// partial fallback would flip the short/over-long/error assertions to Ok.
|
|
#[tokio::test]
|
|
async fn strict_materialize_object_body_requires_exact_length() {
|
|
// Exact length: the only accepted outcome.
|
|
let buf = strict_materialize_object_body(cursor_reader(b"hello"), 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ)
|
|
.await
|
|
.expect("exact-length read must materialize");
|
|
assert_eq!(buf, b"hello");
|
|
assert_eq!(buf.capacity(), 5, "exact materialization must allocate only the declared body length");
|
|
|
|
let exact_large = vec![7_u8; 64 * 1024];
|
|
let buf = strict_materialize_object_body(
|
|
std::io::Cursor::new(exact_large.clone()),
|
|
exact_large.len(),
|
|
GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ,
|
|
)
|
|
.await
|
|
.expect("64 KiB exact-length read must materialize");
|
|
assert_eq!(buf.capacity(), exact_large.len());
|
|
|
|
let mut overlong_large = exact_large;
|
|
overlong_large.push(9);
|
|
let overlong = strict_materialize_object_body(
|
|
std::io::Cursor::new(overlong_large),
|
|
64 * 1024,
|
|
GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ,
|
|
)
|
|
.await;
|
|
assert!(matches!(
|
|
overlong,
|
|
Err(StrictMaterializeError::LengthMismatch {
|
|
expected: 65_536,
|
|
actual: 65_537
|
|
})
|
|
));
|
|
|
|
// Short read (actual = expected - 1): a clean EOF before the declared
|
|
// length must be a hard error, never a truncated served body.
|
|
let short = strict_materialize_object_body(cursor_reader(b"hell"), 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await;
|
|
assert!(
|
|
matches!(
|
|
short,
|
|
Err(StrictMaterializeError::LengthMismatch {
|
|
expected: 5,
|
|
actual: 4,
|
|
..
|
|
})
|
|
),
|
|
"short read must fail with a length mismatch, got {short:?}",
|
|
short = short.as_ref().map(|b| b.len())
|
|
);
|
|
|
|
// Over-long read (actual = expected + 1): must fail rather than silently
|
|
// truncate to the committed Content-Length.
|
|
let long = strict_materialize_object_body(cursor_reader(b"hello!"), 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await;
|
|
assert!(
|
|
matches!(long, Err(StrictMaterializeError::LengthMismatch { expected: 5, actual: 6 })),
|
|
"over-long read must fail with a length mismatch, got {long:?}",
|
|
long = long.as_ref().map(|b| b.len())
|
|
);
|
|
|
|
// Read K bytes then Err: must surface the read error and never return the
|
|
// partially consumed buffer (which the caller could otherwise re-stream).
|
|
let reader = ErrAfterReader {
|
|
data: cursor_reader(b"hello"),
|
|
fail_after: 3,
|
|
emitted: 0,
|
|
};
|
|
let errored = strict_materialize_object_body(reader, 5, GET_OBJECT_STAGE_BODY_SEEK_BUFFER_READ).await;
|
|
assert!(
|
|
matches!(errored, Err(StrictMaterializeError::Read { consumed: 3, .. })),
|
|
"a mid-stream read error must be reported as a read failure"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cold_fill_zero_timeout_policy_disables_deadline() {
|
|
let policy = GetObjectTimeoutPolicy {
|
|
get_object_timeout: Duration::ZERO,
|
|
..GetObjectTimeoutPolicy::default()
|
|
};
|
|
let wrapper = RequestTimeoutWrapper::with_request_id(policy.clone(), "cold-fill-zero-timeout");
|
|
assert!(cold_fill_deadline(&wrapper, &policy, 1).is_none());
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn cold_fill_producer_deadline_is_capped_at_ten_minutes() {
|
|
let disabled = GetObjectTimeoutPolicy {
|
|
get_object_timeout: Duration::ZERO,
|
|
..GetObjectTimeoutPolicy::default()
|
|
};
|
|
let now = tokio::time::Instant::now();
|
|
assert_eq!(cold_fill_producer_deadline(&disabled, 1) - now, Duration::from_secs(600));
|
|
|
|
let long = GetObjectTimeoutPolicy {
|
|
get_object_timeout: Duration::from_secs(3600),
|
|
enable_dynamic_timeout: false,
|
|
..GetObjectTimeoutPolicy::default()
|
|
};
|
|
let now = tokio::time::Instant::now();
|
|
assert_eq!(cold_fill_producer_deadline(&long, 1) - now, Duration::from_secs(600));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_fill_startup_wait_stops_when_last_consumer_cancels() {
|
|
let cancellation = tokio_util::sync::CancellationToken::new();
|
|
let waiting = tokio::spawn({
|
|
let cancellation = cancellation.clone();
|
|
async move { await_cold_fill_startup(std::future::pending::<()>(), &cancellation, None).await }
|
|
});
|
|
tokio::task::yield_now().await;
|
|
|
|
cancellation.cancel();
|
|
|
|
let result = tokio::time::timeout(Duration::from_secs(1), waiting)
|
|
.await
|
|
.expect("startup wait must observe cancellation")
|
|
.expect("startup wait task must not panic");
|
|
assert!(matches!(result, Err(ColdFillStartupWaitError::Cancelled)));
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn cold_fill_startup_wait_with_deadline_still_observes_cancellation() {
|
|
let cancellation = tokio_util::sync::CancellationToken::new();
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
|
|
let waiting = tokio::spawn({
|
|
let cancellation = cancellation.clone();
|
|
async move { await_cold_fill_startup(std::future::pending::<()>(), &cancellation, Some(deadline)).await }
|
|
});
|
|
tokio::task::yield_now().await;
|
|
|
|
cancellation.cancel();
|
|
|
|
let result = waiting.await.expect("startup wait task must not panic");
|
|
assert!(matches!(result, Err(ColdFillStartupWaitError::Cancelled)));
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn cold_fill_startup_wait_reports_deadline_exceeded() {
|
|
let cancellation = tokio_util::sync::CancellationToken::new();
|
|
let deadline = tokio::time::Instant::now() + Duration::from_millis(1);
|
|
|
|
let result = await_cold_fill_startup(std::future::pending::<()>(), &cancellation, Some(deadline)).await;
|
|
|
|
assert!(matches!(result, Err(ColdFillStartupWaitError::DeadlineExceeded)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_fill_late_miss_second_chance_hits_without_reader() {
|
|
let adapter = ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
|
|
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled,
|
|
max_bytes: 1024 * 1024,
|
|
max_memory_percent: 0,
|
|
max_entry_bytes: 1024,
|
|
min_free_memory_percent: 0,
|
|
fill_concurrency_max: 1,
|
|
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
|
|
})
|
|
.expect("second-chance cache config must be valid");
|
|
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
|
|
bucket: "late-bucket",
|
|
object: "late-object",
|
|
version_id: None,
|
|
etag: "late-etag",
|
|
size: 4,
|
|
data_dir_u128: Some(1),
|
|
mod_time_unix_nanos: 1,
|
|
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
|
|
});
|
|
assert!(matches!(
|
|
adapter.lookup_body(&plan).await,
|
|
rustfs_object_data_cache::ObjectDataCacheLookup::Miss
|
|
));
|
|
let request_lookups = adapter.cache().stats().lookups;
|
|
assert_eq!(request_lookups, 1, "the authoritative request lookup must be counted once");
|
|
|
|
let reservation = adapter.reserve_body(&plan).expect("late producer must reserve");
|
|
let reserved = reservation.wrap_bytes(Bytes::from_static(b"body"));
|
|
let _ = adapter.fill_reserved_body(&plan, reserved).await;
|
|
let coordinator = adapter.cold_fill_coordinator();
|
|
let cache_key = plan.key().cloned().expect("late plan must be cacheable");
|
|
let adapter = Arc::new(adapter);
|
|
let readers = Arc::new(AtomicUsize::new(0));
|
|
let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, {
|
|
let adapter = Arc::clone(&adapter);
|
|
let readers = Arc::clone(&readers);
|
|
move |producer| {
|
|
let adapter = Arc::clone(&adapter);
|
|
let plan = plan.clone();
|
|
let readers = Arc::clone(&readers);
|
|
async move {
|
|
if let Some(body) = lookup_cold_fill_second_chance(&adapter, &plan).await {
|
|
producer.finish_shared(Ok(body));
|
|
return;
|
|
}
|
|
readers.fetch_add(1, AtomicOrdering::Relaxed);
|
|
producer.bypass();
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
let ColdFillCoordinateOutcome::Ready(Ok(body)) = outcome else {
|
|
panic!("late request must observe the completed fill, got {outcome:?}");
|
|
};
|
|
assert_eq!(body, Bytes::from_static(b"body"));
|
|
assert_eq!(
|
|
adapter.cache().stats().lookups,
|
|
request_lookups,
|
|
"the producer second chance must not count another request lookup"
|
|
);
|
|
assert_eq!(readers.load(AtomicOrdering::Relaxed), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_fill_timeout_is_shared_and_releases_resources() {
|
|
let adapter = Arc::new(
|
|
ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
|
|
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled,
|
|
max_bytes: 1024 * 1024,
|
|
max_memory_percent: 0,
|
|
max_entry_bytes: 1024,
|
|
min_free_memory_percent: 0,
|
|
fill_concurrency_max: 1,
|
|
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
|
|
})
|
|
.expect("timeout cache config must be valid"),
|
|
);
|
|
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
|
|
bucket: "timeout-bucket",
|
|
object: "timeout-object",
|
|
version_id: None,
|
|
etag: "timeout-etag",
|
|
size: 1,
|
|
data_dir_u128: Some(1),
|
|
mod_time_unix_nanos: 1,
|
|
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
|
|
});
|
|
let key = plan.key().cloned().expect("timeout body must be cacheable");
|
|
let coordinator = adapter.cold_fill_coordinator();
|
|
let ColdFillRole::Produce(mut producer) = coordinator.join(key.clone()) else {
|
|
panic!("first timeout request must produce");
|
|
};
|
|
let leader = producer.waiter();
|
|
let reservation = adapter.reserve_body(&plan);
|
|
let disk_permits = Arc::new(tokio::sync::Semaphore::new(1));
|
|
let disk_gate = Arc::clone(&disk_permits);
|
|
let readers = Arc::new(AtomicUsize::new(0));
|
|
let reader_count = Arc::clone(&readers);
|
|
let producer_task = tokio::spawn(start_cold_fill_producer(
|
|
producer,
|
|
reservation,
|
|
move || async move {
|
|
let permit = disk_gate
|
|
.acquire_owned()
|
|
.await
|
|
.map_err(|_| ColdFillError::DiskAdmissionClosed)?;
|
|
let mut io = DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager());
|
|
io.disk_permit = Some(permit.into());
|
|
Ok(io)
|
|
},
|
|
move || async move {
|
|
reader_count.fetch_add(1, AtomicOrdering::Relaxed);
|
|
Ok(GetObjectReader {
|
|
stream: Box::new(PendingReader),
|
|
object_info: ObjectInfo {
|
|
size: 1,
|
|
actual_size: 1,
|
|
..Default::default()
|
|
},
|
|
buffered_body: None,
|
|
body_source: GetObjectBodySource::HookMissed,
|
|
})
|
|
},
|
|
ColdFillProducerExecution {
|
|
expected: 1,
|
|
deadline: Some(tokio::time::Instant::now() + Duration::from_millis(20)),
|
|
adapter: Arc::clone(&adapter),
|
|
engine_plan: plan.clone(),
|
|
},
|
|
));
|
|
tokio::time::timeout(Duration::from_secs(1), async {
|
|
while readers.load(AtomicOrdering::Relaxed) == 0 {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("producer reader must open");
|
|
let ColdFillRole::Wait(follower) = coordinator.join(key.clone()) else {
|
|
panic!("second timeout request must follow");
|
|
};
|
|
|
|
let (leader_result, follower_result) =
|
|
tokio::time::timeout(Duration::from_secs(2), async { tokio::join!(leader.wait(), follower.wait()) })
|
|
.await
|
|
.expect("typed timeout must wake all waiters");
|
|
assert!(matches!(
|
|
leader_result,
|
|
ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout)))
|
|
));
|
|
assert!(matches!(
|
|
follower_result,
|
|
ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout)))
|
|
));
|
|
assert_eq!(readers.load(AtomicOrdering::Relaxed), 1);
|
|
assert_eq!(disk_permits.available_permits(), 1);
|
|
assert_eq!(coordinator.global_waiter_count_for_test(), 0);
|
|
assert_eq!(coordinator.active_session_count_for_test(), 0);
|
|
assert!(matches!(
|
|
adapter.lookup_body(&plan).await,
|
|
rustfs_object_data_cache::ObjectDataCacheLookup::Miss
|
|
));
|
|
producer_task.await.expect("producer task must join");
|
|
assert!(adapter.reserve_body(&plan).is_some(), "timeout must release the body reservation");
|
|
let ColdFillRole::Produce(successor) = coordinator.join(key) else {
|
|
panic!("timeout must release the session for a successor");
|
|
};
|
|
drop(successor);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_fill_survives_leader_request_cancellation_without_second_producer() {
|
|
let adapter = Arc::new(
|
|
ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
|
|
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled,
|
|
max_bytes: 1024 * 1024,
|
|
max_memory_percent: 0,
|
|
max_entry_bytes: 1024,
|
|
min_free_memory_percent: 0,
|
|
fill_concurrency_max: 1,
|
|
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
|
|
})
|
|
.expect("cancellation cache config must be valid"),
|
|
);
|
|
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
|
|
bucket: "cancel-bucket",
|
|
object: "cancel-object",
|
|
version_id: None,
|
|
etag: "cancel-etag",
|
|
size: 4,
|
|
data_dir_u128: Some(1),
|
|
mod_time_unix_nanos: 1,
|
|
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
|
|
});
|
|
let key = plan.key().cloned().expect("cancellation body must be cacheable");
|
|
let coordinator = adapter.cold_fill_coordinator();
|
|
let ColdFillRole::Produce(mut producer) = coordinator.join(key.clone()) else {
|
|
panic!("first cancellation request must produce");
|
|
};
|
|
let leader = producer.waiter();
|
|
let reservation = adapter.reserve_body(&plan);
|
|
let readers = Arc::new(AtomicUsize::new(0));
|
|
let reader_count = Arc::clone(&readers);
|
|
let writer_slot = Arc::new(Mutex::new(None));
|
|
let writer_output = Arc::clone(&writer_slot);
|
|
let producer_task = tokio::spawn(start_cold_fill_producer(
|
|
producer,
|
|
reservation,
|
|
|| async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) },
|
|
move || async move {
|
|
reader_count.fetch_add(1, AtomicOrdering::Relaxed);
|
|
let (writer, reader) = tokio::io::duplex(16);
|
|
*writer_output.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(writer);
|
|
Ok(GetObjectReader {
|
|
stream: Box::new(reader),
|
|
object_info: ObjectInfo {
|
|
size: 4,
|
|
actual_size: 4,
|
|
..Default::default()
|
|
},
|
|
buffered_body: None,
|
|
body_source: GetObjectBodySource::HookMissed,
|
|
})
|
|
},
|
|
ColdFillProducerExecution {
|
|
expected: 4,
|
|
deadline: None,
|
|
adapter: Arc::clone(&adapter),
|
|
engine_plan: plan.clone(),
|
|
},
|
|
));
|
|
tokio::time::timeout(Duration::from_secs(1), async {
|
|
while readers.load(AtomicOrdering::Relaxed) == 0 {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("cancellation producer reader must open");
|
|
let ColdFillRole::Wait(follower) = coordinator.join(key.clone()) else {
|
|
panic!("second cancellation request must follow");
|
|
};
|
|
drop(leader);
|
|
assert_eq!(readers.load(AtomicOrdering::Relaxed), 1);
|
|
let ColdFillRole::Wait(late) = coordinator.join(key) else {
|
|
panic!("leader cancellation must not open a successor session");
|
|
};
|
|
drop(late);
|
|
|
|
let mut writer = writer_slot
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
.take()
|
|
.expect("reader factory must publish writer");
|
|
tokio::io::AsyncWriteExt::write_all(&mut writer, b"body")
|
|
.await
|
|
.expect("body write must succeed");
|
|
tokio::io::AsyncWriteExt::shutdown(&mut writer)
|
|
.await
|
|
.expect("body writer must close");
|
|
let ColdFillWaitOutcome::Ready(result) = follower.wait().await else {
|
|
panic!("follower must receive producer result");
|
|
};
|
|
assert_eq!(result.expect("surviving producer must succeed"), Bytes::from_static(b"body"));
|
|
producer_task.await.expect("producer task must join");
|
|
assert_eq!(readers.load(AtomicOrdering::Relaxed), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_fill_reservation_rejection_streams_without_materializing() {
|
|
let coordinator = Arc::new(crate::app::object_data_cache::ColdFillCoordinator::default());
|
|
let plan = rustfs_object_data_cache::ObjectDataCacheGetPlan::Disabled;
|
|
let ColdFillRole::Produce(mut producer) = coordinator.join(rustfs_object_data_cache::ObjectDataCacheKey::new(
|
|
"bucket",
|
|
"object",
|
|
None,
|
|
"etag",
|
|
4,
|
|
rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
|
|
)) else {
|
|
panic!("first rejected reservation request must produce");
|
|
};
|
|
let leader = producer.waiter();
|
|
let permits = Arc::new(AtomicUsize::new(0));
|
|
let readers = Arc::new(AtomicUsize::new(0));
|
|
let permit_count = Arc::clone(&permits);
|
|
let reader_count = Arc::clone(&readers);
|
|
start_cold_fill_producer(
|
|
producer,
|
|
None,
|
|
move || async move {
|
|
permit_count.fetch_add(1, AtomicOrdering::Relaxed);
|
|
Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager()))
|
|
},
|
|
move || async move {
|
|
reader_count.fetch_add(1, AtomicOrdering::Relaxed);
|
|
Err(StorageError::other("reader must not open"))
|
|
},
|
|
ColdFillProducerExecution {
|
|
expected: 4,
|
|
deadline: None,
|
|
adapter: Arc::new(ObjectDataCacheAdapter::disabled()),
|
|
engine_plan: plan,
|
|
},
|
|
)
|
|
.await;
|
|
assert!(matches!(leader.wait().await, ColdFillWaitOutcome::Bypass));
|
|
assert_eq!(permits.load(AtomicOrdering::Relaxed), 0);
|
|
assert_eq!(readers.load(AtomicOrdering::Relaxed), 0);
|
|
|
|
let fallback_reads = Arc::new(AtomicUsize::new(0));
|
|
let fallback_reader = DataProbeReader {
|
|
reads: Arc::clone(&fallback_reads),
|
|
data: std::io::Cursor::new(b"body".to_vec()),
|
|
};
|
|
let info = ObjectInfo {
|
|
size: 4,
|
|
actual_size: 4,
|
|
..Default::default()
|
|
};
|
|
let mut fallback_body = DefaultObjectUsecase::build_get_object_body(
|
|
fallback_reader,
|
|
&info,
|
|
4,
|
|
"req-cold-fill",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
None,
|
|
"bucket",
|
|
"object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await
|
|
.expect("reservation bypass must construct the normal streaming fallback");
|
|
let chunk = fallback_body
|
|
.next()
|
|
.await
|
|
.expect("fallback stream must yield a body chunk")
|
|
.expect("fallback stream must not fail");
|
|
assert_eq!(chunk, Bytes::from_static(b"body"));
|
|
assert!(fallback_reads.load(AtomicOrdering::Relaxed) > 0);
|
|
assert_eq!(readers.load(AtomicOrdering::Relaxed), 0, "cold-fill materialization must remain unopened");
|
|
assert_eq!(coordinator.active_session_count_for_test(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_fill_internal_movement_and_restore_reads_never_join_sessions() {
|
|
let coordinator = Arc::new(crate::app::object_data_cache::ColdFillCoordinator::default());
|
|
let info = ObjectInfo {
|
|
size: 4,
|
|
actual_size: 4,
|
|
..Default::default()
|
|
};
|
|
let mut restore = ObjectOptions::default();
|
|
restore.transition.restore_request.days = Some(1);
|
|
let cases = [
|
|
ObjectOptions {
|
|
raw_data_movement_read: true,
|
|
..Default::default()
|
|
},
|
|
ObjectOptions {
|
|
data_movement: true,
|
|
..Default::default()
|
|
},
|
|
restore,
|
|
];
|
|
|
|
for opts in &cases {
|
|
assert!(matches!(
|
|
lookup_get_object_body_cache_hook("bucket", "object", &None, opts, &info).await,
|
|
GetObjectBodyCacheHookLookup::Ineligible
|
|
));
|
|
assert_eq!(coordinator.active_session_count_for_test(), 0);
|
|
}
|
|
|
|
let delete_marker = ObjectInfo {
|
|
delete_marker: true,
|
|
etag: Some("delete-marker-etag".to_string()),
|
|
..Default::default()
|
|
};
|
|
let delete_marker_part = ObjectOptions {
|
|
part_number: Some(2),
|
|
..Default::default()
|
|
};
|
|
assert!(matches!(
|
|
lookup_get_object_body_cache_hook("bucket", "object", &None, &delete_marker_part, &delete_marker).await,
|
|
GetObjectBodyCacheHookLookup::Ineligible
|
|
));
|
|
assert_eq!(coordinator.active_session_count_for_test(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_fill_generation_change_bypasses_before_opening_body() {
|
|
let adapter = Arc::new(
|
|
ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
|
|
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled,
|
|
max_bytes: 1024 * 1024,
|
|
max_memory_percent: 0,
|
|
max_entry_bytes: 1024,
|
|
min_free_memory_percent: 0,
|
|
fill_concurrency_max: 1,
|
|
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
|
|
})
|
|
.expect("generation retry cache config must be valid"),
|
|
);
|
|
let request = |data_dir_u128| rustfs_object_data_cache::ObjectDataCacheGetRequest {
|
|
bucket: "generation-bucket",
|
|
object: "generation-object",
|
|
version_id: None,
|
|
etag: "generation-etag",
|
|
size: 4,
|
|
data_dir_u128: Some(data_dir_u128),
|
|
mod_time_unix_nanos: 1,
|
|
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
|
|
};
|
|
let initial_plan = adapter.plan_get(request(1));
|
|
let changed_plan = GetObjectBodyCachePlan::Cacheable(adapter.plan_get(request(2)));
|
|
let cache_key = initial_plan.key().cloned().expect("initial generation must be cacheable");
|
|
let coordinator = adapter.cold_fill_coordinator();
|
|
let body_opens = Arc::new(AtomicUsize::new(0));
|
|
let producer_attempts = Arc::new(AtomicUsize::new(0));
|
|
|
|
let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, {
|
|
let body_opens = Arc::clone(&body_opens);
|
|
let producer_attempts = Arc::clone(&producer_attempts);
|
|
move |producer| {
|
|
let body_opens = Arc::clone(&body_opens);
|
|
let producer_attempts = Arc::clone(&producer_attempts);
|
|
let changed_plan = changed_plan.clone();
|
|
let initial_plan = initial_plan.clone();
|
|
async move {
|
|
producer_attempts.fetch_add(1, AtomicOrdering::Relaxed);
|
|
let Some(producer) = retain_cold_fill_producer_for_matching_plan(producer, &changed_plan, &initial_plan)
|
|
else {
|
|
return;
|
|
};
|
|
body_opens.fetch_add(1, AtomicOrdering::Relaxed);
|
|
producer.bypass();
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
|
|
assert!(matches!(outcome, ColdFillCoordinateOutcome::Bypass));
|
|
assert_eq!(producer_attempts.load(AtomicOrdering::Relaxed), 1);
|
|
assert_eq!(body_opens.load(AtomicOrdering::Relaxed), 0);
|
|
assert_eq!(coordinator.active_session_count_for_test(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial(body_cache_hook)]
|
|
async fn execute_get_object_rejects_conditions_before_joining_cold_fill() {
|
|
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
|
|
|
let (store, context) = real_cold_fill_test_context().await;
|
|
let bucket = format!("cold-condition-{}", Uuid::new_v4());
|
|
let object = "object.bin";
|
|
store
|
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
|
.await
|
|
.expect("real cold-fill condition bucket must be created");
|
|
let body = vec![b'a'; 1_300_000];
|
|
let info = put_real_cold_fill_object(&store, &bucket, object, &body).await;
|
|
let adapter = context.object_data_cache();
|
|
let plan = real_cold_fill_plan(&adapter, &bucket, object, &info);
|
|
let coordinator = adapter.cold_fill_coordinator();
|
|
let ColdFillRole::Produce(producer) =
|
|
coordinator.join(plan.key().cloned().expect("real cold-fill plan must expose its key"))
|
|
else {
|
|
panic!("test must reserve the initial cold-fill producer");
|
|
};
|
|
|
|
let input = GetObjectInput::builder()
|
|
.bucket(bucket)
|
|
.key(object.to_string())
|
|
.build()
|
|
.expect("real cold-fill GET input must build");
|
|
let mut req = build_request(input, Method::GET);
|
|
let etag = info.etag.expect("real cold-fill test object must have an ETag");
|
|
req.headers.insert(
|
|
http::header::IF_NONE_MATCH,
|
|
HeaderValue::from_str(&format!("\"{etag}\"")).expect("ETag header must be valid"),
|
|
);
|
|
let usecase = DefaultObjectUsecase::with_context(Some(context));
|
|
let result = tokio::time::timeout(Duration::from_secs(2), usecase.execute_get_object(req))
|
|
.await
|
|
.expect("conditional GET must not wait for the reserved cold-fill session")
|
|
.expect_err("matching If-None-Match must reject the GET");
|
|
|
|
assert_eq!(result.code(), &S3ErrorCode::NotModified);
|
|
assert_eq!(coordinator.global_waiter_count_for_test(), 0);
|
|
drop(producer);
|
|
assert_eq!(coordinator.active_session_count_for_test(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial(body_cache_hook)]
|
|
async fn execute_get_object_maps_cold_fill_session_rejection_to_slow_down_without_opening_reader() {
|
|
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
|
|
|
let (store, context) = real_cold_fill_test_context().await;
|
|
let bucket = format!("cold-rejected-{}", Uuid::new_v4());
|
|
let object = "object.bin";
|
|
store
|
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
|
.await
|
|
.expect("real cold-fill rejection bucket must be created");
|
|
let body = vec![b'a'; 1_300_000];
|
|
let info = put_real_cold_fill_object(&store, &bucket, object, &body).await;
|
|
let adapter = context.object_data_cache();
|
|
let plan = real_cold_fill_plan(&adapter, &bucket, object, &info);
|
|
let cache_key = plan.key().cloned().expect("real cold-fill plan must expose its key");
|
|
let coordinator = adapter.cold_fill_coordinator();
|
|
let mut held_producers = Vec::new();
|
|
for index in 0..2048 {
|
|
let saturation_key = rustfs_object_data_cache::ObjectDataCacheKey::new(
|
|
"cold-fill-saturation",
|
|
format!("object-{index}"),
|
|
None,
|
|
"etag",
|
|
4,
|
|
rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
|
|
);
|
|
match coordinator.join(saturation_key) {
|
|
ColdFillRole::Produce(producer) => held_producers.push(producer),
|
|
ColdFillRole::Rejected => break,
|
|
ColdFillRole::Wait(_) | ColdFillRole::Bypass => panic!("unique saturation keys must produce or reject"),
|
|
}
|
|
}
|
|
assert_eq!(coordinator.active_session_count_for_test(), held_producers.len());
|
|
assert!(!held_producers.is_empty(), "saturation must reserve cold-fill sessions");
|
|
|
|
let reader_opens = Arc::new(AtomicU64::new(0));
|
|
*COLD_FILL_READER_OPEN_PROBE
|
|
.get_or_init(|| Mutex::new(None))
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((cache_key, Arc::clone(&reader_opens)));
|
|
let input = GetObjectInput::builder()
|
|
.bucket(bucket)
|
|
.key(object.to_string())
|
|
.build()
|
|
.expect("real cold-fill rejection GET input must build");
|
|
let usecase = DefaultObjectUsecase::with_context(Some(context));
|
|
let result = tokio::time::timeout(Duration::from_secs(2), usecase.execute_get_object(build_request(input, Method::GET)))
|
|
.await
|
|
.expect("rejected real GET must not wait for a cold-fill session")
|
|
.expect_err("rejected real GET must return an S3 error");
|
|
*COLD_FILL_READER_OPEN_PROBE
|
|
.get_or_init(|| Mutex::new(None))
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
|
|
|
|
assert_eq!(result.code(), &S3ErrorCode::SlowDown);
|
|
assert_eq!(reader_opens.load(Ordering::Relaxed), 0, "rejected GET must not open its body reader");
|
|
assert_eq!(coordinator.active_session_count_for_test(), held_producers.len());
|
|
drop(held_producers);
|
|
assert_eq!(coordinator.active_session_count_for_test(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial(body_cache_hook)]
|
|
async fn execute_get_object_generation_change_bypasses_old_cold_fill_plan() {
|
|
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
|
|
|
let (store, context) = real_cold_fill_test_context().await;
|
|
let bucket = format!("cold-generation-{}", Uuid::new_v4());
|
|
let object = "object.bin";
|
|
store
|
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
|
.await
|
|
.expect("real cold-fill generation bucket must be created");
|
|
let initial_body = vec![b'a'; 1_300_000];
|
|
let changed_body = vec![b'b'; initial_body.len()];
|
|
let initial_info = put_real_cold_fill_object(&store, &bucket, object, &initial_body).await;
|
|
let adapter = context.object_data_cache();
|
|
let initial_plan = real_cold_fill_plan(&adapter, &bucket, object, &initial_info);
|
|
let coordinator = adapter.cold_fill_coordinator();
|
|
let ColdFillRole::Produce(producer) =
|
|
coordinator.join(initial_plan.key().cloned().expect("real cold-fill plan must expose its key"))
|
|
else {
|
|
panic!("test must reserve the initial cold-fill producer");
|
|
};
|
|
|
|
let input = GetObjectInput::builder()
|
|
.bucket(bucket.clone())
|
|
.key(object.to_string())
|
|
.build()
|
|
.expect("real cold-fill GET input must build");
|
|
// The request is intentionally held behind the first producer while a
|
|
// 1.3 MiB replacement write changes its generation. Disable dynamic
|
|
// sizing for this test so runner I/O load cannot consume the five-second
|
|
// production minimum before the behavior under test is released.
|
|
let usecase = DefaultObjectUsecase::with_context_and_get_object_timeout_policy(
|
|
Some(context),
|
|
GetObjectTimeoutPolicy {
|
|
enable_dynamic_timeout: false,
|
|
..GetObjectTimeoutPolicy::default()
|
|
},
|
|
);
|
|
let request = tokio::spawn(async move { usecase.execute_get_object(build_request(input, Method::GET)).await });
|
|
tokio::time::timeout(Duration::from_secs(2), async {
|
|
while coordinator.global_waiter_count_for_test() != 1 {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("real GET must join the reserved cold-fill session");
|
|
|
|
let changed_info = put_real_cold_fill_object(&store, &bucket, object, &changed_body).await;
|
|
assert_ne!(initial_info.etag, changed_info.etag);
|
|
producer.relinquish_or_finish(ColdFillError::Storage(StorageError::Timeout));
|
|
|
|
let mut response = tokio::time::timeout(Duration::from_secs(10), request)
|
|
.await
|
|
.expect("generation-changing GET must complete")
|
|
.expect("generation-changing GET task must join")
|
|
.expect("generation-changing GET must fall back successfully");
|
|
let mut response_body = response.output.body.take().expect("GET response must include a body");
|
|
let mut actual = Vec::with_capacity(changed_body.len());
|
|
while let Some(chunk) = response_body.next().await {
|
|
actual.extend_from_slice(&chunk.expect("fallback body chunk must be readable"));
|
|
}
|
|
|
|
assert_eq!(actual, changed_body);
|
|
assert!(matches!(
|
|
adapter.lookup_body(&initial_plan).await,
|
|
rustfs_object_data_cache::ObjectDataCacheLookup::Miss
|
|
));
|
|
assert_eq!(coordinator.global_waiter_count_for_test(), 0);
|
|
assert_eq!(coordinator.active_session_count_for_test(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_fill_open_error_retries_once_then_single_successor_succeeds() {
|
|
let adapter = Arc::new(
|
|
ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
|
|
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled,
|
|
max_bytes: 1024 * 1024,
|
|
max_memory_percent: 0,
|
|
max_entry_bytes: 1024,
|
|
min_free_memory_percent: 0,
|
|
fill_concurrency_max: 1,
|
|
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
|
|
})
|
|
.expect("open retry cache config must be valid"),
|
|
);
|
|
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
|
|
bucket: "open-retry-bucket",
|
|
object: "open-retry-object",
|
|
version_id: None,
|
|
etag: "open-retry-etag",
|
|
size: 4,
|
|
data_dir_u128: Some(1),
|
|
mod_time_unix_nanos: 1,
|
|
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
|
|
});
|
|
let cache_key = plan.key().cloned().expect("open retry plan must be cacheable");
|
|
let coordinator = adapter.cold_fill_coordinator();
|
|
let open_attempts = Arc::new(AtomicUsize::new(0));
|
|
let open_attempts_for_start = Arc::clone(&open_attempts);
|
|
|
|
let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, move |producer| {
|
|
let reservation = adapter.reserve_body(&plan);
|
|
let adapter = Arc::clone(&adapter);
|
|
let plan = plan.clone();
|
|
let open_attempts = Arc::clone(&open_attempts_for_start);
|
|
async move {
|
|
start_cold_fill_producer(
|
|
producer,
|
|
reservation,
|
|
|| async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) },
|
|
move || async move {
|
|
let attempt = open_attempts.fetch_add(1, AtomicOrdering::Relaxed);
|
|
if attempt == 0 {
|
|
return Err(StorageError::other("first open fails"));
|
|
}
|
|
Ok(GetObjectReader {
|
|
stream: Box::new(std::io::Cursor::new(Vec::<u8>::new())),
|
|
object_info: ObjectInfo {
|
|
size: 4,
|
|
actual_size: 4,
|
|
..Default::default()
|
|
},
|
|
buffered_body: Some(Bytes::from_static(b"body")),
|
|
body_source: GetObjectBodySource::HookMissed,
|
|
})
|
|
},
|
|
ColdFillProducerExecution {
|
|
expected: 4,
|
|
deadline: None,
|
|
adapter,
|
|
engine_plan: plan,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
})
|
|
.await;
|
|
|
|
let ColdFillCoordinateOutcome::Ready(Ok(body)) = outcome else {
|
|
panic!("the unique successor must publish the body");
|
|
};
|
|
assert_eq!(body, Bytes::from_static(b"body"));
|
|
assert_eq!(open_attempts.load(AtomicOrdering::Relaxed), 2);
|
|
assert_eq!(coordinator.active_session_count_for_test(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_fill_open_timeout_retries_once_then_is_terminal() {
|
|
tokio::time::pause();
|
|
let adapter = Arc::new(
|
|
ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
|
|
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled,
|
|
max_bytes: 1024 * 1024,
|
|
max_memory_percent: 0,
|
|
max_entry_bytes: 1024,
|
|
min_free_memory_percent: 0,
|
|
fill_concurrency_max: 1,
|
|
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
|
|
})
|
|
.expect("open timeout cache config must be valid"),
|
|
);
|
|
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
|
|
bucket: "open-timeout-bucket",
|
|
object: "open-timeout-object",
|
|
version_id: None,
|
|
etag: "open-timeout-etag",
|
|
size: 4,
|
|
data_dir_u128: Some(1),
|
|
mod_time_unix_nanos: 1,
|
|
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
|
|
});
|
|
let cache_key = plan.key().cloned().expect("open timeout plan must be cacheable");
|
|
let coordinator = adapter.cold_fill_coordinator();
|
|
let open_attempts = Arc::new(AtomicUsize::new(0));
|
|
|
|
let deadline = tokio::time::Instant::now() + Duration::from_millis(10);
|
|
let task = tokio::spawn({
|
|
let adapter = Arc::clone(&adapter);
|
|
let coordinator = Arc::clone(&coordinator);
|
|
let plan = plan.clone();
|
|
let open_attempts = Arc::clone(&open_attempts);
|
|
async move {
|
|
coordinate_cold_fill(&coordinator, cache_key, None, Some(deadline), move |producer| {
|
|
let adapter = Arc::clone(&adapter);
|
|
let plan = plan.clone();
|
|
let open_attempts = Arc::clone(&open_attempts);
|
|
let reservation = adapter.reserve_body(&plan);
|
|
let producer_deadline = producer.deadline();
|
|
async move {
|
|
start_cold_fill_producer(
|
|
producer,
|
|
reservation,
|
|
|| async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) },
|
|
move || async move {
|
|
open_attempts.fetch_add(1, AtomicOrdering::Relaxed);
|
|
std::future::pending::<Result<GetObjectReader, StorageError>>().await
|
|
},
|
|
ColdFillProducerExecution {
|
|
expected: 4,
|
|
deadline: producer_deadline,
|
|
adapter,
|
|
engine_plan: plan,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
})
|
|
.await
|
|
}
|
|
});
|
|
while open_attempts.load(AtomicOrdering::Relaxed) == 0 {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
tokio::time::advance(Duration::from_millis(11)).await;
|
|
let outcome = task.await.expect("open timeout task must join");
|
|
assert!(matches!(
|
|
outcome,
|
|
ColdFillCoordinateOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout)))
|
|
));
|
|
|
|
assert_eq!(open_attempts.load(AtomicOrdering::Relaxed), 2);
|
|
assert_eq!(coordinator.active_session_count_for_test(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_fill_pre_reader_failure_promotes_one_of_two_thousand_waiters() {
|
|
const REQUESTS: usize = 2000;
|
|
let adapter = Arc::new(
|
|
ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
|
|
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled,
|
|
max_bytes: 1024 * 1024,
|
|
max_memory_percent: 0,
|
|
max_entry_bytes: 1024,
|
|
min_free_memory_percent: 0,
|
|
fill_concurrency_max: 1,
|
|
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
|
|
})
|
|
.expect("successor cache config must be valid"),
|
|
);
|
|
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
|
|
bucket: "successor-bucket",
|
|
object: "successor-object",
|
|
version_id: None,
|
|
etag: "successor-etag",
|
|
size: 4,
|
|
data_dir_u128: Some(1),
|
|
mod_time_unix_nanos: 1,
|
|
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
|
|
});
|
|
let cache_key = plan.key().cloned().expect("successor plan must be cacheable");
|
|
let coordinator = adapter.cold_fill_coordinator();
|
|
let admission_attempts = Arc::new(AtomicUsize::new(0));
|
|
let open_attempts = Arc::new(AtomicUsize::new(0));
|
|
let first_open_release = Arc::new(tokio::sync::Semaphore::new(0));
|
|
let mut tasks = tokio::task::JoinSet::new();
|
|
|
|
for _ in 0..REQUESTS {
|
|
let adapter = Arc::clone(&adapter);
|
|
let coordinator = Arc::clone(&coordinator);
|
|
let cache_key = cache_key.clone();
|
|
let plan = plan.clone();
|
|
let admission_attempts = Arc::clone(&admission_attempts);
|
|
let open_attempts = Arc::clone(&open_attempts);
|
|
let first_open_release = Arc::clone(&first_open_release);
|
|
tasks.spawn(async move {
|
|
coordinate_cold_fill(&coordinator, cache_key, None, None, move |producer| {
|
|
let reservation = adapter.reserve_body(&plan);
|
|
let adapter = Arc::clone(&adapter);
|
|
let plan = plan.clone();
|
|
let admission_attempts = Arc::clone(&admission_attempts);
|
|
let open_attempts = Arc::clone(&open_attempts);
|
|
let first_open_release = Arc::clone(&first_open_release);
|
|
async move {
|
|
start_cold_fill_producer(
|
|
producer,
|
|
reservation,
|
|
move || async move {
|
|
admission_attempts.fetch_add(1, AtomicOrdering::Relaxed);
|
|
Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager()))
|
|
},
|
|
move || async move {
|
|
if open_attempts.fetch_add(1, AtomicOrdering::Relaxed) == 0 {
|
|
first_open_release
|
|
.acquire()
|
|
.await
|
|
.expect("first open release gate must remain open")
|
|
.forget();
|
|
return Err(StorageError::other("first open fails"));
|
|
}
|
|
Ok(GetObjectReader {
|
|
stream: Box::new(std::io::Cursor::new(Vec::<u8>::new())),
|
|
object_info: ObjectInfo {
|
|
size: 4,
|
|
actual_size: 4,
|
|
..Default::default()
|
|
},
|
|
buffered_body: Some(Bytes::from_static(b"body")),
|
|
body_source: GetObjectBodySource::HookMissed,
|
|
})
|
|
},
|
|
ColdFillProducerExecution {
|
|
expected: 4,
|
|
deadline: None,
|
|
adapter,
|
|
engine_plan: plan,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
})
|
|
.await
|
|
});
|
|
}
|
|
|
|
tokio::time::timeout(Duration::from_secs(5), async {
|
|
loop {
|
|
if coordinator.global_waiter_count_for_test() == REQUESTS - 1 && open_attempts.load(AtomicOrdering::Relaxed) == 1
|
|
{
|
|
break;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("all followers must join before the first open fails");
|
|
first_open_release.add_permits(1);
|
|
|
|
while let Some(result) = tasks.join_next().await {
|
|
let ColdFillCoordinateOutcome::Ready(Ok(body)) = result.expect("successor request task must join") else {
|
|
panic!("all followers must receive the successor body");
|
|
};
|
|
assert_eq!(body, Bytes::from_static(b"body"));
|
|
}
|
|
assert_eq!(admission_attempts.load(AtomicOrdering::Relaxed), 2);
|
|
assert_eq!(open_attempts.load(AtomicOrdering::Relaxed), 2);
|
|
assert_eq!(coordinator.global_waiter_count_for_test(), 0);
|
|
assert_eq!(coordinator.active_session_count_for_test(), 0);
|
|
}
|
|
|
|
fn install_cold_fill_publication_barrier(
|
|
plan: &rustfs_object_data_cache::ObjectDataCacheGetPlan,
|
|
) -> Arc<ColdFillPublicationBarrier> {
|
|
let barrier = Arc::new(ColdFillPublicationBarrier {
|
|
reached: tokio::sync::Semaphore::new(0),
|
|
release: tokio::sync::Semaphore::new(0),
|
|
});
|
|
let key = plan.key().cloned().expect("publication barrier plan must be cacheable");
|
|
*COLD_FILL_PUBLICATION_BARRIER
|
|
.get_or_init(|| Mutex::new(None))
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((key, Arc::clone(&barrier)));
|
|
barrier
|
|
}
|
|
|
|
fn clear_cold_fill_publication_barrier() {
|
|
*COLD_FILL_PUBLICATION_BARRIER
|
|
.get_or_init(|| Mutex::new(None))
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
|
|
}
|
|
|
|
fn publication_test_adapter() -> Arc<ObjectDataCacheAdapter> {
|
|
Arc::new(
|
|
ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
|
|
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled,
|
|
max_bytes: 1024 * 1024,
|
|
max_memory_percent: 0,
|
|
max_entry_bytes: 1024,
|
|
min_free_memory_percent: 0,
|
|
fill_concurrency_max: 1,
|
|
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
|
|
})
|
|
.expect("publication cache config must be valid"),
|
|
)
|
|
}
|
|
|
|
fn publication_test_plan(adapter: &ObjectDataCacheAdapter, object: &str) -> rustfs_object_data_cache::ObjectDataCacheGetPlan {
|
|
adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
|
|
bucket: "publication-bucket",
|
|
object,
|
|
version_id: None,
|
|
etag: "publication-etag",
|
|
size: 4,
|
|
data_dir_u128: Some(1),
|
|
mod_time_unix_nanos: 1,
|
|
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial(cold_fill_publication_barrier)]
|
|
async fn cold_fill_last_consumer_cancel_releases_session_before_publication_barrier() {
|
|
let adapter = publication_test_adapter();
|
|
let plan = publication_test_plan(&adapter, "cancel");
|
|
let barrier = install_cold_fill_publication_barrier(&plan);
|
|
let coordinator = adapter.cold_fill_coordinator();
|
|
let key = plan.key().cloned().expect("publication plan must be cacheable");
|
|
let ColdFillRole::Produce(mut producer) = coordinator.join(key) else {
|
|
panic!("publication request must produce");
|
|
};
|
|
let leader = producer.waiter();
|
|
let reservation = adapter.reserve_body(&plan);
|
|
let disk_permits = Arc::new(tokio::sync::Semaphore::new(1));
|
|
let disk_gate = Arc::clone(&disk_permits);
|
|
let producer_task = tokio::spawn(scope_cold_fill_disk_permit_owner_for_test(
|
|
ColdFillDiskPermitOwner::Producer,
|
|
start_cold_fill_producer(
|
|
producer,
|
|
reservation,
|
|
move || async move {
|
|
let permit = disk_gate
|
|
.acquire_owned()
|
|
.await
|
|
.map_err(|_| ColdFillError::DiskAdmissionClosed)?;
|
|
let mut io = DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager());
|
|
io.disk_permit = Some(permit.into());
|
|
Ok(io)
|
|
},
|
|
|| async {
|
|
Ok(GetObjectReader {
|
|
stream: Box::new(std::io::Cursor::new(b"body".to_vec())),
|
|
object_info: ObjectInfo {
|
|
size: 4,
|
|
actual_size: 4,
|
|
..Default::default()
|
|
},
|
|
buffered_body: None,
|
|
body_source: GetObjectBodySource::HookMissed,
|
|
})
|
|
},
|
|
ColdFillProducerExecution {
|
|
expected: 4,
|
|
deadline: None,
|
|
adapter: Arc::clone(&adapter),
|
|
engine_plan: plan.clone(),
|
|
},
|
|
),
|
|
));
|
|
|
|
let reached = barrier.reached.acquire().await.expect("publication barrier must remain open");
|
|
reached.forget();
|
|
assert_eq!(
|
|
disk_permits.available_permits(),
|
|
1,
|
|
"the producer disk permit and its gauge guard must end before publication"
|
|
);
|
|
let clear_adapter = Arc::clone(&adapter);
|
|
let clear = tokio::spawn(async move {
|
|
clear_adapter
|
|
.clear(rustfs_object_data_cache::ObjectDataCacheInvalidationReason::Manual)
|
|
.await
|
|
});
|
|
tokio::task::yield_now().await;
|
|
assert!(!clear.is_finished(), "clear must wait while publication owns its reservation");
|
|
drop(leader);
|
|
tokio::time::timeout(Duration::from_secs(1), async {
|
|
while coordinator.active_session_count_for_test() != 0 {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("last-consumer cancellation must release the session immediately");
|
|
tokio::time::timeout(Duration::from_secs(1), clear)
|
|
.await
|
|
.expect("clear must finish after publication cancellation")
|
|
.expect("clear task must join");
|
|
producer_task.await.expect("producer task must join");
|
|
|
|
barrier.release.add_permits(1);
|
|
clear_cold_fill_publication_barrier();
|
|
drop(adapter.reserve_body(&plan).expect("publication reservation must be released"));
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
#[serial_test::serial(cold_fill_publication_barrier)]
|
|
async fn cold_fill_hard_deadline_releases_session_at_publication_barrier() {
|
|
let adapter = publication_test_adapter();
|
|
let plan = publication_test_plan(&adapter, "deadline");
|
|
let barrier = install_cold_fill_publication_barrier(&plan);
|
|
let coordinator = adapter.cold_fill_coordinator();
|
|
let key = plan.key().cloned().expect("publication plan must be cacheable");
|
|
let ColdFillRole::Produce(mut producer) = coordinator.join(key) else {
|
|
panic!("publication request must produce");
|
|
};
|
|
let leader = producer.waiter();
|
|
let reservation = adapter.reserve_body(&plan);
|
|
let deadline = tokio::time::Instant::now() + Duration::from_millis(20);
|
|
let producer_task = tokio::spawn(start_cold_fill_producer(
|
|
producer,
|
|
reservation,
|
|
|| async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) },
|
|
|| async {
|
|
Ok(GetObjectReader {
|
|
stream: Box::new(std::io::Cursor::new(Vec::<u8>::new())),
|
|
object_info: ObjectInfo {
|
|
size: 4,
|
|
actual_size: 4,
|
|
..Default::default()
|
|
},
|
|
buffered_body: Some(Bytes::from_static(b"body")),
|
|
body_source: GetObjectBodySource::HookMissed,
|
|
})
|
|
},
|
|
ColdFillProducerExecution {
|
|
expected: 4,
|
|
deadline: Some(deadline),
|
|
adapter: Arc::clone(&adapter),
|
|
engine_plan: plan.clone(),
|
|
},
|
|
));
|
|
|
|
let reached = barrier.reached.acquire().await.expect("publication barrier must remain open");
|
|
reached.forget();
|
|
tokio::time::advance(Duration::from_millis(20)).await;
|
|
assert!(matches!(
|
|
leader.wait().await,
|
|
ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout)))
|
|
));
|
|
assert_eq!(coordinator.active_session_count_for_test(), 0);
|
|
producer_task.await.expect("producer task must join");
|
|
|
|
barrier.release.add_permits(1);
|
|
clear_cold_fill_publication_barrier();
|
|
drop(
|
|
adapter
|
|
.reserve_body(&plan)
|
|
.expect("deadline must release the publication reservation"),
|
|
);
|
|
tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
adapter.clear(rustfs_object_data_cache::ObjectDataCacheInvalidationReason::Manual),
|
|
)
|
|
.await
|
|
.expect("clear must complete after publication deadline");
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn cold_fill_without_request_timeout_stops_at_ten_minute_hard_cap() {
|
|
let adapter = publication_test_adapter();
|
|
let plan = publication_test_plan(&adapter, "hard-cap");
|
|
let coordinator = adapter.cold_fill_coordinator();
|
|
let key = plan.key().cloned().expect("hard-cap plan must be cacheable");
|
|
let ColdFillRole::Produce(mut producer) = coordinator.join(key) else {
|
|
panic!("hard-cap request must produce");
|
|
};
|
|
let leader = producer.waiter();
|
|
let reservation = adapter.reserve_body(&plan);
|
|
let producer_task = tokio::spawn(start_cold_fill_producer(
|
|
producer,
|
|
reservation,
|
|
|| async { Ok(DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager())) },
|
|
|| async {
|
|
Ok(GetObjectReader {
|
|
stream: Box::new(PendingReader),
|
|
object_info: ObjectInfo {
|
|
size: 4,
|
|
actual_size: 4,
|
|
..Default::default()
|
|
},
|
|
buffered_body: None,
|
|
body_source: GetObjectBodySource::HookMissed,
|
|
})
|
|
},
|
|
ColdFillProducerExecution {
|
|
expected: 4,
|
|
deadline: None,
|
|
adapter: Arc::clone(&adapter),
|
|
engine_plan: plan.clone(),
|
|
},
|
|
));
|
|
let wait = tokio::spawn(async move { leader.wait().await });
|
|
|
|
tokio::time::advance(Duration::from_secs(599)).await;
|
|
tokio::task::yield_now().await;
|
|
assert!(!wait.is_finished(), "hard cap must not fire before 600 seconds");
|
|
assert!(adapter.reserve_body(&plan).is_none(), "reservation must remain owned before the hard cap");
|
|
|
|
tokio::time::advance(Duration::from_secs(1)).await;
|
|
assert!(matches!(
|
|
wait.await.expect("hard-cap waiter must join"),
|
|
ColdFillWaitOutcome::Ready(Err(ColdFillError::Storage(StorageError::Timeout)))
|
|
));
|
|
producer_task.await.expect("producer task must join");
|
|
assert_eq!(coordinator.active_session_count_for_test(), 0);
|
|
drop(
|
|
adapter
|
|
.reserve_body(&plan)
|
|
.expect("hard cap must release the body reservation"),
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_get_object_body_with_cache_same_key_cold_fill_consumes_one_reader() {
|
|
const REQUESTS: usize = 2000;
|
|
const BODY_BYTES: usize = 64 * 1024;
|
|
const BODY_BYTES_U64: u64 = 64 * 1024;
|
|
const BODY_BYTES_I64: i64 = 64 * 1024;
|
|
|
|
for key_count in [1_usize, 4, 32] {
|
|
let adapter = Arc::new(
|
|
ObjectDataCacheAdapter::new(rustfs_object_data_cache::ObjectDataCacheConfig {
|
|
mode: rustfs_object_data_cache::ObjectDataCacheMode::FillMaterializeEnabled,
|
|
max_bytes: 128 * 1024 * 1024,
|
|
max_memory_percent: 0,
|
|
max_entry_bytes: 1024 * 1024,
|
|
min_free_memory_percent: 0,
|
|
fill_concurrency_per_cpu: 64,
|
|
fill_concurrency_max: 64,
|
|
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
|
|
})
|
|
.expect("matrix cache config must be valid"),
|
|
);
|
|
let coordinator = adapter.cold_fill_coordinator();
|
|
let disk_permits = Arc::new(tokio::sync::Semaphore::new(key_count));
|
|
let writers = Arc::new(tokio::sync::Mutex::new(Vec::with_capacity(key_count)));
|
|
let permit_acquires = Arc::new(AtomicUsize::new(0));
|
|
let reader_factories = Arc::new(AtomicUsize::new(0));
|
|
let first_polls = Arc::new(AtomicUsize::new(0));
|
|
let completed = Arc::new(AtomicUsize::new(0));
|
|
let bytes_read = Arc::new(AtomicUsize::new(0));
|
|
let mut tasks = tokio::task::JoinSet::new();
|
|
|
|
for request in 0..REQUESTS {
|
|
let key_index = request % key_count;
|
|
let object = format!("matrix-object-{key_index}");
|
|
let engine_plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
|
|
bucket: "matrix-bucket",
|
|
object: &object,
|
|
version_id: None,
|
|
etag: "matrix-etag",
|
|
size: BODY_BYTES_U64,
|
|
data_dir_u128: Some(u128::try_from(key_index).unwrap_or(u128::MAX) + 1),
|
|
mod_time_unix_nanos: 1,
|
|
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
|
|
});
|
|
let cache_key = engine_plan.key().cloned().expect("matrix body must be cacheable");
|
|
let adapter = Arc::clone(&adapter);
|
|
let coordinator = Arc::clone(&coordinator);
|
|
let disk_permits = Arc::clone(&disk_permits);
|
|
let writers = Arc::clone(&writers);
|
|
let permit_acquires = Arc::clone(&permit_acquires);
|
|
let reader_factories = Arc::clone(&reader_factories);
|
|
let first_polls = Arc::clone(&first_polls);
|
|
let completed = Arc::clone(&completed);
|
|
let bytes_read = Arc::clone(&bytes_read);
|
|
tasks.spawn(async move {
|
|
let outcome = coordinate_cold_fill(&coordinator, cache_key, None, None, move |producer| {
|
|
let reservation = adapter.reserve_body(&engine_plan);
|
|
let adapter = Arc::clone(&adapter);
|
|
let disk_permits = Arc::clone(&disk_permits);
|
|
let writers = Arc::clone(&writers);
|
|
let permit_acquires = Arc::clone(&permit_acquires);
|
|
let reader_factories = Arc::clone(&reader_factories);
|
|
let first_polls = Arc::clone(&first_polls);
|
|
let completed = Arc::clone(&completed);
|
|
let bytes_read = Arc::clone(&bytes_read);
|
|
let fill_plan = engine_plan.clone();
|
|
async move {
|
|
start_cold_fill_producer(
|
|
producer,
|
|
reservation,
|
|
|| async move {
|
|
permit_acquires.fetch_add(1, AtomicOrdering::Relaxed);
|
|
let permit = disk_permits
|
|
.acquire_owned()
|
|
.await
|
|
.map_err(|_| ColdFillError::DiskAdmissionClosed)?;
|
|
let mut io =
|
|
DefaultObjectUsecase::get_object_io_planning_without_disk(get_concurrency_manager());
|
|
io.disk_permit = Some(permit.into());
|
|
Ok(io)
|
|
},
|
|
|| async move {
|
|
reader_factories.fetch_add(1, AtomicOrdering::Relaxed);
|
|
let (writer, reader) = tokio::io::duplex(BODY_BYTES * 2);
|
|
writers.lock().await.push(writer);
|
|
Ok(GetObjectReader {
|
|
stream: Box::new(ColdFillMatrixReader {
|
|
inner: reader,
|
|
first_poll_recorded: false,
|
|
completion_recorded: false,
|
|
first_polls,
|
|
completed,
|
|
bytes_read,
|
|
}),
|
|
object_info: ObjectInfo {
|
|
size: BODY_BYTES_I64,
|
|
actual_size: BODY_BYTES_I64,
|
|
..Default::default()
|
|
},
|
|
buffered_body: None,
|
|
body_source: GetObjectBodySource::HookMissed,
|
|
})
|
|
},
|
|
ColdFillProducerExecution {
|
|
expected: BODY_BYTES,
|
|
deadline: None,
|
|
adapter,
|
|
engine_plan: fill_plan,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
})
|
|
.await;
|
|
let ColdFillCoordinateOutcome::Ready(Ok(body)) = outcome else {
|
|
panic!("matrix request must receive the shared body, got {outcome:?}");
|
|
};
|
|
assert_eq!(body.len(), BODY_BYTES);
|
|
assert!(body.iter().all(|byte| *byte == 7));
|
|
(key_index, body.as_ptr() as usize)
|
|
});
|
|
}
|
|
|
|
tokio::time::timeout(Duration::from_secs(30), async {
|
|
loop {
|
|
if writers.lock().await.len() == key_count
|
|
&& coordinator.global_waiter_count_for_test() == REQUESTS - key_count
|
|
&& first_polls.load(AtomicOrdering::Relaxed) == key_count
|
|
{
|
|
break;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("all matrix followers must join before releasing bodies");
|
|
|
|
let mut body_writers = std::mem::take(&mut *writers.lock().await);
|
|
let body = vec![7_u8; BODY_BYTES];
|
|
for writer in &mut body_writers {
|
|
tokio::io::AsyncWriteExt::write_all(writer, &body)
|
|
.await
|
|
.expect("matrix body write must succeed");
|
|
tokio::io::AsyncWriteExt::shutdown(writer)
|
|
.await
|
|
.expect("matrix body writer must close");
|
|
}
|
|
let mut backing_pointers = std::collections::HashMap::<usize, std::collections::HashSet<usize>>::new();
|
|
tokio::time::timeout(Duration::from_secs(30), async {
|
|
while let Some(result) = tasks.join_next().await {
|
|
let (key_index, body_pointer) = result.expect("matrix GET task must complete");
|
|
backing_pointers.entry(key_index).or_default().insert(body_pointer);
|
|
}
|
|
})
|
|
.await
|
|
.expect("matrix GET tasks must complete before the watchdog");
|
|
|
|
assert_eq!(permit_acquires.load(AtomicOrdering::Relaxed), key_count);
|
|
assert_eq!(reader_factories.load(AtomicOrdering::Relaxed), key_count);
|
|
assert_eq!(first_polls.load(AtomicOrdering::Relaxed), key_count);
|
|
assert_eq!(completed.load(AtomicOrdering::Relaxed), key_count);
|
|
assert_eq!(bytes_read.load(AtomicOrdering::Relaxed), key_count * BODY_BYTES);
|
|
assert_eq!(backing_pointers.len(), key_count);
|
|
assert!(
|
|
backing_pointers.values().all(|pointers| pointers.len() == 1),
|
|
"all followers of one key must share one backing allocation"
|
|
);
|
|
assert_eq!(
|
|
backing_pointers
|
|
.values()
|
|
.flatten()
|
|
.copied()
|
|
.collect::<std::collections::HashSet<_>>()
|
|
.len(),
|
|
key_count
|
|
);
|
|
assert_eq!(coordinator.global_waiter_count_for_test(), 0);
|
|
assert_eq!(coordinator.active_session_count_for_test(), 0);
|
|
assert_eq!(disk_permits.available_permits(), key_count);
|
|
|
|
for key_index in 0..key_count {
|
|
let object = format!("matrix-object-{key_index}");
|
|
let plan = adapter.plan_get(rustfs_object_data_cache::ObjectDataCacheGetRequest {
|
|
bucket: "matrix-bucket",
|
|
object: &object,
|
|
version_id: None,
|
|
etag: "matrix-etag",
|
|
size: BODY_BYTES_U64,
|
|
data_dir_u128: Some(u128::try_from(key_index).unwrap_or(u128::MAX) + 1),
|
|
mod_time_unix_nanos: 1,
|
|
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
|
|
});
|
|
assert!(matches!(
|
|
adapter.lookup_body(&plan).await,
|
|
rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_)
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// #1324: the in-memory (buffered/cache) source is guarded by
|
|
// MemoryTrackedBytesStream. A buffer whose length disagrees with the declared
|
|
// content length must yield a stream error on first poll instead of a clean
|
|
// short body or an over-long body. Reverting to the old warn-and-serve
|
|
// behavior would make these assertions observe Ok chunks.
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn memory_tracked_bytes_stream_fails_short_body() {
|
|
let mut stream = MemoryTrackedBytesStream::new(
|
|
Bytes::from_static(b"test"),
|
|
5,
|
|
GET_MEMORY_BODY_SOURCE_BUFFERED_BODY,
|
|
None,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
);
|
|
let err = stream
|
|
.next()
|
|
.await
|
|
.expect("mismatched memory body must yield an item")
|
|
.expect_err("a short memory body must fail the stream instead of serving a truncated body");
|
|
assert_eq!(
|
|
err.downcast_ref::<std::io::Error>().map(std::io::Error::kind),
|
|
Some(std::io::ErrorKind::InvalidData)
|
|
);
|
|
assert!(stream.next().await.is_none(), "stream must terminate after the error");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn memory_tracked_bytes_stream_fails_over_long_body() {
|
|
let mut stream = MemoryTrackedBytesStream::new(
|
|
Bytes::from_static(b"hello!"),
|
|
5,
|
|
GET_MEMORY_BODY_SOURCE_BUFFERED_BODY,
|
|
None,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
);
|
|
let err = stream
|
|
.next()
|
|
.await
|
|
.expect("mismatched memory body must yield an item")
|
|
.expect_err("an over-long memory body must fail the stream instead of serving mismatched bytes");
|
|
assert_eq!(
|
|
err.downcast_ref::<std::io::Error>().map(std::io::Error::kind),
|
|
Some(std::io::ErrorKind::InvalidData)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn memory_blob_preserves_exact_remaining_length() {
|
|
let blob = DefaultObjectUsecase::build_memory_bytes_blob(
|
|
Bytes::from_static(b"hello"),
|
|
5,
|
|
GET_MEMORY_BODY_SOURCE_BUFFERED_BODY,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
);
|
|
|
|
assert_eq!(blob.remaining_length().exact(), Some(5));
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn memory_blob_once_fast_path_holds_guard_until_bytes_drop() {
|
|
temp_env::with_var(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, Some("true"), || {
|
|
let initial = GetObjectGuard::concurrent_count();
|
|
let guard = GetObjectGuard::new();
|
|
assert_eq!(GetObjectGuard::concurrent_count(), initial + 1);
|
|
|
|
let blob = DefaultObjectUsecase::build_memory_bytes_blob(
|
|
Bytes::from_static(b"hello"),
|
|
5,
|
|
GET_MEMORY_BODY_SOURCE_BUFFERED_BODY,
|
|
GetObjectBodyLifecycle::tracked(guard),
|
|
);
|
|
let mut body = s3s::Body::from(blob);
|
|
let bytes = body.take_bytes().expect("opt-in exact memory body should stay on Body::Once");
|
|
|
|
assert_eq!(bytes, Bytes::from_static(b"hello"));
|
|
assert_eq!(GetObjectGuard::concurrent_count(), initial + 1);
|
|
drop(bytes);
|
|
assert_eq!(GetObjectGuard::concurrent_count(), initial);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn memory_blob_once_fast_path_rejects_length_mismatch() {
|
|
temp_env::with_var(ENV_RUSTFS_GET_SMALL_BODY_ONCE_ENABLE, Some("true"), || {
|
|
let blob = DefaultObjectUsecase::build_memory_bytes_blob(
|
|
Bytes::from_static(b"test"),
|
|
5,
|
|
GET_MEMORY_BODY_SOURCE_BUFFERED_BODY,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
);
|
|
let mut body = s3s::Body::from(blob);
|
|
|
|
assert!(body.take_bytes().is_none(), "mismatched memory body must keep the guarded stream path");
|
|
});
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_object_streaming_reader_times_out_when_body_stalls() {
|
|
let reader = GetObjectStreamingReader::new(
|
|
PendingReader,
|
|
"test-bucket",
|
|
"stalled-object",
|
|
"req-stalled-stream",
|
|
None,
|
|
1,
|
|
Duration::from_millis(1),
|
|
GetObjectBodyLifecycle::disabled(),
|
|
None,
|
|
);
|
|
let mut stream = ReaderStream::with_capacity(reader, 1024);
|
|
|
|
let err = stream
|
|
.next()
|
|
.await
|
|
.expect("reader stream should yield timeout")
|
|
.expect_err("stalled reader should return an error");
|
|
|
|
assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_object_streaming_reader_fails_closed_without_active_reader() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
let mut reader = GetObjectStreamingReader::new(
|
|
cursor_reader(b"x"),
|
|
"test-bucket",
|
|
"missing-reader-object",
|
|
"req-missing-reader",
|
|
None,
|
|
1,
|
|
Duration::ZERO,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
None,
|
|
);
|
|
reader.inner.take();
|
|
|
|
let err = reader
|
|
.read_to_end(&mut Vec::new())
|
|
.await
|
|
.expect_err("an impossible missing active reader must fail closed");
|
|
|
|
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
|
assert_eq!(err.to_string(), "get object streaming reader lost its active read outside resume");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn get_object_streaming_reader_holds_request_guard_until_eof() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
let initial = GetObjectGuard::concurrent_count();
|
|
let guard = GetObjectGuard::new();
|
|
assert_eq!(GetObjectGuard::concurrent_count(), initial + 1);
|
|
|
|
let mut reader = GetObjectStreamingReader::new(
|
|
std::io::Cursor::new(b"hello".to_vec()),
|
|
"test-bucket",
|
|
"complete-object",
|
|
"req-complete-stream",
|
|
None,
|
|
5,
|
|
Duration::ZERO,
|
|
GetObjectBodyLifecycle::tracked(guard),
|
|
None,
|
|
);
|
|
let mut out = Vec::new();
|
|
|
|
reader
|
|
.read_to_end(&mut out)
|
|
.await
|
|
.expect("complete streaming body should read successfully");
|
|
|
|
assert_eq!(out, b"hello");
|
|
assert_eq!(GetObjectGuard::concurrent_count(), initial);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn get_object_streaming_reader_errors_on_short_eof() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
// The inner reader delivers 5 bytes then a clean EOF, but the advertised
|
|
// Content-Length is 10. The reader must surface an error rather than a clean EOF, so
|
|
// the client sees a failed transfer instead of silently persisting a truncated body
|
|
// (the "incomplete data mirroring" of #2955).
|
|
let initial = GetObjectGuard::concurrent_count();
|
|
let guard = GetObjectGuard::new();
|
|
assert_eq!(GetObjectGuard::concurrent_count(), initial + 1);
|
|
|
|
let mut reader = GetObjectStreamingReader::new(
|
|
std::io::Cursor::new(b"short".to_vec()),
|
|
"test-bucket",
|
|
"truncated-object",
|
|
"req-short-eof",
|
|
None,
|
|
10,
|
|
Duration::ZERO,
|
|
GetObjectBodyLifecycle::tracked(guard),
|
|
None,
|
|
);
|
|
let mut out = Vec::new();
|
|
let err = reader
|
|
.read_to_end(&mut out)
|
|
.await
|
|
.expect_err("short body under a larger Content-Length must fail the stream");
|
|
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
|
|
let incomplete_body = err
|
|
.get_ref()
|
|
.and_then(|inner| inner.downcast_ref::<rustfs_rio::IncompleteBody>())
|
|
.expect("short eof should include remaining bytes as IncompleteBody");
|
|
assert_eq!(incomplete_body.remaining, 5);
|
|
assert_eq!(out, b"short", "bytes read before the short EOF are still delivered");
|
|
|
|
drop(reader);
|
|
assert_eq!(GetObjectGuard::concurrent_count(), initial);
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn get_object_streaming_reader_releases_request_guard_when_dropped_incomplete() {
|
|
let initial = GetObjectGuard::concurrent_count();
|
|
let guard = GetObjectGuard::new();
|
|
assert_eq!(GetObjectGuard::concurrent_count(), initial + 1);
|
|
|
|
let reader = GetObjectStreamingReader::new(
|
|
std::io::Cursor::new(b"short".to_vec()),
|
|
"test-bucket",
|
|
"dropped-object",
|
|
"req-dropped-stream",
|
|
None,
|
|
10,
|
|
Duration::ZERO,
|
|
GetObjectBodyLifecycle::tracked(guard),
|
|
None,
|
|
);
|
|
drop(reader);
|
|
|
|
assert_eq!(GetObjectGuard::concurrent_count(), initial);
|
|
}
|
|
|
|
// Emits all of `data`, then either the injected error or a clean EOF. Drives
|
|
// the mid-stream resume state machine through its typed-error and
|
|
// premature-EOF triggers without a store.
|
|
struct FailAtEndReader {
|
|
data: std::io::Cursor<Vec<u8>>,
|
|
error: Option<std::io::Error>,
|
|
}
|
|
|
|
impl FailAtEndReader {
|
|
fn new(data: &[u8], error: Option<std::io::Error>) -> Self {
|
|
Self {
|
|
data: std::io::Cursor::new(data.to_vec()),
|
|
error,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AsyncRead for FailAtEndReader {
|
|
fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
|
let position = usize::try_from(self.data.position()).unwrap_or(usize::MAX);
|
|
let source_len = self.data.get_ref().len();
|
|
if position >= source_len {
|
|
return match self.error.take() {
|
|
Some(error) => Poll::Ready(Err(error)),
|
|
None => Poll::Ready(Ok(())),
|
|
};
|
|
}
|
|
let want = buf.remaining().min(source_len - position);
|
|
if want == 0 {
|
|
return Poll::Ready(Ok(()));
|
|
}
|
|
buf.put_slice(&self.data.get_ref()[position..position + want]);
|
|
self.data.set_position(u64::try_from(position + want).unwrap_or(u64::MAX));
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
fn relocation_read_error() -> std::io::Error {
|
|
std::io::Error::other(StorageError::FileNotFound)
|
|
}
|
|
|
|
fn counting_resume_control(
|
|
reopen_count: Arc<AtomicUsize>,
|
|
mut reopen: impl FnMut(usize) -> Result<FailAtEndReader, GetObjectResumeFailure> + Send + Sync + 'static,
|
|
) -> GetObjectResumeControl<FailAtEndReader> {
|
|
let reopen: GetObjectReopen<FailAtEndReader> = Box::new(move |emitted| {
|
|
reopen_count.fetch_add(1, Ordering::Relaxed);
|
|
let outcome = reopen(emitted);
|
|
Box::pin(async move { outcome })
|
|
});
|
|
GetObjectResumeControl::new(
|
|
reopen,
|
|
RetryTimer::new(
|
|
GET_OBJECT_RESUME_MAX_ATTEMPTS,
|
|
Duration::from_millis(1),
|
|
Duration::from_millis(2),
|
|
rustfs_utils::retry::NO_JITTER,
|
|
0,
|
|
),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_object_streaming_reader_resumes_after_relocation_error() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
// Every typed relocation variant the codec read path can surface
|
|
// mid-body must arm the resume flow.
|
|
for variant in [
|
|
StorageError::FileNotFound,
|
|
StorageError::ObjectNotFound("test-bucket".to_string(), "relocated-object".to_string()),
|
|
StorageError::InsufficientReadQuorum("test-bucket".to_string(), "relocated-object".to_string()),
|
|
StorageError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "relocated shard disappeared")),
|
|
] {
|
|
let reopen_count = Arc::new(AtomicUsize::new(0));
|
|
let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| {
|
|
assert_eq!(emitted, 6, "resume must reopen at the emitted offset");
|
|
Ok(FailAtEndReader::new(b"world", None))
|
|
});
|
|
let mut reader = GetObjectStreamingReader::new(
|
|
FailAtEndReader::new(b"hello ", Some(std::io::Error::other(variant))),
|
|
"test-bucket",
|
|
"relocated-object",
|
|
"req-resume-typed-error",
|
|
None,
|
|
11,
|
|
Duration::ZERO,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
Some(control),
|
|
);
|
|
let mut out = Vec::new();
|
|
reader
|
|
.read_to_end(&mut out)
|
|
.await
|
|
.expect("a resumed body must deliver the full committed content");
|
|
|
|
assert_eq!(out, b"hello world");
|
|
assert_eq!(reopen_count.load(Ordering::Relaxed), 1);
|
|
}
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn get_object_streaming_reader_releases_failed_disk_permit_before_reopen() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
let manager = Arc::new(ConcurrencyManager::with_disk_read_caps_for_test(1, 0));
|
|
let initial_permit = match manager
|
|
.admit_disk_read(Duration::ZERO)
|
|
.await
|
|
.expect("test disk admission must remain open")
|
|
{
|
|
DiskReadAdmission::Primary(permit) => permit,
|
|
other => panic!("initial read must hold the only primary permit, got {other:?}"),
|
|
};
|
|
let reopen_count = Arc::new(AtomicUsize::new(0));
|
|
let reopen: GetObjectReopen<DiskReadPermitReader<FailAtEndReader>> = Box::new({
|
|
let manager = Arc::clone(&manager);
|
|
let reopen_count = Arc::clone(&reopen_count);
|
|
move |emitted| {
|
|
assert_eq!(emitted, 6, "resume must reopen at the emitted offset");
|
|
reopen_count.fetch_add(1, Ordering::Relaxed);
|
|
let manager = Arc::clone(&manager);
|
|
Box::pin(async move {
|
|
match manager
|
|
.admit_disk_read(Duration::from_millis(1))
|
|
.await
|
|
.map_err(|_| GetObjectResumeFailure::Fatal)?
|
|
{
|
|
DiskReadAdmission::Primary(permit) => {
|
|
Ok(DiskReadPermitReader::new(FailAtEndReader::new(b"world", None), permit.into()))
|
|
}
|
|
_ => Err(GetObjectResumeFailure::Retryable),
|
|
}
|
|
})
|
|
}
|
|
});
|
|
let control = GetObjectResumeControl::new(
|
|
reopen,
|
|
RetryTimer::new(
|
|
GET_OBJECT_RESUME_MAX_ATTEMPTS,
|
|
Duration::from_millis(1),
|
|
Duration::from_millis(2),
|
|
rustfs_utils::retry::NO_JITTER,
|
|
0,
|
|
),
|
|
);
|
|
let initial_reader =
|
|
DiskReadPermitReader::new(FailAtEndReader::new(b"hello ", Some(relocation_read_error())), initial_permit.into());
|
|
let mut reader = GetObjectStreamingReader::new(
|
|
initial_reader,
|
|
"test-bucket",
|
|
"relocated-object",
|
|
"req-resume-single-permit",
|
|
None,
|
|
11,
|
|
Duration::ZERO,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
Some(control),
|
|
);
|
|
let mut out = Vec::new();
|
|
|
|
reader
|
|
.read_to_end(&mut out)
|
|
.await
|
|
.expect("resume must not wait on the failed reader's permit");
|
|
|
|
assert_eq!(out, b"hello world");
|
|
assert_eq!(reopen_count.load(Ordering::Relaxed), 1);
|
|
assert_eq!(
|
|
manager.io_queue_status().permits_in_use,
|
|
0,
|
|
"the replacement reader must release its permit at EOF"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_object_streaming_reader_resumes_after_premature_eof() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
// The legacy duplex read path surfaces vanished object data as a clean
|
|
// EOF before the committed length; the resume flow must treat it like
|
|
// the typed relocation error.
|
|
let reopen_count = Arc::new(AtomicUsize::new(0));
|
|
let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| {
|
|
assert_eq!(emitted, 6, "resume must reopen at the emitted offset");
|
|
Ok(FailAtEndReader::new(b"world", None))
|
|
});
|
|
let mut reader = GetObjectStreamingReader::new(
|
|
FailAtEndReader::new(b"hello ", None),
|
|
"test-bucket",
|
|
"truncated-object",
|
|
"req-resume-short-eof",
|
|
None,
|
|
11,
|
|
Duration::ZERO,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
Some(control),
|
|
);
|
|
let mut out = Vec::new();
|
|
reader
|
|
.read_to_end(&mut out)
|
|
.await
|
|
.expect("a resumed body must deliver the full committed content");
|
|
|
|
assert_eq!(out, b"hello world");
|
|
assert_eq!(reopen_count.load(Ordering::Relaxed), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_object_streaming_reader_clean_eof_does_not_resume() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
let reopen_count = Arc::new(AtomicUsize::new(0));
|
|
let control = counting_resume_control(Arc::clone(&reopen_count), |_| {
|
|
panic!("a cleanly completed body must never reopen");
|
|
});
|
|
let mut reader = GetObjectStreamingReader::new(
|
|
FailAtEndReader::new(b"hello world", None),
|
|
"test-bucket",
|
|
"complete-object",
|
|
"req-resume-clean-eof",
|
|
None,
|
|
11,
|
|
Duration::ZERO,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
Some(control),
|
|
);
|
|
let mut out = Vec::new();
|
|
reader.read_to_end(&mut out).await.expect("complete body must read");
|
|
|
|
assert_eq!(out, b"hello world");
|
|
assert_eq!(reopen_count.load(Ordering::Relaxed), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_object_streaming_reader_fatal_resume_failure_returns_original_error() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
// A fatal reopen failure (the reopened object is a different version)
|
|
// must surface the original trigger error after exactly one attempt,
|
|
// with only the originally emitted prefix delivered.
|
|
let reopen_count = Arc::new(AtomicUsize::new(0));
|
|
let control = counting_resume_control(Arc::clone(&reopen_count), |_| Err(GetObjectResumeFailure::Fatal));
|
|
let mut reader = GetObjectStreamingReader::new(
|
|
FailAtEndReader::new(b"hello ", Some(relocation_read_error())),
|
|
"test-bucket",
|
|
"replaced-object",
|
|
"req-resume-fatal",
|
|
None,
|
|
11,
|
|
Duration::ZERO,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
Some(control),
|
|
);
|
|
let mut out = Vec::new();
|
|
let err = reader
|
|
.read_to_end(&mut out)
|
|
.await
|
|
.expect_err("a fatal resume failure must fail the body with the original error");
|
|
|
|
assert!(
|
|
err.get_ref().is_some_and(|inner| inner.is::<StorageError>()),
|
|
"the surfaced error must be the original typed trigger, got: {err}"
|
|
);
|
|
assert_eq!(out, b"hello ");
|
|
assert_eq!(
|
|
reopen_count.load(Ordering::Relaxed),
|
|
1,
|
|
"a fatal failure must short-circuit the retry budget"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_object_streaming_reader_exhausts_resume_budget() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
let reopen_count = Arc::new(AtomicUsize::new(0));
|
|
let control = counting_resume_control(Arc::clone(&reopen_count), |_| Err(GetObjectResumeFailure::Retryable));
|
|
let mut reader = GetObjectStreamingReader::new(
|
|
FailAtEndReader::new(b"hello ", Some(relocation_read_error())),
|
|
"test-bucket",
|
|
"vanished-object",
|
|
"req-resume-budget",
|
|
None,
|
|
11,
|
|
Duration::ZERO,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
Some(control),
|
|
);
|
|
let mut out = Vec::new();
|
|
let err = reader
|
|
.read_to_end(&mut out)
|
|
.await
|
|
.expect_err("an exhausted resume budget must fail the body with the original error");
|
|
|
|
assert!(
|
|
err.get_ref().is_some_and(|inner| inner.is::<StorageError>()),
|
|
"the surfaced error must be the original typed trigger, got: {err}"
|
|
);
|
|
assert_eq!(out, b"hello ");
|
|
assert_eq!(
|
|
reopen_count.load(Ordering::Relaxed),
|
|
usize::try_from(GET_OBJECT_RESUME_MAX_ATTEMPTS).expect("resume budget fits usize"),
|
|
"resume must stop after its reopen budget"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_object_streaming_reader_rearms_resume_after_a_successful_resume() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
// A successful resume restores the armed state: a second mid-stream
|
|
// relocation error on the replacement stream must resume again.
|
|
let reopen_count = Arc::new(AtomicUsize::new(0));
|
|
let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| match emitted {
|
|
6 => Ok(FailAtEndReader::new(b"wo", Some(relocation_read_error()))),
|
|
8 => Ok(FailAtEndReader::new(b"rld", None)),
|
|
other => panic!("unexpected reopen offset {other}"),
|
|
});
|
|
let mut reader = GetObjectStreamingReader::new(
|
|
FailAtEndReader::new(b"hello ", Some(relocation_read_error())),
|
|
"test-bucket",
|
|
"twice-relocated-object",
|
|
"req-resume-rearm",
|
|
None,
|
|
11,
|
|
Duration::ZERO,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
Some(control),
|
|
);
|
|
let mut out = Vec::new();
|
|
reader
|
|
.read_to_end(&mut out)
|
|
.await
|
|
.expect("a re-armed resume must deliver the full committed content");
|
|
|
|
assert_eq!(out, b"hello world");
|
|
assert_eq!(reopen_count.load(Ordering::Relaxed), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_object_streaming_reader_resume_budget_is_per_body() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
// The retry budget is consumed across the whole body, not reset per
|
|
// error: one successful resume plus two failed reopens exhausts it.
|
|
let reopen_count = Arc::new(AtomicUsize::new(0));
|
|
let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| match emitted {
|
|
6 => Ok(FailAtEndReader::new(b"wo", Some(relocation_read_error()))),
|
|
_ => Err(GetObjectResumeFailure::Retryable),
|
|
});
|
|
let mut reader = GetObjectStreamingReader::new(
|
|
FailAtEndReader::new(b"hello ", Some(relocation_read_error())),
|
|
"test-bucket",
|
|
"budget-shared-object",
|
|
"req-resume-budget-per-body",
|
|
None,
|
|
11,
|
|
Duration::ZERO,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
Some(control),
|
|
);
|
|
let mut out = Vec::new();
|
|
let err = reader
|
|
.read_to_end(&mut out)
|
|
.await
|
|
.expect_err("the shared budget must exhaust and surface the latest trigger error");
|
|
|
|
assert!(
|
|
err.get_ref().is_some_and(|inner| inner.is::<StorageError>()),
|
|
"the surfaced error must be the typed trigger, got: {err}"
|
|
);
|
|
assert_eq!(out, b"hello wo");
|
|
assert_eq!(
|
|
reopen_count.load(Ordering::Relaxed),
|
|
usize::try_from(GET_OBJECT_RESUME_MAX_ATTEMPTS).expect("resume budget fits usize"),
|
|
"the budget spans every resume of the same body"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_object_streaming_reader_non_relocation_error_passes_through() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
let reopen_count = Arc::new(AtomicUsize::new(0));
|
|
let control = counting_resume_control(Arc::clone(&reopen_count), |_| {
|
|
panic!("a non-relocation read error must not reopen");
|
|
});
|
|
let mut reader = GetObjectStreamingReader::new(
|
|
FailAtEndReader::new(b"hello ", Some(std::io::Error::new(std::io::ErrorKind::InvalidData, "corrupt"))),
|
|
"test-bucket",
|
|
"corrupt-object",
|
|
"req-resume-passthrough",
|
|
None,
|
|
11,
|
|
Duration::ZERO,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
Some(control),
|
|
);
|
|
let mut out = Vec::new();
|
|
let err = reader
|
|
.read_to_end(&mut out)
|
|
.await
|
|
.expect_err("a non-relocation error must fail the body unchanged");
|
|
|
|
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
|
assert_eq!(out, b"hello ");
|
|
assert_eq!(reopen_count.load(Ordering::Relaxed), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_object_streaming_reader_error_after_full_delivery_does_not_resume() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
// The committed length is already delivered when the inner stream
|
|
// errors, so the error must keep the existing fail-loud behavior
|
|
// instead of arming a resume.
|
|
let reopen_count = Arc::new(AtomicUsize::new(0));
|
|
let control = counting_resume_control(Arc::clone(&reopen_count), |_| {
|
|
panic!("an error after full delivery must not reopen");
|
|
});
|
|
let mut reader = GetObjectStreamingReader::new(
|
|
FailAtEndReader::new(b"hello world", Some(relocation_read_error())),
|
|
"test-bucket",
|
|
"fully-delivered-object",
|
|
"req-resume-after-full",
|
|
None,
|
|
11,
|
|
Duration::ZERO,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
Some(control),
|
|
);
|
|
let mut out = Vec::new();
|
|
let err = reader
|
|
.read_to_end(&mut out)
|
|
.await
|
|
.expect_err("a post-completion inner error still surfaces instead of being swallowed");
|
|
|
|
assert!(
|
|
err.get_ref().is_some_and(|inner| inner.is::<StorageError>()),
|
|
"the surfaced error must be the inner typed error, got: {err}"
|
|
);
|
|
assert_eq!(out, b"hello world");
|
|
assert_eq!(reopen_count.load(Ordering::Relaxed), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn get_object_resume_identity_requires_same_version() {
|
|
let version_id = Uuid::from_u128(0x1234);
|
|
let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp");
|
|
let later_mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_100).expect("valid timestamp");
|
|
let identity = GetObjectResumeIdentity {
|
|
version_id: Some(version_id),
|
|
mod_time: Some(mod_time),
|
|
size: 11,
|
|
etag: Some("etag-a".to_string()),
|
|
range_dependent_size: false,
|
|
};
|
|
let info = ObjectInfo {
|
|
version_id: Some(version_id),
|
|
mod_time: Some(mod_time),
|
|
size: 11,
|
|
etag: Some("etag-a".to_string()),
|
|
..Default::default()
|
|
};
|
|
assert!(identity.matches(&info, 0));
|
|
assert!(identity.matches(&info, 6), "a plain read reports the range-invariant oi.size");
|
|
// Rebalance regenerates data_dir for the same version: identity must
|
|
// still match so a relocated read can resume.
|
|
assert!(identity.matches(
|
|
&ObjectInfo {
|
|
data_dir: Some(Uuid::from_u128(0xbeef)),
|
|
..info.clone()
|
|
},
|
|
0
|
|
));
|
|
assert!(!identity.matches(
|
|
&ObjectInfo {
|
|
version_id: Some(Uuid::from_u128(0x5678)),
|
|
..info.clone()
|
|
},
|
|
0
|
|
));
|
|
assert!(!identity.matches(
|
|
&ObjectInfo {
|
|
version_id: None,
|
|
..info.clone()
|
|
},
|
|
0
|
|
));
|
|
assert!(!identity.matches(
|
|
&ObjectInfo {
|
|
mod_time: Some(later_mod_time),
|
|
..info.clone()
|
|
},
|
|
0
|
|
));
|
|
assert!(!identity.matches(
|
|
&ObjectInfo {
|
|
size: 12,
|
|
..info.clone()
|
|
},
|
|
0
|
|
));
|
|
assert!(!identity.matches(
|
|
&ObjectInfo {
|
|
etag: Some("etag-b".to_string()),
|
|
..info.clone()
|
|
},
|
|
0
|
|
));
|
|
assert!(!identity.matches(&ObjectInfo { etag: None, ..info }, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn get_object_resume_identity_normalizes_range_dependent_size() {
|
|
// Encrypted and compressed reads report the per-read delivered length
|
|
// as object_info.size, so the reopened subrange reports size - emitted
|
|
// for the same version.
|
|
let version_id = Uuid::from_u128(0x1234);
|
|
let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp");
|
|
let identity = GetObjectResumeIdentity {
|
|
version_id: Some(version_id),
|
|
mod_time: Some(mod_time),
|
|
size: 11,
|
|
etag: Some("etag-a".to_string()),
|
|
range_dependent_size: true,
|
|
};
|
|
let reopened = ObjectInfo {
|
|
version_id: Some(version_id),
|
|
mod_time: Some(mod_time),
|
|
size: 5,
|
|
etag: Some("etag-a".to_string()),
|
|
..Default::default()
|
|
};
|
|
assert!(identity.matches(&reopened, 6), "the reopened subrange reports size - emitted");
|
|
assert!(identity.matches(
|
|
&ObjectInfo {
|
|
size: 11,
|
|
..reopened.clone()
|
|
},
|
|
0
|
|
));
|
|
assert!(
|
|
!identity.matches(
|
|
&ObjectInfo {
|
|
size: 11,
|
|
..reopened.clone()
|
|
},
|
|
6
|
|
),
|
|
"an unshrunk range-dependent size after emitted bytes is a different object"
|
|
);
|
|
assert!(!identity.matches(&ObjectInfo { size: 4, ..reopened }, 6));
|
|
}
|
|
|
|
#[test]
|
|
fn get_object_resume_range_offsets() {
|
|
// A full-object read that emitted nothing reopens range-free so the
|
|
// replacement stream keeps the codec fast path.
|
|
assert!(GetObjectResumeContext::resume_range(0, -1, 0).is_none());
|
|
|
|
// Mid-stream full-object resume: open-ended from the emitted offset.
|
|
let range = GetObjectResumeContext::resume_range(0, -1, 6).expect("a mid-stream resume must carry a range");
|
|
assert!(!range.is_suffix_length);
|
|
assert_eq!((range.start, range.end), (6, -1));
|
|
|
|
// Ranged reads resume at absolute offsets with the committed end
|
|
// preserved (suffix ranges and partNumber GETs are resolved to absolute
|
|
// offsets before these values are captured).
|
|
let range = GetObjectResumeContext::resume_range(10, 19, 0).expect("a ranged resume must carry a range");
|
|
assert!(!range.is_suffix_length);
|
|
assert_eq!((range.start, range.end), (10, 19));
|
|
let range = GetObjectResumeContext::resume_range(10, 19, 5).expect("a ranged resume must carry a range");
|
|
assert_eq!((range.start, range.end), (15, 19));
|
|
}
|
|
|
|
async fn real_get_resume_test_context() -> (Vec<std::path::PathBuf>, Arc<ECStore>, Arc<AppContext>) {
|
|
let (disk_paths, store) = crate::app::gating_test_env::shared_gating_ecstore_and_disk_paths().await;
|
|
if current_app_context().is_none() {
|
|
crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await;
|
|
}
|
|
let ambient = current_app_context().expect("resume wiring tests require an ambient AppContext");
|
|
let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms()));
|
|
(disk_paths, store, context)
|
|
}
|
|
|
|
// Uploads a real multipart object through the store and returns the
|
|
// concatenated body, so resume wiring tests can verify byte-exact delivery
|
|
// against on-disk part files.
|
|
async fn put_real_multipart_object(
|
|
store: &Arc<ECStore>,
|
|
bucket: &str,
|
|
object: &str,
|
|
part_size: usize,
|
|
part_count: usize,
|
|
fill: u8,
|
|
) -> Vec<u8> {
|
|
use crate::app::storage_api::multipart_usecase::contract::multipart::{CompletePart, MultipartOperations as _};
|
|
|
|
let upload = store
|
|
.new_multipart_upload(bucket, object, &ObjectOptions::default())
|
|
.await
|
|
.expect("create multipart upload");
|
|
let mut parts = Vec::new();
|
|
let mut body = Vec::with_capacity(part_size * part_count);
|
|
for part_id in 1..=part_count {
|
|
let part_fill = fill.wrapping_add(u8::try_from(part_id - 1).expect("test part index must fit u8"));
|
|
let part_body = vec![part_fill; part_size];
|
|
body.extend_from_slice(&part_body);
|
|
let mut reader = PutObjReader::from_vec(part_body);
|
|
let part = store
|
|
.put_object_part(bucket, object, &upload.upload_id, part_id, &mut reader, &ObjectOptions::default())
|
|
.await
|
|
.expect("upload multipart part");
|
|
parts.push(CompletePart {
|
|
part_num: part_id,
|
|
etag: part.etag,
|
|
..Default::default()
|
|
});
|
|
}
|
|
store
|
|
.clone()
|
|
.complete_multipart_upload(bucket, object, &upload.upload_id, parts, &ObjectOptions::default())
|
|
.await
|
|
.expect("complete multipart upload");
|
|
body
|
|
}
|
|
|
|
// An erasure-coded write returns once write-quorum disks commit, so a
|
|
// lagging disk can legally still be missing its xl.meta when the write
|
|
// call returns. Fixtures that iterate every disk of the owning pool must
|
|
// wait for full materialization first, or they race the trailing disk
|
|
// writes under CI load (issue #6703). Bounded so a genuinely failed disk
|
|
// write still surfaces as a test failure instead of a hang.
|
|
async fn wait_for_object_on_every_disk(disk_paths: &[std::path::PathBuf], bucket: &str, object: &str) {
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
|
loop {
|
|
if disk_paths
|
|
.iter()
|
|
.all(|path| path.join(bucket).join(object).join("xl.meta").is_file())
|
|
{
|
|
return;
|
|
}
|
|
assert!(
|
|
std::time::Instant::now() < deadline,
|
|
"object {bucket}/{object} must materialize xl.meta on every pool disk within the readiness window"
|
|
);
|
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
|
}
|
|
}
|
|
|
|
// Deletes the given part files from every version data dir present on the
|
|
// disks, simulating rebalance removing the object data while xl.meta stays
|
|
// readable. Returns the number of version dirs visited and files removed.
|
|
fn delete_object_part_shards(
|
|
disk_paths: &[std::path::PathBuf],
|
|
bucket: &str,
|
|
object: &str,
|
|
part_numbers: &[usize],
|
|
) -> (usize, usize) {
|
|
let mut version_dirs = 0;
|
|
let mut deleted = 0;
|
|
for disk_path in disk_paths {
|
|
let object_dir = disk_path.join(bucket).join(object);
|
|
let entries = match std::fs::read_dir(&object_dir) {
|
|
Ok(entries) => entries,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
|
Err(error) => panic!("object directory must be readable: {error}"),
|
|
};
|
|
for entry in entries {
|
|
let entry = entry.expect("object directory entry must read");
|
|
if !entry.file_type().expect("entry file type must read").is_dir() {
|
|
continue;
|
|
}
|
|
version_dirs += 1;
|
|
for part_number in part_numbers {
|
|
let part_file = entry.path().join(format!("part.{part_number}"));
|
|
if part_file.exists() {
|
|
std::fs::remove_file(&part_file).expect("part shard must be removable");
|
|
deleted += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
(version_dirs, deleted)
|
|
}
|
|
|
|
// The surfaced mid-stream failure must be the original trigger: a typed
|
|
// relocation StorageError from the codec read path, or an IncompleteBody
|
|
// (UnexpectedEof) from the duplex path. The resume flow must never
|
|
// fabricate a different error.
|
|
fn assert_original_trigger_error(error: &(dyn std::error::Error + Send + Sync + 'static)) {
|
|
let Some(io_error) = error.downcast_ref::<std::io::Error>() else {
|
|
panic!("body error must be an io::Error, got: {error}");
|
|
};
|
|
let is_trigger = io_error.kind() == std::io::ErrorKind::UnexpectedEof || is_object_relocation_error(io_error);
|
|
assert!(is_trigger, "body error must be the original relocation trigger, got: {error}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
// SAFETY: the test mutates one process env var before any use; nextest runs
|
|
// each test in its own process, so the mutation cannot race another test.
|
|
#[allow(unsafe_code)]
|
|
async fn execute_get_object_resume_exhausts_budget_when_object_data_vanishes() {
|
|
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
|
|
|
// The resume phase runs inside the body stall budget (default 10s),
|
|
// and three real reopen attempts against missing shards approach it on
|
|
// loaded CI disks; widen the budget so this test asserts the resume
|
|
// outcome instead of racing the stall timer.
|
|
unsafe { std::env::set_var(rustfs_config::ENV_OBJECT_DISK_READ_TIMEOUT, "120") };
|
|
|
|
let (disk_paths, store, context) = real_get_resume_test_context().await;
|
|
let bucket = format!("resume-vanish-{}", Uuid::new_v4());
|
|
let object = "object.bin";
|
|
store
|
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
|
.await
|
|
.expect("create resume failure-path bucket");
|
|
let part_size = 6 * 1024 * 1024;
|
|
let body = put_real_multipart_object(&store, &bucket, object, part_size, 3, 0xAA).await;
|
|
|
|
// Remove the part.2/part.3 shards on every disk before the GET starts,
|
|
// so no file descriptor for them can exist: the stream must fail at the
|
|
// part-2 boundary, and every reopen resolves intact metadata whose data
|
|
// is gone, so the whole resume budget burns down.
|
|
let (version_dirs, deleted) = delete_object_part_shards(&disk_paths, &bucket, object, &[2, 3]);
|
|
assert!(version_dirs > 0, "the multipart object must have at least one version data directory");
|
|
assert_eq!(deleted, version_dirs * 2);
|
|
|
|
let input = GetObjectInput::builder()
|
|
.bucket(bucket)
|
|
.key(object.to_string())
|
|
.build()
|
|
.expect("resume failure-path GET input must build");
|
|
let usecase = DefaultObjectUsecase::with_context(Some(context));
|
|
let attempts_before = GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed);
|
|
let mut response = usecase
|
|
.execute_get_object(build_request(input, Method::GET))
|
|
.await
|
|
.expect("the GET commits a response; the body fails mid-stream");
|
|
let mut response_body = response.output.body.take().expect("GET response must include a body");
|
|
let mut collected = Vec::new();
|
|
let mut stream_error = None;
|
|
while let Some(chunk) = response_body.next().await {
|
|
match chunk {
|
|
Ok(bytes) => collected.extend_from_slice(&bytes),
|
|
Err(error) => {
|
|
stream_error = Some(error);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert_eq!(
|
|
collected,
|
|
&body[..part_size],
|
|
"only the first part can be delivered before the object data vanishes"
|
|
);
|
|
assert_original_trigger_error(
|
|
stream_error
|
|
.as_deref()
|
|
.expect("the body stream must fail at the missing part"),
|
|
);
|
|
let attempts = GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed) - attempts_before;
|
|
assert_eq!(
|
|
attempts,
|
|
usize::try_from(GET_OBJECT_RESUME_MAX_ATTEMPTS).expect("resume budget fits usize"),
|
|
"resume must exhaust its reopen budget before failing"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn execute_get_object_resumes_from_relocated_pool_without_splicing_body() {
|
|
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
|
|
|
let (temp_dir, pool_disk_paths, store) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await;
|
|
if current_app_context().is_none() {
|
|
crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await;
|
|
}
|
|
let ambient = current_app_context().expect("multi-pool resume test requires an ambient AppContext");
|
|
let context = Arc::new(AppContext::new(Arc::clone(&store), ambient.iam(), ambient.kms()));
|
|
let bucket = format!("resume-relocate-{}", Uuid::new_v4());
|
|
let object = "object.bin";
|
|
store
|
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
|
.await
|
|
.expect("create multi-pool resume bucket");
|
|
let part_size = 24 * 1024 * 1024;
|
|
let body = put_real_multipart_object(&store, &bucket, object, part_size, 3, 0xA5).await;
|
|
let upload_pool = pool_disk_paths
|
|
.iter()
|
|
.position(|paths| {
|
|
paths
|
|
.iter()
|
|
.any(|path| path.join(&bucket).join(object).join("xl.meta").is_file())
|
|
})
|
|
.expect("multipart object must be placed in one source pool");
|
|
wait_for_object_on_every_disk(&pool_disk_paths[upload_pool], &bucket, object).await;
|
|
if upload_pool != 0 {
|
|
let mut normalized_disks = 0;
|
|
for (source_disk, target_disk) in pool_disk_paths[upload_pool].iter().zip(&pool_disk_paths[0]) {
|
|
let source_object = source_disk.join(&bucket).join(object);
|
|
// The multipart commit succeeds on write quorum, so under suite
|
|
// IO load a lagging disk of the erasure set can legitimately
|
|
// hold no object directory (#6701). Normalize the disks that
|
|
// do hold it; the reader tolerates the same minority gap.
|
|
if !source_object.exists() {
|
|
continue;
|
|
}
|
|
let target_bucket = target_disk.join(&bucket);
|
|
std::fs::create_dir_all(&target_bucket).expect("create normalized target bucket directory");
|
|
std::fs::rename(source_object, target_bucket.join(object)).expect("normalize the test object into the old pool");
|
|
normalized_disks += 1;
|
|
}
|
|
assert!(
|
|
normalized_disks > pool_disk_paths[upload_pool].len() / 2,
|
|
"a write-quorum majority of the upload pool's disks must hold the multipart object to normalize"
|
|
);
|
|
}
|
|
let source_pool = 0;
|
|
let target_pool = 1;
|
|
|
|
let input = GetObjectInput::builder()
|
|
.bucket(bucket.clone())
|
|
.key(object.to_string())
|
|
.build()
|
|
.expect("multi-pool resume GET input must build");
|
|
let usecase = DefaultObjectUsecase::with_context(Some(context));
|
|
let mut response = usecase
|
|
.execute_get_object(build_request(input, Method::GET))
|
|
.await
|
|
.expect("multi-pool GET must commit its response");
|
|
let mut response_body = response
|
|
.output
|
|
.body
|
|
.take()
|
|
.expect("multi-pool GET response must include a body");
|
|
|
|
// Open the source reader before publishing the relocated object. Build
|
|
// each replica outside the bucket and rename it into place atomically so
|
|
// background maintenance never observes a metadata-less target object.
|
|
let mut staged_targets = Vec::with_capacity(pool_disk_paths[target_pool].len());
|
|
for (source_disk, target_disk) in pool_disk_paths[source_pool].iter().zip(&pool_disk_paths[target_pool]) {
|
|
let source_dir = source_disk.join(&bucket).join(object);
|
|
// The same write-quorum minority gap tolerated above (#6701) can
|
|
// leave a lagging source-pool disk without the object; skip it and
|
|
// stage the replicas that exist — the reader tolerates the gap.
|
|
if !source_dir.join("xl.meta").is_file() {
|
|
continue;
|
|
}
|
|
let target_dir = target_disk.join(&bucket).join(object);
|
|
let staging_dir = temp_dir.path().join(format!("resume-relocate-{}", Uuid::new_v4()));
|
|
std::fs::create_dir_all(&staging_dir).expect("create relocated target staging directory");
|
|
for entry in std::fs::read_dir(&source_dir).expect("read source object directory") {
|
|
let entry = entry.expect("read source object entry");
|
|
if !entry.file_type().expect("read source object entry type").is_dir() {
|
|
continue;
|
|
}
|
|
let target_entry = staging_dir.join(entry.file_name());
|
|
std::fs::create_dir_all(&target_entry).expect("create relocated target data directory");
|
|
for child in std::fs::read_dir(entry.path()).expect("read source object data directory") {
|
|
let child = child.expect("read source object data entry");
|
|
std::fs::copy(child.path(), target_entry.join(child.file_name())).expect("copy relocated object data entry");
|
|
}
|
|
}
|
|
std::fs::copy(source_dir.join("xl.meta"), staging_dir.join("xl.meta")).expect("stage relocated object metadata");
|
|
staged_targets.push((staging_dir, target_dir, source_dir.join("xl.meta")));
|
|
}
|
|
assert!(
|
|
staged_targets.len() > pool_disk_paths[source_pool].len() / 2,
|
|
"a write-quorum majority of the source pool's disks must hold the object to stage the relocation"
|
|
);
|
|
let (version_dirs, deleted) = delete_object_part_shards(&pool_disk_paths[source_pool], &bucket, object, &[2, 3]);
|
|
assert!(version_dirs > 0, "the source pool must have at least one version data directory");
|
|
assert_eq!(deleted, version_dirs * 2);
|
|
|
|
for (staging_dir, target_dir, source_meta) in staged_targets {
|
|
std::fs::rename(staging_dir, target_dir).expect("publish relocated target object");
|
|
std::fs::remove_file(source_meta).expect("remove relocated source object metadata");
|
|
}
|
|
store
|
|
.get_object_info(&bucket, object, &ObjectOptions::default())
|
|
.await
|
|
.expect("the relocated object must resolve from the target pool");
|
|
|
|
let attempts_before = GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed);
|
|
let mut collected = Vec::new();
|
|
while let Some(chunk) = response_body.next().await {
|
|
match chunk {
|
|
Ok(chunk) => collected.extend_from_slice(&chunk),
|
|
Err(err) => panic!(
|
|
"relocated GET from pool {source_pool} must resume from pool {target_pool} after {} attempts: {err:?}",
|
|
GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed) - attempts_before
|
|
),
|
|
}
|
|
}
|
|
|
|
assert_eq!(collected, body, "resumed production GET must preserve the complete body byte-for-byte");
|
|
assert_eq!(
|
|
GET_OBJECT_RESUME_ATTEMPTS_FOR_TEST.load(Ordering::Relaxed) - attempts_before,
|
|
1,
|
|
"the relocated body must reopen exactly once"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn get_object_resume_reopen_rejects_a_replaced_object_version() {
|
|
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
|
use tokio::io::AsyncReadExt as _;
|
|
|
|
let (_disk_paths, store, _context) = real_get_resume_test_context().await;
|
|
let bucket = format!("resume-identity-{}", Uuid::new_v4());
|
|
let object = "object.bin";
|
|
store
|
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
|
.await
|
|
.expect("create resume identity bucket");
|
|
let body = vec![0xAA; 1024 * 1024];
|
|
put_real_cold_fill_object(&store, &bucket, object, &body).await;
|
|
let info = store
|
|
.get_object_info(&bucket, object, &ObjectOptions::default())
|
|
.await
|
|
.expect("read the committed object metadata");
|
|
|
|
let ctx = GetObjectResumeContext::new(
|
|
Arc::clone(&store),
|
|
&bucket,
|
|
object,
|
|
ObjectOptions::default(),
|
|
&HeaderMap::new(),
|
|
&info,
|
|
0,
|
|
-1,
|
|
);
|
|
|
|
// Positive control: the same version reopens and streams the body.
|
|
let manager = get_concurrency_manager();
|
|
let permits_before = manager.io_queue_status().permits_in_use;
|
|
let mut reader = ctx.reopen(0).await.expect("reopening the same version must succeed");
|
|
assert_eq!(
|
|
manager.io_queue_status().permits_in_use,
|
|
permits_before + 1,
|
|
"the resumed stream must hold disk-read admission like the initial read"
|
|
);
|
|
let mut reopened_body = Vec::new();
|
|
reader
|
|
.read_to_end(&mut reopened_body)
|
|
.await
|
|
.expect("the reopened reader must stream the body");
|
|
assert_eq!(reopened_body, body);
|
|
// The reopened reader holds the object read lock; drop it before the
|
|
// delete below requests the write lock.
|
|
drop(reader);
|
|
assert_eq!(
|
|
manager.io_queue_status().permits_in_use,
|
|
permits_before,
|
|
"dropping the resumed stream must release its disk-read admission"
|
|
);
|
|
|
|
// A nonzero-offset reopen must splice the remaining bytes exactly.
|
|
let mut reader = ctx.reopen(1024).await.expect("reopening at a nonzero offset must succeed");
|
|
let mut tail = Vec::new();
|
|
reader
|
|
.read_to_end(&mut tail)
|
|
.await
|
|
.expect("the offset reader must stream the remaining body");
|
|
assert_eq!(tail, body[1024..], "the resumed stream must continue from the emitted offset exactly");
|
|
drop(reader);
|
|
|
|
// Delete and re-PUT the key, then the stale context must refuse to
|
|
// splice the replacement version into the committed response.
|
|
store
|
|
.delete_object(&bucket, object, ObjectOptions::default())
|
|
.await
|
|
.expect("delete the original object");
|
|
let replacement_body = vec![0xBB; 2 * 1024 * 1024];
|
|
put_real_cold_fill_object(&store, &bucket, object, &replacement_body).await;
|
|
let result = ctx.reopen(0).await;
|
|
assert!(
|
|
matches!(result, Err(GetObjectResumeFailure::Fatal)),
|
|
"reopening a replaced version must fail closed"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn get_object_resume_context_pins_latest_read_to_resolved_version() {
|
|
let (_disk_paths, store, _context) = real_get_resume_test_context().await;
|
|
let resolved_version = Uuid::new_v4();
|
|
let info = ObjectInfo {
|
|
version_id: Some(resolved_version),
|
|
..Default::default()
|
|
};
|
|
|
|
let ctx = GetObjectResumeContext::new(
|
|
Arc::clone(&store),
|
|
"bucket",
|
|
"object.bin",
|
|
ObjectOptions::default(),
|
|
&HeaderMap::new(),
|
|
&info,
|
|
0,
|
|
-1,
|
|
);
|
|
assert_eq!(
|
|
ctx.opts.version_id,
|
|
Some(resolved_version.to_string()),
|
|
"latest GET resume must reopen the initially resolved version, not the moving latest"
|
|
);
|
|
|
|
let explicit_version = Uuid::new_v4().to_string();
|
|
let explicit_opts = ObjectOptions {
|
|
version_id: Some(explicit_version.clone()),
|
|
..Default::default()
|
|
};
|
|
let ctx = GetObjectResumeContext::new(
|
|
Arc::clone(&store),
|
|
"bucket",
|
|
"object.bin",
|
|
explicit_opts,
|
|
&HeaderMap::new(),
|
|
&info,
|
|
0,
|
|
-1,
|
|
);
|
|
assert_eq!(
|
|
ctx.opts.version_id.as_deref(),
|
|
Some(explicit_version.as_str()),
|
|
"an explicit request version must stay authoritative"
|
|
);
|
|
|
|
let unversioned_info = ObjectInfo::default();
|
|
let ctx = GetObjectResumeContext::new(
|
|
Arc::clone(&store),
|
|
"bucket",
|
|
"object.bin",
|
|
ObjectOptions::default(),
|
|
&HeaderMap::new(),
|
|
&unversioned_info,
|
|
0,
|
|
-1,
|
|
);
|
|
assert_eq!(ctx.opts.version_id, None, "unversioned reads have no version to pin");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn get_object_resume_context_redacts_ssec_headers_and_flags_range_dependent_size() {
|
|
let (_disk_paths, store, _context) = real_get_resume_test_context().await;
|
|
|
|
let mut request_headers = HeaderMap::new();
|
|
request_headers.insert(SSEC_ALGORITHM_HEADER, HeaderValue::from_static("AES256"));
|
|
request_headers.insert(SSEC_KEY_HEADER, HeaderValue::from_static("dGVzdC1rZXk="));
|
|
request_headers.insert(SSEC_KEY_MD5_HEADER, HeaderValue::from_static("bWQ1"));
|
|
request_headers.insert(http::header::AUTHORIZATION, HeaderValue::from_static("AWS4-HMAC-SHA256 Credential=test"));
|
|
request_headers.insert("x-amz-security-token", HeaderValue::from_static("session-token"));
|
|
let store_headers = project_ssec_transport_headers(&request_headers);
|
|
assert_eq!(store_headers.len(), 3, "only store-consumed SSE-C headers are forwarded");
|
|
assert!(store_headers.values().all(HeaderValue::is_sensitive));
|
|
assert!(store_headers.get(http::header::AUTHORIZATION).is_none());
|
|
assert!(store_headers.get("x-amz-security-token").is_none());
|
|
assert!(!format!("{store_headers:?}").contains("dGVzdC1rZXk="));
|
|
let plain_info = ObjectInfo {
|
|
size: 11,
|
|
..Default::default()
|
|
};
|
|
let ctx = GetObjectResumeContext::new(
|
|
Arc::clone(&store),
|
|
"bucket",
|
|
"object.bin",
|
|
ObjectOptions::default(),
|
|
&request_headers,
|
|
&plain_info,
|
|
0,
|
|
-1,
|
|
);
|
|
for name in [SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER] {
|
|
let value = ctx.ssec_headers.get(name).expect("the SSE-C trio is retained");
|
|
assert!(value.is_sensitive(), "store spans record headers at debug; {name} must be redacted there");
|
|
}
|
|
assert_eq!(
|
|
ctx.ssec_headers.len(),
|
|
3,
|
|
"only the SSE-C trio may be retained; credential headers must never be replayed into store spans"
|
|
);
|
|
assert!(!ctx.identity.range_dependent_size, "plain reads report the range-invariant oi.size");
|
|
|
|
let encrypted_info = ObjectInfo {
|
|
size: 11,
|
|
user_defined: Arc::new(
|
|
[("x-amz-server-side-encryption".to_string(), "aws:kms".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
),
|
|
..Default::default()
|
|
};
|
|
let ctx = GetObjectResumeContext::new(
|
|
Arc::clone(&store),
|
|
"bucket",
|
|
"object.bin",
|
|
ObjectOptions::default(),
|
|
&HeaderMap::new(),
|
|
&encrypted_info,
|
|
0,
|
|
-1,
|
|
);
|
|
assert!(ctx.identity.range_dependent_size, "encrypted reads report the per-read delivered length");
|
|
|
|
let compressed_info = ObjectInfo {
|
|
size: 11,
|
|
user_defined: Arc::new(
|
|
[("x-rustfs-internal-compression".to_string(), "snappy".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
),
|
|
..Default::default()
|
|
};
|
|
let ctx = GetObjectResumeContext::new(
|
|
Arc::clone(&store),
|
|
"bucket",
|
|
"object.bin",
|
|
ObjectOptions::default(),
|
|
&HeaderMap::new(),
|
|
&compressed_info,
|
|
0,
|
|
-1,
|
|
);
|
|
assert!(ctx.identity.range_dependent_size, "compressed reads report the per-read delivered length");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn memory_tracked_bytes_stream_releases_request_guard_after_emit() {
|
|
let initial = GetObjectGuard::concurrent_count();
|
|
let guard = GetObjectGuard::new();
|
|
assert_eq!(GetObjectGuard::concurrent_count(), initial + 1);
|
|
|
|
let mut stream = MemoryTrackedBytesStream::new(
|
|
Bytes::from_static(b"hello"),
|
|
5,
|
|
GET_MEMORY_BODY_SOURCE_BUFFERED_BODY,
|
|
None,
|
|
GetObjectBodyLifecycle::tracked(guard),
|
|
);
|
|
let chunk = stream
|
|
.next()
|
|
.await
|
|
.expect("memory body should emit one chunk")
|
|
.expect("memory body chunk should be readable");
|
|
|
|
assert_eq!(chunk.as_ref(), b"hello");
|
|
assert_eq!(GetObjectGuard::concurrent_count(), initial);
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial]
|
|
fn memory_tracked_bytes_stream_releases_request_guard_for_zero_length_without_poll() {
|
|
let initial = GetObjectGuard::concurrent_count();
|
|
let guard = GetObjectGuard::new();
|
|
assert_eq!(GetObjectGuard::concurrent_count(), initial + 1);
|
|
|
|
let stream = MemoryTrackedBytesStream::new(
|
|
Bytes::new(),
|
|
0,
|
|
GET_MEMORY_BODY_SOURCE_BUFFERED_BODY,
|
|
None,
|
|
GetObjectBodyLifecycle::tracked(guard),
|
|
);
|
|
drop(stream);
|
|
|
|
assert_eq!(GetObjectGuard::concurrent_count(), initial);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disk_read_permit_reader_holds_permit_until_reader_is_dropped() {
|
|
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
|
|
let permit = semaphore
|
|
.clone()
|
|
.acquire_owned()
|
|
.await
|
|
.expect("test semaphore should grant owned permit");
|
|
|
|
let reader = DiskReadPermitReader::new(std::io::Cursor::new(Vec::<u8>::new()), permit.into());
|
|
assert_eq!(semaphore.available_permits(), 0);
|
|
|
|
drop(reader);
|
|
assert_eq!(semaphore.available_permits(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn buffered_body_releases_disk_permit_before_output_planning() {
|
|
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
|
|
let permit = semaphore
|
|
.clone()
|
|
.acquire_owned()
|
|
.await
|
|
.expect("test semaphore should grant owned permit");
|
|
let mut disk_permit = Some(permit.into());
|
|
release_disk_read_permit_if_buffered(&mut disk_permit, Some(&Bytes::from_static(b"body")));
|
|
assert_eq!(semaphore.available_permits(), 1);
|
|
assert!(disk_permit.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn streaming_body_retains_disk_permit_for_output_planning() {
|
|
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
|
|
let permit = semaphore
|
|
.clone()
|
|
.acquire_owned()
|
|
.await
|
|
.expect("test semaphore should grant owned permit");
|
|
let mut disk_permit = Some(permit.into());
|
|
|
|
release_disk_read_permit_if_buffered(&mut disk_permit, None);
|
|
assert_eq!(semaphore.available_permits(), 0);
|
|
assert!(disk_permit.is_some());
|
|
|
|
drop(disk_permit);
|
|
assert_eq!(semaphore.available_permits(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial_test::serial(cold_fill_metrics_gate)]
|
|
async fn cold_fill_follower_disk_permit_metric_tracks_actual_permit_lifetime() {
|
|
COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.store(0, Ordering::Relaxed);
|
|
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
|
|
scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Follower, async {
|
|
let permit = semaphore
|
|
.clone()
|
|
.acquire_owned()
|
|
.await
|
|
.expect("follower test semaphore must grant an owned permit");
|
|
let tracked = GetObjectDiskPermit::new(permit);
|
|
assert_eq!(semaphore.available_permits(), 0);
|
|
assert_eq!(COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.load(Ordering::Relaxed), 1);
|
|
|
|
drop(tracked);
|
|
assert_eq!(semaphore.available_permits(), 1);
|
|
assert_eq!(COLD_FILL_FOLLOWER_DISK_PERMITS_FOR_TEST.load(Ordering::Relaxed), 0);
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[test]
|
|
#[serial_test::serial(cold_fill_metrics_gate)]
|
|
fn cold_fill_disk_permit_metrics_obey_gate_and_return_to_zero() {
|
|
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
|
|
|
|
let metrics_was_enabled = rustfs_io_metrics::metrics_enabled();
|
|
let recorder = DebuggingRecorder::new();
|
|
let snapshotter = recorder.snapshotter();
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("metric test runtime must build");
|
|
metrics::with_local_recorder(&recorder, || {
|
|
runtime.block_on(async {
|
|
rustfs_io_metrics::set_metrics_enabled(false);
|
|
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
|
|
scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Follower, async {
|
|
let permit = semaphore
|
|
.clone()
|
|
.acquire_owned()
|
|
.await
|
|
.expect("metric test permit must be available");
|
|
let tracked = GetObjectDiskPermit::new(permit);
|
|
rustfs_io_metrics::set_metrics_enabled(true);
|
|
drop(tracked);
|
|
})
|
|
.await;
|
|
assert!(
|
|
snapshotter.snapshot().into_vec().into_iter().all(|(composite, _, _, _)| {
|
|
!composite.key().name().starts_with("rustfs_object_data_cache_cold_fill_")
|
|
}),
|
|
"a permit acquired while metrics were disabled must not record an unmatched decrement"
|
|
);
|
|
|
|
scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Producer, async {
|
|
let permit = semaphore
|
|
.clone()
|
|
.acquire_owned()
|
|
.await
|
|
.expect("metric test permit must be available");
|
|
let tracked = GetObjectDiskPermit::new(permit);
|
|
rustfs_io_metrics::set_metrics_enabled(false);
|
|
drop(tracked);
|
|
})
|
|
.await;
|
|
rustfs_io_metrics::set_metrics_enabled(true);
|
|
scope_cold_fill_disk_permit_owner_for_test(ColdFillDiskPermitOwner::Follower, async {
|
|
let permit = semaphore.acquire_owned().await.expect("metric test permit must be available");
|
|
let tracked = GetObjectDiskPermit::new(permit);
|
|
let _replacement = crate::app::object_data_cache::ColdFillCoordinator::default();
|
|
drop(tracked);
|
|
})
|
|
.await;
|
|
});
|
|
});
|
|
|
|
let values = snapshotter
|
|
.snapshot()
|
|
.into_vec()
|
|
.into_iter()
|
|
.filter_map(|(composite, _unit, _description, value)| {
|
|
composite
|
|
.key()
|
|
.name()
|
|
.starts_with("rustfs_object_data_cache_cold_fill_")
|
|
.then_some((composite.key().name().to_string(), value))
|
|
})
|
|
.collect::<std::collections::HashMap<_, _>>();
|
|
assert_eq!(values.len(), 2);
|
|
for name in [
|
|
"rustfs_object_data_cache_cold_fill_producer_disk_permits",
|
|
"rustfs_object_data_cache_cold_fill_follower_disk_permits",
|
|
] {
|
|
let DebugValue::Gauge(value) = values.get(name).unwrap_or_else(|| panic!("missing {name} gauge")) else {
|
|
panic!("{name} must be a gauge");
|
|
};
|
|
assert_eq!(value.into_inner(), 0.0, "{name} must return to zero after permit drop");
|
|
}
|
|
rustfs_io_metrics::set_metrics_enabled(metrics_was_enabled);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_get_object_body_keeps_large_objects_on_streaming_path_without_preread() {
|
|
let reads = Arc::new(AtomicUsize::new(0));
|
|
let reader = ReadProbeReader {
|
|
reads: Arc::clone(&reads),
|
|
};
|
|
let info = ObjectInfo {
|
|
size: 18_i64 * 1024 * 1024 * 1024,
|
|
..Default::default()
|
|
};
|
|
|
|
let _body = DefaultObjectUsecase::build_get_object_body(
|
|
reader,
|
|
&info,
|
|
18_i64 * 1024 * 1024 * 1024,
|
|
"req-large-object",
|
|
None,
|
|
128 * 1024,
|
|
true,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
None,
|
|
"test-bucket",
|
|
"large-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await
|
|
.expect("build_get_object_body should succeed for streaming path");
|
|
|
|
assert_eq!(
|
|
reads.load(AtomicOrdering::Relaxed),
|
|
0,
|
|
"large-object response construction should not pre-read object data"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_get_object_body_keeps_large_encrypted_objects_on_streaming_path_without_preread() {
|
|
let reads = Arc::new(AtomicUsize::new(0));
|
|
let reader = ReadProbeReader {
|
|
reads: Arc::clone(&reads),
|
|
};
|
|
let info = ObjectInfo {
|
|
size: 18_i64 * 1024 * 1024 * 1024,
|
|
..Default::default()
|
|
};
|
|
|
|
let _body = DefaultObjectUsecase::build_get_object_body(
|
|
reader,
|
|
&info,
|
|
18_i64 * 1024 * 1024 * 1024,
|
|
"req-large-encrypted-object",
|
|
None,
|
|
128 * 1024,
|
|
true,
|
|
1,
|
|
None,
|
|
false,
|
|
true,
|
|
None,
|
|
"test-bucket",
|
|
"large-encrypted-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await
|
|
.expect("build_get_object_body should succeed for encrypted streaming path");
|
|
|
|
assert_eq!(
|
|
reads.load(AtomicOrdering::Relaxed),
|
|
0,
|
|
"large encrypted object response construction should not pre-read object data"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_get_object_body_uses_buffered_body_without_reader_preread() {
|
|
let reads = Arc::new(AtomicUsize::new(0));
|
|
let reader = ReadProbeReader {
|
|
reads: Arc::clone(&reads),
|
|
};
|
|
let info = ObjectInfo {
|
|
size: 4,
|
|
..Default::default()
|
|
};
|
|
|
|
let _body = DefaultObjectUsecase::build_get_object_body(
|
|
reader,
|
|
&info,
|
|
4,
|
|
"req-direct-memory-object",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
Some(Bytes::from_static(b"test")),
|
|
"test-bucket",
|
|
"direct-memory-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| panic!("a buffered body must not initialize streaming resume state"),
|
|
)
|
|
.await
|
|
.expect("build_get_object_body should consume buffered body");
|
|
|
|
assert_eq!(
|
|
reads.load(AtomicOrdering::Relaxed),
|
|
0,
|
|
"buffered GetObject body must not be read from the fallback reader"
|
|
);
|
|
}
|
|
|
|
#[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,
|
|
// Fill must not depend on the live memory reading (host vs container).
|
|
min_free_memory_percent: 0,
|
|
..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,
|
|
data_dir_u128: None,
|
|
mod_time_unix_nanos: 0,
|
|
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,
|
|
"req-cached-object",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
None,
|
|
false,
|
|
false,
|
|
true,
|
|
"test-bucket",
|
|
"cached-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| panic!("a cache hit must not initialize streaming resume state"),
|
|
)
|
|
.await
|
|
.expect("cache hit body handoff should succeed");
|
|
|
|
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,
|
|
// Fill must not depend on the live memory reading (host vs container).
|
|
min_free_memory_percent: 0,
|
|
..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,
|
|
data_dir_u128: None,
|
|
mod_time_unix_nanos: 0,
|
|
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,
|
|
"req-rejects-size-mismatch-fill",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
None,
|
|
false,
|
|
false,
|
|
true,
|
|
"test-bucket",
|
|
"cached-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.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_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,
|
|
// Fill must not depend on the live memory reading (host vs container).
|
|
min_free_memory_percent: 0,
|
|
..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,
|
|
"req-cache-fill-first",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
Some(Bytes::from_static(b"hello")),
|
|
false,
|
|
false,
|
|
true,
|
|
"test-bucket",
|
|
"cached-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await
|
|
.expect("buffered-body handoff should succeed");
|
|
|
|
// ODC-15: the fill is detached from the response path, so wait for it to
|
|
// populate the cache before the follow-up GET to keep the hit deterministic.
|
|
wait_for_cache_hit(&adapter, "test-bucket", "cached-object", "etag", 5).await;
|
|
|
|
let _second_body = DefaultObjectUsecase::build_get_object_body_with_cache(
|
|
&adapter,
|
|
second_reader,
|
|
&info,
|
|
5,
|
|
"req-cache-fill-second",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
None,
|
|
false,
|
|
false,
|
|
true,
|
|
"test-bucket",
|
|
"cached-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await
|
|
.expect("follow-up cache hit should succeed");
|
|
|
|
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,
|
|
// Fill must not depend on the live memory reading (host vs container).
|
|
min_free_memory_percent: 0,
|
|
..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,
|
|
data_dir_u128: None,
|
|
mod_time_unix_nanos: 0,
|
|
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
|
|
});
|
|
|
|
let _body = DefaultObjectUsecase::build_get_object_body_with_cache(
|
|
&adapter,
|
|
reader,
|
|
&info,
|
|
5,
|
|
"req-rejects-buffered-size-mismatch",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
Some(Bytes::from_static(b"oops")),
|
|
false,
|
|
false,
|
|
true,
|
|
"test-bucket",
|
|
"cached-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await
|
|
.expect("size-mismatched buffered-body handoff should still return a response body");
|
|
let lookup = adapter.lookup_body(&plan).await;
|
|
|
|
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_hook_served_records_no_second_lookup() {
|
|
// ODC-16 (backlog#1121): a hook-served GET must record exactly one
|
|
// lookup — the ecstore hook's. The app layer, handed the cache body as
|
|
// buffered_body with cache_hook_served=true, must serve it directly
|
|
// without a second lookup (which would double the hits and hit_bytes).
|
|
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,
|
|
min_free_memory_percent: 0,
|
|
..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: "hook-served",
|
|
version_id: None,
|
|
etag: "etag",
|
|
size: 5,
|
|
data_dir_u128: None,
|
|
mod_time_unix_nanos: 0,
|
|
body_variant: rustfs_object_data_cache::ObjectDataCacheBodyVariant::FullObjectPlainV1,
|
|
});
|
|
let hit_body = Bytes::from_static(b"hello");
|
|
assert_eq!(
|
|
adapter.cache().fill_body(&plan, hit_body.clone()).await,
|
|
rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted
|
|
);
|
|
|
|
// Simulate the ecstore hook: it performs exactly one lookup after fresh
|
|
// metadata resolution, hits, and hands the body forward as buffered_body.
|
|
assert!(matches!(
|
|
adapter.lookup_body(&plan).await,
|
|
rustfs_object_data_cache::ObjectDataCacheLookup::Hit(_)
|
|
));
|
|
let lookups_after_hook = adapter.cache().stats().lookups;
|
|
assert_eq!(lookups_after_hook, 1, "the hook performs exactly one lookup");
|
|
|
|
let _body = DefaultObjectUsecase::build_get_object_body_with_cache(
|
|
&adapter,
|
|
reader,
|
|
&info,
|
|
5,
|
|
"req-hook-served",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
Some(hit_body),
|
|
/* cache_hook_served */ true,
|
|
/* cache_hook_probed */ true,
|
|
/* cache_fill_allowed */ true,
|
|
"test-bucket",
|
|
"hook-served",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await
|
|
.expect("hook-served body handoff should succeed");
|
|
|
|
assert_eq!(
|
|
adapter.cache().stats().lookups,
|
|
lookups_after_hook,
|
|
"a hook-served GET must not record a second lookup in the app layer"
|
|
);
|
|
assert_eq!(
|
|
reads.load(AtomicOrdering::Relaxed),
|
|
0,
|
|
"hook-served body handoff must not read from the fallback reader"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_get_object_body_with_cache_hook_miss_skips_app_lookup() {
|
|
// ODC-16: when the hook probed and missed, its miss is authoritative
|
|
// (it ran after fresh metadata resolution), so the app layer must not
|
|
// run a second lookup — it only fills from the buffered body.
|
|
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,
|
|
min_free_memory_percent: 0,
|
|
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
|
|
})
|
|
.expect("fill-enabled cache adapter should initialize");
|
|
|
|
let lookups_before = adapter.cache().stats().lookups;
|
|
let _body = DefaultObjectUsecase::build_get_object_body_with_cache(
|
|
&adapter,
|
|
reader,
|
|
&info,
|
|
5,
|
|
"req-hook-missed",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
Some(Bytes::from_static(b"hello")),
|
|
/* cache_hook_served */ false,
|
|
/* cache_hook_probed */ true,
|
|
/* cache_fill_allowed */ true,
|
|
"test-bucket",
|
|
"hook-missed",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await
|
|
.expect("hook-miss buffered-body handoff should succeed");
|
|
|
|
assert_eq!(
|
|
adapter.cache().stats().lookups,
|
|
lookups_before,
|
|
"a hook-probed miss must not trigger an app-layer lookup"
|
|
);
|
|
assert_eq!(
|
|
reads.load(AtomicOrdering::Relaxed),
|
|
0,
|
|
"buffered-body handoff must not read from the fallback reader"
|
|
);
|
|
}
|
|
|
|
#[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,
|
|
// Fill must not depend on the live memory reading (host vs container).
|
|
min_free_memory_percent: 0,
|
|
..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,
|
|
"req-materialize-first",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
None,
|
|
false,
|
|
false,
|
|
true,
|
|
"test-bucket",
|
|
"materialized-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await
|
|
.expect("materialize-fill handoff should succeed");
|
|
|
|
// ODC-15: the fill is detached from the response path, so wait for it to
|
|
// populate the cache before the follow-up GET to keep the hit deterministic.
|
|
wait_for_cache_hit(&adapter, "test-bucket", "materialized-object", "etag", 5).await;
|
|
|
|
let _second_body = DefaultObjectUsecase::build_get_object_body_with_cache(
|
|
&adapter,
|
|
second_reader,
|
|
&info,
|
|
5,
|
|
"req-materialize-second",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
None,
|
|
false,
|
|
false,
|
|
true,
|
|
"test-bucket",
|
|
"materialized-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await
|
|
.expect("follow-up cache hit should succeed");
|
|
|
|
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"
|
|
);
|
|
}
|
|
|
|
// ODC-07: a materialize read that yields more than the declared content
|
|
// length must be a hard error, not a warn-and-serve, matching the
|
|
// direct-memory GET path. The bounded `take` reads one byte past capacity so
|
|
// the over-long stream is detected without buffering it unbounded.
|
|
#[tokio::test]
|
|
async fn build_get_object_body_with_cache_materialize_rejects_length_mismatch() {
|
|
let reads = Arc::new(AtomicUsize::new(0));
|
|
// Declared content length is 5, but the stream yields 6 bytes.
|
|
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,
|
|
min_free_memory_percent: 0,
|
|
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
|
|
})
|
|
.expect("materialize-fill cache adapter should initialize");
|
|
|
|
let result = DefaultObjectUsecase::build_get_object_body_with_cache(
|
|
&adapter,
|
|
reader,
|
|
&info,
|
|
5,
|
|
"req-materialize-mismatch",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
None,
|
|
false,
|
|
false,
|
|
true,
|
|
"test-bucket",
|
|
"mismatch-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"an over-long materialize read must be a hard error, not a truncated served body"
|
|
);
|
|
}
|
|
|
|
// #1324: a materialize-fill read that ends short of the declared content
|
|
// length (clean EOF at N-1 for a declared N) must hard-fail, matching the
|
|
// over-long case above. Reverting to warn-and-serve would return Ok with a
|
|
// truncated body.
|
|
#[tokio::test]
|
|
async fn build_get_object_body_with_cache_materialize_rejects_short_read() {
|
|
let reads = Arc::new(AtomicUsize::new(0));
|
|
// Declared content length is 5, but the stream only yields 4 bytes.
|
|
let reader = DataProbeReader {
|
|
reads: Arc::clone(&reads),
|
|
data: std::io::Cursor::new(b"hell".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,
|
|
min_free_memory_percent: 0,
|
|
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
|
|
})
|
|
.expect("materialize-fill cache adapter should initialize");
|
|
|
|
let result = DefaultObjectUsecase::build_get_object_body_with_cache(
|
|
&adapter,
|
|
reader,
|
|
&info,
|
|
5,
|
|
"req-materialize-short",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
None,
|
|
false,
|
|
false,
|
|
true,
|
|
"test-bucket",
|
|
"short-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"a short materialize read must be a hard error, not a truncated served body"
|
|
);
|
|
}
|
|
|
|
// #1324: a materialize-fill read that fails after draining K bytes must
|
|
// propagate the read error and must NOT fall back to streaming the same
|
|
// (partially consumed) reader, which would ship a prefix-misaligned body.
|
|
#[tokio::test]
|
|
async fn build_get_object_body_with_cache_materialize_rejects_partial_read_error() {
|
|
let reader = ErrAfterReader {
|
|
data: std::io::Cursor::new(b"hello".to_vec()),
|
|
fail_after: 3,
|
|
emitted: 0,
|
|
};
|
|
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,
|
|
min_free_memory_percent: 0,
|
|
..rustfs_object_data_cache::ObjectDataCacheConfig::default()
|
|
})
|
|
.expect("materialize-fill cache adapter should initialize");
|
|
|
|
let result = DefaultObjectUsecase::build_get_object_body_with_cache(
|
|
&adapter,
|
|
reader,
|
|
&info,
|
|
5,
|
|
"req-materialize-partial",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
None,
|
|
false,
|
|
false,
|
|
true,
|
|
"test-bucket",
|
|
"partial-read-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"a partial-read error during materialization must fail the request, not stream a prefix-misaligned body"
|
|
);
|
|
}
|
|
|
|
// #1324: the buffered-body (direct-memory / cache-served) source must also
|
|
// enforce the exact-length contract. A buffered body shorter than the
|
|
// declared content length is a hard error before headers.
|
|
#[tokio::test]
|
|
async fn build_get_object_body_rejects_short_buffered_body() {
|
|
let reads = Arc::new(AtomicUsize::new(0));
|
|
let reader = ReadProbeReader {
|
|
reads: Arc::clone(&reads),
|
|
};
|
|
let info = ObjectInfo {
|
|
size: 5,
|
|
..Default::default()
|
|
};
|
|
|
|
let result = DefaultObjectUsecase::build_get_object_body(
|
|
reader,
|
|
&info,
|
|
5,
|
|
"req-short-buffered-object",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
// Declared length 5 but only 4 buffered bytes.
|
|
Some(Bytes::from_static(b"hell")),
|
|
"test-bucket",
|
|
"short-buffered-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_err(), "a buffered body shorter than the declared content length must hard-fail");
|
|
assert_eq!(
|
|
reads.load(AtomicOrdering::Relaxed),
|
|
0,
|
|
"the mismatch must be caught without touching the fallback reader"
|
|
);
|
|
}
|
|
|
|
// #1324 compatibility boundary: a legacy/backfilled object whose decoded
|
|
// bytes exactly equal its declared content length must still serve cleanly.
|
|
// The strict contract keys off actual-vs-declared equality only, so it never
|
|
// flips a legitimate exact-length object into a hard failure — it only
|
|
// rejects genuine short/over-long/errored reads.
|
|
#[tokio::test]
|
|
async fn build_get_object_body_serves_exact_length_buffered_body() {
|
|
let reads = Arc::new(AtomicUsize::new(0));
|
|
let reader = ReadProbeReader {
|
|
reads: Arc::clone(&reads),
|
|
};
|
|
let info = ObjectInfo {
|
|
size: 5,
|
|
..Default::default()
|
|
};
|
|
|
|
let _body = DefaultObjectUsecase::build_get_object_body(
|
|
reader,
|
|
&info,
|
|
5,
|
|
"req-exact-buffered-object",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
Some(Bytes::from_static(b"hello")),
|
|
"test-bucket",
|
|
"exact-buffered-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| panic!("an exact-length buffered body must not initialize streaming resume state"),
|
|
)
|
|
.await
|
|
.expect("an exact-length buffered body must serve without error");
|
|
|
|
assert_eq!(
|
|
reads.load(AtomicOrdering::Relaxed),
|
|
0,
|
|
"an exact-length buffered body 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,
|
|
// Fill must not depend on the live memory reading (host vs container).
|
|
min_free_memory_percent: 0,
|
|
..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,
|
|
"req-materialize-too-large",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
None,
|
|
false,
|
|
false,
|
|
true,
|
|
"test-bucket",
|
|
"too-large-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await
|
|
.expect("too-large cache candidate should use streaming fallback");
|
|
|
|
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));
|
|
let reader = ReadProbeReader {
|
|
reads: Arc::clone(&reads),
|
|
};
|
|
let info = ObjectInfo {
|
|
size: 4,
|
|
..Default::default()
|
|
};
|
|
|
|
let _body = DefaultObjectUsecase::build_get_object_body(
|
|
reader,
|
|
&info,
|
|
4,
|
|
"req-small-plain-object",
|
|
None,
|
|
128 * 1024,
|
|
false,
|
|
1,
|
|
None,
|
|
false,
|
|
false,
|
|
None,
|
|
"test-bucket",
|
|
"small-plain-object",
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| None,
|
|
)
|
|
.await
|
|
.expect("build_get_object_body should keep small plain object on streaming path");
|
|
|
|
assert_eq!(
|
|
reads.load(AtomicOrdering::Relaxed),
|
|
0,
|
|
"default GetObject response construction should not pre-read small plain object data"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn select_stream_buffer_strategy_expands_large_sequential_gets() {
|
|
let (buffer_size, strategy) =
|
|
DefaultObjectUsecase::select_stream_buffer_strategy(2_i64 * 1024 * 1024 * 1024, 2 * MI_B, true, false);
|
|
|
|
assert_eq!(strategy, GetObjectStreamStrategy::LargeSequentialReadahead);
|
|
assert_eq!(buffer_size, 4 * MI_B);
|
|
}
|
|
|
|
#[test]
|
|
fn select_stream_buffer_strategy_keeps_ranges_and_small_gets_standard() {
|
|
let (range_buffer_size, range_strategy) =
|
|
DefaultObjectUsecase::select_stream_buffer_strategy(2_i64 * 1024 * 1024 * 1024, 2 * MI_B, true, true);
|
|
assert_eq!(range_strategy, GetObjectStreamStrategy::Standard);
|
|
assert_eq!(range_buffer_size, 2 * MI_B);
|
|
|
|
let (small_buffer_size, small_strategy) =
|
|
DefaultObjectUsecase::select_stream_buffer_strategy(64 * 1024 * 1024, 512 * 1024, true, false);
|
|
assert_eq!(small_strategy, GetObjectStreamStrategy::Standard);
|
|
assert_eq!(small_buffer_size, 512 * 1024);
|
|
}
|
|
|
|
#[test]
|
|
fn tune_reader_stream_buffer_size_raises_large_standard_streams_only() {
|
|
assert_eq!(
|
|
tune_reader_stream_buffer_size(128 * 1024, 10 * MI_B as i64, GetObjectStreamStrategy::Standard),
|
|
LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES
|
|
);
|
|
assert_eq!(
|
|
tune_reader_stream_buffer_size(512 * 1024, 10 * MI_B as i64, GetObjectStreamStrategy::Standard),
|
|
LARGE_BODY_READER_STREAM_BUFFER_FLOOR_BYTES
|
|
);
|
|
assert_eq!(
|
|
tune_reader_stream_buffer_size(2 * MI_B, 10 * MI_B as i64, GetObjectStreamStrategy::Standard),
|
|
2 * MI_B
|
|
);
|
|
assert_eq!(
|
|
tune_reader_stream_buffer_size(128 * 1024, MI_B as i64, GetObjectStreamStrategy::Standard),
|
|
MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES
|
|
);
|
|
assert_eq!(
|
|
tune_reader_stream_buffer_size(256 * 1024, 2 * MI_B as i64, GetObjectStreamStrategy::Standard),
|
|
MID_BODY_READER_STREAM_BUFFER_FLOOR_BYTES
|
|
);
|
|
assert_eq!(
|
|
tune_reader_stream_buffer_size(128 * 1024, 10 * MI_B as i64, GetObjectStreamStrategy::LargeSequentialReadahead),
|
|
128 * 1024
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_reader_stream_buffer_size_keeps_selected_default() {
|
|
let (buffer_size, source) = resolve_reader_stream_buffer_size(128 * 1024, None);
|
|
|
|
assert_eq!(buffer_size, 128 * 1024);
|
|
assert_eq!(source, GET_READER_STREAM_BUFFER_SOURCE_SELECTED);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_reader_stream_buffer_size_applies_positive_override() {
|
|
let (buffer_size, source) = resolve_reader_stream_buffer_size(128 * 1024, Some(MI_B));
|
|
|
|
assert_eq!(buffer_size, MI_B);
|
|
assert_eq!(source, GET_READER_STREAM_BUFFER_SOURCE_ENV_OVERRIDE);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_reader_stream_buffer_size_ignores_zero_override() {
|
|
let (buffer_size, source) = resolve_reader_stream_buffer_size(128 * 1024, Some(0));
|
|
|
|
assert_eq!(buffer_size, 128 * 1024);
|
|
assert_eq!(source, GET_READER_STREAM_BUFFER_SOURCE_SELECTED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_object_reader_stream_tracks_remaining_length() {
|
|
let mut stream = GetObjectReaderStream::new(
|
|
std::io::Cursor::new(b"hello".to_vec()),
|
|
2,
|
|
5,
|
|
GetObjectStreamStrategy::Standard.as_str(),
|
|
GET_READER_STREAM_BUFFER_SOURCE_SELECTED,
|
|
);
|
|
|
|
assert_eq!(stream.remaining_length().exact(), Some(5));
|
|
|
|
let first = stream
|
|
.next()
|
|
.await
|
|
.expect("reader stream should emit first chunk")
|
|
.expect("first chunk should read");
|
|
|
|
assert_eq!(first.as_ref(), b"he");
|
|
assert_eq!(stream.remaining_length().exact(), Some(3));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_object_reader_stream_truncates_to_expected_length() {
|
|
let stream = GetObjectReaderStream::new(
|
|
std::io::Cursor::new(b"hello!".to_vec()),
|
|
64,
|
|
5,
|
|
GetObjectStreamStrategy::Standard.as_str(),
|
|
GET_READER_STREAM_BUFFER_SOURCE_SELECTED,
|
|
);
|
|
|
|
let chunks = stream
|
|
.collect::<Vec<_>>()
|
|
.await
|
|
.into_iter()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.expect("reader stream should read");
|
|
let body = chunks.into_iter().fold(Vec::new(), |mut acc, chunk| {
|
|
acc.extend_from_slice(&chunk);
|
|
acc
|
|
});
|
|
|
|
assert_eq!(body, b"hello");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_object_reader_stream_bounds_read_buffer_to_remaining() {
|
|
struct RecordingReader {
|
|
data: &'static [u8],
|
|
pos: usize,
|
|
observed_remaining: Arc<Mutex<Vec<usize>>>,
|
|
}
|
|
|
|
impl AsyncRead for RecordingReader {
|
|
fn poll_read(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
|
let requested = buf.remaining();
|
|
self.observed_remaining
|
|
.lock()
|
|
.expect("observed buffer sizes should not poison")
|
|
.push(requested);
|
|
let available = self.data.len().saturating_sub(self.pos);
|
|
let to_copy = requested.min(available);
|
|
if to_copy > 0 {
|
|
let end = self.pos + to_copy;
|
|
buf.put_slice(&self.data[self.pos..end]);
|
|
self.pos = end;
|
|
}
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
let observed_remaining = Arc::new(Mutex::new(Vec::new()));
|
|
let stream = GetObjectReaderStream::new(
|
|
RecordingReader {
|
|
data: b"hello",
|
|
pos: 0,
|
|
observed_remaining: Arc::clone(&observed_remaining),
|
|
},
|
|
64,
|
|
5,
|
|
GetObjectStreamStrategy::Standard.as_str(),
|
|
GET_READER_STREAM_BUFFER_SOURCE_SELECTED,
|
|
);
|
|
|
|
let chunks = stream
|
|
.collect::<Vec<_>>()
|
|
.await
|
|
.into_iter()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.expect("reader stream should read exact payload");
|
|
assert_eq!(chunks, vec![Bytes::from_static(b"hello")]);
|
|
assert_eq!(
|
|
*observed_remaining.lock().expect("observed buffer sizes should not poison"),
|
|
vec![5],
|
|
"stream should not ask the reader for more bytes than the response has left"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_object_reader_stream_bounds_multi_chunk_final_read() {
|
|
let stream = GetObjectReaderStream::new(
|
|
std::io::Cursor::new(vec![b'a'; 66]),
|
|
64,
|
|
65,
|
|
GetObjectStreamStrategy::Standard.as_str(),
|
|
GET_READER_STREAM_BUFFER_SOURCE_SELECTED,
|
|
);
|
|
|
|
let chunks = stream
|
|
.collect::<Vec<_>>()
|
|
.await
|
|
.into_iter()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.expect("reader stream should ignore bytes past declared length");
|
|
let chunk_lengths = chunks.iter().map(Bytes::len).collect::<Vec<_>>();
|
|
let body = chunks.into_iter().fold(Vec::new(), |mut acc, chunk| {
|
|
acc.extend_from_slice(&chunk);
|
|
acc
|
|
});
|
|
|
|
assert_eq!(chunk_lengths, vec![64, 1]);
|
|
assert_eq!(body, vec![b'a'; 65]);
|
|
}
|
|
|
|
// Serial with the capture test below: both drive the same short-EOF log
|
|
// callsite, and `tracing` caches callsite interest process-wide. Running
|
|
// this one concurrently on a thread with no subscriber re-caches that
|
|
// callsite as "never interested" and blinds the capture.
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn get_object_reader_stream_errors_on_short_eof() {
|
|
let stream = GetObjectReaderStream::new(
|
|
std::io::Cursor::new(b"he".to_vec()),
|
|
64,
|
|
5,
|
|
GetObjectStreamStrategy::Standard.as_str(),
|
|
GET_READER_STREAM_BUFFER_SOURCE_SELECTED,
|
|
);
|
|
|
|
let err = stream
|
|
.collect::<Vec<_>>()
|
|
.await
|
|
.into_iter()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.expect_err("short reader should fail the streaming body");
|
|
|
|
assert_eq!(
|
|
err.downcast_ref::<std::io::Error>().map(std::io::Error::kind),
|
|
Some(std::io::ErrorKind::UnexpectedEof)
|
|
);
|
|
}
|
|
|
|
/// Collects the structured fields of every event emitted while installed,
|
|
/// so a test can assert what an operator would actually read in the log
|
|
/// rather than only that an error value was returned.
|
|
type CapturedFieldMap = std::collections::HashMap<String, String>;
|
|
|
|
type CapturedEventLog = Arc<Mutex<Vec<CapturedFieldMap>>>;
|
|
|
|
struct CapturedEvents(CapturedEventLog);
|
|
|
|
struct CapturedFields(CapturedFieldMap);
|
|
|
|
impl tracing::field::Visit for CapturedFields {
|
|
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
|
self.0.insert(field.name().to_string(), format!("{value:?}"));
|
|
}
|
|
|
|
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
|
self.0.insert(field.name().to_string(), value.to_string());
|
|
}
|
|
}
|
|
|
|
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
|
|
fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
|
|
let mut fields = CapturedFields(CapturedFieldMap::new());
|
|
event.record(&mut fields);
|
|
self.0.lock().expect("captured events should not poison").push(fields.0);
|
|
}
|
|
}
|
|
|
|
fn capture_events() -> (CapturedEventLog, tracing::subscriber::DefaultGuard) {
|
|
use tracing_subscriber::{Registry, prelude::*};
|
|
|
|
let captured = Arc::new(Mutex::new(Vec::new()));
|
|
let subscriber = Registry::default().with(CapturedEvents(Arc::clone(&captured)));
|
|
let guard = tracing::subscriber::set_default(subscriber);
|
|
// `tracing` caches per-callsite interest process-wide, so a subscriber
|
|
// installed by a test running in parallel can leave the log sites below
|
|
// cached as "never interested" and this capture would silently see
|
|
// nothing. Force the callsites to re-ask the subscriber we just
|
|
// installed.
|
|
tracing::callsite::rebuild_interest_cache();
|
|
(captured, guard)
|
|
}
|
|
|
|
fn find_stream_body_event(captured: &CapturedEventLog, state: &str) -> CapturedFieldMap {
|
|
let events = captured.lock().expect("captured events should not poison");
|
|
events
|
|
.iter()
|
|
.find(|fields| fields.get("state").is_some_and(|value| value == state))
|
|
.unwrap_or_else(|| {
|
|
panic!(
|
|
"a `{state}` streaming body failure must be logged, not only counted in a metric. \
|
|
Captured {} event(s): {:?}",
|
|
events.len(),
|
|
events
|
|
)
|
|
})
|
|
.clone()
|
|
}
|
|
|
|
/// rustfs#4784: a GET body that ends short of its committed Content-Length
|
|
/// is the fault that breaks every downstream copier (replication, site
|
|
/// replication, `rclone sync`), yet this layer only fed a metric counter —
|
|
/// its log line was compiled out unless the `tracing-chunk-debug` feature
|
|
/// was on, so operators saw nothing on the source side.
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn get_object_reader_stream_short_eof_names_the_object() {
|
|
let (captured, _guard) = capture_events();
|
|
|
|
let stream = GetObjectReaderStream::new(
|
|
std::io::Cursor::new(b"he".to_vec()),
|
|
64,
|
|
5,
|
|
GetObjectStreamStrategy::Standard.as_str(),
|
|
GET_READER_STREAM_BUFFER_SOURCE_SELECTED,
|
|
)
|
|
.with_diagnostics("restic-paperless", "index/41b5a4c2344edb90", "req-reader-stream-short-eof");
|
|
|
|
stream
|
|
.collect::<Vec<_>>()
|
|
.await
|
|
.into_iter()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.expect_err("short reader should fail the streaming body");
|
|
|
|
let event = find_stream_body_event(&captured, "reader_stream_short_eof");
|
|
assert_eq!(event.get("bucket").map(String::as_str), Some("restic-paperless"));
|
|
assert_eq!(event.get("object").map(String::as_str), Some("index/41b5a4c2344edb90"));
|
|
assert_eq!(event.get("request_id").map(String::as_str), Some("req-reader-stream-short-eof"));
|
|
assert_eq!(event.get("expected").map(String::as_str), Some("5"));
|
|
assert_eq!(event.get("emitted").map(String::as_str), Some("2"));
|
|
assert_eq!(event.get("remaining").map(String::as_str), Some("3"));
|
|
}
|
|
|
|
/// The inner reader already logged mid-stream failures, but only under a
|
|
/// request_id — which cannot be resolved back to an object once the request
|
|
/// is gone. Without the identity the report in #4784 was unactionable.
|
|
#[tokio::test]
|
|
#[serial_test::serial]
|
|
async fn get_object_streaming_reader_short_eof_names_the_object() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
let (captured, _guard) = capture_events();
|
|
|
|
let mut reader = GetObjectStreamingReader::new(
|
|
std::io::Cursor::new(b"short".to_vec()),
|
|
"restic-paperless",
|
|
"index/41b5a4c2344edb90",
|
|
"req-streaming-short-eof",
|
|
None,
|
|
10,
|
|
Duration::ZERO,
|
|
GetObjectBodyLifecycle::tracked(GetObjectGuard::new()),
|
|
None,
|
|
);
|
|
|
|
let mut out = Vec::new();
|
|
reader
|
|
.read_to_end(&mut out)
|
|
.await
|
|
.expect_err("short body under a larger Content-Length must fail the stream");
|
|
|
|
let event = find_stream_body_event(&captured, "short_eof");
|
|
assert_eq!(event.get("bucket").map(String::as_str), Some("restic-paperless"));
|
|
assert_eq!(event.get("object").map(String::as_str), Some("index/41b5a4c2344edb90"));
|
|
assert_eq!(event.get("request_id").map(String::as_str), Some("req-streaming-short-eof"));
|
|
}
|
|
|
|
#[test]
|
|
fn get_object_stream_failure_labels_are_low_cardinality() {
|
|
assert_eq!(get_object_stream_failure_reason("short_eof"), GET_STREAMING_BODY_FAILURE_REASON_SHORT_EOF);
|
|
assert_eq!(
|
|
get_object_stream_failure_reason("timeout"),
|
|
GET_STREAMING_BODY_FAILURE_REASON_READER_ERROR
|
|
);
|
|
assert_eq!(
|
|
get_object_stream_size_bucket(4 * 1024 * 1024),
|
|
rustfs_io_metrics::GET_OBJECT_SIZE_BUCKET_GT_1_MIB
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disk_read_permit_reader_releases_permit_at_eof() {
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
|
|
let permit = semaphore.clone().acquire_owned().await.expect("acquire permit");
|
|
assert_eq!(semaphore.available_permits(), 0);
|
|
|
|
let mut reader = DiskReadPermitReader::new(std::io::Cursor::new(b"hello".to_vec()), permit.into());
|
|
let mut body = Vec::new();
|
|
reader.read_to_end(&mut body).await.expect("read body");
|
|
assert_eq!(body, b"hello");
|
|
|
|
// The reader is still alive (client hasn't dropped the body), but EOF
|
|
// was observed, so the permit must already be back in the semaphore.
|
|
assert_eq!(semaphore.available_permits(), 1);
|
|
drop(reader);
|
|
assert_eq!(semaphore.available_permits(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_get_object_output_context_returns_standard_headers() {
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert("cache-control".to_string(), "public, max-age=259200".to_string());
|
|
metadata.insert("content-disposition".to_string(), "attachment; filename=\"demo.png\"".to_string());
|
|
|
|
let info = ObjectInfo {
|
|
bucket: "test-bucket".to_string(),
|
|
name: "path/raw".to_string(),
|
|
user_defined: Arc::new(metadata),
|
|
..Default::default()
|
|
};
|
|
|
|
let input = GetObjectInput::builder()
|
|
.bucket("test-bucket".to_string())
|
|
.key("path/raw".to_string())
|
|
.build()
|
|
.unwrap();
|
|
let req = build_request(input, Method::GET);
|
|
let usecase = DefaultObjectUsecase::without_context();
|
|
let queue_status = concurrency::IoQueueStatus::default();
|
|
|
|
let context = usecase
|
|
.build_get_object_output_context(
|
|
&req,
|
|
get_concurrency_manager(),
|
|
"test-bucket",
|
|
"path/raw",
|
|
info.clone(),
|
|
Some(info),
|
|
wrap_reader(tokio::io::empty()),
|
|
Some(Bytes::new()),
|
|
false,
|
|
false,
|
|
true,
|
|
None,
|
|
None,
|
|
None,
|
|
0,
|
|
None,
|
|
"req-output-content-disposition",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
false,
|
|
Duration::ZERO,
|
|
0.0,
|
|
&queue_status,
|
|
1,
|
|
None,
|
|
false,
|
|
GetObjectBodyLifecycle::disabled(),
|
|
|_| panic!("a buffered output must not initialize streaming resume state"),
|
|
)
|
|
.await
|
|
.expect("get object output context");
|
|
|
|
assert_eq!(context.output.cache_control.as_deref(), Some("public, max-age=259200"));
|
|
assert_eq!(context.output.content_disposition.as_deref(), Some("attachment; filename=\"demo.png\""));
|
|
assert!(
|
|
!context
|
|
.output
|
|
.metadata
|
|
.as_ref()
|
|
.is_some_and(|metadata| metadata.contains_key("cache-control"))
|
|
);
|
|
assert!(
|
|
!context
|
|
.output
|
|
.metadata
|
|
.as_ref()
|
|
.is_some_and(|metadata| metadata.contains_key("content-disposition"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_get_object_rejects_zero_part_number() {
|
|
let input = GetObjectInput::builder()
|
|
.bucket("test-bucket".to_string())
|
|
.key("test-key".to_string())
|
|
.part_number(Some(0))
|
|
.build()
|
|
.unwrap();
|
|
|
|
let req = build_request(input, Method::GET);
|
|
let usecase = DefaultObjectUsecase::without_context();
|
|
|
|
let err = Box::pin(usecase.execute_get_object(req)).await.unwrap_err();
|
|
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_get_object_part_number_rejects_above_s3_max() {
|
|
let err = parse_part_number_i32_to_usize(Some(10001), "GET").expect_err("partNumber above S3 max must fail");
|
|
|
|
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
|
assert_eq!(err.message(), Some("GET: partNumber must be between 1 and 10000"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_get_object_part_number_rejects_missing_part() {
|
|
let info = ObjectInfo {
|
|
parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo {
|
|
number: 1,
|
|
..Default::default()
|
|
}]),
|
|
..Default::default()
|
|
};
|
|
|
|
let err =
|
|
DefaultObjectUsecase::validate_get_object_part_number(Some(2), &info).expect_err("missing requested part must fail");
|
|
|
|
assert_eq!(err.code(), &S3ErrorCode::InvalidPart);
|
|
assert!(DefaultObjectUsecase::validate_get_object_part_number(Some(1), &info).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn cold_fill_conditions_fail_before_phase_probe_advances() {
|
|
fn run_phase_probe(headers: &HeaderMap, info: &ObjectInfo) -> (S3Result<()>, [usize; 3]) {
|
|
let coordination = AtomicUsize::new(0);
|
|
let permit = AtomicUsize::new(0);
|
|
let reader = AtomicUsize::new(0);
|
|
let result = DefaultObjectUsecase::validate_get_object_before_cold_fill(headers, None, info);
|
|
if result.is_ok() {
|
|
coordination.fetch_add(1, AtomicOrdering::Relaxed);
|
|
permit.fetch_add(1, AtomicOrdering::Relaxed);
|
|
reader.fetch_add(1, AtomicOrdering::Relaxed);
|
|
}
|
|
(
|
|
result,
|
|
[
|
|
coordination.load(AtomicOrdering::Relaxed),
|
|
permit.load(AtomicOrdering::Relaxed),
|
|
reader.load(AtomicOrdering::Relaxed),
|
|
],
|
|
)
|
|
}
|
|
|
|
let info = ObjectInfo {
|
|
etag: Some("phase-etag".to_string()),
|
|
parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo {
|
|
number: 1,
|
|
..Default::default()
|
|
}]),
|
|
..Default::default()
|
|
};
|
|
|
|
let mut not_modified = HeaderMap::new();
|
|
not_modified.insert(http::header::IF_NONE_MATCH, HeaderValue::from_static("\"phase-etag\""));
|
|
let (result, phases) = run_phase_probe(¬_modified, &info);
|
|
assert_eq!(result.expect_err("matching If-None-Match must reject").code(), &S3ErrorCode::NotModified);
|
|
assert_eq!(phases, [0, 0, 0]);
|
|
|
|
let mut precondition_failed = HeaderMap::new();
|
|
precondition_failed.insert(http::header::IF_MATCH, HeaderValue::from_static("\"other-etag\""));
|
|
let (result, phases) = run_phase_probe(&precondition_failed, &info);
|
|
assert_eq!(
|
|
result.expect_err("mismatched If-Match must reject").code(),
|
|
&S3ErrorCode::PreconditionFailed
|
|
);
|
|
assert_eq!(phases, [0, 0, 0]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_get_object_rejects_range_with_part_number() {
|
|
let input = GetObjectInput::builder()
|
|
.bucket("test-bucket".to_string())
|
|
.key("test-key".to_string())
|
|
.part_number(Some(1))
|
|
.range(Some(Range::Int { first: 0, last: Some(1) }))
|
|
.build()
|
|
.unwrap();
|
|
|
|
let req = build_request(input, Method::GET);
|
|
let usecase = DefaultObjectUsecase::without_context();
|
|
|
|
let err = Box::pin(usecase.execute_get_object(req)).await.unwrap_err();
|
|
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_get_object_attributes_returns_internal_error_when_store_uninitialized() {
|
|
let input = GetObjectAttributesInput::builder()
|
|
.bucket("test-bucket".to_string())
|
|
.key("test-key".to_string())
|
|
.build()
|
|
.unwrap();
|
|
|
|
let req = build_request(input, Method::GET);
|
|
let usecase = DefaultObjectUsecase::without_context();
|
|
|
|
let err = usecase.execute_get_object_attributes(req).await.unwrap_err();
|
|
assert_eq!(err.code(), &S3ErrorCode::InternalError);
|
|
}
|
|
|
|
#[test]
|
|
fn object_attributes_requested_with_single_value() {
|
|
let object_attributes = vec![ObjectAttributes::from_static(ObjectAttributes::ETAG)];
|
|
|
|
assert!(object_attributes_requested(&object_attributes, ObjectAttributes::ETAG));
|
|
assert!(!object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE));
|
|
}
|
|
|
|
#[test]
|
|
fn object_attributes_requested_with_comma_separated_values() {
|
|
let object_attributes = vec![
|
|
ObjectAttributes::from_static("ObjectParts,etag"),
|
|
ObjectAttributes::from_static("StorageClass"),
|
|
];
|
|
|
|
assert!(object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_PARTS));
|
|
assert!(object_attributes_requested(&object_attributes, ObjectAttributes::ETAG));
|
|
assert!(!object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE));
|
|
}
|
|
|
|
#[test]
|
|
fn object_attributes_requested_with_quotes_and_spaces() {
|
|
let object_attributes = vec![ObjectAttributes::from_static("'ObjectSize', \"Checksum\" , \"Etag\"")];
|
|
|
|
assert!(object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE));
|
|
assert!(object_attributes_requested(&object_attributes, ObjectAttributes::CHECKSUM));
|
|
assert!(object_attributes_requested(&object_attributes, ObjectAttributes::ETAG));
|
|
}
|
|
|
|
#[test]
|
|
fn object_attributes_requested_returns_false_for_missing_name() {
|
|
let object_attributes = vec![ObjectAttributes::from_static("Checksum")];
|
|
|
|
assert!(!object_attributes_requested(&object_attributes, ObjectAttributes::OBJECT_SIZE));
|
|
}
|
|
}
|