// 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::>().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::()), 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(()) }