Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/rustfs-s3-compat-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ jobs:
openssl version
df -h /data | tail -1

- name: Install bulk-delete regression client
run: |
set -euo pipefail
BULK_CLIENT="${FUNCTIONAL_ARTIFACTS_DIR}-scratch/bulk-client"
python3 -m venv "${BULK_CLIENT}"
"${BULK_CLIENT}/bin/python" -m pip install 'boto3>=1.35,<2'
printf 'RUSTFS_S3_BULK_PYTHON=%s/bin/python\n' "${BULK_CLIENT}" >> "${GITHUB_ENV}"

- name: Admit clean shared environment
id: environment
timeout-minutes: 5
Expand Down Expand Up @@ -113,7 +121,7 @@ jobs:
chmod +x rustfs-s3-compat-test.sh
PACKAGE_URL='${{ inputs.package_url }}'
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
ARGS=(--all-topologies -y --log-file "${LOG_FILE}")
ARGS=(--all-topologies -y --log-file "${LOG_FILE}" --bulk-delete-results-dir "${FUNCTIONAL_ARTIFACTS_DIR}/bulk-delete")
if [ -n "${PACKAGE_URL}" ]; then
ARGS+=(--package-url "${PACKAGE_URL}")
elif [ -n "${RUSTFS_VERSION}" ]; then
Expand Down Expand Up @@ -292,6 +300,7 @@ jobs:
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/bulk-delete/
if-no-files-found: error

- name: Cleanup environment (after)
Expand Down
10 changes: 8 additions & 2 deletions .github/workflows/s3-script-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ on:
- test_rustfs_s3_usage.py
- test_rustfs_s3_signing.py
- test_rustfs_s3_regressions.py
- scripts/s3_bulk_delete_regressions.py
- scripts/performance_s3.py
- test_s3_bulk_delete_regressions.py
- test_rustfs_worm_object_lock.py
- test_validation_s3_basic.sh
- .github/workflows/s3-script-tests.yml
Expand All @@ -19,6 +22,9 @@ on:
- test_rustfs_s3_usage.py
- test_rustfs_s3_signing.py
- test_rustfs_s3_regressions.py
- scripts/s3_bulk_delete_regressions.py
- scripts/performance_s3.py
- test_s3_bulk_delete_regressions.py
- test_rustfs_worm_object_lock.py
- test_validation_s3_basic.sh
- .github/workflows/s3-script-tests.yml
Expand All @@ -37,8 +43,8 @@ jobs:
- name: Validate shell syntax
run: bash -n rustfs-s3-compat-test.sh scripts/campaign/run-worm-object-lock.sh
- name: Install independent SigV4 verifier
run: python3 -m pip install botocore==1.43.19
run: python3 -m pip install botocore==1.43.19 'boto3>=1.43.19,<1.43.20'
- name: Test S3 cases and signatures without a cluster
run: python3 -m unittest test_rustfs_s3_usage.py test_rustfs_s3_signing.py test_rustfs_s3_regressions.py test_rustfs_worm_object_lock.py
run: python3 -B -m unittest test_rustfs_s3_usage.py test_rustfs_s3_signing.py test_rustfs_s3_regressions.py test_rustfs_worm_object_lock.py test_s3_bulk_delete_regressions.py
- name: Test validation S3 round trip without a cluster
run: bash test_validation_s3_basic.sh
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ per-key failures, request ids, GET results and residue before/after cleanup.
Cleanup runs in `finally`; an incomplete cleanup aborts the remaining cases.
The caller must hold the environment's test lock throughout this probe.

## Bulk-delete regressions

The complete S3 topology suite includes bulk-delete regressions `S3C-213`, `S3C-214`, `S3C-1306` and `S3C-1307`. They cover batch boundaries, duplicate keys, immutable GET load and finite same-key PUT contention in off/on/suspended versioning modes. Python boto3 is required. Each subcase retains JSON, phase/request timing, HTTP 503 and per-object error counts, version/marker cleanup receipts and a Chinese Markdown summary under `--bulk-delete-results-dir`. The standalone runner accepts existing endpoints and does not deploy nodes; its caller must hold the environment lock. See [the case contracts and usage](docs/s3-bulk-delete-regressions.zh-CN.md).

## Release validation migration

`validation-cases.json` snapshots all 158 canonical cases from
Expand Down
47 changes: 47 additions & 0 deletions docs/s3-bulk-delete-regressions.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 批量删除回归用例

这些用例来自 main `0f9258594` 全量复测和后续批量删除诊断中发现的逐对象 `ServiceUnavailable`、准备/清理超时及热点并发问题。用例验证 S3 可观察行为;RPC 内部的迟到响应、exact-ID 清理和按键重试仍由 RustFS 锁库的回归测试验证,不能仅凭外部请求成功声称内部缺陷已修复。

## 用例与判定

| 用例 | 子场景 | 通过条件 |
|---|---|---|
| S3C-213 BulkDeleteBatchMatrix | 版本关闭/开启/暂停 × 1、32、128、500、1000 键,共 15 项 | 一次请求中每条输入均有结果、无逐对象错误;各端点均看不到已删当前对象;未参与删除的对象字节不变;开启和暂停模式下保留抽样历史版本的原始字节。 |
| S3C-214 BulkDeleteDuplicateKeys | 三种版本状态,共 3 项;包含同一键重复 16 次和两个重复的不存在键 | 按输入条目数核对响应,不把重复条目当作独立对象;当前键删除且控制对象、历史版本保持正确。 |
| S3C-1306 BulkDeleteUnderGetLoad | 三种版本状态,共 3 项;默认 32 个读取工作线程和 1000 键删除 | 所有线程先成功读取控制对象;并发负载期间读取字节保持一致;一次批删无错误、无当前对象残留;所有传入端点均验证结果。 |
| S3C-1307 BulkDeleteWriteRecovery | 三种版本状态,共 3 项;默认 32 个有限写入线程、一个热点键和 127 个其他键,热点键在批删输入中重复 16 次 | 原始请求及负载无错误、其他键删除;竞争结束时热点值不存在或为完整已写入值,各端点视图一致;随后同键 PUT、各端点 GET、DELETE 及其他键重试成功,最终无当前对象残留。 |

默认每个拓扑执行 24 个子场景,全量 S3 套件的三个拓扑共执行 72 个子场景。对象体为 128 KiB,最大单批种子数据约 125 MiB,不复用压测中的历史大桶。每个子场景使用新建的独立桶,清理显式移除对象版本、删除标记及未完成分片上传,再检查没有残留并删除桶。不会删除其他桶、修改节点配置或注入环境故障。

原始批删只发起一次请求,SDK 自动重试关闭。HTTP 200 的 `DeleteResult/Errors` 是失败;顶层 HTTP 503 响应数和逐对象 `ServiceUnavailable` 条目数分别记录,不能互相换算。恢复或清理重试成功不会覆盖原始失败,清理曾出现错误的子场景也保持 FAIL。一次调用中清理最终失败或中断时,该调用的后续子场景记录 NOT_RUN,保留责任与证据;全量拓扑套件其他独立用例沿用原有调度。

热点用例在记录原始请求失败后继续验证无负载恢复,分别保留原始失败和恢复结果。故意制造高负载下的合法仲裁超时仍会使严格回归判定失败;失败结果是需要调查的现象,不能自动认定每个 503 都是同一种产品缺陷。

两个负载场景均在所有工作线程完成预热后释放后续请求,并等待至少一个实际 SDK 调用进入,再发起原始批删。只有预热之后成功的 GET/PUT 调用与批删调用在客户端的单调时钟区间存在正交集,负载条件才通过;仅预热成功、所有请求提前完成或重叠请求全部失败都不能通过。GET 的区间包含响应体读取,客户端创建、PUT 响应后的日志写入和批删响应完整性核对不计入请求区间。结果记录重叠成功次数、工作线程数和首个交集,事件文件保留每次交集;这些是客户端并发证据,不是服务端锁重叠的证明。

HTTP 200 响应即使漏掉部分输入,也会先保存已解析的删除确认和逐对象错误,再以 `IncompleteDeleteResponse` 判失败。恢复阶段自身失败时保留原始阶段的独立结果;原始验证中的流式读取超时或短读也会记录为失败,然后继续热点键的无负载恢复检查。

## 执行与证据

全量入口为 `rustfs-s3-compat-test.sh --all-topologies`,也可以使用 `--cases S3C-213,S3C-214,S3C-1306,S3C-1307` 选择这些用例。`--bulk-delete-results-dir DIR` 指定持久证据目录;未指定时创建独立目录,有日志文件则放在日志旁边。多节点拓扑使用既有入口和配置的其他节点地址直接验证。S3 workflow 在本次任务的独立 venv 中安装 boto3,避免修改宿主 Python 包,并自动上传结果目录;手工执行时可通过 `RUSTFS_S3_BULK_PYTHON` 指定已安装 boto3 的 Python 解释器。

已经部署的集群可以直接运行以下命令。凭据使用 `RUSTFS_ACCESS_KEY`/`RUSTFS_SECRET_KEY` 或 `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` 环境变量;令牌对应 `RUSTFS_SESSION_TOKEN` 或 `AWS_SESSION_TOKEN`。调用方必须在整个测试和清理期间持有环境共享锁。

```bash
python3 scripts/s3_bulk_delete_regressions.py \
--endpoint http://node1:9000 \
--endpoint http://node2:9000 \
--endpoint http://node3:9000 \
--endpoint http://node4:9000 \
--result-dir /absolute/path/to/new-results
```

默认准备预算 300 秒,请求/验证/恢复各阶段预算 60 秒,负载预算 30 秒,清理预算 180 秒;通过 `--prepare-budget`、`--request-budget`、`--load-budget`、`--cleanup-budget` 调整。每阶段各端点共用一个截止时间,不允许后续请求重新获得完整预算。客户端连接和读取设置有界超时,负载线程执行有限轮次;网络读取超时是 socket 读取超时,不应解读为逐字节流的硬墙钟限制。

每个负载线程复用自己的客户端与连接池;剩余预算不足其 10 秒读取超时后才重建并收紧超时。共享性能工具仍保持原有 60 秒读取超时。

每个子场景保留 `result.json`、`s3-events.jsonl` 和 `bucket.s3.json`。结果包含第一失败阶段、后续失败列表、各阶段耗时、请求 ID、SDK 重试次数、各阶段 HTTP 503 响应数、逐对象错误条目、内容校验和清理结果。`summary.json` 和中文 `summary.md` 汇总全部计划子场景及未执行项。结果目录必须全新,禁止用后续运行覆盖原始证据。

## 本地验证范围

`python3 -B -m unittest test_s3_bulk_delete_regressions.py` 使用响应变异和版本模型验证通过/失败判定、重复输入计数、历史版本保留、泄漏检测、失败后恢复、阶段预算、清理失败历史、中断证据和全量入口选择。这些测试验证测试工具本身,不等同于 RustFS 测试机复测通过。性能准备/清理中断和报告前后超时的既有回归继续位于 `test_rustfs_performance_testing.py` 与 `test_performance_s3.py`,避免重复编写同一套模拟。
36 changes: 36 additions & 0 deletions rustfs-s3-compat-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ VALIDATION_OBJECT_CLEANUP_STATUS=PASS
VALIDATION_BINARY_SHA256=""
ENDPOINT=""
VOLUMES=""
S3_BULK_RESULTS_DIR="${RUSTFS_S3_BULK_RESULTS_DIR:-}"
S3_BULK_PYTHON="${RUSTFS_S3_BULK_PYTHON:-python3}"

# ==================== Helpers ====================

Expand Down Expand Up @@ -882,6 +884,27 @@ worm_object_lock_tests() {
run_test S3C-605 "LegalHold" test_s3c605
}

bulk_delete_regression() {
local id="$1" index=1 node
local args=(--case "${id}" --endpoint "${ENDPOINT}")
if [ -z "${S3_BULK_RESULTS_DIR}" ]; then
S3_BULK_RESULTS_DIR="$(mktemp -d "${LOG_FILE:+$(dirname "${LOG_FILE}")/}rustfs-s3-bulk.XXXXXX")"
fi
mkdir -p "${S3_BULK_RESULTS_DIR}/${CURRENT_TOPOLOGY:-existing}"
args+=(--result-dir "${S3_BULK_RESULTS_DIR}/${CURRENT_TOPOLOGY:-existing}/${id}")
for node in $(topology_nodes "${CURRENT_TOPOLOGY}"); do
if [ "${index}" -gt 1 ]; then args+=(--endpoint "http://${node}:9000"); fi
index=$((index+1))
done
RUSTFS_ACCESS_KEY="${ACCESS_KEY}" RUSTFS_SECRET_KEY="${SECRET_KEY}" AWS_DEFAULT_REGION="${REGION}" \
"${S3_BULK_PYTHON}" "${SCRIPT_DIR}/scripts/s3_bulk_delete_regressions.py" "${args[@]}"
}

test_s3c213() { bulk_delete_regression S3C-213; }
test_s3c214() { bulk_delete_regression S3C-214; }
test_s3c1306() { bulk_delete_regression S3C-1306; }
test_s3c1307() { bulk_delete_regression S3C-1307; }

run_all_tests() {
run_test S3C-101 "CreateBucket" test_s3c101
run_test S3C-102 "HeadBucket" test_s3c102
Expand All @@ -901,6 +924,8 @@ run_all_tests() {
run_test S3C-210 "SpecialCharKey" test_s3c210
run_test S3C-211 "ServerSideMove" test_s3c211
run_test S3C-212 "ReservedStorageNames" test_s3c212
run_test S3C-213 "BulkDeleteBatchMatrix" test_s3c213
run_test S3C-214 "BulkDeleteDuplicateKeys" test_s3c214
run_test S3C-301 "MultipartFullFlow" test_s3c301
run_test S3C-302 "UploadPartCopy" test_s3c302
run_test S3C-303 "ListPartsAndUploads" test_s3c303
Expand Down Expand Up @@ -963,6 +988,8 @@ run_all_tests() {
run_test S3C-1303 "HugeKeyMetadata" test_s3c1303
run_test S3C-1304 "MalformedXmlParams" test_s3c1304
run_test S3C-1305 "ConcurrentOverwrite" test_s3c1305
run_test S3C-1306 "BulkDeleteUnderGetLoad" test_s3c1306
run_test S3C-1307 "BulkDeleteWriteRecovery" test_s3c1307
run_test S3C-1401 "CopyMetadataOnCopy" test_s3c1401
run_test S3C-1402 "AwsChunkedNoDecodedLen" test_s3c1402
run_test S3C-1403 "ConcurrentUploadPart" test_s3c1403
Expand Down Expand Up @@ -3141,6 +3168,7 @@ Options:
--ssh-user USER SSH user for the nodes (default azureuser)
--ssh-port PORT SSH port for the nodes (default 22)
--log-file FILE Append all output to FILE
--bulk-delete-results-dir DIR Retain bulk-delete JSON, request traces and Markdown
--validation-case ID Run only s3.basic-object-round-trip (single-single)
--validation-artifacts-dir DIR Write canonical validation result and evidence
--candidate-manifest FILE Immutable candidate manifest for validation results
Expand Down Expand Up @@ -3188,6 +3216,7 @@ main() {
--ssh-user) SSH_USER="$1"; shift ;;
--ssh-port) SSH_PORT="$1"; shift ;;
--log-file) LOG_FILE="$1"; shift ;;
--bulk-delete-results-dir) S3_BULK_RESULTS_DIR="$1"; shift ;;
--validation-case) VALIDATION_CASE="$1"; shift ;;
--validation-artifacts-dir) VALIDATION_ARTIFACTS_DIR="$1"; shift ;;
--candidate-manifest) VALIDATION_CANDIDATE_MANIFEST="$1"; shift ;;
Expand Down Expand Up @@ -3247,6 +3276,13 @@ main() {
need_cmd openssl "OpenSSL (SigV4)"
need_cmd python3 "Python 3 (archive fixtures)"
need_cmd tar "tar archive tool"
if [ "${DRY_RUN}" -eq 0 ] && [ -z "${VALIDATION_CASE}" ]; then
case ",${SELECTED_CASES}," in
,,|*,S3C-213,*|*,S3C-214,*|*,S3C-1306,*|*,S3C-1307,*)
"${S3_BULK_PYTHON}" -c 'import boto3' >/dev/null 2>&1 || die "bulk-delete regressions require Python boto3"
;;
esac
fi
log "nodes: ${NODES[*]} package: $(resolve_package_url)"
local overall_fail=0 topo
for topo in "${SELECTED_TOPOLOGIES[@]}"; do
Expand Down
13 changes: 10 additions & 3 deletions scripts/performance_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ def new_bucket(prefix):


class Session:
read_timeout = 60

def __init__(self, endpoint, events, budget, factory=None):
self.endpoint = endpoint
self.events = events
Expand All @@ -57,7 +59,7 @@ def client(self, remaining):
aws_secret_access_key=os.environ["WARP_SECRET_KEY"],
aws_session_token=os.environ.get("WARP_SESSION_TOKEN") or None,
config=Config(s3={"addressing_style": "path"}, proxies={},
connect_timeout=min(3, remaining), read_timeout=min(60, remaining),
connect_timeout=min(3, remaining), read_timeout=min(self.read_timeout, remaining),
retries={"total_max_attempts": 1}),
)

Expand All @@ -66,15 +68,20 @@ def emit(self, **record):
with self.events.open("a") as stream:
stream.write(json.dumps(record) + "\n")

def invoke(self, operation, **arguments):
return getattr(self.s3, operation)(**arguments)

def call(self, operation, **arguments):
remaining = self.deadline - time.monotonic()
if remaining <= 0:
raise RequestFailure("CleanupDeadlineExceeded")
try:
# Rebuild near the deadline so a call cannot retain a longer old timeout.
if self.s3 is None or remaining < 60:
if self.s3 is None or remaining < self.read_timeout:
self.s3 = self.factory(max(0.01, remaining))
response = getattr(self.s3, operation)(**arguments)
response = self.invoke(operation, **arguments)
except RequestFailure:
raise
except Exception as exc:
response = getattr(exc, "response", {})
if not isinstance(response, dict):
Expand Down
Loading
Loading