mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-06 03:59:14 +00:00
3005efe845
* fix(odm): declare the remote client retry policy per consumer The SDK retry policy was an inherited default: one logical call could cost three wire requests, so the migration breaker counted logical calls on top of a threefold amplification against a source that was already failing. Make it an explicit RemoteS3EndpointSpec field. Replication targets declare today's standard three attempts and keep their behaviour; the on-demand migration source and its admin probe declare a disabled policy, so one counted failure is exactly one source request and pull.rs owns the only retry budget. * fix(odm): count a stalled inline source as a source timeout The inline tee wraps its source body in the idle guard, but the tee turns a stalled source into an ordinary body read error, so the write-back reported it as a local write failure. Hand commit_inline the guard so the pull is counted under source_timeout instead. The background pump now enforces the idle budget through the same guard rather than a second copy of the timeout loop. * test(odm): cover a stalled source body end to end The fake target can now deliver a GetObject body in slices with a pause between them, so the inline abort can be driven by a stalled source instead of a truncated one. Two fault cases drop the workarounds they carried for the SDK's retries: the scripted fault count and the observed source request count now have to agree. The operations guide records the retry and idle-timeout guarantees.
552 lines
22 KiB
Rust
552 lines
22 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.
|
|
|
|
//! Source-failure scenarios for on-demand migration (rustfs/backlog#2158):
|
|
//! access denied, the circuit breaker, first-byte and mid-body stream
|
|
//! failures (a cut body and a stalled one), ETag integrity, the negative
|
|
//! cache, and an unsupported (SSE-C) source object.
|
|
//!
|
|
//! Every case asserts what the source was asked for, not only what the
|
|
//! client received: a fault that silently turned into a second source
|
|
//! request would otherwise pass.
|
|
|
|
use super::common::{BoxError, OdmTestEnv, SeedObject, start_configured_env};
|
|
use crate::fake_s3_target::{FaultAction, Operation};
|
|
use bytes::Bytes;
|
|
use std::time::{Duration, Instant};
|
|
|
|
type TestResult = Result<(), BoxError>;
|
|
|
|
const SOURCE_BUCKET: &str = "odm-fault-source";
|
|
/// Header the GET/HEAD paths add when the answer came from the source.
|
|
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
|
|
/// Status of the `SourceUnavailable` error the `propagate` policy returns.
|
|
const SOURCE_UNAVAILABLE_STATUS: u16 = 424;
|
|
/// Background pulls and their counters land after the response.
|
|
const SETTLE: Duration = Duration::from_secs(60);
|
|
/// Consecutive counted source failures that open the breaker
|
|
/// (`BREAKER_FAILURE_THRESHOLD` in ecstore).
|
|
const BREAKER_FAILURE_THRESHOLD: usize = 5;
|
|
|
|
/// Position-dependent payload so a misaligned or truncated copy is caught.
|
|
fn payload(len: usize) -> Bytes {
|
|
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
|
|
}
|
|
|
|
/// A source object with a well-formed but deliberately wrong single-part
|
|
/// ETag: the fake source retains `x-rustfs-source-etag` verbatim, so HEAD
|
|
/// and GET advertise an MD5 the body does not have.
|
|
async fn seed_with_etag(env: &OdmTestEnv, key: &str, body: Bytes, etag: &str) -> TestResult {
|
|
let response = env
|
|
.source_client()
|
|
.put_object()
|
|
.bucket(SOURCE_BUCKET)
|
|
.key(key)
|
|
.body(aws_sdk_s3::primitives::ByteStream::from(body))
|
|
.customize()
|
|
.mutate_request({
|
|
let etag = etag.to_string();
|
|
move |request| {
|
|
request.headers_mut().insert("x-rustfs-source-etag", etag.clone());
|
|
}
|
|
})
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
response.e_tag(),
|
|
Some(format!("\"{etag}\"").as_str()),
|
|
"the fake source stores the announced ETag"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// A source object that reports SSE-C: the fake source echoes the customer
|
|
/// algorithm it captured from the replication passthrough transport header.
|
|
async fn seed_with_ssec(env: &OdmTestEnv, key: &str, body: Bytes) -> TestResult {
|
|
env.source_client()
|
|
.put_object()
|
|
.bucket(SOURCE_BUCKET)
|
|
.key(key)
|
|
.body(aws_sdk_s3::primitives::ByteStream::from(body))
|
|
.customize()
|
|
.mutate_request(|request| {
|
|
request.headers_mut().insert("x-rustfs-replication-ssec-algorithm", "AES256");
|
|
})
|
|
.send()
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Case 1: a 403 from the source is a configuration error, not a health
|
|
/// signal. `propagate` answers 424 and records the class; `not_found` hides
|
|
/// it as a 404. Neither counts toward the breaker.
|
|
#[tokio::test]
|
|
async fn test_odm_source_access_denied_propagates_without_opening_the_breaker() -> TestResult {
|
|
let propagating = "odm-fault-denied-propagate";
|
|
let hiding = "odm-fault-denied-notfound";
|
|
let env = start_configured_env(propagating, SOURCE_BUCKET, |_| {}).await?;
|
|
let mut hiding_spec = env.fake_source_spec(SOURCE_BUCKET);
|
|
hiding_spec.policy.source_error = "not_found".to_string();
|
|
env.configure_and_wait(hiding, &hiding_spec).await?;
|
|
|
|
let propagate_key = "denied/propagate.bin";
|
|
let hidden_key = "denied/hidden.bin";
|
|
env.seed_source(
|
|
SOURCE_BUCKET,
|
|
&[
|
|
SeedObject::new(propagate_key, payload(4096)),
|
|
SeedObject::new(hidden_key, payload(4096)),
|
|
],
|
|
);
|
|
|
|
env.source
|
|
.inject_for_key(Operation::HeadObject, propagate_key, FaultAction::ResponseStatus(403), 1);
|
|
let denied = env.raw_get(propagating, propagate_key).await?;
|
|
assert_eq!(denied.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&denied.body));
|
|
assert!(
|
|
String::from_utf8_lossy(&denied.body).contains("SourceUnavailable"),
|
|
"the propagated error names the ODM source code: {}",
|
|
String::from_utf8_lossy(&denied.body)
|
|
);
|
|
assert_eq!(env.source.count_requests(Operation::HeadObject, propagate_key), 1);
|
|
assert_eq!(
|
|
env.source.count_requests(Operation::GetObject, propagate_key),
|
|
0,
|
|
"a denied HEAD never reaches the body"
|
|
);
|
|
|
|
let status = env.status_json(propagating).await?;
|
|
assert_eq!(
|
|
status.pointer("/last_source_error/class").and_then(|v| v.as_str()),
|
|
Some("access_denied"),
|
|
"{status}"
|
|
);
|
|
assert_eq!(
|
|
status.pointer("/breaker/state").and_then(|v| v.as_str()),
|
|
Some("closed"),
|
|
"a configuration error must not open the breaker: {status}"
|
|
);
|
|
assert_eq!(
|
|
status
|
|
.pointer("/counters/requests_total/get/source_error")
|
|
.and_then(|v| v.as_u64()),
|
|
Some(1),
|
|
"{status}"
|
|
);
|
|
|
|
env.source
|
|
.inject_for_key(Operation::HeadObject, hidden_key, FaultAction::ResponseStatus(403), 1);
|
|
let hidden = env.raw_get(hiding, hidden_key).await?;
|
|
assert_eq!(hidden.status, 404, "{}", String::from_utf8_lossy(&hidden.body));
|
|
assert_eq!(env.source.count_requests(Operation::HeadObject, hidden_key), 1);
|
|
assert_eq!(env.source.count_requests(Operation::GetObject, hidden_key), 0);
|
|
|
|
env.assert_local_absent(propagating, propagate_key).await;
|
|
env.assert_local_absent(hiding, hidden_key).await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Case 2: repeated transport failures open the breaker; while it is open
|
|
/// the source is not touched at all, and the half-open probe after the open
|
|
/// window closes it again. The open window is a compiled-in 30 s constant
|
|
/// (`BREAKER_OPEN_DURATION`), so this case waits in real time.
|
|
///
|
|
/// The source client disables SDK retries, so one logical source call is
|
|
/// exactly one wire request: the script is exactly as deep as the number of
|
|
/// breaker failures it has to produce, and the scripted fault count and the
|
|
/// observed source request count must agree.
|
|
#[tokio::test]
|
|
async fn test_odm_repeated_source_errors_open_the_breaker_and_recover() -> TestResult {
|
|
let bucket = "odm-fault-breaker";
|
|
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
|
let key = "breaker/doc.bin";
|
|
let body = payload(8192);
|
|
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
|
|
|
env.source
|
|
.inject_for_key(Operation::HeadObject, key, FaultAction::ResponseStatus(503), BREAKER_FAILURE_THRESHOLD);
|
|
for attempt in 1..=BREAKER_FAILURE_THRESHOLD {
|
|
let response = env.raw_get(bucket, key).await?;
|
|
assert_eq!(
|
|
response.status,
|
|
SOURCE_UNAVAILABLE_STATUS,
|
|
"attempt {attempt}: {}",
|
|
String::from_utf8_lossy(&response.body)
|
|
);
|
|
}
|
|
assert_eq!(
|
|
env.status_json(bucket)
|
|
.await?
|
|
.pointer("/breaker/state")
|
|
.and_then(|v| v.as_str()),
|
|
Some("open"),
|
|
"the threshold of consecutive source failures must open the breaker"
|
|
);
|
|
assert_eq!(
|
|
env.source.count_requests(Operation::HeadObject, key),
|
|
BREAKER_FAILURE_THRESHOLD,
|
|
"every counted failure is exactly one source request"
|
|
);
|
|
|
|
// With the script cleared, the only thing that can still fail a read is
|
|
// the open breaker itself.
|
|
env.source.clear_faults();
|
|
let source_requests = env.source.count_requests(Operation::HeadObject, key);
|
|
let rejected = env.raw_get(bucket, key).await?;
|
|
assert_eq!(rejected.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&rejected.body));
|
|
assert_eq!(
|
|
env.source.count_requests(Operation::HeadObject, key),
|
|
source_requests,
|
|
"an open breaker never touches the source"
|
|
);
|
|
assert!(
|
|
env.status_counter(bucket, "/counters/requests_total/get/breaker_open")
|
|
.await?
|
|
>= 1,
|
|
"the rejected request is counted as breaker_open"
|
|
);
|
|
|
|
// Half-open admits exactly one probe once the open window elapses.
|
|
let deadline = Instant::now() + Duration::from_secs(120);
|
|
let recovered = loop {
|
|
let response = env.raw_get(bucket, key).await?;
|
|
if response.status == 200 {
|
|
break response;
|
|
}
|
|
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS);
|
|
assert!(Instant::now() < deadline, "the breaker never left the open state");
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
};
|
|
assert_eq!(recovered.body, body, "the recovered read serves the source bytes");
|
|
assert_eq!(recovered.header(ODM_RESPONSE_HEADER), Some("source"));
|
|
assert_eq!(
|
|
env.source.count_requests(Operation::HeadObject, key),
|
|
source_requests + 1,
|
|
"only the half-open probe reached the source"
|
|
);
|
|
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
|
|
assert_eq!(
|
|
env.status_json(bucket)
|
|
.await?
|
|
.pointer("/breaker/state")
|
|
.and_then(|v| v.as_str()),
|
|
Some("closed"),
|
|
"a successful probe closes the breaker"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Case 3: a source that holds the response past `first_byte_ms` is a
|
|
/// timeout, and the client never sees a 200 head. One logical source call is
|
|
/// one wire request, so a single scripted stall is enough to fail the read.
|
|
#[tokio::test]
|
|
async fn test_odm_source_stall_times_out_before_the_first_byte() -> TestResult {
|
|
let bucket = "odm-fault-stall";
|
|
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
|
|
spec.policy.source_timeout.first_byte_ms = 500;
|
|
})
|
|
.await?;
|
|
let key = "stall/doc.bin";
|
|
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, payload(4096))]);
|
|
|
|
env.source
|
|
.inject_for_key(Operation::HeadObject, key, FaultAction::Stall(Duration::from_secs(5)), 1);
|
|
let started = Instant::now();
|
|
let response = env.raw_get(bucket, key).await?;
|
|
let elapsed = started.elapsed();
|
|
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&response.body));
|
|
assert_eq!(
|
|
env.source.count_requests(Operation::HeadObject, key),
|
|
1,
|
|
"the stalled HEAD is the only source request"
|
|
);
|
|
assert!(
|
|
elapsed < Duration::from_secs(5),
|
|
"the read timeout must cut the attempt short, took {elapsed:?}"
|
|
);
|
|
assert_eq!(
|
|
env.source.count_requests(Operation::GetObject, key),
|
|
0,
|
|
"a timed-out HEAD never starts a body read"
|
|
);
|
|
assert_eq!(
|
|
env.status_json(bucket)
|
|
.await?
|
|
.pointer("/last_source_error/class")
|
|
.and_then(|v| v.as_str()),
|
|
Some("timeout"),
|
|
);
|
|
env.assert_local_absent(bucket, key).await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Case 4: the source cuts the body of an inline pull. The client sees a
|
|
/// short read, nothing is stored, and no multipart upload is left behind.
|
|
#[tokio::test]
|
|
async fn test_odm_inline_pull_aborts_when_the_source_body_is_cut() -> TestResult {
|
|
let bucket = "odm-fault-inline-cut";
|
|
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
|
let key = "cut/inline.bin";
|
|
let body = payload(256 * 1024);
|
|
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
|
|
|
env.source
|
|
.inject_for_key(Operation::GetObject, key, FaultAction::TruncateBodyAt(1024), 1);
|
|
// The client sees a transport failure while reading the body: the
|
|
// announced Content-Length is never delivered.
|
|
env.raw_get(bucket, key)
|
|
.await
|
|
.expect_err("a cut source body must not read back as a complete object");
|
|
|
|
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
|
assert_eq!(
|
|
env.source.count_requests(Operation::GetObject, key),
|
|
1,
|
|
"an aborted inline pull is not retried on the same request"
|
|
);
|
|
// Give a stray background pull time to appear before asserting absence.
|
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
|
env.assert_local_absent(bucket, key).await;
|
|
let uploads = env.client.list_multipart_uploads().bucket(bucket).send().await?;
|
|
assert!(
|
|
uploads.uploads().is_empty(),
|
|
"an aborted pull leaves no multipart upload: {:?}",
|
|
uploads.uploads()
|
|
);
|
|
assert_eq!(
|
|
env.source.count_requests(Operation::GetObject, key),
|
|
1,
|
|
"nothing re-reads the source afterwards"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Case 5: the source answers, sends part of the body and then goes quiet
|
|
/// for longer than `source_timeout.idle_ms`. The inline tee must end both
|
|
/// ends: the client gets a short read rather than a silently truncated 200,
|
|
/// the pull is counted as a source timeout, and nothing (object or multipart
|
|
/// upload) is left behind locally.
|
|
#[tokio::test]
|
|
async fn test_odm_inline_pull_aborts_when_the_source_body_stalls() -> TestResult {
|
|
let bucket = "odm-fault-inline-stall";
|
|
const IDLE_MS: u64 = 1_000;
|
|
let idle = Duration::from_millis(IDLE_MS);
|
|
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
|
|
spec.policy.source_timeout.idle_ms = IDLE_MS;
|
|
})
|
|
.await?;
|
|
let key = "stall/inline.bin";
|
|
let body = payload(256 * 1024);
|
|
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
|
|
|
// The head and the first slice arrive at once; the source then pauses for
|
|
// four times the idle budget, which is what a stalled source looks like.
|
|
env.source.inject_for_key(
|
|
Operation::GetObject,
|
|
key,
|
|
FaultAction::SlowSendBody {
|
|
chunk_bytes: 32 * 1024,
|
|
delay: idle * 4,
|
|
},
|
|
1,
|
|
);
|
|
let started = Instant::now();
|
|
env.raw_get(bucket, key)
|
|
.await
|
|
.expect_err("a stalled source body must not read back as a complete object");
|
|
let elapsed = started.elapsed();
|
|
assert!(
|
|
elapsed < idle * 4,
|
|
"the idle budget, not the source's own pause, must end the read (took {elapsed:?})"
|
|
);
|
|
|
|
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
|
assert_eq!(
|
|
env.source.count_requests(Operation::GetObject, key),
|
|
1,
|
|
"an aborted inline pull is not retried on the same request"
|
|
);
|
|
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/source_timeout", 1, SETTLE)
|
|
.await?;
|
|
// The leader releases its slot just after it records the failure.
|
|
let deadline = Instant::now() + SETTLE;
|
|
loop {
|
|
let inflight = env
|
|
.status_json(bucket)
|
|
.await?
|
|
.pointer("/inflight_pulls")
|
|
.and_then(|value| value.as_u64());
|
|
if inflight == Some(0) {
|
|
break;
|
|
}
|
|
assert!(Instant::now() < deadline, "the aborted pull never released its slot: {inflight:?}");
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
}
|
|
env.assert_local_absent(bucket, key).await;
|
|
let uploads = env.client.list_multipart_uploads().bucket(bucket).send().await?;
|
|
assert!(
|
|
uploads.uploads().is_empty(),
|
|
"a stalled pull leaves no multipart upload: {:?}",
|
|
uploads.uploads()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Case 6: the background pull of a large object hits a cut body, counts the
|
|
/// failure, and the retry stores the object.
|
|
#[tokio::test]
|
|
async fn test_odm_background_pull_retries_a_truncated_source_body() -> TestResult {
|
|
let bucket = "odm-fault-background-cut";
|
|
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| spec.policy.inline_max_bytes = 4096).await?;
|
|
let key = "cut/background.bin";
|
|
let body = payload(512 * 1024);
|
|
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
|
|
|
// The faults are consumed in order by the two GETs the large-object path
|
|
// makes: the passthrough that answers the client (unaffected), then the
|
|
// background pull (cut).
|
|
env.source
|
|
.inject_for_key(Operation::GetObject, key, FaultAction::Delay(Duration::ZERO), 1);
|
|
env.source
|
|
.inject_for_key(Operation::GetObject, key, FaultAction::TruncateBodyAt(2048), 1);
|
|
|
|
let response = env.raw_get(bucket, key).await?;
|
|
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
|
assert_eq!(response.body, body, "the passthrough is unaffected by the pull's fault");
|
|
|
|
// The cut body ends the pull attempt as a retryable source transport
|
|
// failure; the retry stores the object, so the pull as a whole succeeds
|
|
// and no failure is counted (only a pull that gives up is).
|
|
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the retry must store the object");
|
|
env.assert_local_present(bucket, key, &body).await;
|
|
assert_eq!(
|
|
env.source.count_requests(Operation::GetObject, key),
|
|
3,
|
|
"one passthrough, one cut pull, one successful retry"
|
|
);
|
|
let status = env.status_json(bucket).await?;
|
|
assert_eq!(
|
|
status
|
|
.pointer("/counters/pulled_objects_total/background")
|
|
.and_then(|v| v.as_u64()),
|
|
Some(1),
|
|
"{status}"
|
|
);
|
|
assert_eq!(
|
|
status
|
|
.pointer("/counters/pull_failures_total")
|
|
.and_then(|failures| failures.as_object())
|
|
.map(|failures| failures.values().filter_map(serde_json::Value::as_u64).sum::<u64>()),
|
|
Some(0),
|
|
"a retried attempt is not a failed pull: {status}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Case 7: the source advertises an ETag its bytes do not match. The client
|
|
/// still gets every byte; the write-back is discarded as an integrity
|
|
/// failure and nothing is stored.
|
|
#[tokio::test]
|
|
async fn test_odm_wrong_source_etag_discards_the_write_back() -> TestResult {
|
|
let bucket = "odm-fault-etag";
|
|
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
|
let key = "etag/mismatch.bin";
|
|
let body = payload(64 * 1024);
|
|
seed_with_etag(&env, key, body.clone(), "0123456789abcdef0123456789abcdef").await?;
|
|
|
|
let response = env.raw_get(bucket, key).await?;
|
|
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
|
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
|
|
assert_eq!(response.body, body, "the client receives the complete source bytes");
|
|
|
|
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/etag_mismatch", 1, SETTLE)
|
|
.await?;
|
|
env.assert_local_absent(bucket, key).await;
|
|
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
|
assert_eq!(
|
|
env.source.count_requests(Operation::GetObject, key),
|
|
1,
|
|
"a discarded write-back is not re-read"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Case 8: a source miss is remembered for `negative_cache_ttl_secs`, and
|
|
/// re-checked once the entry expires.
|
|
#[tokio::test]
|
|
async fn test_odm_source_not_found_is_negative_cached_for_the_ttl() -> TestResult {
|
|
let bucket = "odm-fault-negative-cache";
|
|
let ttl = Duration::from_secs(3);
|
|
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
|
|
spec.policy.negative_cache_ttl_secs = ttl.as_secs();
|
|
})
|
|
.await?;
|
|
let key = "negative/nowhere.bin";
|
|
|
|
for attempt in 1..=10 {
|
|
let response = env.raw_get(bucket, key).await?;
|
|
assert_eq!(response.status, 404, "attempt {attempt}: {}", String::from_utf8_lossy(&response.body));
|
|
}
|
|
assert_eq!(
|
|
env.source.count_requests(Operation::HeadObject, key),
|
|
1,
|
|
"nine of the ten misses stop at the negative cache"
|
|
);
|
|
assert!(
|
|
env.status_counter(bucket, "/counters/requests_total/get/negative_cached")
|
|
.await?
|
|
>= 9,
|
|
"the cached misses are counted"
|
|
);
|
|
|
|
tokio::time::sleep(ttl + Duration::from_secs(2)).await;
|
|
let response = env.raw_get(bucket, key).await?;
|
|
assert_eq!(response.status, 404);
|
|
assert_eq!(
|
|
env.source.count_requests(Operation::HeadObject, key),
|
|
2,
|
|
"an expired entry re-checks the source once"
|
|
);
|
|
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
|
|
Ok(())
|
|
}
|
|
|
|
/// Case 9: an SSE-C source object cannot be migrated (the key belongs to the
|
|
/// source's client), so the read fails as unsupported without a body read.
|
|
#[tokio::test]
|
|
async fn test_odm_ssec_source_object_is_unsupported() -> TestResult {
|
|
let bucket = "odm-fault-ssec";
|
|
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
|
let key = "ssec/secret.bin";
|
|
seed_with_ssec(&env, key, payload(4096)).await?;
|
|
|
|
let response = env.raw_get(bucket, key).await?;
|
|
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&response.body));
|
|
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
|
assert_eq!(
|
|
env.source.count_requests(Operation::GetObject, key),
|
|
0,
|
|
"an unsupported object is rejected on the HEAD"
|
|
);
|
|
assert_eq!(
|
|
env.status_json(bucket)
|
|
.await?
|
|
.pointer("/counters/requests_total/get/unsupported")
|
|
.and_then(|v| v.as_u64()),
|
|
Some(1),
|
|
);
|
|
env.assert_local_absent(bucket, key).await;
|
|
Ok(())
|
|
}
|