Compare commits

..

1 Commits

Author SHA1 Message Date
xiaomage e55c9ffece feat(ci): add fault-tolerance degradation suite to the functional chain
Adds a fault-tolerance suite that verifies read/write behavior under
drive and node loss against the erasure-coding contract and snapshots
health-endpoint responses at every degradation tier. Scenarios are
derived from product source (default_parity_count, erasure set sizing):

- A: single-node 4 drives (EC:2, read quorum 2): hide 1/2/3 drives
- B: multi-node 4x1 (one set of 4, EC:2): stop 1/2/3 nodes
- C: multi-node 4x4 (one set of 16, EC:4, read quorum 12): 1 node down
  lands exactly on the read-quorum boundary; 2 nodes down breaks it
- C2: multi-node 4x4 with RUSTFS_STORAGE_CLASS_STANDARD=EC:8 (read
  quorum 8, write quorum 9, lock majority 9): 2 nodes down puts reads
  inside the reported divergence window (read quorum met while the
  lock majority is broken)

By default a reads-refused-despite-met-read-quorum observation is
reported as known-divergence without failing the suite; the strict
input escalates it. Chain order becomes:
upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security ->
replication -> fault-tolerance -> performance.

Covers the findings of the 2026-09 external degradation report
(reads at read quorum, health-endpoint readiness truthfulness,
degradedReasons capture) as automated regression probes.
2026-09-11 22:45:56 +08:00
5 changed files with 311 additions and 217 deletions
@@ -0,0 +1,276 @@
# RustFS Fault-Tolerance (degradation) Test
#
# Scenario suite for the 2026-09 degradation report: verifies read/write
# behavior under drive and node loss against the erasure-coding contract and
# snapshots health-endpoint responses at every tier.
#
# A single-node 4 drives (SNMD): hide 1/2/3 drives, restore
# B multi-node 4x1 (one drive per node): stop 1/2/3 nodes, restore
# C multi-node 4x4 (16 drives, EC:4): stop 1 node (read-quorum boundary),
# stop 2 nodes, restore
# C2 multi-node 4x4 with EC:8: 2 nodes down puts 8 drives online -- reads
# satisfy the EC read quorum while the lock majority is broken (the
# reported divergence window: reads 503 with lock_quorum_unavailable)
#
# Expectations come from product source (default_parity_count, erasure set
# sizing). By default a "reads refused although the read quorum is met"
# observation is reported as known-divergence without failing the suite; the
# strict input turns those into failures once the product behavior changes.
name: RustFS Fault-Tolerance Test
on:
workflow_dispatch:
inputs:
package_url:
description: 'Direct .deb URL. Required unless the nightly default is wanted.'
required: false
type: string
strict:
description: 'Fail the suite when reads are refused despite a met read quorum'
type: boolean
default: false
cleanup_before:
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
type: boolean
default: true
cleanup_after:
description: 'Reset the nodes after the test (DESTROYS test data/config)'
type: boolean
default: true
repository_dispatch:
# Chain handoff: dispatched when the replication suite finishes, ahead of
# the performance suite.
types: [rustfs-chain-fault-tolerance]
permissions:
contents: read
# The suite stops services and hides drive dirs on the shared fleet; only one
# functional suite may touch the environment at a time.
concurrency:
group: rustfs-shared-functional-tests
cancel-in-progress: false
defaults:
run:
shell: bash
env:
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
jobs:
fault-tolerance-test:
runs-on: smoke-testing
timeout-minutes: 480
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
- name: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-ft-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}/evidence"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'EVIDENCE_DIR=%s/evidence\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not
# GITHUB_TOKEN) and retry transient GitHub/network failures.
- name: Checkout auto-testing scripts (with retry)
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
rm -rf auto-testing
for attempt in 1 2 3 4 5; do
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
echo "auto-testing cloned (attempt ${attempt})"
exit 0
fi
rm -rf auto-testing
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
sleep $((attempt * 15))
done
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
exit 1
- name: Show environment
run: |
uname -a
jq --version
aws --version
df -h /data | tail -1
- name: Cleanup environment (before)
if: ${{ inputs.cleanup_before != 'false' }}
run: |
./auto-testing/rustfs-fault-tolerance-test.sh --cleanup -y --log-file "${LOG_FILE}"
- name: Run fault-tolerance scenarios (A, B, C, C2)
id: test
run: |
ARGS=(--all -y --package-url "${{ inputs.package_url || env.RUSTFS_NIGHTLY_PACKAGE_URL }}" --log-file "${LOG_FILE}")
if [ "${{ inputs.strict }}" = "true" ]; then
ARGS+=(--strict)
fi
./auto-testing/rustfs-fault-tolerance-test.sh "${ARGS[@]}"
- name: Generate report
if: ${{ always() && steps.evidence.outcome == 'success' }}
run: |
set -euo pipefail
{
echo "# RustFS fault-tolerance test report"
echo ""
echo "- Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Package: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
echo "- Strict mode: ${{ inputs.strict || 'false' }}"
echo ""
echo "## Per-probe results"
echo ""
echo '```'
grep -E '^FT-(CASE|SUMMARY|REPORT)' "${LOG_FILE}" || echo "(no FT-CASE lines found)"
echo '```'
echo ""
echo "## Health snapshots"
echo ""
for f in "${FUNCTIONAL_ARTIFACTS_DIR}"/evidence/*.code; do
[ -e "${f}" ] || continue
printf '%s -> %s\n' "$(basename "${f}" .code)" "$(cat "${f}")"
done
} > "${REPORT_FILE}"
- name: File failure issue in rustfs/backlog
if: ${{ failure() && steps.evidence.outcome == 'success' }}
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
SUITE: fault-tolerance
SUITE_LABEL: Fault-Tolerance
run: |
set -euo pipefail
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
EXISTING="$(gh issue list -R rustfs/backlog \
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
--json number --jq '.[].number' || true)"
if [ -n "${EXISTING}" ]; then
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
exit 0
fi
redact() {
sed -E \
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
}
BODY_FILE="$(mktemp)"
{
echo "The **${SUITE_LABEL}** functional suite failed."
echo ""
echo "- Suite: \`${SUITE}\`"
echo "- Run: ${RUN_URL}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)"
echo ""
echo "## Report (errors and symptoms)"
echo ""
if [ -s "${REPORT_FILE:-}" ]; then
redact < "${REPORT_FILE}"
elif [ -s "${LOG_FILE:-}" ]; then
echo "(report file missing; log tail below)"
echo ""
tail -n 200 "${LOG_FILE}" | redact
else
echo "(no report or log file was produced)"
fi
} | head -c 55000 > "${BODY_FILE}"
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
--body-file "${BODY_FILE}" --label functional-test; then
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
fi
echo "filed backlog issue for suite ${SUITE}"
- name: Upload test logs & evidence
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-fault-tolerance-${{ github.run_id }}-${{ github.run_attempt }}
path: |
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/evidence/
if-no-files-found: warn
- name: Cleanup environment (after)
if: ${{ always() && inputs.cleanup_after != 'false' }}
run: |
./auto-testing/rustfs-fault-tolerance-test.sh --cleanup -y --log-file "${LOG_FILE}" || true
- name: "Continue functional chain (next: Performance)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup. A failed
# handoff retries, then files an alert issue in rustfs/backlog.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -uo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-performance' \
-F 'client_payload[from_suite]=fault-tolerance'; then
echo "dispatched next suite Performance (attempt ${attempt})"
DISPATCHED=1
break
fi
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
sleep "${attempt}0"
done
if [ "${DISPATCHED:-0}" -ne 1 ]; then
echo "ERROR: functional chain stalled: could not dispatch Performance after 3 attempts" >&2
TITLE="[functional][chain] stalled after fault-tolerance (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
{
echo "The functional chain could not hand off from **fault-tolerance** to **Performance** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-performance'"
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
echo "- Recovery: re-dispatch manually with"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-performance'"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
} > "${BODY_FILE}"
gh issue create -R rustfs/backlog --title "${TITLE}" \
--body-file "${BODY_FILE}" --label functional-test \
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|| echo "could not file the stall alert issue either; check the token" >&2
exit 1
fi
- name: Notify on failure
if: failure()
run: |
echo "RustFS fault-tolerance test failed"
echo "Package source: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
echo "See the uploaded log artifact and FT-CASE lines for details."
@@ -14,7 +14,7 @@
# Functional chain driver: runs the ten functional suites in a fixed order
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security ->
# replication -> performance). Each suite attempts the next handoff even
# replication -> fault-tolerance -> performance). Each suite attempts the next handoff even
# when its tests fail.
#
# Each suite workflow can still be dispatched standalone (workflow_dispatch);
@@ -329,7 +329,7 @@ jobs:
'
done
- name: "Continue functional chain (next: Performance)"
- name: "Continue functional chain (next: Fault tolerance)"
if: ${{ always() && github.event_name == 'repository_dispatch' }}
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
@@ -342,9 +342,9 @@ jobs:
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-performance' \
-f event_type='rustfs-chain-fault-tolerance' \
-F 'client_payload[from_suite]=replication'; then
echo "dispatched next suite Performance (attempt ${attempt})"
echo "dispatched next suite Fault tolerance (attempt ${attempt})"
DISPATCHED=1
break
fi
@@ -352,19 +352,19 @@ jobs:
sleep "${attempt}0"
done
if [ "${DISPATCHED:-0}" -ne 1 ]; then
echo "ERROR: functional chain stalled: could not dispatch Performance after 3 attempts" >&2
echo "ERROR: functional chain stalled: could not dispatch Fault tolerance after 3 attempts" >&2
TITLE="[functional][chain] stalled after replication (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
trap 'rm -f "${BODY_FILE}"' EXIT
{
echo "The functional chain could not hand off from **replication** to **Performance** after 3 attempts."
echo "The functional chain could not hand off from **replication** to **Fault tolerance** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-performance'"
echo "- Expected next event: 'rustfs-chain-fault-tolerance'"
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
echo "- Recovery: re-dispatch manually with"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-performance'"
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-fault-tolerance'"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
} > "${BODY_FILE}"
gh issue create -R rustfs/backlog --title "${TITLE}" \
+21 -184
View File
@@ -2133,7 +2133,7 @@ impl DefaultObjectUsecase {
}
#[allow(clippy::too_many_arguments)]
async fn build_reader_blob<R>(
fn build_reader_blob<R>(
reader: R,
response_content_length: i64,
request_id: &str,
@@ -2144,12 +2144,10 @@ impl DefaultObjectUsecase {
key: &str,
lifecycle: GetObjectBodyLifecycle,
resume: Option<GetObjectResumeControl<R>>,
) -> S3Result<StreamingBlob>
) -> StreamingBlob
where
R: AsyncRead + Send + Sync + Unpin + 'static,
{
use tokio::io::AsyncReadExt as _;
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 =
@@ -2165,7 +2163,7 @@ impl DefaultObjectUsecase {
);
}
let handoff_start = get_stage_metrics_enabled.then(std::time::Instant::now);
let mut reader = GetObjectStreamingReader::new(
let reader = GetObjectStreamingReader::new(
reader,
bucket,
key,
@@ -2176,17 +2174,6 @@ impl DefaultObjectUsecase {
lifecycle,
resume,
);
let mut prefix = [0_u8; 1];
let prefix_len = if expected == 0 {
0
} else {
reader
.read_exact(&mut prefix)
.await
.map_err(|error| map_get_object_reader_error(StorageError::from(error)))?;
1
};
let reader = std::io::Cursor::new(prefix).take(prefix_len).chain(reader);
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);
@@ -2200,7 +2187,7 @@ impl DefaultObjectUsecase {
);
}
record_get_object_s3_handler_stage_duration(GET_OBJECT_STAGE_BODY_STREAMING_BLOB, streaming_blob_start);
Ok(blob)
blob
}
fn init_get_object_bootstrap(&self, bucket: &str, key: &str, request_id: &str) -> S3Result<GetObjectBootstrap> {
@@ -3174,7 +3161,7 @@ impl DefaultObjectUsecase {
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 Self::build_reader_blob(
return Ok(Self::build_reader_blob(
final_stream,
response_content_length,
request_id,
@@ -3185,8 +3172,7 @@ impl DefaultObjectUsecase {
key,
lifecycle,
resume(info),
)
.await;
));
}
if let Some(buffered_body) = buffered_body {
@@ -3253,7 +3239,7 @@ impl DefaultObjectUsecase {
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);
Self::build_reader_blob(
Ok(Self::build_reader_blob(
final_stream,
response_content_length,
request_id,
@@ -3264,8 +3250,7 @@ impl DefaultObjectUsecase {
key,
lifecycle,
resume(info),
)
.await
))
}
#[allow(clippy::too_many_arguments)]
@@ -5862,11 +5847,8 @@ mod tests {
}
impl AsyncRead for ReadProbeReader {
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
self.reads.fetch_add(1, AtomicOrdering::Relaxed);
if buf.remaining() > 0 {
buf.put_slice(b"x");
}
Poll::Ready(Ok(()))
}
}
@@ -7791,151 +7773,6 @@ mod tests {
)
}
#[tokio::test]
async fn build_reader_blob_rejects_quorum_failure_before_handoff() {
let result = DefaultObjectUsecase::build_reader_blob(
FailAtEndReader::new(
b"",
Some(std::io::Error::other(StorageError::InsufficientReadQuorum(
"test-bucket".to_string(),
"unavailable-object".to_string(),
))),
),
5,
"req-preheader-quorum",
None,
64,
GetObjectStreamStrategy::Standard,
"test-bucket",
"unavailable-object",
GetObjectBodyLifecycle::disabled(),
None,
)
.await;
let error = result.expect_err("a read quorum failure before the first byte must reject response construction");
assert_eq!(error.code(), &S3ErrorCode::Custom("SlowDownRead".into()));
assert_eq!(error.status_code(), Some(StatusCode::SERVICE_UNAVAILABLE));
assert_eq!(error.message(), Some("Resource requested is unreadable, please reduce your request rate"));
}
#[tokio::test]
async fn build_reader_blob_preserves_primed_byte_for_full_and_range() {
for (request_id, content_range) in [("req-preheader-full", None), ("req-preheader-range", Some("bytes 10-14/100"))] {
let reads = Arc::new(AtomicUsize::new(0));
let mut body = DefaultObjectUsecase::build_reader_blob(
DataProbeReader {
reads: Arc::clone(&reads),
data: std::io::Cursor::new(b"hello".to_vec()),
},
5,
request_id,
content_range,
64,
GetObjectStreamStrategy::Standard,
"test-bucket",
"test-object",
GetObjectBodyLifecycle::disabled(),
None,
)
.await
.expect("the first byte should be available before response handoff");
assert_eq!(reads.load(AtomicOrdering::Relaxed), 1, "response construction must prime one byte");
let mut received = Vec::new();
while let Some(chunk) = body.next().await {
received.extend_from_slice(&chunk.expect("the primed body should remain readable"));
}
assert_eq!(received, b"hello", "the primed byte must be delivered exactly once");
}
}
#[tokio::test]
async fn build_reader_blob_does_not_poll_empty_object() {
let reads = Arc::new(AtomicUsize::new(0));
let mut body = DefaultObjectUsecase::build_reader_blob(
ReadProbeReader {
reads: Arc::clone(&reads),
},
0,
"req-empty-object",
None,
64,
GetObjectStreamStrategy::Standard,
"test-bucket",
"empty-object",
GetObjectBodyLifecycle::disabled(),
None,
)
.await
.expect("an empty response should not require a storage read");
assert_eq!(reads.load(AtomicOrdering::Relaxed), 0);
assert!(body.next().await.is_none());
assert_eq!(reads.load(AtomicOrdering::Relaxed), 0);
}
#[tokio::test]
async fn build_reader_blob_leaves_later_failure_in_body_stream() {
let mut body = DefaultObjectUsecase::build_reader_blob(
FailAtEndReader::new(b"h", Some(std::io::Error::other("failure after handoff"))),
5,
"req-postheader-failure",
None,
64,
GetObjectStreamStrategy::Standard,
"test-bucket",
"later-failure-object",
GetObjectBodyLifecycle::disabled(),
None,
)
.await
.expect("the available first byte should allow response handoff");
let first = body
.next()
.await
.expect("the primed byte must be present")
.expect("the primed byte must be successful");
assert_eq!(first, Bytes::from_static(b"h"));
let error = body
.next()
.await
.expect("the later read must produce a body result")
.expect_err("a failure after the first byte must stay in the body stream");
assert!(error.to_string().contains("failure after handoff"));
}
#[tokio::test]
async fn build_reader_blob_resume_offset_includes_primed_byte() {
let reopen_count = Arc::new(AtomicUsize::new(0));
let control = counting_resume_control(Arc::clone(&reopen_count), |emitted| {
assert_eq!(emitted, 1, "resume must start after the byte consumed before response handoff");
Ok(FailAtEndReader::new(b"ello", None))
});
let mut body = DefaultObjectUsecase::build_reader_blob(
FailAtEndReader::new(b"h", Some(relocation_read_error())),
5,
"req-preheader-resume-offset",
None,
64,
GetObjectStreamStrategy::Standard,
"test-bucket",
"relocated-object",
GetObjectBodyLifecycle::disabled(),
Some(control),
)
.await
.expect("the first byte should permit response handoff before relocation");
let mut received = Vec::new();
while let Some(chunk) = body.next().await {
received.extend_from_slice(&chunk.expect("resume should complete the body"));
}
assert_eq!(received, b"hello");
assert_eq!(reopen_count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn get_object_streaming_reader_resumes_after_relocation_error() {
use tokio::io::AsyncReadExt;
@@ -9204,7 +9041,7 @@ mod tests {
}
#[tokio::test]
async fn build_get_object_body_primes_large_stream_before_handoff() {
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),
@@ -9237,13 +9074,13 @@ mod tests {
assert_eq!(
reads.load(AtomicOrdering::Relaxed),
1,
"large-object response construction should prime exactly one byte"
0,
"large-object response construction should not pre-read object data"
);
}
#[tokio::test]
async fn build_get_object_body_primes_large_encrypted_stream_before_handoff() {
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),
@@ -9276,8 +9113,8 @@ mod tests {
assert_eq!(
reads.load(AtomicOrdering::Relaxed),
1,
"large encrypted object response construction should prime exactly one byte"
0,
"large encrypted object response construction should not pre-read object data"
);
}
@@ -9447,8 +9284,8 @@ mod tests {
assert_eq!(fill, rustfs_object_data_cache::ObjectDataCacheFillResult::SkippedSizeMismatch);
assert_eq!(
reads.load(AtomicOrdering::Relaxed),
1,
"size-mismatched rejected fill should prime the fallback stream before handoff"
0,
"size-mismatched rejected fill should construct the fallback stream without pre-reading"
);
assert!(
matches!(lookup_after_mismatch, rustfs_object_data_cache::ObjectDataCacheLookup::Miss),
@@ -10156,8 +9993,8 @@ mod tests {
assert_eq!(
reads.load(AtomicOrdering::Relaxed),
1,
"too-large materialize-fill candidate must prime the streaming fallback"
0,
"too-large materialize-fill candidate must not pre-read the fallback reader"
);
}
@@ -10195,8 +10032,8 @@ mod tests {
assert_eq!(
reads.load(AtomicOrdering::Relaxed),
1,
"default GetObject response construction should prime exactly one byte"
0,
"default GetObject response construction should not pre-read small plain object data"
);
}
+6 -25
View File
@@ -20,8 +20,6 @@ use s3s::{S3Error, S3ErrorCode};
const MAX_VERSIONS_EXCEEDED_CODE: &str = "MaxVersionsExceeded";
const MAX_VERSIONS_EXCEEDED_MESSAGE: &str = "You've exceeded the limit on the number of versions you can create on this object";
const SLOW_DOWN_READ_CODE: &str = "SlowDownRead";
const SLOW_DOWN_READ_MESSAGE: &str = "Resource requested is unreadable, please reduce your request rate";
/// S3 error code for a request that names a KMS key the KMS does not hold.
pub const KMS_KEY_NOT_FOUND_ERROR_CODE: &str = "KMS.NotFoundException";
@@ -107,7 +105,6 @@ fn custom_error_status(code: &S3ErrorCode) -> Option<StatusCode> {
S3ErrorCode::Custom(custom) if &**custom == KMS_KEY_NOT_FOUND_ERROR_CODE || &**custom == MAX_VERSIONS_EXCEEDED_CODE => {
Some(StatusCode::BAD_REQUEST)
}
S3ErrorCode::Custom(custom) if &**custom == SLOW_DOWN_READ_CODE => Some(StatusCode::SERVICE_UNAVAILABLE),
_ => None,
}
}
@@ -406,7 +403,6 @@ impl ApiError {
S3ErrorCode::Custom(code) if &**code == MAX_VERSIONS_EXCEEDED_CODE => {
MAX_VERSIONS_EXCEEDED_MESSAGE.to_string()
}
S3ErrorCode::Custom(code) if &**code == SLOW_DOWN_READ_CODE => SLOW_DOWN_READ_MESSAGE.to_string(),
_ => code.as_str().to_string(),
}
}
@@ -581,10 +577,10 @@ impl From<StorageError> for ApiError {
| StorageError::FaultyRemoteDisk
| StorageError::DiskNotFound
| StorageError::TooManyOpenFiles => S3ErrorCode::ServiceUnavailable,
StorageError::ErasureReadQuorum | StorageError::InsufficientReadQuorum(_, _) => {
S3ErrorCode::Custom(SLOW_DOWN_READ_CODE.into())
}
StorageError::ErasureWriteQuorum | StorageError::InsufficientWriteQuorum(_, _) => S3ErrorCode::ServiceUnavailable,
StorageError::ErasureReadQuorum
| StorageError::InsufficientReadQuorum(_, _)
| StorageError::ErasureWriteQuorum
| StorageError::InsufficientWriteQuorum(_, _) => S3ErrorCode::ServiceUnavailable,
StorageError::NamespaceLockQuorumUnavailable { .. } => S3ErrorCode::ServiceUnavailable,
StorageError::QuotaExceeded { .. } => S3ErrorCode::InvalidRequest,
StorageError::MaxVersionsExceeded => S3ErrorCode::Custom(MAX_VERSIONS_EXCEEDED_CODE.into()),
@@ -1292,10 +1288,10 @@ mod tests {
(StorageError::FaultyRemoteDisk, S3ErrorCode::ServiceUnavailable),
(StorageError::DiskNotFound, S3ErrorCode::ServiceUnavailable),
(StorageError::TooManyOpenFiles, S3ErrorCode::ServiceUnavailable),
(StorageError::ErasureReadQuorum, S3ErrorCode::Custom(SLOW_DOWN_READ_CODE.into())),
(StorageError::ErasureReadQuorum, S3ErrorCode::ServiceUnavailable),
(
StorageError::InsufficientReadQuorum("test".into(), "test".into()),
S3ErrorCode::Custom(SLOW_DOWN_READ_CODE.into()),
S3ErrorCode::ServiceUnavailable,
),
(StorageError::ErasureWriteQuorum, S3ErrorCode::ServiceUnavailable),
(
@@ -1433,21 +1429,6 @@ mod tests {
assert_eq!(s3_error.status_code(), Some(http::StatusCode::SERVICE_UNAVAILABLE));
}
#[test]
fn read_quorum_failure_matches_minio_slow_down_read_response() {
for error in [
StorageError::ErasureReadQuorum,
StorageError::InsufficientReadQuorum("bucket".into(), "object".into()),
] {
let api_error = ApiError::from(error);
assert_eq!(api_error.code, S3ErrorCode::Custom(SLOW_DOWN_READ_CODE.into()));
assert_eq!(api_error.message, SLOW_DOWN_READ_MESSAGE);
let s3_error: S3Error = api_error.into();
assert_eq!(s3_error.status_code(), Some(StatusCode::SERVICE_UNAVAILABLE));
}
}
#[test]
fn quota_exceeded_preserves_existing_s3_error_contract() {
let api_error: ApiError = StorageError::QuotaExceeded { current: 5, limit: 10 }.into();