Compare commits

..

4 Commits

Author SHA1 Message Date
马登山 0f5efb47f7 test(tier): pin candidate package digest 2026-09-01 22:07:34 +08:00
马登山 c81267c600 test(tier): use exact rc candidate 2026-09-01 22:07:24 +08:00
马登山 1b34bf76eb test(tier): pin issue 2128 black-box scripts 2026-09-01 22:07:24 +08:00
马登山 c8fe9ff345 ci(tier): isolate per-run evidence 2026-09-01 21:48:30 +08:00
188 changed files with 11773 additions and 13521 deletions
+4 -5
View File
@@ -50,11 +50,10 @@ consider adding it to the script's `checked_files` list.
## `check_doc_paths.sh`
Instruction docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`) and every
Markdown file under `docs/` (architecture, operations, testing, index) must not
reference repo file paths that no longer exist. If your refactor moved code,
update the docs that point at it — the error message lists `doc -> stale-path`
pairs. Cite paths plus symbol names, never line numbers (see `docs/README.md`).
Instruction/architecture docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`,
`docs/architecture/*.md`) must not reference repo file paths that no longer
exist. If your refactor moved code, update the docs that point at it — the
error message lists `doc -> stale-path` pairs.
## `check_no_planning_docs.sh`
+1 -1
View File
@@ -1,2 +1,2 @@
sha256-darwin=9dccb0cd537cf79ae70c1c20e8281d36d03f2f09f81142a5341e26e3dc18709d
sha256-darwin=ef914ec0b8daa9c2c5e52f501d339914662f42d6f6ed9d33877d56b97adf16f9
sha256-linux=a8a816d7bb0e7cb5632b1863b33794bcb9fc7e765f150aa5e1bf16518e28dfb4
+1 -1
View File
@@ -1 +1 @@
sha256=d06524b44de97ed8f62b0fd8cf9fa504e3cd520ffcaacc32691d6f890ebe7f20
sha256=51da41c54167602f2bd6c45921b39a44562bf3cfcdf468d992bb992c62cad7fd
+1 -1
View File
@@ -1 +1 @@
sha256=db9bd8cdcb0abe43461aa6b36499b17cabd4098e5b34e300b1a0f0d0f34d9884
sha256=dbebfbab9b9efd4eff31211e69dd32235dc00e207f2ab0dd919a1b2ac9e724c2
+2 -3
View File
@@ -355,8 +355,7 @@ test-group = 'ecstore-serial-flaky'
# allowlist", so any new replication test lands in nightly by default (never
# silently unrun) until it is explicitly blessed as fast here. Keep the two
# regexes byte-identical. The committed profile selection digests make changes
# visible in CI; list current membership with `cargo nextest list -p e2e_test
# --profile <profile>` (platform-dependent; see docs/testing/README.md).
# visible in CI; current counts live in docs/testing/e2e-suite-inventory.md.
# HISTORY (2026-07-11): the 20 fast tests were briefly pulled out of this lane
# (#4724) because they set a loopback (127.0.0.1) replication target that the
# SSRF egress guard rejected on every PR after repl-1 (#4712). That is fixed —
@@ -509,7 +508,7 @@ path = "junit.xml"
# quota, checksum, encryption,
# security-boundary, ... — that the fast PR `e2e-smoke` subset deliberately
# skips. Budget <= 45 min; authority for the suite count is `cargo nextest list
# --profile e2e-full -p e2e_test` (platform-dependent; see docs/testing/README.md).
# --profile e2e-full` (see docs/testing/e2e-suite-inventory.md).
#
# The filter is "the whole e2e_test crate MINUS the sets owned by other lanes":
# * protocols:: — FTPS/SFTP/WebDAV, run from the dedicated protocol profile
+1 -1
View File
@@ -1065,7 +1065,7 @@ jobs:
while IFS= read -r preview_tag; do
[[ -n "$preview_tag" ]] || continue
echo "🧹 Deleting preview release $preview_tag (tag kept)"
gh release delete "$preview_tag" --repo "${GITHUB_REPOSITORY}" --yes
gh release delete "$preview_tag" --yes
DELETED=$((DELETED + 1))
done < <(
jq -r --arg tag "$TAG" '
@@ -12,10 +12,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# Functional chain driver: runs the ten functional suites in a fixed order
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security ->
# replication, with performance on its own runner in parallel) and guarantees
# the chain keeps moving even when individual suites fail.
# Functional chain driver: runs the nine functional suites in a fixed order
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security, with
# performance on its own runner in parallel) and guarantees the chain keeps
# moving even when individual suites fail.
#
# Each suite workflow can still be dispatched standalone (workflow_dispatch);
# only chain-triggered runs forward to the next suite via repository_dispatch,
+6 -37
View File
@@ -284,52 +284,21 @@ jobs:
- name: "Continue functional chain (next: Pool expansion)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup. A failed
# handoff must never pass silently: it retries, then files an alert
# issue in rustfs/backlog so a stalled chain is visible.
# workflow_dispatch runs stop after their own cleanup.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -uo pipefail
set -euo 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-pool' \
-F 'client_payload[from_suite]=heal'; then
echo "dispatched next suite Pool expansion (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 Pool expansion after 3 attempts" >&2
TITLE="[functional][chain] stalled after heal (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
{
echo "The functional chain could not hand off from **heal** to **Pool expansion** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-pool'"
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-pool'"
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
echo "Dispatching next functional suite: Pool expansion"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-pool' \
-F 'client_payload[from_suite]=heal'
- name: Notify on failure
if: failure()
+6 -37
View File
@@ -340,52 +340,21 @@ jobs:
- name: "Continue functional chain (next: Tier)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup. A failed
# handoff must never pass silently: it retries, then files an alert
# issue in rustfs/backlog so a stalled chain is visible.
# workflow_dispatch runs stop after their own cleanup.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -uo pipefail
set -euo 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-tier' \
-F 'client_payload[from_suite]=kms'; then
echo "dispatched next suite Tier (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 Tier after 3 attempts" >&2
TITLE="[functional][chain] stalled after kms (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
{
echo "The functional chain could not hand off from **kms** to **Tier** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-tier'"
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-tier'"
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
echo "Dispatching next functional suite: Tier"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-tier' \
-F 'client_payload[from_suite]=kms'
- name: Notify on failure
if: failure()
+6 -37
View File
@@ -596,52 +596,21 @@ jobs:
- name: "Continue functional chain (next: Security)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup. A failed
# handoff must never pass silently: it retries, then files an alert
# issue in rustfs/backlog so a stalled chain is visible.
# workflow_dispatch runs stop after their own cleanup.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -uo pipefail
set -euo 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-security' \
-F 'client_payload[from_suite]=pool'; then
echo "dispatched next suite Security (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 Security after 3 attempts" >&2
TITLE="[functional][chain] stalled after pool (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
{
echo "The functional chain could not hand off from **pool** to **Security** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-security'"
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-security'"
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
echo "Dispatching next functional suite: Security"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-security' \
-F 'client_payload[from_suite]=pool'
- name: Notify on failure
if: failure()
@@ -1,365 +0,0 @@
# 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.
name: RustFS Replication Test
on:
workflow_dispatch:
inputs:
rustfs_version:
description: 'RustFS release tag to test (e.g. 1.0.0-rc.4-preview.1)'
required: false
default: '1.0.0-rc.4-preview.1'
package_url:
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
required: false
type: string
suite:
description: 'Suite to run (all = bucket REP-* then site SITE-*)'
type: choice
options:
- all
- bucket
- site
default: all
repository_dispatch:
# Chain handoff: dispatched when the security suite finishes. This is the
# last link of the functional chain.
types: [rustfs-chain-replication]
permissions:
contents: read
# The replication suite uses the same shared VMs as the other functional
# tests, so it must serialize with them instead of running in parallel.
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 }}
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
jobs:
replication-test:
runs-on: smoke-testing
# A failed replication run must not break the chain or the workflow: the
# failure is reported to rustfs/backlog instead (see the issue step).
continue-on-error: true
timeout-minutes: 360
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
# 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
openssl version
aws --version
df -h /data | tail -1 || true
- name: Cleanup environment (before)
run: |
set -euo pipefail
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
for node in "${NODES[@]}"; do
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
set -euo pipefail
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
${SUDO} systemctl stop rustfs rustfs-rep2 2>/dev/null || true
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
${SUDO} dpkg -P rustfs
fi
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
${SUDO} rm -rf /data/rustfs-rep2 /var/log/rustfs /var/log/rustfs-rep2 /var/lib/rustfs/kms
'
done
- name: Run replication suite
id: test
continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-replication.log
run: |
set -euo pipefail
chmod +x auto-testing/rustfs-replication-test.sh
PACKAGE_URL='${{ inputs.package_url }}'
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
SUITE='${{ inputs.suite }}'
ARGS=(-y --log-file "${LOG_FILE}")
if [ "${SUITE}" = "all" ] || [ -z "${SUITE}" ] || [ "${SUITE}" = "null" ]; then
ARGS+=(--suite all)
else
ARGS+=(--suite "${SUITE}")
fi
if [ -n "${PACKAGE_URL}" ]; then
ARGS+=(--package-url "${PACKAGE_URL}")
elif [ -n "${RUSTFS_VERSION}" ] && [ "${RUSTFS_VERSION}" != "null" ]; then
ARGS+=(--version "${RUSTFS_VERSION}")
else
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
fi
./auto-testing/rustfs-replication-test.sh "${ARGS[@]}"
- name: Generate report
if: always()
env:
LOG_FILE: /tmp/rustfs-replication.log
REPORT_FILE: /tmp/rustfs-replication-report.md
run: |
set -euo pipefail
PACKAGE_URL='${{ inputs.package_url }}'
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
if [ -n "${PACKAGE_URL}" ]; then
PACKAGE_SOURCE="${PACKAGE_URL}"
elif [ -n "${RUSTFS_VERSION}" ] && [ "${RUSTFS_VERSION}" != "null" ]; then
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
RUSTFS_VERSION_INFO="N/A"
if [ "${#NODES[@]}" -gt 0 ]; then
DETECTED_VERSION="$(ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
"${SSH_USER}@${NODES[0]}" 'rustfs --version' 2>/dev/null | tr -d '\r' | head -n 1 || true)"
if [ -n "${DETECTED_VERSION}" ]; then
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
fi
fi
CASE_TABLE="/tmp/rustfs-replication-cases.md"
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
import re
import sys
log_file, out_file = sys.argv[1], sys.argv[2]
ansi = re.compile(r'\x1b\[[0-9;]*m')
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
rows = []
index = {}
try:
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
for raw in fh:
line = ansi.sub('', raw).strip()
m = start_re.match(line)
if m:
case_id, name = m.group(1), m.group(2)
if case_id not in index:
index[case_id] = len(rows)
rows.append([case_id, name, 'RUNNING'])
continue
m = done_re.match(line)
if m:
status, case_id = m.group(1), m.group(2)
if case_id in index:
rows[index[case_id]][2] = status
else:
rows.append([case_id, case_id, status])
index[case_id] = len(rows) - 1
except FileNotFoundError:
rows = []
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
for _, _, status in rows:
counts[status] = counts.get(status, 0) + 1
with open(out_file, 'w', encoding='utf-8') as out:
out.write('## Case Summary\n\n')
out.write(f"- Total: {len(rows)}\\n")
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
out.write('\\n')
out.write('| Case | Name | Status |\\n')
out.write('| --- | --- | --- |\\n')
for case_id, name, status in rows:
out.write(f'| {case_id} | {name} | {status} |\\n')
PY
{
echo "# RustFS replication test report"
echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}"
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
echo ""
cat "${CASE_TABLE}" || true
echo ""
echo "## Log tail"
echo '```text'
tail -n 200 "${LOG_FILE}" || true
echo '```'
} | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
- name: Upload functional report to dashboard
if: always()
continue-on-error: true
env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
REPORT_FILE: /tmp/rustfs-replication-report.md
SUITE: replication
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
exit 0
fi
DATE="$(date -u +%Y-%m-%d)"
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
else
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
'{message:$msg, content:$content}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi
- name: File failure issue in rustfs/backlog
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
SUITE: 'replication'
SUITE_LABEL: 'Replication (bucket + site)'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-replication-report.md'
LOG_FILE: '/tmp/rustfs-replication.log'
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
exit 0
fi
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
EXISTING="$(gh issue list -R rustfs/backlog --state all \
--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 "- 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 report and logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-replication-${{ github.run_id }}
path: |
/tmp/rustfs-replication.log
/tmp/rustfs-replication-report.md
if-no-files-found: warn
- name: Cleanup environment (after)
if: always()
run: |
set -euo pipefail
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
for node in "${NODES[@]}"; do
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
set -euo pipefail
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
${SUDO} systemctl stop rustfs rustfs-rep2 2>/dev/null || true
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
${SUDO} dpkg -P rustfs
fi
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
${SUDO} rm -rf /data/rustfs-rep2 /var/log/rustfs /var/log/rustfs-rep2
'
done
- name: Chain complete
# Replication is the last link of the functional chain: nothing to
# dispatch after it. This step just records that the chain finished.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
run: |
echo "Functional chain complete: replication (final suite) finished."
echo "from_suite=security trigger=${{ github.event_name }} outcome=${{ steps.test.outcome }}"
- name: Notify on failure
if: failure()
run: |
echo "RustFS replication suite failed"
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
echo "See the uploaded report and log artifacts for details."
+6 -37
View File
@@ -320,52 +320,21 @@ jobs:
- name: "Continue functional chain (next: KMS)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup. A failed
# handoff must never pass silently: it retries, then files an alert
# issue in rustfs/backlog so a stalled chain is visible.
# workflow_dispatch runs stop after their own cleanup.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -uo pipefail
set -euo 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-kms' \
-F 'client_payload[from_suite]=s3'; then
echo "dispatched next suite KMS (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 KMS after 3 attempts" >&2
TITLE="[functional][chain] stalled after s3 (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
{
echo "The functional chain could not hand off from **s3** to **KMS** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-kms'"
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-kms'"
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
echo "Dispatching next functional suite: KMS"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-kms' \
-F 'client_payload[from_suite]=s3'
- name: Notify on failure
if: failure()
+1 -19
View File
@@ -47,7 +47,7 @@ on:
type: boolean
default: true
repository_dispatch:
# Chain handoff: dispatched when the pool expansion suite finishes.
# Chain handoff: dispatched when the pool expansion suite finishes (last link).
types: [rustfs-chain-security]
permissions:
@@ -292,24 +292,6 @@ jobs:
'
done
- name: "Continue functional chain (next: Replication)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
echo "Dispatching next functional suite: Replication"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-replication' \
-F 'client_payload[from_suite]=security'
- name: Notify on failure
if: failure()
run: |
+6 -37
View File
@@ -335,52 +335,21 @@ jobs:
- name: "Continue functional chain (next: Heal)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup. A failed
# handoff must never pass silently: it retries, then files an alert
# issue in rustfs/backlog so a stalled chain is visible.
# workflow_dispatch runs stop after their own cleanup.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -uo pipefail
set -euo 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-heal' \
-F 'client_payload[from_suite]=storage'; then
echo "dispatched next suite Heal (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 Heal after 3 attempts" >&2
TITLE="[functional][chain] stalled after storage (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
{
echo "The functional chain could not hand off from **storage** to **Heal** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-heal'"
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-heal'"
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
echo "Dispatching next functional suite: Heal"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-heal' \
-F 'client_payload[from_suite]=storage'
- name: Notify on failure
if: failure()
+59 -41
View File
@@ -11,6 +11,10 @@ on:
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
required: false
type: string
package_sha256:
description: 'Optional SHA-256 for package_url; mismatch is an infrastructure failure.'
required: false
type: string
rc_sha256:
description: 'Optional SHA-256 for the preinstalled rc binary; mismatch is an infrastructure failure.'
required: false
@@ -71,13 +75,20 @@ jobs:
- name: Checkout auto-testing scripts (with retry)
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
AUTO_TESTING_REF: cxymds/fix-2132-tier-log-isolation
AUTO_TESTING_COMMIT: 02da54dd62110649dc2860fc5fcd9e08d2e9a1ca
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
if gh repo clone rustfs/auto-testing auto-testing -- \
--branch "${AUTO_TESTING_REF}" --single-branch --depth 1 --quiet; then
actual_commit="$(git -C auto-testing rev-parse HEAD)"
if [[ "${actual_commit}" == "${AUTO_TESTING_COMMIT}" ]]; then
echo "auto-testing ${actual_commit} cloned (attempt ${attempt})"
exit 0
fi
echo "auto-testing commit mismatch: expected ${AUTO_TESTING_COMMIT}, got ${actual_commit}" >&2
fi
rm -rf auto-testing
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
@@ -86,6 +97,39 @@ jobs:
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
exit 1
- name: Download exact rc candidate
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
repository: rustfs/rustfs-release-validation
run-id: '33465191972'
name: rc-under-test-33465191972-1
path: ${{ runner.temp }}/issue-2128-rc
github-token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Verify exact rc candidate
env:
RC_BIN: ${{ runner.temp }}/issue-2128-rc/rc
RC_PROVENANCE: ${{ runner.temp }}/issue-2128-rc/rc-build.json
RC_EXPECTED_COMMIT: f6b9b509a60ef172a2b037d638c2cac46e762129
RC_EXPECTED_SHA256: 3d128d99f05403f4028c7c9ae24b03d66a3e98f2090e66cb7f9f45a9e11fdce1
run: |
set -euo pipefail
test -s "${RC_BIN}"
test -s "${RC_PROVENANCE}"
jq -e \
--arg commit "${RC_EXPECTED_COMMIT}" \
--arg digest "${RC_EXPECTED_SHA256}" \
'.repository == "rustfs/cli"
and .requestedCommit == $commit
and .resolvedCommit == $commit
and .binarySha256 == $digest
and .target == "x86_64-unknown-linux-gnu"' \
"${RC_PROVENANCE}" >/dev/null
actual_sha256="$(sha256sum -- "${RC_BIN}" | awk '{print $1}')"
test "${actual_sha256}" = "${RC_EXPECTED_SHA256}"
chmod 0555 "${RC_BIN}"
"${RC_BIN}" --version
- name: Show environment
run: |
uname -a
@@ -146,13 +190,15 @@ jobs:
continue-on-error: true
env:
PACKAGE_URL_INPUT: ${{ inputs.package_url }}
PACKAGE_SHA256_INPUT: ${{ inputs.package_sha256 }}
RUSTFS_VERSION_INPUT: ${{ inputs.rustfs_version }}
run: |
set -euo pipefail
LOG_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier.log"
chmod +x auto-testing/rustfs-tier-test.sh
RC_BIN="$(command -v rc)"
RC_BIN="${RUNNER_TEMP}/issue-2128-rc/rc"
PACKAGE_URL="${PACKAGE_URL_INPUT}"
PACKAGE_SHA256="${PACKAGE_SHA256_INPUT}"
RUSTFS_VERSION="${RUSTFS_VERSION_INPUT}"
ARGS=(
--all-topologies
@@ -164,6 +210,9 @@ jobs:
if [ -n "${RUSTFS_EXPECTED_RC_SHA256}" ]; then
ARGS+=(--expected-rc-sha256 "${RUSTFS_EXPECTED_RC_SHA256}")
fi
if [ -n "${PACKAGE_SHA256}" ]; then
ARGS+=(--sha256 "${PACKAGE_SHA256}")
fi
if [ -n "${PACKAGE_URL}" ]; then
ARGS+=(--package-url "${PACKAGE_URL}")
elif [ -n "${RUSTFS_VERSION}" ]; then
@@ -430,52 +479,21 @@ jobs:
- name: "Continue functional chain (next: Storage engine)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup. A failed
# handoff must never pass silently: it retries, then files an alert
# issue in rustfs/backlog so a stalled chain is visible.
# workflow_dispatch runs stop after their own cleanup.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -uo pipefail
set -euo 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-storage' \
-F 'client_payload[from_suite]=tier'; then
echo "dispatched next suite Storage engine (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 Storage engine after 3 attempts" >&2
TITLE="[functional][chain] stalled after tier (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
{
echo "The functional chain could not hand off from **tier** to **Storage engine** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-storage'"
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-storage'"
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
echo "Dispatching next functional suite: Storage engine"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-storage' \
-F 'client_payload[from_suite]=tier'
- name: Notify on failure
if: failure()
+6 -37
View File
@@ -388,52 +388,21 @@ jobs:
- name: "Continue functional chain (next: S3 compatibility)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup. A failed
# handoff must never pass silently: it retries, then files an alert
# issue in rustfs/backlog so a stalled chain is visible.
# workflow_dispatch runs stop after their own cleanup.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -uo pipefail
set -euo 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-s3' \
-F 'client_payload[from_suite]=upgrade'; then
echo "dispatched next suite S3 compatibility (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 S3 compatibility after 3 attempts" >&2
TITLE="[functional][chain] stalled after upgrade (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
{
echo "The functional chain could not hand off from **upgrade** to **S3 compatibility** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-s3'"
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-s3'"
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
echo "Dispatching next functional suite: S3 compatibility"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-s3' \
-F 'client_payload[from_suite]=upgrade'
- name: Notify on failure
if: failure()
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: overtrue/repo-visuals-action@fd79cba437ecfac933d00a69add17eb95d3939c3 # v1.3.1
- uses: overtrue/repo-visuals-action@72f34d24769ff5d341956da2f23952594ef2f1e2 # v1.3.0
with:
github-token: ${{ github.token }}
output-branch: star-history
+3 -2
View File
@@ -57,6 +57,9 @@ docs/*
!docs/operations/**
!docs/testing/
!docs/testing/**
docs/heal-scanner-logging-governance.md
docs/benchmark/rustfs-target-bench/
docs/benchmark/*.md
.codegraph/*
.docker/test/compat/data/*
.docker/test/compat/kms/*
@@ -80,8 +83,6 @@ worktrees/*
# Local AI-agent review artifacts (omo evidence dumps)
.omo/
# Legacy per-tool skill dir; skills live in .agents/skills (shared by all agents)
.mimocode/
# insta scratch files; the accepted .snap files ARE the assertions and are committed
*.snap.new
-1
View File
@@ -86,7 +86,6 @@ This file contains repository-wide rules. Use the nearest subdirectory
- CI gates: `.github/workflows/ci.yml`.
- PR format: `.github/pull_request_template.md`.
- Architecture routing: `ARCHITECTURE.md` and `docs/architecture/README.md`.
- Knowledge-base index and documentation rules: `docs/architecture/README.md`.
- Agent skills: `.agents/skills/*/SKILL.md`.
Do not commit one-shot plans, trackers, migration ledgers, benchmark snapshots,
+1 -1
View File
@@ -62,7 +62,7 @@ rustfs/ # Workspace root (virtual manifest)
│ ├── utils/ # Pure utility functions
│ ├── ... # (see "Crate Reference" below)
│ └── e2e_test/ # End-to-end integration tests
└── docs/ # Agent knowledge base: contracts, runbooks, testing rules (index: docs/architecture/README.md)
└── docs/ # Design documents and analysis
```
### Main Crate Layers (`rustfs/src/`)
-1
View File
@@ -27,7 +27,6 @@ make build-docker BUILD_OS=ubuntu22.04
## Where to look (do not duplicate here)
- Agent knowledge base index and doc-writing rules: [docs/architecture/README.md](docs/architecture/README.md)
- Crate membership: `Cargo.toml` `[workspace].members`
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
Generated
+46 -45
View File
@@ -347,9 +347,9 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "arrow"
version = "59.3.0"
version = "59.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c14b3d39f306bc28fd639d59f06e17a0f377d0021e1b7e9054e4d6fedc98774"
checksum = "61d285d16bce7d0be61912f7928342b673067b6b7d7ef6cc179258ba7de1fecf"
dependencies = [
"arrow-arith",
"arrow-array",
@@ -368,9 +368,9 @@ dependencies = [
[[package]]
name = "arrow-arith"
version = "59.3.0"
version = "59.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2961626677665b2195eb59242af4c7befe7b8737ca2050295389362380104e"
checksum = "757ef1836251e88222542a7da2623bc1c9cb9e20afefa6db2c41e79991cd91d4"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -382,9 +382,9 @@ dependencies = [
[[package]]
name = "arrow-array"
version = "59.3.0"
version = "59.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e5f6adeffdf587d7a31db5d2266189624b526730cd3627f9ff9fedae97ad584"
checksum = "bc9a4a4b2b5ecd0e04df03471661cb61f28bed3c7fd50994715129b01b2edb97"
dependencies = [
"ahash",
"arrow-buffer",
@@ -401,9 +401,9 @@ dependencies = [
[[package]]
name = "arrow-buffer"
version = "59.3.0"
version = "59.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "097d193003ce7995d5d087089069ec2a6e0187faf5a6f8c9f38af2645d987182"
checksum = "c12b576ef18c1deb80925a248b25ad84f419198d791b8e293fc6aaa60441fe90"
dependencies = [
"bytes",
"half",
@@ -413,9 +413,9 @@ dependencies = [
[[package]]
name = "arrow-cast"
version = "59.3.0"
version = "59.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "635c9c635668ad26adf76cce8fb276c4be7cf06e63bd516de7da514f9680ee53"
checksum = "68338a9096a5dc9bc11927c58c43a8526d96bf6abd2012ef6c0c9f505991cc79"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -435,9 +435,9 @@ dependencies = [
[[package]]
name = "arrow-csv"
version = "59.3.0"
version = "59.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c2ebf8d631e79b02c16cf5ae860561272c26024ec88fce389a56aaddd558e86"
checksum = "25011b52b346407d497ef0030e12b45e4f2d0cc279efc09c4f3d09106db30e36"
dependencies = [
"arrow-array",
"arrow-cast",
@@ -450,9 +450,9 @@ dependencies = [
[[package]]
name = "arrow-data"
version = "59.3.0"
version = "59.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ba2f832eaeca24b8f26143dba750e42ee4ab51cf7d65e701ca9607cfda9f358"
checksum = "723fe4aeed7604e00b9883a465af4ff0a0e6c44c03e41a68c3d1cbc403e0e44d"
dependencies = [
"arrow-buffer",
"arrow-schema",
@@ -463,9 +463,9 @@ dependencies = [
[[package]]
name = "arrow-ipc"
version = "59.3.0"
version = "59.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcc41681ea80f521df14c36725b74d4c60702c47f0793af2be469c04527e2599"
checksum = "149437b14371f5b9ec60f5ddc751483ae99d7a7072653c0075e5e469156eea7b"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -479,9 +479,9 @@ dependencies = [
[[package]]
name = "arrow-json"
version = "59.3.0"
version = "59.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2f57d7a81969f24ccf80809587b76c09897e6f829d2d65a5976bfb3218851f1"
checksum = "f18b9123ccfec418a663f821c9a034af339711678c11ffe00d3ec07da5ff9f7e"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -504,9 +504,9 @@ dependencies = [
[[package]]
name = "arrow-ord"
version = "59.3.0"
version = "59.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c900759f3bd8354fd4196bc4403eee846894dc2adf66b4225472006a0bf18c5"
checksum = "e6c08dff0686cf23ca4f562803f191ccbeb726dbae6309cd4b4aaf65e0f2c979"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -517,9 +517,9 @@ dependencies = [
[[package]]
name = "arrow-row"
version = "59.3.0"
version = "59.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c6425032e28266e3fc4ff680805e57e670d6ea92473043f3e65b7ed6ac79f2"
checksum = "bbec439386df71ad570e6758a946111322b9e9dc8db83b5527321f0b4c9119c2"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -530,9 +530,9 @@ dependencies = [
[[package]]
name = "arrow-schema"
version = "59.3.0"
version = "59.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10fab8d4563491417ba801fab29d205104d20d4bdf37bda6cd1cf425cff598cd"
checksum = "e6fed2ca0d1eade57e811cbe73b98ad50cc08a1183e13b2d2aa43a7df593f40e"
dependencies = [
"serde_core",
"serde_json",
@@ -540,9 +540,9 @@ dependencies = [
[[package]]
name = "arrow-select"
version = "59.3.0"
version = "59.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc58569193c2525915f3cc6310edba3792f1200f65d6e9ed330aa33e691493b8"
checksum = "466b19cf75130b891dc1b23a84b343c714c62c64c9c62e365c76aa0ff90a53fb"
dependencies = [
"ahash",
"arrow-array",
@@ -554,9 +554,9 @@ dependencies = [
[[package]]
name = "arrow-string"
version = "59.3.0"
version = "59.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e0813f3c35c1cfea65e14c20a953440f7783c088b7ad2d0db162ccdeefcec14"
checksum = "c838a25bb3691e919e0f617616ac51a4ff8517a952e29ca133cf0c22b2ce65b1"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -919,9 +919,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.18.1"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -930,9 +930,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.45.0"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
dependencies = [
"cc",
"cmake",
@@ -6106,9 +6106,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.23"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed"
checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96"
dependencies = [
"libc",
]
@@ -7014,7 +7014,7 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [
"base64 0.22.1",
"base64 0.21.7",
"chrono",
"getrandom 0.2.17",
"http 1.5.0",
@@ -7528,9 +7528,9 @@ dependencies = [
[[package]]
name = "parquet"
version = "59.3.0"
version = "59.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff322f54b1a0f9288e614ed1f2d329b380af5476420db19f46ffb865e1163d73"
checksum = "7065842956a20c2a536924ce8e4d9955f7422451511b9eb7500d7bfe5077e59c"
dependencies = [
"ahash",
"arrow-array",
@@ -8241,7 +8241,7 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck 0.5.0",
"heck 0.4.1",
"itertools 0.14.0",
"log",
"multimap",
@@ -8261,7 +8261,7 @@ version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
dependencies = [
"heck 0.5.0",
"heck 0.4.1",
"itertools 0.14.0",
"log",
"multimap",
@@ -10091,6 +10091,7 @@ dependencies = [
"time",
"tokio",
"tracing",
"url",
"uuid",
]
@@ -11083,7 +11084,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "s3s"
version = "0.15.0"
source = "git+https://github.com/rustfs/s3s.git?rev=bdcb6259339c41369f9f1c60e3a42b5ab8da607b#bdcb6259339c41369f9f1c60e3a42b5ab8da607b"
source = "git+https://github.com/rustfs/s3s.git?rev=28e9ebb23dd2fb7d667084f34121b4aa4807a5c6#28e9ebb23dd2fb7d667084f34121b4aa4807a5c6"
dependencies = [
"arc-swap",
"arrayvec",
@@ -11141,7 +11142,7 @@ dependencies = [
[[package]]
name = "s3s-rfc2047"
version = "0.16.0-alpha.1"
source = "git+https://github.com/rustfs/s3s.git?rev=bdcb6259339c41369f9f1c60e3a42b5ab8da607b#bdcb6259339c41369f9f1c60e3a42b5ab8da607b"
source = "git+https://github.com/rustfs/s3s.git?rev=28e9ebb23dd2fb7d667084f34121b4aa4807a5c6#28e9ebb23dd2fb7d667084f34121b4aa4807a5c6"
dependencies = [
"base64-simd",
"thiserror 2.0.20",
@@ -11150,7 +11151,7 @@ dependencies = [
[[package]]
name = "s3s-sigv2"
version = "0.16.0-alpha.1"
source = "git+https://github.com/rustfs/s3s.git?rev=bdcb6259339c41369f9f1c60e3a42b5ab8da607b#bdcb6259339c41369f9f1c60e3a42b5ab8da607b"
source = "git+https://github.com/rustfs/s3s.git?rev=28e9ebb23dd2fb7d667084f34121b4aa4807a5c6#28e9ebb23dd2fb7d667084f34121b4aa4807a5c6"
dependencies = [
"base64-simd",
"hmac 0.13.0",
@@ -11163,7 +11164,7 @@ dependencies = [
[[package]]
name = "s3s-sigv4"
version = "0.16.0-alpha.1"
source = "git+https://github.com/rustfs/s3s.git?rev=bdcb6259339c41369f9f1c60e3a42b5ab8da607b#bdcb6259339c41369f9f1c60e3a42b5ab8da607b"
source = "git+https://github.com/rustfs/s3s.git?rev=28e9ebb23dd2fb7d667084f34121b4aa4807a5c6#28e9ebb23dd2fb7d667084f34121b4aa4807a5c6"
dependencies = [
"arrayvec",
"base64-simd",
@@ -11786,9 +11787,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.16.0"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
dependencies = [
"serde",
]
+2 -2
View File
@@ -307,11 +307,11 @@ rustify = { version = "0.7", default-features = false }
rustix = { version = "1.1.4" }
rust-embed = { version = "8.12.0" }
rustc-hash = { version = "2.1.3" }
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "bdcb6259339c41369f9f1c60e3a42b5ab8da607b", version = "0.15.0", features = ["minio"] }
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "28e9ebb23dd2fb7d667084f34121b4aa4807a5c6", version = "0.15.0", features = ["minio"] }
serial_test = "4.0.1"
shadow-rs = { default-features = false, version = "2.0.0" }
siphasher = "1.0.3"
smallvec = { version = "1.16.0" }
smallvec = { version = "1.15.2" }
compact_str = "0.10.0"
snap = "1.1.2"
starshard = { version = "2.3.0" }
+10 -27
View File
@@ -48,33 +48,16 @@ Unlike other storage systems, RustFS is released under the permissible Apache 2.
- **Open Source**: Licensed under Apache 2.0, encouraging unrestricted community contributions and commercial usage.
- **User-Friendly**: Designed with simplicity in mind for easy deployment and management.
Status legend: ✅ Available — shipped and covered by CI gates; 🧪 Preview — shipped behind an opt-in flag or with a bounded compatibility claim.
| Feature | Status | Feature | Status |
| :------------------------------- | :----------- | :--------------------------------- | :----------- |
| **S3 Core Features** | ✅ Available | **Distributed Mode** | ✅ Available |
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
| **Versioning** | ✅ Available | **Bitrot Protection** | ✅ Available |
| **Object Lock (WORM)** | ✅ Available | **Healing & Scanner** | ✅ Available |
| **Server-Side Encryption** | ✅ Available | **Pool Expansion / Decommission** | ✅ Available |
| **RustFS KMS** | ✅ Available | **Bucket Replication** | ✅ Available |
| **Lifecycle Management (ILM)** | ✅ Available | **Site Replication** | ✅ Available |
| **ILM Tiering (Remote S3)** | ✅ Available | **Bucket Quota** | ✅ Available |
| **S3 Select** | ✅ Available | **Event Notifications** | ✅ Available |
| **S3 Tables (Iceberg REST)** | 🧪 Preview | **Audit Logging** | ✅ Available |
| **IAM / Policies** | ✅ Available | **Logging & Observability** | ✅ Available |
| **OIDC / SSO** | ✅ Available | **Web Console** | ✅ Available |
| **Keystone Auth** | ✅ Available | **K8s Helm Charts** | ✅ Available |
| **Swift API** | ✅ Available | **FTPS / WebDAV** | ✅ Available |
| **Multi-Tenancy** | ✅ Available | **SFTP** | ✅ Available |
| **MinIO On-Disk Compatibility** | 🧪 Preview | | |
Notes:
- **RustFS KMS**: Vault (KV2 / Transit) and AWS KMS backends are supported for production. The `Local` and `Static` backends are for development and testing only. See [KMS backend security properties](docs/operations/kms-backend-security.md).
- **Swift API / SFTP**: opt-in cargo features (`--features swift`, `--features sftp`, or `full`). FTPS and WebDAV are enabled in the default build.
- **S3 Tables**: ships as an Iceberg REST Catalog with automated PyIceberg and DuckDB coverage; other engines and vendor profiles carry bounded claims listed in the [S3 Tables support matrix](docs/architecture/s3-tables-support-matrix.md).
- **MinIO On-Disk Compatibility**: gated behind the `rio-v2` feature and not part of the default build. Objects MinIO encrypted are not readable by RustFS. See [MinIO file-format interoperability](docs/architecture/minio-file-format-compat.md).
| Feature | Status | Feature | Status |
| :---------------------- | :----------- | :----------------------- | :--------------- |
| **S3 Core Features** | ✅ Available | **Bitrot Protection** | ✅ Available |
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
| **Versioning** | ✅ Available | **Bucket Replication** | ✅ Available |
| **Logging** | ✅ Available | **Lifecycle Management** | 🚧 Under Testing |
| **Event Notifications** | ✅ Available | **Distributed Mode** | 🚧 Under Testing |
| **K8s Helm Charts** | ✅ Available | **RustFS KMS** | 🚧 Under Testing |
| **Keystone Auth** | ✅ Available | **Multi-Tenancy** | ✅ Available |
| **Swift API** | ✅ Available | **Swift Metadata Ops** | 🚧 Partial |
## RustFS vs MinIO Performance
+7 -7
View File
@@ -233,8 +233,8 @@ spawn error. Install the pinned CI version before running their profiles.
[`src/policy/README.md`](src/policy/README.md),
[`src/protocols/README.md`](src/protocols/README.md),
[`src/reliant/README.md`](src/reliant/README.md)
- Per-module counts: `cargo nextest list -p e2e_test --profile <profile>`
(one-liner in [`docs/testing/README.md`](../../docs/testing/README.md))
- Authoritative per-module counts:
[`docs/testing/e2e-suite-inventory.md`](../../docs/testing/e2e-suite-inventory.md)
- Test pyramid & flake policy: [`docs/testing/README.md`](../../docs/testing/README.md)
## CI smoke subset (`--profile e2e-smoke`)
@@ -271,12 +271,12 @@ Note on `#[serial]`: nextest runs each test in its own process, so
parallel-safe by construction (random port + isolated temp dir), which the
current subset is.
### Test inventory
### Authoritative test inventory
Per-module counts are not committed; list them with
`cargo nextest list -p e2e_test --profile <profile>` (the result is
platform-dependent because some modules are linux-only; the `jq` one-liner is
in `docs/testing/README.md`). When a profile membership change is
`docs/testing/e2e-suite-inventory.md` records the per-module test counts as
listed by `cargo nextest list -p e2e_test`. Regenerate it when adding or
moving e2e tests so acceptance numbers in the test-strategy issues
(backlog#1147#1155) stay auditable. When a profile membership change is
intentional, review its JSON listing before updating the matching
`.config/e2e-*-selection.txt` test-ID digest. Update only the platform that
produced the listing:
+1 -112
View File
@@ -22,7 +22,7 @@ mod tests {
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart, ServerSideEncryption};
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use md5::{Digest as Md5Digest, Md5};
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
@@ -260,117 +260,6 @@ mod tests {
info!("PASSED: HeadObject returns stored SHA256 digest");
}
#[tokio::test]
async fn test_head_object_returns_sse_s3_checksum() {
init_logging();
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server_with_env(
vec![],
&[
("RUSTFS_SSE_S3_MASTER_KEY", "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI="),
("RUSTFS_CONSOLE_ENABLE", "false"),
],
)
.await
.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-sse-s3-checksum-head";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
let put = client
.put_object()
.bucket(bucket)
.key("encrypted.txt")
.body(ByteStream::from_static(b"encrypted checksum"))
.server_side_encryption(ServerSideEncryption::Aes256)
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.send()
.await
.expect("SSE-S3 PutObject with CRC32 failed");
let expected = put.checksum_crc32().expect("PutObject must return CRC32");
let head = client
.head_object()
.bucket(bucket)
.key("encrypted.txt")
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("SSE-S3 HeadObject failed");
assert_eq!(head.checksum_crc32(), Some(expected));
client
.copy_object()
.bucket(bucket)
.key("encrypted-copy.txt")
.copy_source(format!("{bucket}/encrypted.txt"))
.server_side_encryption(ServerSideEncryption::Aes256)
.send()
.await
.expect("SSE-S3 CopyObject failed");
let copy_head = client
.head_object()
.bucket(bucket)
.key("encrypted-copy.txt")
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("SSE-S3 copied HeadObject failed");
assert_eq!(copy_head.checksum_crc32(), Some(expected));
let multipart_key = "encrypted-multipart.txt";
let create = client
.create_multipart_upload()
.bucket(bucket)
.key(multipart_key)
.server_side_encryption(ServerSideEncryption::Aes256)
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.send()
.await
.expect("SSE-S3 CreateMultipartUpload with CRC32 failed");
let upload_id = create.upload_id().expect("CreateMultipartUpload must return an upload ID");
let part = client
.upload_part()
.bucket(bucket)
.key(multipart_key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from_static(b"encrypted multipart checksum"))
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.send()
.await
.expect("SSE-S3 UploadPart with CRC32 failed");
let completed_part = CompletedPart::builder()
.part_number(1)
.e_tag(part.e_tag().expect("UploadPart must return an ETag"))
.checksum_crc32(part.checksum_crc32().expect("UploadPart must return CRC32"))
.build();
let complete = client
.complete_multipart_upload()
.bucket(bucket)
.key(multipart_key)
.upload_id(upload_id)
.multipart_upload(CompletedMultipartUpload::builder().parts(completed_part).build())
.send()
.await
.expect("SSE-S3 CompleteMultipartUpload with CRC32 failed");
let expected_multipart = complete.checksum_crc32().expect("CompleteMultipartUpload must return CRC32");
let multipart_head = client
.head_object()
.bucket(bucket)
.key(multipart_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("SSE-S3 multipart HeadObject failed");
assert_eq!(multipart_head.checksum_crc32(), Some(expected_multipart));
}
/// Multipart upload with checksum: CreateMultipartUpload, UploadPart(s) with checksum_sha256, CompleteMultipartUpload; then GetObject verifies content.
/// Uses part size >= 5MB (server minimum) for two parts.
#[tokio::test]
-45
View File
@@ -1699,51 +1699,6 @@ impl RustFSTestClusterEnvironment {
process.wait()?;
Ok(())
}
/// Gracefully stop one cluster node and wait for its process to exit.
///
/// This is intentionally separate from [`Self::stop_node`]: the latter is
/// a hard kill used by crash-recovery tests, while this path lets RustFS
/// complete its normal shutdown hooks before a test restarts the node.
pub async fn stop_node_gracefully(&mut self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.ensure_node_index(node_idx)?;
#[cfg(unix)]
{
let Some(process) = self.nodes[node_idx].process.as_ref() else {
return Ok(());
};
let pid = process.id().to_string();
let signal_status = Command::new("kill").args(["-TERM", &pid]).status()?;
if !signal_status.success() {
return Err(format!("failed to send SIGTERM to cluster node {node_idx} (pid {pid})").into());
}
let mut process = self.nodes[node_idx]
.process
.take()
.ok_or_else(|| format!("cluster node {node_idx} process disappeared while stopping"))?;
let deadline = std::time::Instant::now() + Duration::from_secs(45);
loop {
if let Some(status) = process.try_wait()? {
info!("Cluster node {} stopped gracefully with {}", node_idx, status);
return Ok(());
}
if std::time::Instant::now() >= deadline {
let _ = process.kill();
let _ = process.wait();
return Err(format!("cluster node {node_idx} did not stop gracefully within 45 seconds").into());
}
sleep(Duration::from_millis(100)).await;
}
}
#[cfg(not(unix))]
{
let _ = node_idx;
Err("graceful cluster-node stop is only supported on Unix E2E hosts".into())
}
}
}
impl Drop for RustFSTestClusterEnvironment {
+4 -8
View File
@@ -1,15 +1,11 @@
# Programmable fake S3 target
This module is the shared failure-injection boundary for replication end-to-end tests and the programmable external source for on-demand-migration (ODM) tests. It runs an in-process, path-style S3 endpoint backed by `s3s`; no production crate depends on it.
This module is the shared failure-injection boundary for replication end-to-end tests. It runs an in-process, path-style S3 endpoint backed by `s3s`; no production crate depends on it.
`FakeS3Target::start()` creates the listener. Add target buckets with `create_bucket`, point a RustFS remote target at `address()`, use `FAKE_ACCESS_KEY` / `FAKE_SECRET_KEY`, then enqueue per-operation faults with `inject`. Faults for one operation are consumed in FIFO order and do not consume faults queued for another operation. A fault is consumed only after `s3s` verifies the full request signature, so anonymous, other-access-key, and bad-signature traffic cannot disturb a script.
Supported data operations are HeadBucket, GetBucketVersioning, ListObjectsV2, PUT/GET/HEAD/DELETE Object, Get/Put/Delete ObjectTagging (tags live per version; Put replaces the whole set, Delete clears it), and create/upload/complete/abort multipart upload. `create_bucket` models general-purpose buckets in S3's shared global namespace; account-regional namespace buckets and their `-an` names are intentionally out of scope. Buckets created with `create_bucket` are versioned: PUT creates a version, DELETE without `versionId` creates a delete marker, and DELETE with `versionId` removes exactly that version. Internal source version IDs must be UUIDs and are stored canonically. Source mtime is honored only for source-replication PUT/DELETE requests; absent or invalid values use receipt time, matching RustFS, while multipart completion always uses receipt time. Replicated versions are ordered newest-first by source mtime so late older versions and delete markers do not become current. Equal mtimes prefer objects over delete markers, then canonical UUID order; RustFS's internal FileMeta signature tie-break is intentionally out of scope because it is not part of the target S3 protocol. Multipart part numbers follow S3's `1..=10000` range, and every completed part except the final part must be at least 5 MiB.
Supported data operations are HeadBucket, GetBucketVersioning, PUT/GET/HEAD/DELETE Object, Get/Put/Delete ObjectTagging (tags live per version; Put replaces the whole set, Delete clears it), and create/upload/complete/abort multipart upload. `create_bucket` models general-purpose buckets in S3's shared global namespace; account-regional namespace buckets and their `-an` names are intentionally out of scope. Buckets are versioned: PUT creates a version, DELETE without `versionId` creates a delete marker, and DELETE with `versionId` removes exactly that version. Internal source version IDs must be UUIDs and are stored canonically. Source mtime is honored only for source-replication PUT/DELETE requests; absent or invalid values use receipt time, matching RustFS, while multipart completion always uses receipt time. Replicated versions are ordered newest-first by source mtime so late older versions and delete markers do not become current. Equal mtimes prefer objects over delete markers, then canonical UUID order; RustFS's internal FileMeta signature tie-break is intentionally out of scope because it is not part of the target S3 protocol. Multipart part numbers follow S3's `1..=10000` range, and every completed part except the final part must be at least 5 MiB.
`create_bucket_with_mode(name, BucketMode::Unversioned)` models a plain migration source: PUT overwrites in place, DELETE removes the key without a delete marker, GetBucketVersioning reports no status, and no `x-amz-version-id` is returned by PUT, GET, HEAD, tagging, or multipart completion. The only `versionId` such a bucket accepts is `null`; any other value is rejected with `InvalidArgument`. The mode is fixed at creation.
Fault actions cover HTTP 401/403/503 responses, pre-dispatch delay, connection abort when a logical request-body threshold is reached, streaming slow drain, and a deliberately wrong response ETag (including multipart-complete XML). `requests()` returns the ordered, credential-free request journal for assertions. Each record also journals a `ProxyHeaderSnapshot` — the read-proxy anti-loop marker (`x-{rustfs,minio}-source-proxy-request`), the replication-check exemption header, and the client SSE-C header family (algorithm and key-MD5 values; for the key itself only its presence) — so proxy tests can pin the exact wire contract.
ListObjectsV2 lists current versions only (a key whose newest version is a delete marker is hidden) in byte order and supports `prefix`, `delimiter`, `max-keys` (clamped to 1000), `start-after`, and `continuation-token`; common prefixes count toward `max-keys`, `IsTruncated` / `NextContinuationToken` / `KeyCount` follow S3, and continuation tokens are opaque. `encoding-type` and `fetch-owner` are accepted but ignored, and ListObjects (v1) is not implemented. GET and HEAD honor `Range` in the `bytes=first-last`, `bytes=first-`, and `bytes=-suffix` forms with a 206 status, exact `Content-Range`, and `Accept-Ranges: bytes`; unsatisfiable ranges answer 416 `InvalidRange` with `Content-Range: bytes */<length>`. PUT and CreateMultipartUpload accept `Content-Type`, `Content-Encoding`, `Content-Disposition`, `Content-Language`, `Cache-Control`, `Expires`, and `x-amz-meta-*` (names stored lowercased), and HEAD/GET replay them verbatim together with `Last-Modified` and the ETag (hex MD5 for single PUTs, `<md5-of-part-md5s>-<parts>` for multipart objects). `put_seed_object` stores an object directly, bypassing the wire, the fault script, and the journal, so a source can be seeded without polluting the assertions a scenario later makes.
Fault actions cover HTTP 401/403/503 responses (`Status`), any 4xx/5xx status paired with the matching S3 error code (`ResponseStatus`), pre-dispatch delay, holding a fully computed successful response before its first byte (`Stall`), connection abort when a logical request-body threshold is reached, GetObject bodies cut off after N bytes while `Content-Length` announces the full size (`TruncateBodyAt`), streaming slow drain, and a deliberately wrong response ETag (including multipart-complete XML). `requests()` returns the ordered, credential-free request journal for assertions and `count_requests(operation, key)` counts entries for one exact key. Each record journals the `Range` and `User-Agent` request headers, the ListObjectsV2 `prefix` and `continuation-token` query values, and a `ProxyHeaderSnapshot` — the read-proxy anti-loop marker (`x-{rustfs,minio}-source-proxy-request`), the replication-check exemption header, and the client SSE-C header family (algorithm and key-MD5 values; for the key itself only its presence) — so proxy tests can pin the exact wire contract.
The listener is loopback-only. It admits at most 64 active connections and two concurrently buffered request bodies; authenticated multipart-complete XML collection and assembly take both body permits. Keep-alive is disabled, request-header reads are bounded to 30 seconds, a parsed request is bounded to 65 seconds, and the complete connection lifetime is bounded to 100 seconds. It retains at most 256 buckets, 4,096 journal entries, 4,096 scripted faults, 4,096 object versions, 256 multipart uploads, and 10,000 multipart parts. Retained identifiers are capped at 1 KiB, user metadata at 2 KiB, and content type and each standard object header at 1 KiB. By default a PUT or uploaded part is capped at 64 MiB and a completed multipart object and all stored object/part data are capped at 128 MiB; `FakeS3Target::start_with_options(FakeS3TargetOptions { max_object_bytes })` raises the object cap up to 256 MiB, and the total budget then becomes twice the object cap (never below 128 MiB). Body drain, body-permit waits, delay, stall, and slow-drain execution are bounded to 30 seconds; each slow-drain slice delay must be below that bound.
The listener is loopback-only. It admits at most 64 active connections and two concurrently buffered request bodies; authenticated multipart-complete XML collection and assembly take both body permits. Keep-alive is disabled, request-header reads are bounded to 30 seconds, a parsed request is bounded to 65 seconds, and the complete connection lifetime is bounded to 100 seconds. It retains at most 256 buckets, 4,096 journal entries, 4,096 scripted faults, 4,096 object versions, 256 multipart uploads, and 10,000 multipart parts. Retained identifiers are capped at 1 KiB, user metadata at 2 KiB, and content type at 1 KiB. A PUT or uploaded part is capped at 64 MiB; a completed multipart object and all stored object/part data are capped at 128 MiB. Body drain, body-permit waits, delay, and slow-drain execution are bounded to 30 seconds; each slow-drain slice delay must be below that bound.
File diff suppressed because it is too large Load Diff
@@ -17,115 +17,15 @@
#[cfg(test)]
mod tests {
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post};
use crate::common::{
FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging,
};
use crate::storage_api::RUSTFS_META_BUCKET;
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging};
use aws_sdk_s3::primitives::ByteStream;
use http::Method;
use std::collections::HashSet;
use std::error::Error;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::Command;
use tokio::net::TcpStream;
use tokio::time::{Duration, Instant, sleep, timeout};
use tracing::info;
const POOL_METADATA_OBJECT: &str = "pool.bin";
struct TcpPortBlackhole {
port: u16,
comment: String,
use_sudo: bool,
active: bool,
}
impl TcpPortBlackhole {
fn install(address: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
let address = address.parse::<SocketAddr>()?;
if !address.ip().is_loopback() {
return Err(format!("refusing to install a test firewall rule for non-loopback address {address}").into());
}
let id = Command::new("id").arg("-u").output()?;
if !id.status.success() {
return Err(format!("failed to determine the test process uid: {}", String::from_utf8_lossy(&id.stderr)).into());
}
let use_sudo = String::from_utf8_lossy(&id.stdout).trim() != "0";
let mut blackhole = Self {
port: address.port(),
comment: format!("rustfs-e2e-{}", uuid::Uuid::new_v4()),
use_sudo,
active: false,
};
blackhole.run_iptables(true)?;
blackhole.active = true;
Ok(blackhole)
}
fn restore(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
if !self.active {
return Ok(());
}
self.run_iptables(false)?;
self.active = false;
Ok(())
}
fn run_iptables(&self, insert: bool) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut command = if self.use_sudo {
let mut command = Command::new("sudo");
command.args(["-n", "iptables"]);
command
} else {
Command::new("iptables")
};
command.args(["-w", "5"]);
if insert {
command.args(["-I", "OUTPUT", "1"]);
} else {
command.args(["-D", "OUTPUT"]);
}
let port = self.port.to_string();
let output = command
.args([
"-p",
"tcp",
"-d",
"127.0.0.1/32",
"--dport",
&port,
"-m",
"comment",
"--comment",
&self.comment,
"-j",
"DROP",
])
.output()?;
if !output.status.success() {
let action = if insert { "install" } else { "remove" };
return Err(format!(
"failed to {action} endpoint blackhole rule for port {}: stdout={}, stderr={}",
self.port,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.into());
}
Ok(())
}
}
impl Drop for TcpPortBlackhole {
fn drop(&mut self) {
if let Err(error) = self.restore() {
eprintln!("failed to remove {} firewall rule during test cleanup: {error}", self.comment);
}
}
}
fn has_file_under(path: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(path) else {
return false;
@@ -191,62 +91,6 @@ mod tests {
.count()
}
async fn assert_all_nodes_list_exact_keys(
clients: &[aws_sdk_s3::Client],
bucket: &str,
expected_keys: &HashSet<String>,
) -> Result<(), Box<dyn Error + Send + Sync>> {
const PAGE_SIZE: i32 = 10;
for (node_index, client) in clients.iter().enumerate() {
let mut listed_keys = Vec::new();
let mut continuation_token = None;
let max_pages = expected_keys.len().div_ceil(PAGE_SIZE as usize) + 1;
let mut page_count = 0;
loop {
page_count += 1;
if page_count > max_pages {
return Err(format!("node {node_index} listing exceeded the bounded {max_pages}-page budget").into());
}
let response = timeout(
Duration::from_secs(15),
client
.list_objects_v2()
.bucket(bucket)
.max_keys(PAGE_SIZE)
.set_continuation_token(continuation_token.clone())
.send(),
)
.await??;
listed_keys.extend(
response
.contents()
.iter()
.filter_map(|object| object.key().map(str::to_owned)),
);
if !response.is_truncated().unwrap_or(false) {
break;
}
let next_token = response
.next_continuation_token()
.filter(|token| Some(*token) != continuation_token.as_deref())
.ok_or_else(|| format!("node {node_index} returned a truncated listing without a new continuation token"))?;
continuation_token = Some(next_token.to_owned());
}
let listed_key_set = listed_keys.iter().cloned().collect::<HashSet<_>>();
assert_eq!(
listed_keys.len(),
listed_key_set.len(),
"node {node_index} returned duplicate keys after recovery: {listed_keys:?}"
);
assert_eq!(
&listed_key_set, expected_keys,
"node {node_index} did not expose the complete recovered namespace"
);
}
Ok(())
}
fn heal_task_status_diagnostic(body: &str) -> String {
let Ok(status) = serde_json::from_str::<serde_json::Value>(body) else {
return body.to_string();
@@ -281,12 +125,11 @@ mod tests {
&& operations["retryingTasks"].as_u64() == Some(0)
}
// Queued low-priority repairs cannot execute while the single admin slot is
// occupied; ownership is determined by active and retrying tasks only.
fn only_admin_heal_is_active(status: &serde_json::Value) -> bool {
let operations = &status["healOperations"];
status["clusterStatusComplete"] == serde_json::Value::Bool(true)
&& status["state"].as_str() == Some("active")
&& operations["queueLength"].as_u64() == Some(0)
&& operations["activeTasks"].as_u64() == Some(1)
&& operations["retryingTasks"].as_u64() == Some(0)
&& operations["activeBySource"]["admin"].as_u64() == Some(1)
@@ -704,144 +547,41 @@ mod tests {
.into())
}
async fn wait_for_scanner_cycle_after(
cluster: &RustFSTestClusterEnvironment,
previous_cycle_end: u64,
) -> Result<u64, Box<dyn Error + Send + Sync>> {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let mut latest_cycle_end = 0;
let mut versions_observed = false;
let mut observations = Vec::with_capacity(cluster.nodes.len());
for (node_index, node) in cluster.nodes.iter().enumerate() {
let (status, body) = timeout(
Duration::from_secs(5),
admin_request(
&node.url,
Method::GET,
"/rustfs/admin/v3/scanner/status",
None,
&cluster.access_key,
&cluster.secret_key,
),
)
.await??;
assert_eq!(status, 200, "scanner status must be available: {body}");
let status: serde_json::Value = serde_json::from_str(&body)?;
assert_eq!(status["enabled"].as_bool(), Some(true), "scanner must stay enabled: {status}");
let metrics = &status["metrics"];
let cycle_end = metrics["last_cycle_end_unix_secs"]
.as_u64()
.ok_or("scanner status is missing its completed-cycle timestamp")?;
let versions_scanned = metrics["versions_scanned"]
.as_u64()
.ok_or("scanner status is missing its version-coverage counter")?;
latest_cycle_end = latest_cycle_end.max(cycle_end);
versions_observed |= versions_scanned > 0;
observations.push(format!(
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}",
metrics["current_cycle"],
metrics["current_cycle_active"],
metrics["leader_lock_state"],
metrics["last_cycle_result"],
));
}
// The coordinator records cycle completion, but remote workers
// record scanned versions. Both witnesses need not share a node.
if latest_cycle_end > previous_cycle_end && versions_observed {
return Ok(latest_cycle_end);
}
if Instant::now() >= deadline {
return Err(format!(
"enabled scanner did not complete an object-scanning cycle after {previous_cycle_end}: {observations:?}"
)
.into());
}
sleep(Duration::from_millis(250)).await;
}
}
// Keep the original unformatted-disk scenario above. This case retains the
// format identity so only the explicit admin task can rebuild missing data.
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_resumes_missing_remote_shards_after_node_restart() -> Result<(), Box<dyn Error + Send + Sync>>
{
run_cluster_root_heal_interruption(InterruptionScenario::IsolatedTargetRestart).await
}
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
{
timeout(
Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundCoordinatorRestart),
)
.await?
}
#[cfg(target_os = "linux")]
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_after_target_endpoint_blackhole() -> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::TargetEndpointBlackhole),
)
.await?
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum InterruptionScenario {
IsolatedTargetRestart,
BackgroundCoordinatorRestart,
TargetEndpointBlackhole,
}
async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> {
let (background_enabled, interruption_node, interruption_kind) = match scenario {
InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"),
InterruptionScenario::BackgroundCoordinatorRestart => (true, 0, "coordinator_restart"),
InterruptionScenario::TargetEndpointBlackhole => (false, 1, "target_endpoint_blackhole"),
};
init_logging();
info!(
event = "heal_interruption_started",
event = "heal_restart_started",
component = "e2e_test",
subsystem = "heal",
background_enabled,
interruption_node,
interruption_kind,
"Starting root-heal interruption test"
"Starting root-heal restart test"
);
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
cluster.set_env("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true");
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
// Heal control uses the first lexicographically sorted grid host.
// Keep that coordinator distinct from the remote target at index 1.
cluster.nodes.sort_by(|left, right| left.url.cmp(&right.url));
cluster.set_env("RUSTFS_HEAL_AUTO_HEAL_ENABLE", background_enabled.to_string());
cluster.set_env("RUSTFS_HEAL_MRF_ENABLE", background_enabled.to_string());
cluster.set_env("RUSTFS_SCANNER_ENABLED", background_enabled.to_string());
if background_enabled {
// Only the scanner cadence is accelerated. Keep normal Heal
// concurrency and every automatic recovery owner enabled.
for &(key, value) in FAST_DATA_USAGE_SCANNER_ENV {
cluster.set_env(key, value);
}
} else {
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_HEALS", "1");
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_PER_SET", "1");
cluster.set_env("RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY", "1");
cluster.set_env("RUSTFS_HEAL_PAGE_PARALLEL_ENABLE", "false");
}
// Keep every node's Heal runtime enabled for normal disk registration.
cluster.set_env("RUSTFS_HEAL_AUTO_HEAL_ENABLE", "false");
cluster.set_env("RUSTFS_HEAL_MRF_ENABLE", "false");
cluster.set_env("RUSTFS_SCANNER_ENABLED", "false");
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_HEALS", "1");
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_PER_SET", "1");
cluster.set_env("RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY", "1");
cluster.set_env("RUSTFS_HEAL_PAGE_PARALLEL_ENABLE", "false");
// Keep all storage nodes' Heal runtimes enabled so their disk services
// complete normal registration after restart. Scanner, auto-heal and
// MRF are disabled; the pre-root idle barrier below drains the direct
// outage-object repair before the explicit admin task starts.
let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG")
.unwrap_or_else(|_| "rustfs::heal::task=info,rustfs=error".to_string());
cluster.set_env("RUST_LOG", server_rust_log);
let log_dir = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR").unwrap_or_else(|_| format!("{}/logs", cluster.temp_dir));
std::fs::create_dir_all(&log_dir)?;
for node_index in 0..cluster.nodes.len() {
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
if let Ok(log_dir) = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR") {
std::fs::create_dir_all(&log_dir)?;
for node_index in 0..cluster.nodes.len() {
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
}
}
cluster.start().await?;
let clients = cluster.create_all_clients()?;
@@ -894,18 +634,6 @@ mod tests {
});
}
let expected_pool_metadata = if background_enabled {
let census = census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
assert!(
census.is_complete(),
"target must hold complete pool metadata before the fault: {census:?}"
);
wait_for_scanner_cycle_after(&cluster, 0).await?;
Some(census)
} else {
None
};
cluster.stop_node(1)?;
std::fs::remove_dir_all(&replaced_disk)?;
std::fs::create_dir_all(
@@ -963,25 +691,25 @@ mod tests {
.find(|index| !outage_peer_erasure_indices.contains(index))
.ok_or("online outage-object shards leave no erasure index for the replacement target")?;
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
if !background_enabled {
// The PUT path may have admitted a direct Internal object repair while
// node 1 was offline. Cancel the isolated bucket path before the target
// returns; otherwise it could rebuild the outage object and invalidate
// the explicit-root ownership assertion below.
let cancel_outage_heal_path = format!("/rustfs/admin/v3/heal/{bucket}?forceStop=true");
let (cancel_status, cancel_body) = admin_request(
&cluster.nodes[0].url,
Method::POST,
&cancel_outage_heal_path,
Some(heal_body.to_string()),
&cluster.access_key,
&cluster.secret_key,
)
.await?;
if !cancel_status.is_success() {
return Err(format!("cancel outage heal failed: {cancel_status} {cancel_body}").into());
}
// The PUT path may have admitted a direct Internal object repair while
// node 1 was offline. Cancel the isolated bucket path before the target
// returns; otherwise it could rebuild the outage object and invalidate
// the explicit-root ownership assertion below.
let cancel_outage_heal_path = format!("/rustfs/admin/v3/heal/{bucket}?forceStop=true");
let (cancel_status, cancel_body) = admin_request(
&cluster.nodes[0].url,
Method::POST,
&cancel_outage_heal_path,
Some(
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#
.to_string(),
),
&cluster.access_key,
&cluster.secret_key,
)
.await?;
if !cancel_status.is_success() {
return Err(format!("cancel outage heal failed: {cancel_status} {cancel_body}").into());
}
cluster.start_node(1).await?;
@@ -996,12 +724,7 @@ mod tests {
);
let recovered: serde_json::Value = serde_json::from_str(&status_body)
.map_err(|err| format!("background heal status is not JSON ({err}): {status_body}"))?;
let ready = if background_enabled {
recovered["clusterStatusComplete"] == serde_json::Value::Bool(true)
} else {
cluster_heal_is_idle(&recovered)
};
if ready {
if cluster_heal_is_idle(&recovered) {
break;
}
if Instant::now() >= recovery_deadline {
@@ -1009,24 +732,23 @@ mod tests {
}
sleep(Duration::from_millis(250)).await;
}
assert_eq!(
matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?,
0,
"non-admin Heal is disabled, so the replacement target must remain empty before the explicit root heal"
);
assert!(
!census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?.has_xl_meta,
"the object written during the outage must be absent before the explicit root heal"
);
let pre_heal_replacement = replacement_recovery_status(&cluster).await?;
if !background_enabled {
assert_eq!(
matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?,
0,
"non-admin Heal is disabled, so the replacement target must remain empty before the explicit root heal"
);
assert!(
!census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?.has_xl_meta,
"the object written during the outage must be absent before the explicit root heal"
);
assert_eq!(
pre_heal_replacement["cluster"]["records"].as_array().map(Vec::len),
Some(0),
"isolated target must not retain an automatic replacement generation: {pre_heal_replacement}"
);
}
assert_eq!(
pre_heal_replacement["cluster"]["records"].as_array().map(Vec::len),
Some(0),
"isolated target must not retain an automatic replacement generation: {pre_heal_replacement}"
);
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
let heal_url = format!("{}/rustfs/admin/v3/heal/?forceStart=true", cluster.nodes[0].url);
let heal_start_body = signed_admin_post(&heal_url, Some(heal_body), &cluster.access_key, &cluster.secret_key).await?;
let heal_start: serde_json::Value = serde_json::from_str(&heal_start_body)
@@ -1042,39 +764,24 @@ mod tests {
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(60);
let partial_deadline = Instant::now() + Duration::from_secs(partial_timeout_secs);
loop {
let pre_interrupt_status = loop {
let status_body = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key).await?;
let active_status: serde_json::Value = serde_json::from_str(&status_body)
.map_err(|err| format!("background heal status is not JSON ({err}): {status_body}"))?;
let active = if background_enabled {
active_status["state"].as_str() == Some("active")
&& active_status["healOperations"]["activeBySource"]["admin"].as_u64() == Some(1)
} else {
only_admin_heal_is_active(&active_status)
};
if active {
break;
if only_admin_heal_is_active(&active_status) {
break active_status;
}
if Instant::now() >= partial_deadline {
return Err(format!("root heal never became active within {partial_timeout_secs}s: {active_status}").into());
}
sleep(Duration::from_millis(50)).await;
}
let (partial_count, partial_manifest) = loop {
// Hash one committed shard to prove progress without letting a
// full-corpus hash pass consume the interruption window.
let materialized = metadata_count(&replaced_disk, bucket, &expected_manifests);
if materialized > 0
&& materialized < expected_manifests.len()
&& let Some(expected) = expected_manifests
.iter()
.find(|expected| object_metadata_exists_on_disk(&replaced_disk, bucket, &expected.key))
&& census_object_version_on_disk(&replaced_disk, bucket, &expected.key, None)?
.matches_manifest(&expected.shard_census)
{
break (materialized, expected);
};
let partial_count = loop {
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
if matching > 0 && matching < expected_manifests.len() {
break matching;
}
if materialized == expected_manifests.len() {
if matching == expected_manifests.len() {
return Err(format!(
"root heal rebuilt all {} baseline objects before the target could be interrupted",
expected_manifests.len()
@@ -1089,195 +796,30 @@ mod tests {
}
sleep(Duration::from_millis(10)).await;
};
let pre_interrupt_status_body = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key).await?;
let pre_interrupt_status: serde_json::Value = serde_json::from_str(&pre_interrupt_status_body)
.map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {pre_interrupt_status_body}"))?;
let pre_interrupt_replacement = replacement_recovery_status(&cluster).await?;
let coordinator_log = std::fs::read_to_string(format!("{log_dir}/node0.log"))?;
assert!(
coordinator_log
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.any(|event| {
event["event"] == "heal_task_state"
&& event["task_id"] == client_token
&& event["heal_type"] == "cluster"
&& event["state"] == "started"
}),
"node 0 must have started the exact admin task before interruption"
);
let pre_interrupt_operations = &pre_interrupt_status["healOperations"];
assert_eq!(
pre_interrupt_operations["activeBySource"]["admin"].as_u64(),
Some(1),
"interruption must occur while the single admin task is active: {pre_interrupt_status}"
);
if !background_enabled {
assert!(
only_admin_heal_is_active(&pre_interrupt_status),
"isolated interruption must retain only the admin task: {pre_interrupt_status}"
);
assert_eq!(
pre_interrupt_replacement["cluster"]["records"].as_array().map(Vec::len),
Some(0),
"root-heal interruption point must not retain an automatic replacement generation: {pre_interrupt_replacement}"
);
}
info!(
event = "heal_interruption_checkpoint",
event = "heal_restart_checkpoint",
component = "e2e_test",
subsystem = "heal",
background_enabled,
interruption_node,
interruption_kind,
partial_metadata_count = partial_count,
verified_key = partial_manifest.key,
"Observed partial rebuild before interruption"
partial_count,
"Verified unique admin owner before target interruption"
);
let target_pid = cluster.nodes[1].process.as_ref().ok_or("target process is not running")?.id();
if scenario == InterruptionScenario::TargetEndpointBlackhole {
let node_pids = cluster
.nodes
.iter()
.map(|node| {
node.process
.as_ref()
.ok_or("cluster process is not running")
.map(std::process::Child::id)
})
.collect::<Result<Vec<_>, _>>()?;
timeout(Duration::from_secs(2), TcpStream::connect(&cluster.nodes[1].address)).await??;
let mut blackhole = TcpPortBlackhole::install(&cluster.nodes[1].address)?;
let blocked_connect = timeout(Duration::from_millis(500), TcpStream::connect(&cluster.nodes[1].address)).await;
assert!(
blocked_connect.is_err(),
"target endpoint connection must time out while the OUTPUT DROP rule is active: {blocked_connect:?}"
);
let stable_window_secs = std::env::var("RUSTFS_HEAL_CHAOS_BLACKHOLE_STABLE_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(2)
.clamp(1, 5);
let blackhole_timeout_secs = std::env::var("RUSTFS_HEAL_CHAOS_BLACKHOLE_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(20)
.clamp(stable_window_secs + 1, 60);
let blackhole_deadline = Instant::now() + Duration::from_secs(blackhole_timeout_secs);
let mut stable_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
let mut stable_since = Instant::now();
loop {
for (node_index, (node, expected_pid)) in cluster.nodes.iter_mut().zip(&node_pids).enumerate() {
let process = node
.process
.as_mut()
.ok_or_else(|| format!("node {node_index} process disappeared"))?;
assert_eq!(process.id(), *expected_pid, "node {node_index} PID changed during endpoint blackhole");
assert!(process.try_wait()?.is_none(), "node {node_index} exited during endpoint blackhole");
}
let current_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
if current_count == expected_manifests.len() {
return Err("root heal completed before the endpoint blackhole became observable".into());
}
if current_count != stable_count {
stable_count = current_count;
stable_since = Instant::now();
}
if stable_since.elapsed() >= Duration::from_secs(stable_window_secs) {
break;
}
if Instant::now() >= blackhole_deadline {
return Err(format!(
"target rebuild never remained stable for {stable_window_secs}s during the endpoint blackhole: last_count={stable_count}, total={}",
expected_manifests.len()
)
.into());
}
sleep(Duration::from_millis(100)).await;
}
let blocked_task_body = timeout(
Duration::from_secs(5),
signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key),
)
.await??;
let blocked_task: serde_json::Value = serde_json::from_str(&blocked_task_body)
.map_err(|err| format!("blackholed task status is not JSON ({err}): {blocked_task_body}"))?;
assert_eq!(
blocked_task["summary"].as_str(),
Some("running"),
"the original admin task must remain resumable during the endpoint blackhole: {blocked_task}"
);
assert!(
census_object_version_on_disk(&replaced_disk, bucket, &partial_manifest.key, None)?
.matches_manifest(&partial_manifest.shard_census),
"the witnessed complete shard must survive the endpoint blackhole"
);
blackhole.restore()?;
timeout(Duration::from_secs(2), TcpStream::connect(&cluster.nodes[1].address)).await??;
info!(
event = "heal_endpoint_blackhole_restored",
component = "e2e_test",
subsystem = "heal",
interruption_kind,
stable_metadata_count = stable_count,
stable_window_secs,
"Restored target endpoint forwarding"
);
} else {
cluster.stop_node(interruption_node)?;
let stopped_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
assert!(
stopped_count > 0 && stopped_count < expected_manifests.len(),
"node {interruption_node} must stop during a partial rebuild, observed before stop={partial_count}, after stop={stopped_count}, total={}",
expected_manifests.len()
);
assert!(
census_object_version_on_disk(&replaced_disk, bucket, &partial_manifest.key, None)?
.matches_manifest(&partial_manifest.shard_census),
"the witnessed complete shard must survive interruption"
);
let unclean_shutdown_marker = Path::new(&cluster.nodes[interruption_node].data_dir)
.join(".rustfs.sys")
.join("unclean-shutdown");
if background_enabled {
assert!(
unclean_shutdown_marker.is_file(),
"background restart must retain the real unclean-shutdown marker"
);
} else {
match std::fs::remove_file(&unclean_shutdown_marker) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(
format!("failed to isolate unclean recovery marker {unclean_shutdown_marker:?}: {error}").into()
);
}
}
}
cluster.start_node(interruption_node).await?;
if interruption_node == 0 {
let target = cluster.nodes[1]
.process
.as_mut()
.ok_or("target process disappeared during coordinator restart")?;
assert_eq!(target.id(), target_pid, "coordinator restart must not replace the target process");
assert!(target.try_wait()?.is_none(), "the target must remain alive during coordinator restart");
cluster.stop_node(1)?;
let stopped_count = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
assert!(
stopped_count > 0 && stopped_count < expected_manifests.len(),
"the target must stop after a partial rebuild, observed before stop={partial_count}, after stop={stopped_count}, total={}",
expected_manifests.len()
);
let unclean_shutdown_marker = replaced_disk.join(".rustfs.sys").join("unclean-shutdown");
match std::fs::remove_file(&unclean_shutdown_marker) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!("failed to isolate unclean recovery marker {unclean_shutdown_marker:?}: {error}").into());
}
}
let scanner_cycle_floor = if background_enabled {
Some(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs())
} else {
None
};
cluster.start_node(1).await?;
let heal_timeout_secs = std::env::var("RUSTFS_HEAL_REPLACED_DISK_TIMEOUT_SECS")
.ok()
@@ -1290,22 +832,13 @@ mod tests {
{
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
let pool_metadata_matches = match &expected_pool_metadata {
Some(expected) => {
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?
.matches_manifest(expected)
}
None => true,
};
if matching == expected_manifests.len() && outage_census.is_complete() && pool_metadata_matches {
if matching == expected_manifests.len() && outage_census.is_complete() {
break;
}
}
if Instant::now() >= heal_deadline {
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
let pool_metadata =
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
let final_status = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key)
.await
.unwrap_or_else(|err| format!("status request failed: {err}"));
@@ -1325,7 +858,7 @@ mod tests {
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
};
return Err(format!(
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
"root heal did not resume after target restart within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, replacement_status={replacement_status}",
expected_manifests.len()
)
.into());
@@ -1352,17 +885,6 @@ mod tests {
"the outage object must be rebuilt into its own missing erasure slot"
);
if let Some(cycle_end) = scanner_cycle_floor {
wait_for_scanner_cycle_after(&cluster, cycle_end).await?;
}
let mut expected_keys = expected_manifests
.iter()
.map(|manifest| manifest.key.clone())
.collect::<HashSet<_>>();
assert!(expected_keys.insert(outage_key.to_string()));
assert_all_nodes_list_exact_keys(&clients, bucket, &expected_keys).await?;
let target_client = cluster.create_s3_client(1)?;
for expected in &expected_manifests {
let response = target_client.get_object().bucket(bucket).key(&expected.key).send().await?;
@@ -1392,30 +914,6 @@ mod tests {
let task_status_body = signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key).await?;
let task_status: serde_json::Value = serde_json::from_str(&task_status_body)
.map_err(|err| format!("heal task status is not JSON ({err}): {task_status_body}"))?;
if interruption_node == 0 {
// Admin tasks are process-local. Physical and queue convergence
// above establish recovery; a lost task must not report success.
assert_eq!(
task_status["summary"].as_str(),
Some("notFound"),
"interrupted task status: {task_status}"
);
assert_eq!(
task_status["detail"].as_str(),
Some("heal task not found or expired"),
"interrupted admin task must be explicitly unavailable: {task_status}"
);
info!(
event = "heal_interruption_recovered",
component = "e2e_test",
subsystem = "heal",
interruption_node,
interruption_kind,
task_state = "not_found",
"Physical recovery completed after coordinator restart"
);
return Ok(());
}
if task_status["summary"].as_str() != Some("finished") {
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
}
@@ -560,12 +560,18 @@ async fn test_multipart_encryption_type(
.set_parts(Some(completed_parts))
.build();
let complete_request = s3_client
let mut complete_request = s3_client
.complete_multipart_upload()
.bucket(bucket)
.key(object_key)
.upload_id(upload_id)
.multipart_upload(completed_multipart_upload);
if matches!(encryption_type, EncryptionType::SSEC) {
complete_request = complete_request
.sse_customer_algorithm("AES256")
.sse_customer_key(sse_c_key.as_ref().unwrap())
.sse_customer_key_md5(sse_c_md5.as_ref().unwrap());
}
let _complete_output = complete_request.send().await?;
// Download and verify
+1 -8
View File
@@ -23,17 +23,10 @@ pub mod common;
#[cfg(test)]
pub mod chaos;
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8)
// and on-demand-migration source scenarios (backlog#2151).
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8).
#[cfg(test)]
pub mod fake_s3_target;
// On-demand migration (backlog#2147): shared two-server environment, admin
// wrappers, and the harness self-test (backlog#2151). Behavior scenarios are
// added by later ODM tasks.
#[cfg(test)]
pub mod on_demand_migration;
// Socket-level network fault-injection proxy for black-box cluster tests
// (backlog#1325 network fault-injection block): latency / blackhole / one-way
// partition on the wire between nodes. Serves #1312/#1319 (lock-plane one-way
+2 -64
View File
@@ -225,8 +225,8 @@ fn encode_unsigned_aws_chunked_with_sha256_trailer(decoded: &[u8]) -> Vec<u8> {
let checksum = sha256_base64(decoded);
let mut encoded = format!("{:x}\r\n", decoded.len()).into_bytes();
encoded.extend_from_slice(decoded);
encoded.extend_from_slice(b"\r\n0\r\n");
encoded.extend_from_slice(format!("x-amz-checksum-sha256:{checksum}\r\n\r\n").as_bytes());
encoded.extend_from_slice(b"\r\n0\r\n\r\n");
encoded.extend_from_slice(format!("x-amz-checksum-sha256:{checksum}").as_bytes());
encoded
}
@@ -549,68 +549,6 @@ async fn tampered_upload_part_payload_is_rejected() -> Result<(), Box<dyn std::e
Ok(())
}
/// s3s v0.16 validates the aws-chunked decoded length while RustFS consumes the
/// body stream. Mismatches are client body errors and must not leak as 500s.
#[tokio::test]
async fn aws_chunked_decoded_length_mismatch_returns_incomplete_body() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
for (key, declared_len) in [
("decoded-length-overrun.bin", 3_usize),
("decoded-length-shortfall.bin", 9_usize),
] {
let decoded = b"decoded";
assert_ne!(declared_len, decoded.len(), "test case must exercise a mismatch");
let encoded_body = encode_unsigned_aws_chunked_with_sha256_trailer(decoded);
let decoded_content_length = declared_len.to_string();
let path = format!("/{BUCKET}/{key}");
let signer = SigV4::new(&env);
let extra_signed_headers = [
("content-encoding", "aws-chunked"),
("x-amz-decoded-content-length", decoded_content_length.as_str()),
("x-amz-trailer", "x-amz-checksum-sha256"),
];
let headers = signer.sign_with_extra_headers("PUT", &path, "", UNSIGNED_PAYLOAD_TRAILER, &extra_signed_headers);
let response = local_http_client()
.put(format!("{}{}", env.url, path))
.header("authorization", &headers.authorization)
.header("content-encoding", "aws-chunked")
.header("x-amz-content-sha256", &headers.content_sha256)
.header("x-amz-date", &headers.amz_date)
.header("x-amz-decoded-content-length", &decoded_content_length)
.header("x-amz-trailer", "x-amz-checksum-sha256")
.body(encoded_body)
.timeout(std::time::Duration::from_secs(10))
.send()
.await?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"decoded length mismatch must be a client error, body:\n{body}"
);
assert_error_code(&body, "IncompleteBody");
let absent = env
.create_s3_client()
.get_object()
.bucket(BUCKET)
.key(key)
.send()
.await
.expect_err("decoded length mismatch must not publish an object");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
}
env.stop_server();
Ok(())
}
/// (e) A request whose `x-amz-date` is skewed beyond the server's tolerance
/// (s3s default 900s / 15 min) must be rejected with RequestTimeTooSkewed /
/// 403. The signature is otherwise valid: the credential-scope date and
@@ -1,452 +0,0 @@
// 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.
//! Shared environment for on-demand migration (ODM) end-to-end tests.
//!
//! [`OdmTestEnv`] pairs one RustFS server under test with one in-process
//! programmable S3 source ([`FakeS3Target`]). Admin calls target the route
//! convention fixed by the tracking plan
//! (`/rustfs/admin/v3/on-demand-migration/{bucket}`, JSON bodies); the
//! server side lands with ODM-07, so until then the wrappers compile but are
//! not exercised by the harness self-test.
use crate::common::{RustFSTestEnvironment, signed_request};
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, FakeS3TargetOptions, SeedMetadata};
use aws_config::retry::RetryConfig;
use aws_sdk_s3::Client;
use aws_sdk_s3::config::{Credentials, Region};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use bytes::Bytes;
use serde::Serialize;
use std::fmt;
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
/// Module switch the server reads at startup (`false` before GA). The harness
/// turns it on so scenario tests exercise the feature without repeating it.
pub const ODM_MODULE_SWITCH_ENV: &str = "RUSTFS_ON_DEMAND_MIGRATION_ENABLED";
/// Admin route prefix; the bucket name is appended as one path segment.
pub const ODM_ADMIN_ROUTE: &str = "/rustfs/admin/v3/on-demand-migration";
/// Region the fake source is addressed with (it accepts any SigV4 region).
pub const FAKE_SOURCE_REGION: &str = "us-east-1";
/// Wire form of the bucket-level ODM configuration (ODM-01 model). Every
/// field is public so a scenario can tweak one knob and serialize the rest
/// with the documented defaults.
#[derive(Debug, Clone, Serialize)]
pub struct OdmSourceSpec {
pub version: u32,
pub enabled: bool,
pub source: OdmSource,
pub filter: OdmFilter,
pub policy: OdmPolicy,
}
#[derive(Debug, Clone, Serialize)]
pub struct OdmSource {
pub provider: String,
pub endpoint: String,
pub region: String,
pub bucket: String,
pub path_style: String,
pub credentials: Option<OdmCredentials>,
pub tls: OdmTls,
}
#[derive(Clone, Serialize)]
pub struct OdmCredentials {
pub access_key: String,
pub secret_key: String,
pub session_token: Option<String>,
}
impl fmt::Debug for OdmCredentials {
/// Test logs are captured into CI artifacts; keep the secret out of them.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OdmCredentials")
.field("access_key", &self.access_key)
.field("secret_key", &"REDACTED")
.field("session_token", &self.session_token.as_ref().map(|_| "REDACTED"))
.finish()
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct OdmTls {
pub skip_verify: bool,
pub ca_cert_pem: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct OdmFilter {
pub prefix: Option<String>,
pub source_prefix: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OdmPolicy {
pub head: String,
pub range_get: String,
pub source_error: String,
pub respect_local_delete_marker: bool,
pub preserve_etag: bool,
pub copy_tags: bool,
pub emit_events: bool,
pub negative_cache_ttl_secs: u64,
pub inline_max_bytes: u64,
pub multipart_part_size_bytes: u64,
pub max_concurrent_pulls: u32,
pub pull_queue_capacity: u32,
pub source_timeout: OdmSourceTimeout,
pub bandwidth_limit_bytes_per_sec: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OdmSourceTimeout {
pub connect_ms: u64,
pub first_byte_ms: u64,
pub idle_ms: u64,
}
impl Default for OdmPolicy {
/// The ODM-01 defaults verbatim.
fn default() -> Self {
Self {
head: "proxy".to_string(),
range_get: "serve_and_backfill".to_string(),
source_error: "propagate".to_string(),
respect_local_delete_marker: true,
preserve_etag: true,
copy_tags: false,
emit_events: true,
negative_cache_ttl_secs: 30,
inline_max_bytes: 16 * 1024 * 1024,
multipart_part_size_bytes: 64 * 1024 * 1024,
max_concurrent_pulls: 8,
pull_queue_capacity: 1024,
source_timeout: OdmSourceTimeout {
connect_ms: 5_000,
first_byte_ms: 15_000,
idle_ms: 30_000,
},
bandwidth_limit_bytes_per_sec: None,
}
}
}
impl OdmSourceSpec {
/// Enabled configuration pointing at a bucket on the fake source with the
/// fixture credentials, path-style addressing, and default policy.
pub fn for_fake_source(source: &FakeS3Target, source_bucket: impl Into<String>) -> Self {
Self::new(
"s3",
source.endpoint(),
FAKE_SOURCE_REGION,
source_bucket,
FAKE_ACCESS_KEY,
FAKE_SECRET_KEY,
)
}
/// Enabled configuration pointing at a bucket on a second RustFS server
/// (see [`start_source_rustfs`]).
pub fn for_rustfs_source(source: &RustFSTestEnvironment, source_bucket: impl Into<String>) -> Self {
Self::new(
"rustfs",
&source.url,
FAKE_SOURCE_REGION,
source_bucket,
&source.access_key,
&source.secret_key,
)
}
fn new(
provider: &str,
endpoint: &str,
region: &str,
source_bucket: impl Into<String>,
access_key: &str,
secret_key: &str,
) -> Self {
Self {
version: 1,
enabled: true,
source: OdmSource {
provider: provider.to_string(),
endpoint: endpoint.to_string(),
region: region.to_string(),
bucket: source_bucket.into(),
path_style: "path".to_string(),
credentials: Some(OdmCredentials {
access_key: access_key.to_string(),
secret_key: secret_key.to_string(),
session_token: None,
}),
tls: OdmTls::default(),
},
filter: OdmFilter::default(),
policy: OdmPolicy::default(),
}
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::to_value(self).expect("ODM source spec serializes")
}
}
/// Backfill job control (ODM-12 route shape).
#[derive(Debug, Clone)]
pub enum BackfillOp {
Start(BackfillRequest),
Cancel,
Status,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct BackfillRequest {
pub prefix: Option<String>,
pub skip_existing: Option<String>,
pub dry_run: bool,
}
/// Status plus raw body of an admin call, so a scenario can assert on the
/// HTTP status first and only then parse the JSON.
#[derive(Debug, Clone)]
pub struct AdminResponse {
pub status: u16,
pub body: String,
}
impl AdminResponse {
pub fn json(&self) -> Result<serde_json::Value, BoxError> {
Ok(serde_json::from_str(&self.body)?)
}
}
/// One object to seed into the source.
#[derive(Clone)]
pub struct SeedObject {
pub key: String,
pub body: Bytes,
pub metadata: SeedMetadata,
}
impl SeedObject {
pub fn new(key: impl Into<String>, body: impl Into<Bytes>) -> Self {
Self {
key: key.into(),
body: body.into(),
metadata: SeedMetadata::new(),
}
}
pub fn with_metadata(mut self, metadata: SeedMetadata) -> Self {
self.metadata = metadata;
self
}
}
/// RustFS under test plus its fake S3 source.
pub struct OdmTestEnv {
pub rustfs: RustFSTestEnvironment,
pub source: FakeS3Target,
/// S3 client for the RustFS under test.
pub client: Client,
}
impl OdmTestEnv {
/// Start a fake source with default limits and a RustFS server with the
/// ODM module switch enabled.
pub async fn start() -> Result<Self, BoxError> {
Self::start_with_options(FakeS3TargetOptions::default()).await
}
pub async fn start_with_options(options: FakeS3TargetOptions) -> Result<Self, BoxError> {
let source = FakeS3Target::start_with_options(options).await?;
let mut rustfs = RustFSTestEnvironment::new().await?;
rustfs
.start_rustfs_server_with_env(vec![], &[(ODM_MODULE_SWITCH_ENV, "true")])
.await?;
let client = rustfs.create_s3_client();
Ok(Self { rustfs, source, client })
}
/// S3 client addressing the fake source directly, for assertions on the
/// source's own state. Retries are off so a scripted fault is consumed by
/// exactly the request the test issued.
pub fn source_client(&self) -> Client {
fake_source_client(&self.source)
}
/// Enabled ODM configuration for `source_bucket` on the fake source.
pub fn fake_source_spec(&self, source_bucket: impl Into<String>) -> OdmSourceSpec {
OdmSourceSpec::for_fake_source(&self.source, source_bucket)
}
/// `PUT /rustfs/admin/v3/on-demand-migration/{bucket}` with the JSON spec.
pub async fn configure_source(&self, bucket: &str, spec: &OdmSourceSpec) -> Result<AdminResponse, BoxError> {
self.admin(http::Method::PUT, &format!("/{bucket}"), Some(spec.to_json()))
.await
}
/// Same as [`Self::configure_source`] with `dry-run=true`: validate and
/// probe without persisting.
pub async fn validate_source(&self, bucket: &str, spec: &OdmSourceSpec) -> Result<AdminResponse, BoxError> {
self.admin(http::Method::PUT, &format!("/{bucket}?dry-run=true"), Some(spec.to_json()))
.await
}
/// `GET .../{bucket}`: redacted configuration, 404 when unconfigured.
pub async fn get_config(&self, bucket: &str) -> Result<AdminResponse, BoxError> {
self.admin(http::Method::GET, &format!("/{bucket}"), None).await
}
/// `DELETE .../{bucket}`: remove the configuration (idempotent).
pub async fn disable(&self, bucket: &str) -> Result<AdminResponse, BoxError> {
self.admin(http::Method::DELETE, &format!("/{bucket}"), None).await
}
/// `GET .../{bucket}/status`: runtime snapshot.
pub async fn status(&self, bucket: &str) -> Result<AdminResponse, BoxError> {
self.admin(http::Method::GET, &format!("/{bucket}/status"), None).await
}
/// Backfill control: `POST .../{bucket}/backfill?op=start|cancel` or
/// `GET .../{bucket}/backfill` for the checkpoint.
pub async fn backfill(&self, bucket: &str, op: BackfillOp) -> Result<AdminResponse, BoxError> {
match op {
BackfillOp::Start(request) => {
self.admin(
http::Method::POST,
&format!("/{bucket}/backfill?op=start"),
Some(serde_json::to_value(request)?),
)
.await
}
BackfillOp::Cancel => {
self.admin(http::Method::POST, &format!("/{bucket}/backfill?op=cancel"), None)
.await
}
BackfillOp::Status => self.admin(http::Method::GET, &format!("/{bucket}/backfill"), None).await,
}
}
async fn admin(
&self,
method: http::Method,
path_and_query: &str,
body: Option<serde_json::Value>,
) -> Result<AdminResponse, BoxError> {
let url = format!("{}{ODM_ADMIN_ROUTE}{path_and_query}", self.rustfs.url);
let body = body.map(|value| serde_json::to_vec(&value)).transpose()?;
let content_type = body.is_some().then_some("application/json");
let response = signed_request(method, &url, &self.rustfs.access_key, &self.rustfs.secret_key, body, content_type).await?;
Ok(AdminResponse {
status: response.status().as_u16(),
body: response.text().await?,
})
}
/// Store objects directly in the fake source (no wire traffic, no journal
/// entries). Returns the ETags in input order.
pub fn seed_source(&self, source_bucket: &str, objects: &[SeedObject]) -> Vec<String> {
objects
.iter()
.map(|object| {
self.source
.put_seed_object(source_bucket, object.key.clone(), object.body.clone(), &object.metadata)
})
.collect()
}
/// Whether `key` is listed by the RustFS under test. Listing is served from
/// local state only, so this does not trigger a migration the way GET or
/// HEAD would.
pub async fn local_key_listed(&self, bucket: &str, key: &str) -> Result<bool, BoxError> {
let listed = self
.client
.list_objects_v2()
.bucket(bucket)
.prefix(key)
.max_keys(1)
.send()
.await?;
Ok(listed.contents().iter().any(|object| object.key() == Some(key)))
}
/// Panics unless `key` is stored locally with exactly `expected` bytes.
/// Presence is checked through listing first so a missing object fails
/// here instead of being pulled from the source by the GET.
pub async fn assert_local_present(&self, bucket: &str, key: &str, expected: &[u8]) {
assert!(
self.local_key_listed(bucket, key)
.await
.unwrap_or_else(|error| panic!("listing {bucket}/{key} failed: {error}")),
"{bucket}/{key} must be present locally"
);
let body = self
.client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.unwrap_or_else(|error| panic!("GET {bucket}/{key} failed: {error}"))
.body
.collect()
.await
.unwrap_or_else(|error| panic!("reading {bucket}/{key} failed: {error}"))
.into_bytes();
assert_eq!(body.as_ref(), expected, "{bucket}/{key} local content mismatch");
}
/// Panics if `key` is listed locally.
pub async fn assert_local_absent(&self, bucket: &str, key: &str) {
assert!(
!self
.local_key_listed(bucket, key)
.await
.unwrap_or_else(|error| panic!("listing {bucket}/{key} failed: {error}")),
"{bucket}/{key} must be absent locally"
);
}
}
/// S3 client for the fake source with retries disabled (see
/// [`OdmTestEnv::source_client`]).
pub fn fake_source_client(source: &FakeS3Target) -> Client {
let credentials = Credentials::new(FAKE_ACCESS_KEY, FAKE_SECRET_KEY, None, None, "odm-fake-source");
Client::from_conf(
aws_sdk_s3::Config::builder()
.credentials_provider(credentials)
.region(Region::new(FAKE_SOURCE_REGION))
.endpoint_url(source.endpoint())
.force_path_style(true)
.behavior_version_latest()
.retry_config(RetryConfig::standard().with_max_attempts(1))
.http_client(SmithyHttpClientBuilder::new().build_http())
.build(),
)
}
/// Start a second, fully independent RustFS process (own port, data
/// directory, and default credentials) to act as a real S3 source. It is
/// spawned the same way `reliant::tiering` starts its cold tier; the process
/// is stopped and its directory removed when the returned environment drops.
pub async fn start_source_rustfs() -> Result<RustFSTestEnvironment, BoxError> {
let mut source = RustFSTestEnvironment::new().await?;
source.start_rustfs_server_without_cleanup(vec![]).await?;
Ok(source)
}
@@ -1,606 +0,0 @@
// 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.
//! Self-test of the ODM harness (rustfs/backlog#2151): the fake source's
//! migration-facing surface (ListObjectsV2 paging, `Range`, unversioned
//! buckets, metadata replay, fault actions) and the two-server environment.
//! No ODM behavior is exercised here.
use super::common::{OdmTestEnv, SeedObject, fake_source_client, start_source_rustfs};
use crate::fake_s3_target::{BucketMode, FakeS3Target, FakeS3TargetOptions, FaultAction, Operation, SeedMetadata};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::{ByteStream, DateTime};
use bytes::Bytes;
use std::collections::BTreeSet;
use std::time::{Duration, Instant};
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
const SOURCE_BUCKET: &str = "odm-source";
/// Position-dependent payload so a misaligned range read is caught.
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
async fn fake_source() -> Result<(FakeS3Target, Client), Box<dyn std::error::Error + Send + Sync>> {
let source = FakeS3Target::start().await?;
source.create_bucket(SOURCE_BUCKET);
let client = fake_source_client(&source);
Ok((source, client))
}
/// Full ListObjectsV2 traversal. Returns `(keys, common prefixes, pages)` and
/// checks the page shape on the way: every page except the last is full and
/// truncated, the last carries no continuation token.
async fn list_all(
client: &Client,
prefix: Option<&str>,
delimiter: Option<&str>,
start_after: Option<&str>,
max_keys: i32,
) -> Result<(Vec<String>, Vec<String>, usize), Box<dyn std::error::Error + Send + Sync>> {
let mut keys = Vec::new();
let mut prefixes = Vec::new();
let mut pages = 0usize;
let mut token: Option<String> = None;
loop {
let page = client
.list_objects_v2()
.bucket(SOURCE_BUCKET)
.set_prefix(prefix.map(str::to_string))
.set_delimiter(delimiter.map(str::to_string))
.set_start_after(start_after.map(str::to_string))
.max_keys(max_keys)
.set_continuation_token(token.clone())
.send()
.await?;
pages += 1;
let page_keys: Vec<String> = page
.contents()
.iter()
.filter_map(|object| object.key().map(str::to_string))
.collect();
let page_prefixes: Vec<String> = page
.common_prefixes()
.iter()
.filter_map(|common| common.prefix().map(str::to_string))
.collect();
let entries = page_keys.len() + page_prefixes.len();
assert_eq!(page.key_count(), Some(entries as i32), "KeyCount must count keys and prefixes");
assert_eq!(page.continuation_token(), token.as_deref(), "the request token must be echoed");
keys.extend(page_keys);
prefixes.extend(page_prefixes);
if page.is_truncated() == Some(true) {
assert_eq!(entries as i32, max_keys, "every truncated page must be full");
token = Some(
page.next_continuation_token()
.expect("truncated page must carry a continuation token")
.to_string(),
);
} else {
assert!(page.next_continuation_token().is_none(), "final page must not carry a token");
return Ok((keys, prefixes, pages));
}
}
}
#[tokio::test]
async fn fake_source_list_objects_v2_paginates_with_delimiter() -> TestResult {
let (source, client) = fake_source().await?;
let mut expected_keys = BTreeSet::new();
for directory in 0..30 {
for file in 0..30 {
expected_keys.insert(format!("d{directory:02}/k{file:03}"));
}
}
for index in 0..100 {
expected_keys.insert(format!("top-{index:03}"));
}
assert_eq!(expected_keys.len(), 1000);
for key in &expected_keys {
source.put_seed_object(SOURCE_BUCKET, key.clone(), Bytes::from(key.clone()), &SeedMetadata::new());
}
// A key whose current version is a delete marker must stay hidden.
client
.put_object()
.bucket(SOURCE_BUCKET)
.key("hidden/marker")
.body(ByteStream::from_static(b"gone"))
.send()
.await?;
client
.delete_object()
.bucket(SOURCE_BUCKET)
.key("hidden/marker")
.send()
.await?;
let expected_sorted: Vec<String> = expected_keys.iter().cloned().collect();
let expected_prefixes: Vec<String> = (0..30).map(|directory| format!("d{directory:02}/")).collect();
let expected_top: Vec<String> = (0..100).map(|index| format!("top-{index:03}")).collect();
// Flat traversal in byte order, 1000 keys in pages of 7.
let (keys, prefixes, pages) = list_all(&client, None, None, None, 7).await?;
assert_eq!(keys, expected_sorted);
assert!(prefixes.is_empty());
assert_eq!(pages, 143);
// Delimiter folding: 30 common prefixes then 100 top-level keys, pages of 7.
let (keys, prefixes, pages) = list_all(&client, None, Some("/"), None, 7).await?;
assert_eq!(prefixes, expected_prefixes);
assert_eq!(keys, expected_top);
assert_eq!(pages, 19);
// Empty prefix equals no prefix.
let (keys, _, _) = list_all(&client, Some(""), None, None, 1000).await?;
assert_eq!(keys, expected_sorted);
// No match: empty, not truncated, no token.
let (keys, prefixes, pages) = list_all(&client, Some("zzz/"), Some("/"), None, 7).await?;
assert!(keys.is_empty() && prefixes.is_empty());
assert_eq!(pages, 1);
let (keys, _, _) = list_all(&client, Some("hidden/"), None, None, 7).await?;
assert!(keys.is_empty(), "a current delete marker must hide its key");
// Exact page boundary: 30 keys under one directory, max-keys=30 -> one
// untruncated page.
let (keys, prefixes, pages) = list_all(&client, Some("d05/"), Some("/"), None, 30).await?;
assert_eq!(keys.len(), 30);
assert!(prefixes.is_empty());
assert_eq!(pages, 1);
// start-after skips keys at or before the marker.
let (keys, _, _) = list_all(&client, None, None, Some("top-097"), 1000).await?;
assert_eq!(keys, ["top-098", "top-099"]);
// max-keys is clamped to 1000; exactly 1000 keys fit in one page.
let (keys, _, pages) = list_all(&client, None, None, None, 5000).await?;
assert_eq!(keys.len(), 1000);
assert_eq!(pages, 1);
let listings: Vec<_> = source
.requests()
.into_iter()
.filter(|record| record.operation == Operation::ListObjectsV2)
.collect();
assert!(listings.len() >= 143 + 19);
assert!(listings.iter().any(|record| record.prefix.as_deref() == Some("d05/")));
assert!(
listings.iter().any(|record| record.continuation_token.is_some()),
"resumed pages must journal their continuation token"
);
assert!(listings.iter().all(|record| record.user_agent.is_some()));
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn fake_source_range_get_variants_and_416() -> TestResult {
let (source, client) = fake_source().await?;
let body = payload(1000);
source.put_seed_object(SOURCE_BUCKET, "ranged", body.clone(), &SeedMetadata::new());
for (range, expected_range, expected_slice) in [
("bytes=10-19", "bytes 10-19/1000", &body[10..20]),
("bytes=990-", "bytes 990-999/1000", &body[990..]),
("bytes=-5", "bytes 995-999/1000", &body[995..]),
("bytes=0-5000", "bytes 0-999/1000", &body[..]),
] {
let output = client
.get_object()
.bucket(SOURCE_BUCKET)
.key("ranged")
.range(range)
.send()
.await?;
assert_eq!(output.content_range(), Some(expected_range), "{range}");
assert_eq!(output.accept_ranges(), Some("bytes"), "{range}");
assert_eq!(output.content_length(), Some(expected_slice.len() as i64), "{range}");
let collected = output.body.collect().await?.into_bytes();
assert_eq!(collected.as_ref(), expected_slice, "{range}");
}
let head = client
.head_object()
.bucket(SOURCE_BUCKET)
.key("ranged")
.range("bytes=10-19")
.send()
.await?;
assert_eq!(head.content_range(), Some("bytes 10-19/1000"));
assert_eq!(head.content_length(), Some(10));
for range in ["bytes=1000-", "bytes=-0"] {
let error = client
.get_object()
.bucket(SOURCE_BUCKET)
.key("ranged")
.range(range)
.send()
.await
.expect_err("unsatisfiable range must fail");
let response = error.raw_response().expect("416 must retain the raw response");
assert_eq!(response.status().as_u16(), 416, "{range}");
assert_eq!(response.headers().get("content-range"), Some("bytes */1000"), "{range}");
assert_eq!(error.code(), Some("InvalidRange"), "{range}");
}
let ranged = source
.requests()
.into_iter()
.find(|record| record.operation == Operation::GetObject && record.range.as_deref() == Some("bytes=10-19"))
.expect("the Range header must be journaled verbatim");
assert_eq!(ranged.key.as_deref(), Some("ranged"));
assert!(source.count_requests(Operation::GetObject, "ranged") >= 6);
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn fake_source_unversioned_bucket_overwrites_and_deletes() -> TestResult {
let (source, client) = fake_source().await?;
source.create_bucket_with_mode("plain-source", BucketMode::Unversioned);
let versioning = client.get_bucket_versioning().bucket("plain-source").send().await?;
assert!(versioning.status().is_none(), "unversioned bucket must report no versioning status");
let first = client
.put_object()
.bucket("plain-source")
.key("doc")
.body(ByteStream::from_static(b"first"))
.send()
.await?;
assert!(first.version_id().is_none());
let second = client
.put_object()
.bucket("plain-source")
.key("doc")
.body(ByteStream::from_static(b"second"))
.send()
.await?;
assert!(second.version_id().is_none());
let get = client.get_object().bucket("plain-source").key("doc").send().await?;
assert!(get.version_id().is_none(), "GET must not return x-amz-version-id");
assert_eq!(get.body.collect().await?.into_bytes().as_ref(), b"second");
let head = client.head_object().bucket("plain-source").key("doc").send().await?;
assert!(head.version_id().is_none(), "HEAD must not return x-amz-version-id");
assert_eq!(source.stored_versions("plain-source", "doc").len(), 1, "overwrite must replace in place");
let deleted = client.delete_object().bucket("plain-source").key("doc").send().await?;
assert!(deleted.delete_marker().is_none() && deleted.version_id().is_none());
let missing = client
.get_object()
.bucket("plain-source")
.key("doc")
.send()
.await
.expect_err("deleted object must be gone");
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
assert_eq!(missing.code(), Some("NoSuchKey"));
let missing_head = client
.head_object()
.bucket("plain-source")
.key("doc")
.send()
.await
.expect_err("deleted object must fail HEAD");
assert_eq!(missing_head.raw_response().map(|response| response.status().as_u16()), Some(404));
assert!(source.stored_versions("plain-source", "doc").is_empty(), "DELETE must not leave a marker");
// The versioned bucket on the same target keeps its version ids.
let versioned = client
.put_object()
.bucket(SOURCE_BUCKET)
.key("doc")
.body(ByteStream::from_static(b"versioned"))
.send()
.await?;
assert!(versioned.version_id().is_some());
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn fake_source_replays_standard_and_user_metadata() -> TestResult {
let (source, client) = fake_source().await?;
let body = payload(4096);
let expected_etag = format!("\"{}\"", {
use md5::Digest as _;
hex_simd::encode_to_string(md5::Md5::digest(&body), hex_simd::AsciiCase::Lower)
});
// 2026-01-01T00:00:00Z rendered as an HTTP date by the SDK.
let expires = DateTime::from_secs(1_767_225_600);
client
.put_object()
.bucket(SOURCE_BUCKET)
.key("meta")
.body(ByteStream::from(body.clone()))
.content_type("application/x-odm")
.content_encoding("gzip")
.content_disposition("attachment; filename=\"meta.bin\"")
.content_language("en-US")
.cache_control("max-age=60")
.expires(expires)
.metadata("Foo-Bar", "mixed case name")
.metadata("UPPER", "upper name")
.metadata("already-lower", "lower name")
.send()
.await?;
let head = client.head_object().bucket(SOURCE_BUCKET).key("meta").send().await?;
let get = client.get_object().bucket(SOURCE_BUCKET).key("meta").send().await?;
for (label, content_type, content_encoding, content_disposition, content_language, cache_control, expires_string, e_tag) in [
(
"HEAD",
head.content_type(),
head.content_encoding(),
head.content_disposition(),
head.content_language(),
head.cache_control(),
head.expires_string(),
head.e_tag(),
),
(
"GET",
get.content_type(),
get.content_encoding(),
get.content_disposition(),
get.content_language(),
get.cache_control(),
get.expires_string(),
get.e_tag(),
),
] {
assert_eq!(content_type, Some("application/x-odm"), "{label}");
assert_eq!(content_encoding, Some("gzip"), "{label}");
assert_eq!(content_disposition, Some("attachment; filename=\"meta.bin\""), "{label}");
assert_eq!(content_language, Some("en-US"), "{label}");
assert_eq!(cache_control, Some("max-age=60"), "{label}");
assert_eq!(expires_string, Some("Thu, 01 Jan 2026 00:00:00 GMT"), "{label}");
assert_eq!(e_tag, Some(expected_etag.as_str()), "{label}");
}
for metadata in [head.metadata(), get.metadata()] {
let metadata = metadata.expect("user metadata must be replayed");
assert_eq!(metadata.get("foo-bar").map(String::as_str), Some("mixed case name"));
assert_eq!(metadata.get("upper").map(String::as_str), Some("upper name"));
assert_eq!(metadata.get("already-lower").map(String::as_str), Some("lower name"));
assert!(!metadata.contains_key("Foo-Bar") && !metadata.contains_key("UPPER"));
}
assert!(head.last_modified().is_some());
assert_eq!(head.last_modified(), get.last_modified());
assert_eq!(head.content_length(), Some(4096));
assert_eq!(get.body.collect().await?.into_bytes(), body);
// Seeded objects replay the same way.
let seeded_etag = source.put_seed_object(
SOURCE_BUCKET,
"seeded",
Bytes::from_static(b"seeded"),
&SeedMetadata::new()
.content_type("text/plain")
.content_encoding("identity")
.cache_control("no-store")
.user_metadata("Origin", "seed"),
);
let seeded = client.head_object().bucket(SOURCE_BUCKET).key("seeded").send().await?;
assert_eq!(seeded.e_tag(), Some(format!("\"{seeded_etag}\"").as_str()));
assert_eq!(seeded.content_type(), Some("text/plain"));
assert_eq!(seeded.content_encoding(), Some("identity"));
assert_eq!(seeded.cache_control(), Some("no-store"));
assert_eq!(
seeded
.metadata()
.and_then(|metadata| metadata.get("origin"))
.map(String::as_str),
Some("seed")
);
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn fake_source_fault_actions_truncate_stall_and_status() -> TestResult {
let (source, client) = fake_source().await?;
let body = payload(4096);
source.put_seed_object(SOURCE_BUCKET, "faulty", body.clone(), &SeedMetadata::new());
// TruncateBodyAt: headers promise 4096 bytes, the body ends after 100.
source.inject_for_key(Operation::GetObject, "faulty", FaultAction::TruncateBodyAt(100), 1);
let truncated = client.get_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
assert_eq!(truncated.content_length(), Some(4096));
let short_read = truncated
.body
.collect()
.await
.expect_err("a truncated body must fail to collect");
let short_read = short_read.to_string();
assert!(!short_read.is_empty());
// ResponseStatus: arbitrary status with the matching S3 error code.
for (code, expected_code) in [
(429u16, "SlowDown"),
(404, "NoSuchKey"),
(500, "InternalError"),
(503, "ServiceUnavailable"),
] {
source.inject(Operation::GetObject, FaultAction::ResponseStatus(code), 1);
let error = client
.get_object()
.bucket(SOURCE_BUCKET)
.key("faulty")
.send()
.await
.expect_err("scripted status must fail");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(code));
assert_eq!(error.code(), Some(expected_code));
}
// Stall: the fully computed response is held before its first byte.
source.inject(Operation::HeadObject, FaultAction::Stall(Duration::from_millis(400)), 1);
let started = Instant::now();
let stalled = client.head_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
assert!(started.elapsed() >= Duration::from_millis(350), "stall must delay the first byte");
assert_eq!(stalled.content_length(), Some(4096));
let post_stall_started = Instant::now();
client.head_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
assert!(post_stall_started.elapsed() < Duration::from_millis(350), "stall is consumed once");
// The object is intact once the script is drained.
let intact = client.get_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
assert_eq!(intact.body.collect().await?.into_bytes(), body);
assert_eq!(source.count_requests(Operation::GetObject, "faulty"), 6);
assert_eq!(source.count_requests(Operation::HeadObject, "faulty"), 2);
assert_eq!(source.count_requests(Operation::GetObject, "other"), 0);
let records = source.requests();
assert!(
records.iter().all(|record| record
.user_agent
.as_deref()
.is_some_and(|agent| agent.contains("aws-sdk-rust"))),
"the SDK user agent must be journaled"
);
assert!(
records
.iter()
.any(|record| record.fault == Some(FaultAction::TruncateBodyAt(100)))
);
assert!(
records
.iter()
.any(|record| record.fault == Some(FaultAction::Stall(Duration::from_millis(400))))
);
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn fake_source_raised_object_cap_accepts_large_put() -> TestResult {
let source = FakeS3Target::start_with_options(FakeS3TargetOptions {
max_object_bytes: 96 * 1024 * 1024,
})
.await?;
source.create_bucket(SOURCE_BUCKET);
let client = fake_source_client(&source);
let len = 64 * 1024 * 1024 + 1;
client
.put_object()
.bucket(SOURCE_BUCKET)
.key("large")
.body(ByteStream::from(vec![7u8; len]))
.send()
.await?;
let head = client.head_object().bucket(SOURCE_BUCKET).key("large").send().await?;
assert_eq!(head.content_length(), Some(len as i64));
let tail = client
.get_object()
.bucket(SOURCE_BUCKET)
.key("large")
.range("bytes=-1")
.send()
.await?;
assert_eq!(tail.content_range(), Some(format!("bytes {}-{}/{len}", len - 1, len - 1).as_str()));
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn odm_env_starts_rustfs_and_fake_source() -> TestResult {
let env = OdmTestEnv::start().await?;
env.source.create_bucket(SOURCE_BUCKET);
let local_bucket = "odm-local";
env.rustfs.create_test_bucket(local_bucket).await?;
let etags = env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new("seed/a", Bytes::from_static(b"alpha")),
SeedObject::new("seed/b", Bytes::from_static(b"beta"))
.with_metadata(SeedMetadata::new().content_type("text/plain").user_metadata("Kind", "seed")),
],
);
assert_eq!(etags.len(), 2);
assert!(env.source.requests().is_empty(), "seeding must not touch the journal");
let source_client = env.source_client();
let seeded = source_client.head_object().bucket(SOURCE_BUCKET).key("seed/b").send().await?;
assert_eq!(seeded.content_type(), Some("text/plain"));
assert_eq!(seeded.e_tag(), Some(format!("\"{}\"", etags[1]).as_str()));
assert_eq!(env.source.count_requests(Operation::HeadObject, "seed/b"), 1);
env.assert_local_absent(local_bucket, "seed/a").await;
env.client
.put_object()
.bucket(local_bucket)
.key("seed/a")
.body(ByteStream::from_static(b"alpha"))
.send()
.await?;
env.assert_local_present(local_bucket, "seed/a", b"alpha").await;
env.assert_local_absent(local_bucket, "seed/b").await;
let spec = env.fake_source_spec(SOURCE_BUCKET).to_json();
assert_eq!(spec["version"], 1);
assert_eq!(spec["enabled"], true);
assert_eq!(spec["source"]["provider"], "s3");
assert_eq!(spec["source"]["endpoint"], env.source.endpoint());
assert_eq!(spec["source"]["bucket"], SOURCE_BUCKET);
assert_eq!(spec["source"]["credentials"]["secret_key"], "fake-secret");
assert_eq!(spec["policy"]["source_timeout"]["first_byte_ms"], 15_000);
assert!(spec["policy"]["bandwidth_limit_bytes_per_sec"].is_null());
let debug = format!("{:?}", env.fake_source_spec(SOURCE_BUCKET));
assert!(!debug.contains("fake-secret"), "Debug output must redact the secret");
Ok(())
}
#[tokio::test]
async fn start_source_rustfs_round_trips_put_get() -> TestResult {
let env = OdmTestEnv::start().await?;
let source = start_source_rustfs().await?;
assert_ne!(source.url, env.rustfs.url, "the source must be a separate instance");
source.create_test_bucket(SOURCE_BUCKET).await?;
let source_client = source.create_s3_client();
let body = payload(70_000);
let put = source_client
.put_object()
.bucket(SOURCE_BUCKET)
.key("real/object")
.body(ByteStream::from(body.clone()))
.content_type("application/octet-stream")
.send()
.await?;
assert!(put.e_tag().is_some());
let get = source_client
.get_object()
.bucket(SOURCE_BUCKET)
.key("real/object")
.send()
.await?;
assert_eq!(get.content_type(), Some("application/octet-stream"));
assert_eq!(get.body.collect().await?.into_bytes(), body);
let visible_to_primary = env
.client
.list_buckets()
.send()
.await?
.buckets()
.iter()
.any(|bucket| bucket.name() == Some(SOURCE_BUCKET));
assert!(!visible_to_primary, "the two servers must not share state");
let spec = super::common::OdmSourceSpec::for_rustfs_source(&source, SOURCE_BUCKET).to_json();
assert_eq!(spec["source"]["provider"], "rustfs");
assert_eq!(spec["source"]["endpoint"], source.url);
Ok(())
}
@@ -1,24 +0,0 @@
// 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.
//! On-demand migration (ODM) end-to-end suite (rustfs/backlog#2147).
//!
//! `common` is the shared environment: one RustFS under test, one programmable
//! fake S3 source, admin-API wrappers, seeding and local-state assertions.
//! `harness_self_test` proves the harness itself; ODM behavior scenarios are
//! separate modules wired by later tasks.
pub mod common;
mod harness_self_test;
@@ -40,10 +40,10 @@ mod tests {
const ENABLE_ENV: &str = "RUSTFS_PRIVILEGED_REPLACEMENT_E2E";
const NAMESPACE_ENV: &str = "RUSTFS_PRIVILEGED_REPLACEMENT_E2E_IN_NAMESPACE";
const LOG_DIR_ENV: &str = "RUSTFS_PRIVILEGED_REPLACEMENT_LOG_DIR";
const TARGET_NODE: usize = 1;
const TARGET_DRIVE: usize = 0;
const MOUNT_SIZE: &str = "size=128m,mode=0700";
const ABSENT_SCANNER_OBSERVATION_TIMEOUT_SECS: u64 = 180;
const REPLACEMENT_RECOVERY_DIR: &str = ".rustfs.sys/buckets/ahm-replacement";
const REPLACEMENT_INTENT_SUFFIX: &str = "_ahm_replacement_intent.json";
const REPLACEMENT_COMPLETION_PROOF_SUFFIX: &str = "_ahm_replacement_completion_proof.json";
@@ -142,23 +142,6 @@ mod tests {
run_command("dmsetup", &["resume", &self.dm_name])
}
fn verify_raw_io_is_unavailable(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
let mapper = format!("/dev/mapper/{}", self.dm_name);
let output = Command::new("dd")
.env("LC_ALL", "C")
.arg(format!("if={mapper}"))
.args(["of=/dev/null", "bs=4096", "count=1", "iflag=direct", "status=none"])
.output()?;
if output.status.success() {
return Err(format!("dm-error target unexpectedly allowed a raw read from {mapper}").into());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("Input/output error") {
return Err(format!("raw read from dm-error target failed unexpectedly: {stderr}").into());
}
Ok(())
}
fn restore_available(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
let sectors = run_command_stdout("blockdev", &["--getsz", &self.loop_device])?;
let linear_table = format!("0 {sectors} linear {} 0", self.loop_device);
@@ -211,72 +194,6 @@ mod tests {
}
}
struct ZramBlockMount {
target: PathBuf,
device: String,
mounted: bool,
}
impl ZramBlockMount {
fn reserve(target: &Path) -> Result<Self, Box<dyn Error + Send + Sync>> {
if !Path::new("/dev/zram-control").exists() {
run_command("modprobe", &["zram"])?;
}
let device = run_command_stdout("zramctl", &["--find", "--size", "256M"])?;
if device.is_empty() {
return Err("zramctl --find --size returned an empty device".into());
}
Ok(Self {
target: target.to_path_buf(),
device,
mounted: false,
})
}
fn mount_target(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
let result = (|| {
run_command("mkfs.ext4", &["-F", &self.device])?;
let target_arg = path_to_string(&self.target, "zram replacement mount target")?;
run_command("mount", &[&self.device, &target_arg])
})();
if let Err(error) = result {
let _ = self.cleanup();
return Err(error);
}
self.mounted = true;
Ok(())
}
fn cleanup(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut first_error: Option<Box<dyn Error + Send + Sync>> = None;
if self.mounted {
if let Err(error) = detach_mount(&self.target) {
first_error.get_or_insert(error);
} else {
self.mounted = false;
}
}
if !self.device.is_empty() {
if let Err(error) = run_command("zramctl", &["--reset", &self.device]) {
first_error.get_or_insert(error);
} else {
self.device.clear();
}
}
if let Some(error) = first_error {
return Err(error);
}
Ok(())
}
}
impl Drop for ZramBlockMount {
fn drop(&mut self) {
let _ = self.cleanup();
}
}
fn checked_command_output(program: &str, args: &[&str]) -> Result<std::process::Output, Box<dyn Error + Send + Sync>> {
let output = Command::new(program).args(args).output()?;
if output.status.success() {
@@ -381,18 +298,6 @@ mod tests {
Err(format!("{ENABLE_ENV}=1 requires root or CAP_SYS_ADMIN; unshare exited with status {status}").into())
}
fn replacement_node_log_path(
cluster_temp_dir: &str,
parity: usize,
node_index: usize,
) -> Result<PathBuf, Box<dyn Error + Send + Sync>> {
let log_dir = std::env::var_os(LOG_DIR_ENV)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(cluster_temp_dir));
fs::create_dir_all(&log_dir)?;
Ok(log_dir.join(format!("replacement-ec{parity}-node{node_index}-{}.log", std::process::id())))
}
fn payload(len: usize, seed: u8) -> Vec<u8> {
let mut next = seed;
(0..len)
@@ -567,20 +472,8 @@ mod tests {
if let Some(version_id) = &version.version_id {
request = request.version_id(version_id);
}
let response = request.send().await.map_err(|error| {
format!("body GET failed for {}/{}@{:?}: {error}", version.bucket, version.key, version.version_id)
})?;
let body = response
.body
.collect()
.await
.map_err(|error| {
format!(
"body stream failed for {}/{}@{:?}: {error}",
version.bucket, version.key, version.version_id
)
})?
.into_bytes();
let response = request.send().await?;
let body = response.body.collect().await?.into_bytes();
assert_eq!(
sha256_hex(&body),
*expected_sha256,
@@ -689,6 +582,81 @@ mod tests {
Ok(())
}
fn log_tail(log: &str) -> String {
let mut lines = log.lines().rev().take(80).collect::<Vec<_>>();
lines.reverse();
lines.join("\n")
}
fn log_len(path: &Path) -> Result<u64, Box<dyn Error + Send + Sync>> {
match fs::metadata(path) {
Ok(metadata) => Ok(metadata.len()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0),
Err(error) => Err(format!("failed to stat target node log {path:?}: {error}").into()),
}
}
fn log_from_offset(path: &Path, offset: u64) -> Result<String, Box<dyn Error + Send + Sync>> {
let log = match fs::read(path) {
Ok(log) => log,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(format!("failed to read target node log {path:?}: {error}").into()),
};
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(log.len());
Ok(String::from_utf8_lossy(&log[start..]).into_owned())
}
fn live_disk_loss_scan_completed(log: &str, target_disk: &Path) -> bool {
let target = target_disk.to_string_lossy();
let mut saw_live_loss = false;
for line in log.lines() {
if line.contains("Heal auto-scan disk inspection failed")
&& line.contains("check_failed")
&& line.contains(target.as_ref())
{
saw_live_loss = true;
continue;
}
if saw_live_loss && (line.contains("Heal auto disk scanner idle") || line.contains("Heal auto-scan cycle completed"))
{
return true;
}
}
false
}
fn live_disk_loss_scan_completed_from_path(
log_path: &Path,
start_offset: u64,
target_disk: &Path,
) -> Result<bool, Box<dyn Error + Send + Sync>> {
Ok(live_disk_loss_scan_completed(&log_from_offset(log_path, start_offset)?, target_disk))
}
async fn wait_for_live_disk_loss_observation(
log_path: &Path,
target_disk: &Path,
start_offset: u64,
timeout_secs: u64,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
let mut tick = interval(Duration::from_secs(1));
loop {
if live_disk_loss_scan_completed_from_path(log_path, start_offset, target_disk)? {
return Ok(());
}
if Instant::now() >= deadline {
let log = log_from_offset(log_path, start_offset)?;
return Err(format!(
"scanner did not finish a live target-loss scan for {target_disk:?} within {timeout_secs}s; log tail:\n{}",
log_tail(&log)
)
.into());
}
tick.tick().await;
}
}
fn cluster_status_is_definitive(status: &serde_json::Value) -> Result<bool, Box<dyn Error + Send + Sync>> {
status["cluster"]["definitive"]
.as_bool()
@@ -739,13 +707,6 @@ mod tests {
.collect()
}
fn is_transient_recovery_version_absence(error: &(dyn Error + 'static)) -> bool {
matches!(
error.downcast_ref::<rustfs_filemeta::Error>(),
Some(rustfs_filemeta::Error::FileVersionNotFound)
)
}
fn incomplete_versions(
target_disk: &Path,
versions: &[BaselineVersion],
@@ -753,21 +714,7 @@ mod tests {
let mut missing = BTreeSet::new();
for version in versions {
let actual =
match census_object_version_on_disk(target_disk, &version.bucket, &version.key, version.version_id.as_deref()) {
Ok(actual) => actual,
// During replacement recovery, xl.meta may arrive before this
// particular historical version. The generic census helper
// correctly reports that as an error; this progress poll must
// instead wait for the version to be restored.
Err(error) if is_transient_recovery_version_absence(error.as_ref()) => {
missing.insert(format!(
"{}/{}@{:?}: version metadata not yet present on replacement",
version.bucket, version.key, version.version_id
));
continue;
}
Err(error) => return Err(error),
};
census_object_version_on_disk(target_disk, &version.bucket, &version.key, version.version_id.as_deref())?;
if !actual.matches_manifest(&version.expected) {
missing.insert(format!("{}/{}@{:?}: {actual:?}", version.bucket, version.key, version.version_id));
}
@@ -875,15 +822,13 @@ mod tests {
let mut mount_ns = MountNamespaceGuard::new()?;
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(3, 4)).await?;
for node_index in 0..cluster.nodes.len() {
let node_log_path = replacement_node_log_path(&cluster.temp_dir, parity, node_index)?;
cluster.set_node_capture_log_path(node_index, node_log_path.to_string_lossy())?;
}
let target_log_path = PathBuf::from(&cluster.temp_dir).join(format!("replacement-node{TARGET_NODE}.log"));
cluster.set_node_capture_log_path(TARGET_NODE, target_log_path.to_string_lossy())?;
let target_disk = PathBuf::from(&cluster.nodes[TARGET_NODE].data_dirs[TARGET_DRIVE]);
// The blank target uses a temporary zram block device, so the
// replacement readiness fence sees no root or sibling alias.
// Each drive below is an independent tmpfs mount, so this privileged
// path must exercise the production distinct-device/readiness fences.
cluster.extra_env.retain(|(key, _)| key != "RUSTFS_UNSAFE_BYPASS_DISK_CHECK");
let image_root = PathBuf::from(&cluster.temp_dir).join("replacement-block-images");
let image_root = PathBuf::from(&cluster.temp_dir).join("replacement-faultable-images");
let mut target_mount = None;
for (node_index, node) in cluster.nodes.iter().enumerate() {
for (drive_index, drive) in node.data_dirs.iter().enumerate() {
@@ -900,7 +845,6 @@ mod tests {
}
}
let mut target_mount = target_mount.ok_or("target drive was not mounted with the faultable block fixture")?;
let mut replacement_mount = ZramBlockMount::reserve(&target_disk)?;
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
cluster.set_env("RUSTFS_SCANNER_ENABLED", "true");
@@ -908,34 +852,28 @@ mod tests {
cluster.set_env("RUSTFS_SCANNER_CYCLE", "1");
cluster.set_env("RUSTFS_SCANNER_START_DELAY_SECS", "0");
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", format!("EC:{parity}"));
for node_index in 0..cluster.nodes.len() {
cluster.set_node_env(node_index, "RUST_LOG", "rustfs=info,rustfs::heal::manager=debug,rustfs_notify=debug")?;
}
cluster.set_node_env(TARGET_NODE, "RUST_LOG", "rustfs=info,rustfs::heal::manager=debug,rustfs_notify=debug")?;
cluster.start().await?;
let clients = cluster.create_all_clients()?;
let versions = seed_baseline(&clients[0], &target_disk)
.await
.map_err(|error| format!("pre-fault baseline seeding failed: {error}"))?;
verify_bodies(&clients[0], &versions)
.await
.map_err(|error| format!("pre-fault body verification failed: {error}"))?;
let versions = seed_baseline(&clients[0], &target_disk).await?;
verify_bodies(&clients[0], &versions).await?;
target_mount
.make_unavailable()
.map_err(|error| format!("failed to install the dm-error target: {error}"))?;
target_mount
.verify_raw_io_is_unavailable()
.map_err(|error| format!("dm-error target was not proven by a direct raw read: {error}"))?;
assert_no_replacement_status_records(&cluster, &target_disk)
.await
.map_err(|error| format!("live-fault replacement status check failed: {error}"))?;
assert_no_replacement_admission_artifacts(&cluster, &target_disk)
.map_err(|error| format!("live-fault replacement artifact check failed: {error}"))?;
let live_loss_log_offset = log_len(&target_log_path)?;
target_mount.make_unavailable()?;
wait_for_live_disk_loss_observation(
&target_log_path,
&target_disk,
live_loss_log_offset,
ABSENT_SCANNER_OBSERVATION_TIMEOUT_SECS,
)
.await?;
assert_no_replacement_status_records(&cluster, &target_disk).await?;
assert_no_replacement_admission_artifacts(&cluster, &target_disk)?;
cluster.stop_node_gracefully(TARGET_NODE).await?;
cluster.stop_node(TARGET_NODE)?;
target_mount.cleanup()?;
replacement_mount.mount_target()?;
mount_ns.mount_tmpfs(&target_disk, &format!("rustfs-e2e-p{parity}-replacement"))?;
let missing_before_restart = incomplete_versions(&target_disk, &versions)?;
assert_eq!(
missing_before_restart.len(),
@@ -944,26 +882,46 @@ mod tests {
);
cluster.start_node(TARGET_NODE).await?;
let recovery_result = async {
wait_for_completed_replacement_with_census(&cluster, &target_disk, &versions, 420).await?;
verify_bodies(&clients[0], &versions).await
}
.await;
let stop_result = cluster.stop_node_gracefully(TARGET_NODE).await;
let replacement_cleanup_result = replacement_mount.cleanup();
wait_for_completed_replacement_with_census(&cluster, &target_disk, &versions, 420).await?;
verify_bodies(&clients[0], &versions).await?;
if let Err(error) = recovery_result {
if let Err(stop_error) = stop_result {
info!(%stop_error, "replacement target stop failed while preserving recovery failure");
}
if let Err(cleanup_error) = replacement_cleanup_result {
info!(%cleanup_error, "replacement zram cleanup failed while preserving recovery failure");
}
return Err(error);
}
stop_result?;
replacement_cleanup_result?;
Ok(())
}
#[test]
fn live_loss_barrier_requires_scanner_failure_after_log_offset() -> Result<(), Box<dyn Error + Send + Sync>> {
let target = Path::new("/mnt/target");
assert!(live_disk_loss_scan_completed(
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto-scan cycle completed",
target
));
assert!(live_disk_loss_scan_completed(
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle",
target
));
assert!(!live_disk_loss_scan_completed(
"Heal auto disk scanner idle\nHeal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed",
target
));
assert!(!live_disk_loss_scan_completed(
"event=disk_health_check_failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle",
target
));
assert!(!live_disk_loss_scan_completed(
"Heal auto-scan disk inspection failed endpoint=/mnt/other disk_state=check_failed\nHeal auto disk scanner idle",
target
));
let path = std::env::temp_dir().join(format!("rustfs-replacement-scan-{}.log", std::process::id()));
let stale =
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle\n";
fs::write(&path, stale)?;
let offset = log_len(&path)?;
assert!(!live_disk_loss_scan_completed_from_path(&path, offset, target)?);
let fresh =
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle\n";
fs::write(&path, format!("{stale}{fresh}"))?;
assert!(live_disk_loss_scan_completed_from_path(&path, offset, target)?);
fs::remove_file(path)?;
Ok(())
}
@@ -996,15 +954,6 @@ mod tests {
);
}
#[test]
fn recovery_census_only_treats_missing_version_as_transient() {
let missing_version: Box<dyn Error + Send + Sync> = Box::new(rustfs_filemeta::Error::FileVersionNotFound);
let missing_file: Box<dyn Error + Send + Sync> = Box::new(rustfs_filemeta::Error::FileNotFound);
assert!(is_transient_recovery_version_absence(missing_version.as_ref()));
assert!(!is_transient_recovery_version_absence(missing_file.as_ref()));
}
#[tokio::test]
async fn completion_poll_samples_census_before_status() {
let order = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
+1 -1
View File
@@ -15,7 +15,7 @@
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::disk::{RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions};
pub(crate) use rustfs_ecstore::api::disk::{VolumeInfo, WalkDirOptions};
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::rpc::{
-5
View File
@@ -440,11 +440,6 @@ pub mod object {
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
SnapshotConsistencyError,
};
#[cfg(feature = "test-util")]
pub mod test_util {
pub use crate::store::DeleteAfterObjectLockSnapshotBarrier;
}
}
pub mod rebalance {
@@ -485,19 +485,6 @@ impl BucketTargetSys {
mutex
}
/// Snapshot the heartbeat-tracked health of `url`'s endpoint.
///
/// Returns `None` when the heartbeat has never seen the endpoint. Unlike
/// [`Self::is_offline`] this deliberately does not call `init_hc`: a caller
/// that only reports metrics must not create health entries as a side
/// effect, or merely rendering a status page would mark an unknown peer
/// online.
pub async fn endpoint_health(&self, url: &Url) -> Option<EpHealth> {
let key = endpoint_health_key(url);
let health_map = self.h_mutex.read().await;
health_map.get(&key).cloned()
}
pub async fn is_offline(&self, url: &Url) -> bool {
let key = endpoint_health_key(url);
{
@@ -921,7 +921,7 @@ impl ExpiryState {
Ok(())
}
pub fn enqueue_free_version(&self, oi: ObjectInfo) -> bool {
pub fn enqueue_free_version(&mut self, oi: ObjectInfo) -> bool {
let task = FreeVersionTask(oi);
let wrkr = self.get_worker_ch(task.op_hash());
if wrkr.is_none() {
@@ -1215,22 +1215,6 @@ impl ExpiryState {
}
}
pub(crate) async fn enqueue_committed_free_versions(api: &ECStore, free_versions: Vec<ObjectInfo>) -> usize {
if free_versions.is_empty() {
return 0;
}
let expiry_state = api.ctx.expiry_state();
let state = expiry_state.read().await;
let mut queued = 0;
for free_version in free_versions {
if state.enqueue_free_version(free_version) {
queued += 1;
}
}
queued
}
async fn enqueue_recovered_free_version_with_state(state: &Arc<RwLock<ExpiryState>>, oi: ObjectInfo) -> bool {
let task = FreeVersionTask(oi);
let hash = task.op_hash();
@@ -6643,7 +6627,7 @@ mod tests {
async fn enqueue_free_version_reports_false_without_worker_channel() {
let state = ExpiryState::new();
let recovery_notify = Arc::clone(&state.read().await.recovery_notify);
let state = state.write().await;
let mut state = state.write().await;
let oi = ObjectInfo {
bucket: "bucket".to_string(),
name: "object".to_string(),
@@ -6835,7 +6819,7 @@ mod tests {
},
..Default::default()
};
let state = state.write().await;
let mut state = state.write().await;
assert!(state.enqueue_free_version(oi.clone()));
assert!(recovery_notify.notified().now_or_never().is_none());
+46 -35
View File
@@ -21,7 +21,7 @@ use crate::bucket::bucket_target_sys::BucketTargetSys;
use crate::bucket::metadata::{load_bucket_metadata_parse, load_bucket_metadata_parse_with_presence};
use crate::bucket::utils::is_meta_bucketname;
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result, is_err_bucket_not_found, is_err_strict_volume_not_found};
use crate::error::{Error, Result, is_err_bucket_not_found};
use crate::runtime::sources as runtime_sources;
use crate::storage_api_contracts::heal::HealOperations as _;
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
@@ -659,12 +659,13 @@ async fn acquire_config_write_guard_for_incarnation(
async {
match metadata_sys
.api
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.peer_sys
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
{
Ok(_) => Ok(()),
Err(err) if is_err_strict_volume_not_found(&err) => Err(Error::BucketNotFound(bucket.to_string())),
Err(err) => Err(err),
Err(crate::disk::error::Error::VolumeNotFound) => Err(Error::BucketNotFound(bucket.to_string())),
Err(err) => Err(err.into()),
}
},
),
@@ -1273,6 +1274,7 @@ pub struct BucketMetadataSys {
/// name floods while avoiding repeated namespace and erasure reads.
missing_buckets: moka::future::Cache<String, ()>,
api: Arc<ECStore>,
initialized: Arc<RwLock<bool>>,
}
impl BucketMetadataSys {
@@ -1301,6 +1303,7 @@ impl BucketMetadataSys {
.time_to_live(MISSING_BUCKET_TTL)
.build(),
api,
initialized: Arc::new(RwLock::new(false)),
}
}
@@ -1361,12 +1364,13 @@ impl BucketMetadataSys {
await_bucket_namespace_operation(Some(namespace_guard), bucket, operation, async {
match self
.api
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.peer_sys
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
{
Ok(_) => Ok(true),
Err(Error::VolumeNotFound) => Ok(false),
Err(err) => Err(err),
Err(crate::disk::error::Error::VolumeNotFound) => Ok(false),
Err(err) => Err(err.into()),
}
})
.await
@@ -1376,15 +1380,9 @@ impl BucketMetadataSys {
let _ = self.init_internal(buckets).await;
}
async fn init_internal(&self, buckets: Vec<String>) -> Result<()> {
let count = self
.api
.pools
.iter()
.map(|pool| pool.disk_set.len())
.sum::<usize>()
.checked_mul(10)
.filter(|count| *count != 0)
.ok_or_else(|| Error::other("bucket metadata store has no erasure sets"))?;
let count = runtime_sources::endpoint_erasure_set_count()
.map(|count| count * 10)
.ok_or_else(|| Error::other("endpoint pools not initialized"))?;
let mut failed_buckets: HashSet<String> = HashSet::new();
let mut buckets = buckets.as_slice();
@@ -1402,6 +1400,9 @@ impl BucketMetadataSys {
buckets = &buckets[count..]
}
let mut initialized = self.initialized.write().await;
*initialized = true;
Ok(())
}
@@ -1478,6 +1479,14 @@ impl BucketMetadataSys {
expected: Option<&Arc<BucketMetadata>>,
namespace_guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<()> {
await_bucket_namespace_operation(
Some(namespace_guard),
bucket,
"bucket metadata heal",
self.api.heal_bucket(bucket, &HealOpts::default()),
)
.await?;
if !self
.bucket_exists(bucket, namespace_guard, "bucket metadata existence check")
.await?
@@ -1497,20 +1506,6 @@ impl BucketMetadataSys {
return Ok(());
}
await_bucket_namespace_operation(
Some(namespace_guard),
bucket,
"bucket metadata heal",
self.api.heal_bucket(
bucket,
&HealOpts {
recreate: true,
..Default::default()
},
),
)
.await?;
let (bm, persisted) = await_bucket_namespace_operation(
Some(namespace_guard),
bucket,
@@ -1892,13 +1887,23 @@ impl BucketMetadataSys {
"lazy metadata IO must start while the bucket namespace read lock is held"
);
}
let (bm, persisted) = await_bucket_namespace_operation(
let (bm, persisted) = match await_bucket_namespace_operation(
Some(&guard),
bucket,
"lazy bucket metadata load",
Box::pin(load_bucket_metadata_parse_with_presence(self.api.clone(), bucket, true)),
)
.await?;
.await
{
Ok(res) => res,
Err(err) => {
return if *self.initialized.read().await {
Err(Error::other("errBucketMetadataNotInitialized"))
} else {
Err(err)
};
}
};
let bm = Arc::new(bm);
@@ -1909,9 +1914,11 @@ impl BucketMetadataSys {
"lazy bucket metadata existence check",
Box::pin(async {
self.api
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.peer_sys
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
.map(|_| ())
.map_err(Into::into)
}),
)
.await?;
@@ -2190,8 +2197,10 @@ impl BucketMetadataSys {
"legacy bucket metadata existence check",
async {
self.api
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.peer_sys
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
.map_err(crate::error::StorageError::from)
},
)
.await
@@ -2293,8 +2302,10 @@ impl BucketMetadataSys {
"bucket metadata snapshot existence check",
async {
self.api
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.peer_sys
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
.map_err(crate::error::StorageError::from)
},
)
.await
@@ -22,7 +22,6 @@ pub(crate) use rustfs_replication::{
delete_marker_purge_version_id, delete_replication_creates_marker, delete_replication_missing_source_decision,
delete_replication_object_opts, heal_uses_delete_replication_path, is_object_lock_denied_delete,
is_retryable_delete_replication_head_error, is_version_delete_replication, replicate_delete_outcome, replication_etags_match,
replication_multipart_complete_actual_size, replication_multipart_part_plan, replication_single_put_size_error,
resync_existing_delete_replication_info, resync_target_for_object, should_retry_delete_marker_purge,
single_part_replica_etag_mismatch, target_delete_version_id,
replication_multipart_complete_actual_size, replication_multipart_part_plan, resync_existing_delete_replication_info,
resync_target_for_object, should_retry_delete_marker_purge, single_part_replica_etag_mismatch, target_delete_version_id,
};
@@ -32,9 +32,8 @@ use super::replication_object_decision_boundary::{
MustReplicateOptions, ReplicationMultipartPartInput, delete_marker_purge_mrf_entry, delete_marker_purge_version_id,
delete_replication_creates_marker, heal_uses_delete_replication_path, is_object_lock_denied_delete,
is_retryable_delete_replication_head_error, is_version_delete_replication, replicate_delete_outcome, replication_etags_match,
replication_multipart_complete_actual_size, replication_multipart_part_plan, replication_single_put_size_error,
resync_existing_delete_replication_info, should_retry_delete_marker_purge, single_part_replica_etag_mismatch,
target_delete_version_id,
replication_multipart_complete_actual_size, replication_multipart_part_plan, resync_existing_delete_replication_info,
should_retry_delete_marker_purge, single_part_replica_etag_mismatch, target_delete_version_id,
};
use super::replication_queue_boundary::{DeletedObjectReplicationInfo, ReplicationQueueAdmission};
use super::replication_resync_boundary::ResyncStatusType;
@@ -89,7 +88,6 @@ use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use std::time::Instant;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tokio::io::AsyncRead;
@@ -120,7 +118,6 @@ const EVENT_DELETE_MARKER_PURGE_FAILED: &str = "replication_delete_marker_purge_
const EVENT_DELETE_MARKER_PURGE_MRF: &str = "replication_delete_marker_purge_mrf";
const METRIC_DELETE_MARKER_PURGE_TOTAL: &str = "rustfs_replication_delete_marker_purge_total";
const EVENT_REPLICATION_VERSION_IDENTITY_DRIFT: &str = "replication_version_identity_drift";
const EVENT_REPLICATION_OBJECT_FAILED: &str = "replication_object_failed";
const EVENT_REPLICATION_PURGE_OBJECT_LOCK_DENIED: &str = "replication_purge_object_lock_denied";
#[allow(
@@ -193,19 +190,11 @@ fn metadata_requires_existing_target(op_type: ReplicationType, object_info: &Obj
const METRIC_VERSION_IDENTITY_DRIFT_TOTAL: &str = "rustfs_replication_version_identity_drift_total";
/// How long a target stays quiet after reporting version-identity drift.
///
/// This used to be a plain "once per ARN per process": one line ever, which on
/// a long-lived server meant the single most important diagnostic for a
/// non-converging generic S3 target scrolled away hours before anyone looked
/// (rustfs#6822). Re-arming on an interval keeps the log bounded while leaving
/// the condition discoverable in any recent window.
const VERSION_IDENTITY_DRIFT_LOG_INTERVAL: TokioDuration = TokioDuration::from_secs(600);
/// When each target last reported version-identity drift, by ARN. Throttling is
/// advisory only — the metric still counts every drifting PUT.
static VERSION_IDENTITY_WARNED_ARNS: LazyLock<StdMutex<HashMap<String, Instant>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
/// Targets that already produced a version-identity-drift warning this
/// process lifetime, by ARN. Deduping is advisory only (the metric still
/// counts every drifting PUT), so a reconfigured target re-warning only
/// after a restart is acceptable.
static VERSION_IDENTITY_WARNED_ARNS: LazyLock<StdMutex<HashSet<String>>> = LazyLock::new(|| StdMutex::new(HashSet::new()));
/// Version purges the peer denied under object lock (#6850). A RustFS peer
/// with the replicated-purge GOVERNANCE exemption
@@ -333,39 +322,20 @@ fn audit_target_version_identity(tgt_client: &TargetClient, source_version_id: &
return;
}
counter!(METRIC_VERSION_IDENTITY_DRIFT_TOTAL).increment(1);
if !version_identity_drift_log_due(&tgt_client.arn, Instant::now()) {
return;
}
// `error`, not `warn`: the target silently refuses the addressing scheme
// every version-addressed delete and heal on it depends on, so replication
// to it can never converge. At `warn` this sat below `DEFAULT_LOG_LEVEL`
// and no default deployment ever saw the one line that explains why a
// purged version is still on the target (rustfs#6822).
error!(
event = EVENT_REPLICATION_VERSION_IDENTITY_DRIFT,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
arn = %tgt_client.arn,
endpoint = %tgt_client.endpoint,
sent_version_id = %source_version_id,
assigned_version_id = assigned_version_id.unwrap_or("<none>"),
"Replication target does not adopt source version ids; version-addressed replication cannot converge (run ?replication-check for details)"
);
}
/// Whether this ARN's version-identity drift is due to be logged again at
/// `now`, re-arming the throttle when it is. Split out from the audit so the
/// interval policy is testable without a target client.
fn version_identity_drift_log_due(arn: &str, now: Instant) -> bool {
let mut warned = VERSION_IDENTITY_WARNED_ARNS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
match warned.get(arn) {
Some(last) if now.duration_since(*last) < VERSION_IDENTITY_DRIFT_LOG_INTERVAL => false,
_ => {
warned.insert(arn.to_string(), now);
true
}
if warned.insert(tgt_client.arn.clone()) {
warn!(
event = EVENT_REPLICATION_VERSION_IDENTITY_DRIFT,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
arn = %tgt_client.arn,
endpoint = %tgt_client.endpoint,
sent_version_id = %source_version_id,
assigned_version_id = assigned_version_id.unwrap_or("<none>"),
"Replication target does not adopt source version ids; version-addressed replication cannot converge (run ?replication-check for details)"
);
}
}
@@ -2080,13 +2050,10 @@ pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
}
}
let delete_version_id = dobj.delete_object.version_id.map(|v| v.to_string());
note_replication_terminal_failure(&bucket, &dobj.delete_object.object_name, delete_version_id.as_deref(), &rinfos);
let mut drs = get_replication_state(
&rinfos,
&dobj.delete_object.replication_state.clone().unwrap_or_default(),
delete_version_id,
dobj.delete_object.version_id.map(|v| v.to_string()),
);
if replication_status != prev_status {
drs.replication_timestamp = Some(OffsetDateTime::now_utc());
@@ -3056,11 +3023,8 @@ pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
}
}
let version_id = roi.version_id.map(|v| v.to_string());
note_replication_terminal_failure(&bucket, &object, version_id.as_deref(), &rinfos);
let previous_state = roi.replication_state.clone().unwrap_or_default();
let merged_state = get_replication_state(&rinfos, &previous_state, version_id);
let merged_state = get_replication_state(&rinfos, &previous_state, roi.version_id.map(|v| v.to_string()));
let replication_status = merged_state.composite_replication_status();
let new_replication_internal = merged_state.replication_status_internal.clone();
let mut object_info = roi.to_object_info();
@@ -3137,61 +3101,6 @@ pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
(merged_state, state_persisted)
}
/// Emit the operator-visible record of a replication attempt that ended FAILED.
///
/// Every per-branch failure log in this module is deliberately quieter than
/// `error`: most of them sit on the replication hot path and fire once per
/// object *per ARN*, so a target that stays unreachable would flood the log
/// from inside the transfer loop. That left a hole customers fell into
/// (rustfs#6825): `DEFAULT_LOG_LEVEL` is `error`, so on a stock deployment a
/// failed object produced no line at all, and an operator staring at a replica
/// that never arrived had nothing to correlate — the same trap already
/// documented for the GET path in
/// `crates/e2e_test/src/get_stream_failure_observability_test.rs`.
///
/// This is the one place that knows an object reached a *terminal* FAILED state
/// for a target, so this is where the guaranteed-visible line belongs. It is
/// bounded by the number of objects that actually fail rather than by attempts
/// inside a transfer, and it carries the target's own error so a remote
/// rejection is diagnosable without the operator first having to lower the
/// global log level and reproduce.
fn note_replication_terminal_failure(bucket: &str, object: &str, version_id: Option<&str>, rinfos: &ReplicatedInfos) {
for target in rinfos.targets.iter() {
if target.is_empty() {
continue;
}
let replication_failed = target.replication_status == ReplicationStatusType::Failed;
let purge_failed = target.version_purge_status == VersionPurgeStatusType::Failed;
if !replication_failed && !purge_failed {
continue;
}
error!(
event = EVENT_REPLICATION_OBJECT_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
object = %object,
version_id = version_id.unwrap_or("-"),
arn = %target.arn,
endpoint = %target.endpoint,
op_type = %target.op_type,
size = target.size,
replication_status = %target.replication_status.as_str(),
version_purge_status = %target.version_purge_status.as_str(),
// The target's error can carry a signed URL or an echoed auth
// header, so it goes through the same redaction as the persisted
// resync detail rather than straight into the log.
error = %target
.error
.as_deref()
.and_then(sanitize_resync_error_detail)
.unwrap_or_else(|| "<none>".to_string()),
"Replication failed for object"
);
}
}
fn unavailable_object_target_info(roi: &ReplicateObjectInfo, arn: &str) -> ReplicatedTargetInfo {
ReplicatedTargetInfo {
arn: arn.to_string(),
@@ -3491,33 +3400,6 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
}
};
if let Some(reason) = replication_single_put_size_error(is_multipart, transfer_size) {
drop(gr);
rinfo.replication_status = ReplicationStatusType::Failed;
rinfo.error = Some(reason.clone());
warn!(
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
target_bucket = %tgt_client.bucket,
arn = %tgt_client.arn,
object = %object,
operation = "put_object",
transfer_size = transfer_size,
error = %reason,
"Replication target operation failed"
);
send_local_event(EventArgs {
event_name: EventName::ObjectReplicationNotTracked.to_string(),
bucket_name: bucket.clone(),
object: object_info,
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
return rinfo;
}
if let Some(err) = if is_multipart {
drop(gr);
let result = replicate_object_with_multipart(MultipartReplicationContext {
@@ -4186,14 +4068,6 @@ async fn replicate_all_payload_to_target<S: ReplicationObjectIO>(
ctx: ReplicateAllPayloadContext<'_, S>,
mut gr: GetObjectReader,
) -> Option<std::io::Error> {
// Fail before streaming a body the target is required to reject: an S3
// PutObject caps at 5 GiB, and this route is chosen by the source object's
// storage shape rather than its size (rustfs#6825).
if let Some(reason) = replication_single_put_size_error(ctx.is_multipart, ctx.transfer_size) {
drop(gr);
return Some(std::io::Error::other(reason));
}
if ctx.is_multipart {
drop(gr);
let result = replicate_object_with_multipart(MultipartReplicationContext {
@@ -5842,207 +5716,4 @@ mod tests {
assert!(!retry_scheduled.load(Ordering::SeqCst));
assert_eq!(result.unwrap_err().to_string(), "transfer failed");
}
/// A replication target's terminal outcome, as the operator sees it.
fn failed_target(arn: &str, error: &str) -> ReplicatedTargetInfo {
ReplicatedTargetInfo {
arn: arn.to_string(),
size: 6 * 1024 * 1024 * 1024,
op_type: ReplicationType::Object,
replication_status: ReplicationStatusType::Failed,
endpoint: "s3.wasabisys.com".to_string(),
error: Some(error.to_string()),
..Default::default()
}
}
/// Capture the log this module writes, filtered exactly the way a stock
/// deployment filters it.
fn logs_at_default_level(emit: impl FnOnce()) -> String {
use std::sync::{Arc, Mutex};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::layer::SubscriberExt;
#[derive(Clone, Default)]
struct CapturedLogs {
buffer: Arc<Mutex<Vec<u8>>>,
}
struct CapturedLogWriter {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl std::io::Write for CapturedLogWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.buffer
.lock()
.expect("captured logs mutex should not be poisoned")
.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for CapturedLogs {
type Writer = CapturedLogWriter;
fn make_writer(&'a self) -> Self::Writer {
CapturedLogWriter {
buffer: Arc::clone(&self.buffer),
}
}
}
let logs = CapturedLogs::default();
let subscriber = tracing_subscriber::registry()
// Not a hand-picked level: this is the filter an operator who has
// changed nothing is actually running.
.with(EnvFilter::new(rustfs_config::DEFAULT_LOG_LEVEL))
.with(
tracing_subscriber::fmt::layer()
.with_writer(logs.clone())
.with_ansi(false)
.without_time(),
);
let _guard = tracing::subscriber::set_default(subscriber);
let _callsite_pin = crate::test_tracing::pin_callsite_interest_for_test();
emit();
let buffer = logs
.buffer
.lock()
.expect("captured logs mutex should not be poisoned")
.clone();
String::from_utf8(buffer).expect("captured logs should be valid UTF-8")
}
/// rustfs#6825: a 6 GiB object never reached the target and the server said
/// nothing an operator could act on, because every failure line in this
/// module sat below `DEFAULT_LOG_LEVEL`. The object key, the target, and
/// the target's own error have to survive the default filter.
#[test]
fn failed_replication_names_the_object_at_the_default_log_level() {
let rinfos = ReplicatedInfos {
replication_timestamp: Some(OffsetDateTime::now_utc()),
targets: vec![failed_target("arn:replication::wasabi", "put_object failed: EntityTooLarge")],
};
let logs = logs_at_default_level(|| {
note_replication_terminal_failure("photos", "backups/vm-image.qcow2", Some("v-9"), &rinfos);
});
assert!(logs.contains("backups/vm-image.qcow2"), "the failed object must be named: {logs}");
assert!(logs.contains("arn:replication::wasabi"), "the target must be named: {logs}");
assert!(logs.contains("EntityTooLarge"), "the target's own error must survive: {logs}");
assert!(logs.contains("v-9"), "the version must be named: {logs}");
assert!(logs.contains(EVENT_REPLICATION_OBJECT_FAILED), "the event must be structured: {logs}");
}
#[test]
fn successful_replication_stays_quiet_at_the_default_log_level() {
let rinfos = ReplicatedInfos {
replication_timestamp: Some(OffsetDateTime::now_utc()),
targets: vec![ReplicatedTargetInfo {
arn: "arn:replication::wasabi".to_string(),
replication_status: ReplicationStatusType::Completed,
..Default::default()
}],
};
let logs = logs_at_default_level(|| {
note_replication_terminal_failure("photos", "backups/ok.bin", None, &rinfos);
});
assert!(logs.is_empty(), "a completed replication must not log an error: {logs}");
}
/// A failed version purge is the 6822 symptom (the version stays on the
/// target); it must be as visible as a failed transfer even though the
/// replication status itself is not FAILED.
#[test]
fn failed_version_purge_is_reported_at_the_default_log_level() {
let rinfos = ReplicatedInfos {
replication_timestamp: Some(OffsetDateTime::now_utc()),
targets: vec![ReplicatedTargetInfo {
arn: "arn:replication::wasabi".to_string(),
op_type: ReplicationType::Delete,
replication_status: ReplicationStatusType::Empty,
version_purge_status: VersionPurgeStatusType::Failed,
error: Some("remove_object failed: NoSuchVersion".to_string()),
..Default::default()
}],
};
let logs = logs_at_default_level(|| {
note_replication_terminal_failure("photos", "backups/purged.bin", Some("v-1"), &rinfos);
});
assert!(logs.contains("backups/purged.bin"), "the purged object must be named: {logs}");
assert!(logs.contains("NoSuchVersion"), "the target's own error must survive: {logs}");
}
/// The target's error is echoed remote text and can carry a signed URL or
/// an auth header, so it goes through the persisted-detail redaction rather
/// than straight into the log.
#[test]
fn failed_replication_redacts_a_sensitive_target_error() {
let rinfos = ReplicatedInfos {
replication_timestamp: Some(OffsetDateTime::now_utc()),
targets: vec![failed_target(
"arn:replication::wasabi",
"put_object failed: rejected Authorization: Bearer super-secret",
)],
};
let logs = logs_at_default_level(|| {
note_replication_terminal_failure("photos", "backups/vm-image.qcow2", None, &rinfos);
});
assert!(logs.contains("backups/vm-image.qcow2"), "the object must still be named: {logs}");
assert!(!logs.contains("super-secret"), "the credential must not reach the log: {logs}");
}
/// An empty target slot carries no outcome; reporting it would invent a
/// failure for a target that was never attempted.
#[test]
fn empty_target_slots_are_not_reported_as_failures() {
let rinfos = ReplicatedInfos {
replication_timestamp: Some(OffsetDateTime::now_utc()),
targets: vec![ReplicatedTargetInfo::default()],
};
let logs = logs_at_default_level(|| {
note_replication_terminal_failure("photos", "backups/unattempted.bin", None, &rinfos);
});
assert!(logs.is_empty(), "an empty target slot must not be reported: {logs}");
}
#[test]
fn version_identity_drift_re_arms_after_the_throttle_interval() {
let arn = "arn:replication::drift-throttle-test";
let start = Instant::now();
assert!(version_identity_drift_log_due(arn, start), "first drift must be reported");
assert!(
!version_identity_drift_log_due(arn, start + VERSION_IDENTITY_DRIFT_LOG_INTERVAL / 2),
"a second drift inside the interval must stay throttled"
);
assert!(
version_identity_drift_log_due(arn, start + VERSION_IDENTITY_DRIFT_LOG_INTERVAL),
"drift must become visible again once the interval elapses, instead of \
going silent for the rest of the process lifetime"
);
}
#[test]
fn version_identity_drift_throttles_each_target_independently() {
let now = Instant::now();
assert!(version_identity_drift_log_due("arn:replication::drift-a", now));
assert!(
version_identity_drift_log_due("arn:replication::drift-b", now),
"one target's report must not silence another's"
);
}
}
@@ -260,15 +260,8 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
// the response path relies on). Keep the object's own multipart
// flag so encrypted objects stay on the multipart route.
} else {
let (checksum_meta, checksum_record_is_multipart) = object_info.decrypt_checksums(0, &HeaderMap::new())?;
// The checksum record describes how the *checksum* is composed,
// not how the object is stored. A full-object checksum carries no
// MULTIPART flag even on a multipart upload, so trusting it here
// routed a 768-part object through a single PutObject and the
// target rejected the 6 GiB body with EntityTooLarge
// (rustfs#6825). The object's own shape is the authority: the
// record may only add multipart-ness, never take it away.
is_multipart = object_info.is_multipart() || checksum_record_is_multipart;
let (checksum_meta, is_mp) = object_info.decrypt_checksums(0, &HeaderMap::new())?;
is_multipart = is_mp;
for (key, value) in checksum_meta.iter() {
if key != AMZ_CHECKSUM_TYPE {
@@ -525,109 +518,6 @@ mod tests {
use time::Duration;
use uuid::Uuid;
/// Serialize an object-level checksum record the way
/// `complete_multipart_upload` persists it for a **full-object** checksum:
/// the record carries the plain algorithm type, without the MULTIPART
/// flags that a composite record gets.
fn full_object_multipart_checksum_record() -> bytes::Bytes {
let checksum_type = rustfs_rio::ChecksumType::from_string_with_obj_type("crc32", "FULL_OBJECT");
assert!(checksum_type.is_set(), "crc32 FULL_OBJECT must be a valid checksum type");
assert!(checksum_type.full_object_requested());
let mut combined = Vec::new();
let mut checksum = rustfs_rio::Checksum {
checksum_type,
..Default::default()
};
for part in [b"part-one".as_slice(), b"part-two".as_slice()] {
let part_checksum = rustfs_rio::Checksum::new_from_data(checksum_type, part).expect("part checksum");
combined.extend_from_slice(part_checksum.raw.as_slice());
checksum.add_part(&part_checksum, part.len() as i64).expect("add part");
}
checksum.to_bytes(&combined)
}
#[test]
fn multipart_object_with_full_object_checksum_keeps_the_multipart_route() {
// rustfs#6825: a 768-part upload was replicated with a single
// PutObject and rejected by the target with EntityTooLarge. The
// object's storage shape says multipart; only the checksum record
// looked single-part, and the checksum record must not decide the
// transport.
let object_info = ObjectInfo {
etag: Some("0123456789abcdef0123456789abcdef-768".to_string()),
checksum: Some(full_object_multipart_checksum_record()),
size: 6 * 1024 * 1024 * 1024,
..Default::default()
};
assert!(object_info.is_multipart(), "the fixture must be a multipart object");
let (_, checksum_says_multipart) = object_info
.decrypt_checksums(0, &HeaderMap::new())
.expect("checksum record must decode");
assert!(
!checksum_says_multipart,
"fixture precondition: a full-object record carries no MULTIPART flag, which is what used to \
downgrade the transport"
);
let (_, is_multipart) = replication_put_object_options("STANDARD", &object_info).expect("build put options");
assert!(
is_multipart,
"a multipart object must replicate over multipart whatever its checksum record looks like"
);
}
#[test]
fn checksum_record_never_changes_the_transport_a_single_part_object_needs() {
// The mirror of the rustfs#6825 guard: an object stored as one PUT
// must keep the single-PUT transport, or its replica's ETag would
// change shape and every ETag-based convergence check would re-copy it.
let checksum =
rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::CRC32, b"whole-object").expect("checksum fixture");
let object_info = ObjectInfo {
etag: Some("0123456789abcdef0123456789abcdef".to_string()),
checksum: Some(checksum.to_bytes(&[])),
..Default::default()
};
assert!(!object_info.is_multipart(), "the fixture must be a single-part object");
let (_, is_multipart) = replication_put_object_options("STANDARD", &object_info).expect("build put options");
assert!(!is_multipart, "a single-part object must not be promoted onto the multipart transport");
}
#[test]
fn composite_checksum_multipart_object_keeps_the_multipart_route() {
// The checksum shape that already worked before rustfs#6825, pinned so
// the fix cannot regress it.
let mut checksum_type = rustfs_rio::ChecksumType::from_string("crc32");
checksum_type
.merge(rustfs_rio::ChecksumType::MULTIPART)
.merge(rustfs_rio::ChecksumType::INCLUDES_MULTIPART);
let mut combined = Vec::new();
for part in [b"part-one".as_slice(), b"part-two".as_slice()] {
let part_checksum =
rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::from_string("crc32"), part).expect("part checksum");
combined.extend_from_slice(part_checksum.raw.as_slice());
}
let checksum = rustfs_rio::Checksum::new_from_data(checksum_type, &combined).expect("composite checksum");
let object_info = ObjectInfo {
etag: Some("0123456789abcdef0123456789abcdef-2".to_string()),
checksum: Some(checksum.to_bytes(&combined)),
..Default::default()
};
let (_, is_multipart) = replication_put_object_options("STANDARD", &object_info).expect("build put options");
assert!(is_multipart, "a composite-checksum multipart object must stay on the multipart transport");
}
#[test]
fn replication_action_for_target_head_existing_object_source_newer_null_version_requires_replication() {
let source = ObjectInfo {
@@ -190,7 +190,7 @@ fn install_heal_bucket_pre_mutation_barrier() -> Arc<DeleteBucketEmptyScanBarrie
}
#[cfg(test)]
pub(crate) async fn pause_after_delete_bucket_empty_scan() {
async fn pause_after_delete_bucket_empty_scan() {
let barrier = DELETE_BUCKET_EMPTY_SCAN_BARRIER
.lock()
.expect("empty scan barrier lock should not be poisoned")
+14 -452
View File
@@ -49,7 +49,7 @@ use crate::error::{
is_err_version_not_found,
};
use crate::layout::endpoints::EndpointServerPools;
use crate::object_api::{DecommissionCapacityOptions, GetObjectReader, ObjectInfo, ObjectOptions};
use crate::object_api::{DecommissionCapacityOptions, GetObjectReader, ObjectOptions};
use crate::runtime::sources as runtime_sources;
use crate::services::notification_sys::{
acquire_tier_delete_journal_fleet_proof, tier_delete_journal_fleet_proof_matches, tier_delete_journal_topology_generation,
@@ -78,9 +78,7 @@ use rmp_serde::Serializer;
use rustfs_filemeta::{FileInfoVersions, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams};
use rustfs_heal_contracts::heal_channel::HealOpts;
use rustfs_utils::crypto::{hex_sha256, is_sha256_checksum};
use rustfs_utils::path::{
decode_dir_object, encode_dir_object, path_join, path_to_bucket_object, path_to_bucket_object_with_base_path,
};
use rustfs_utils::path::{encode_dir_object, path_join, path_to_bucket_object, path_to_bucket_object_with_base_path};
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ReplicationConfiguration};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -4077,54 +4075,23 @@ pub(crate) struct PoolMetaWriteState {
expected_cluster_id: Option<uuid::Uuid>,
cluster_epoch: Option<u64>,
pool_meta_absent: bool,
bootstrap_authority: PoolMetaBootstrapAuthority,
fresh_bootstrap_proven: bool,
identity_initialized: Option<bool>,
identity_fresh_bootstrap_nonce: Option<uuid::Uuid>,
identity_needs_repair: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum PoolMetaBootstrapAuthority {
#[default]
None,
Fresh,
LegacyAdoption,
}
impl PoolMetaBootstrapAuthority {
pub(crate) fn combine_across_pools(self, other: Self) -> Self {
if self == other { self } else { Self::None }
}
fn is_proven(self) -> bool {
!matches!(self, Self::None)
}
}
impl PoolMetaWriteState {
#[cfg(test)]
pub(crate) fn for_startup(cluster_id: uuid::Uuid, fresh_bootstrap_proven: bool) -> Self {
let bootstrap_authority = if fresh_bootstrap_proven {
PoolMetaBootstrapAuthority::Fresh
} else {
PoolMetaBootstrapAuthority::None
};
Self::for_startup_with_bootstrap_authority(cluster_id, bootstrap_authority)
}
pub(crate) fn for_startup_with_bootstrap_authority(
cluster_id: uuid::Uuid,
bootstrap_authority: PoolMetaBootstrapAuthority,
) -> Self {
Self {
expected_cluster_id: Some(cluster_id),
bootstrap_authority,
fresh_bootstrap_proven,
..Default::default()
}
}
pub(crate) fn bootstrap_identity_proven(&self) -> bool {
self.bootstrap_authority.is_proven()
pub(crate) fn fresh_bootstrap_proven(&self) -> bool {
self.fresh_bootstrap_proven
}
pub(crate) fn identity_is_pending(&self) -> bool {
@@ -4146,7 +4113,7 @@ impl PoolMetaWriteState {
#[cfg(any(test, feature = "test-util"))]
fn for_test_bootstrap() -> Self {
Self {
bootstrap_authority: PoolMetaBootstrapAuthority::Fresh,
fresh_bootstrap_proven: true,
identity_initialized: Some(false),
identity_fresh_bootstrap_nonce: Some(uuid::Uuid::new_v4()),
..Default::default()
@@ -4207,7 +4174,7 @@ impl PoolMetaWriteState {
self.identity_fresh_bootstrap_nonce = selection.identity.and_then(|identity| identity.fresh_bootstrap_nonce);
if let Some(identity) = selection.identity {
if identity.initialized {
self.bootstrap_authority = PoolMetaBootstrapAuthority::None;
self.fresh_bootstrap_proven = false;
}
if let Some(metadata_epoch) = self.cluster_epoch
&& metadata_epoch != identity.epoch
@@ -4231,11 +4198,11 @@ impl PoolMetaWriteState {
return Ok(());
}
match self.identity_initialized {
Some(false) if self.bootstrap_identity_proven() && self.identity_fresh_bootstrap_nonce.is_some() => Ok(()),
Some(false) if self.fresh_bootstrap_proven && self.identity_fresh_bootstrap_nonce.is_some() => Ok(()),
Some(false) => {
self.block_writes();
Err(Error::other(
"pool metadata recovery required: pending cluster identity exists but this startup has no verified fresh-bootstrap proof or legacy-adoption proof",
"pool metadata recovery required: pending cluster identity exists but this startup has no verified fresh-bootstrap proof",
))
}
Some(true) => {
@@ -5217,10 +5184,10 @@ where
..identity
},
Some(identity) => identity,
None if !initialized && !write_state.bootstrap_identity_proven() => {
None if !initialized && !write_state.fresh_bootstrap_proven() => {
write_state.block_writes();
return Err(Error::other(
"pool metadata recovery required: cannot create a pending cluster identity without verified fresh-bootstrap proof or legacy-adoption proof",
"pool metadata recovery required: cannot create a pending cluster identity without verified fresh-bootstrap proof",
));
}
None => PersistedPoolMetaIdentity {
@@ -7192,170 +7159,6 @@ pub(crate) fn decommission_capacity_mutation_id(
uuid::Uuid::from_bytes(bytes)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ExactDeleteCapacityReconciliation {
source_pool_index: usize,
target_pool_index: usize,
mutation_id: uuid::Uuid,
expected_data_bytes: usize,
expected_target_physical_bytes: usize,
}
fn plan_exact_delete_capacity_reconciliations(
meta: &PoolMeta,
object: &str,
exact: &ObjectInfo,
) -> Result<Vec<ExactDeleteCapacityReconciliation>> {
let version_id = exact.version_id.map(|version_id| version_id.to_string());
let mut matches = Vec::new();
for (source_pool_index, pool) in meta.pools.iter().enumerate() {
let Some(reservation) = pool
.decommission
.as_ref()
.and_then(|info| info.capacity_reservation.as_ref())
.filter(|reservation| reservation.active())
else {
continue;
};
let owner = DecommissionCapacityOwner {
source_pool_index,
operation_id: reservation.operation_id,
generation: reservation.generation,
owner_nonce: reservation.owner_nonce,
mutation_id: None,
};
let logical_mutation_id = decommission_capacity_mutation_id(
owner,
&exact.bucket,
&exact.name,
version_id.as_deref(),
exact.delete_marker,
exact.mod_time,
);
// Existing data-movement producers persist directory-key intents using
// either the logical name or its internal `__XLDIR__` representation.
// Accept both while retaining the exact persisted identity for CAS.
let internal_mutation_id = if object == exact.name {
logical_mutation_id
} else {
decommission_capacity_mutation_id(
owner,
&exact.bucket,
object,
version_id.as_deref(),
exact.delete_marker,
exact.mod_time,
)
};
let mut source_match = None;
for target in &reservation.targets {
if target.pending_physical_bytes == 0 {
continue;
}
let Some(pending_mutation_id) = target.pending_mutation_id else {
return Err(decommission_capacity_blocked_error(format!(
"source pool {source_pool_index} target pool {} has pending capacity without an object identity",
target.pool_index
)));
};
if pending_mutation_id != logical_mutation_id && pending_mutation_id != internal_mutation_id {
continue;
}
if source_match.is_some() {
return Err(decommission_capacity_blocked_error(format!(
"source pool {source_pool_index} has the same exact-delete capacity intent on multiple targets"
)));
}
source_match = Some((target.pool_index, target.layout, target.pending_physical_bytes, pending_mutation_id));
}
let Some((target_pool_index, target_layout, pending_physical_bytes, mutation_id)) = source_match else {
continue;
};
if exact.version_id.is_none() && exact.mod_time.is_none() {
return Err(decommission_capacity_blocked_error(
"unversioned exact delete cannot identify pending capacity without a modification time",
));
}
let expected_data_bytes = if exact.delete_marker {
0
} else {
usize::try_from(exact.size).map_err(|_| {
decommission_capacity_blocked_error("exact delete cannot reconcile a negative or overflowing object size")
})?
};
let expected_target_physical_bytes = capacity_target_physical_bytes(expected_data_bytes.max(1), target_layout)?;
if pending_physical_bytes != expected_target_physical_bytes {
return Err(decommission_capacity_blocked_error(format!(
"source pool {source_pool_index} target pool {target_pool_index} pending capacity does not match the exact object size"
)));
}
let remaining_target_physical_bytes = reservation
.targets
.iter()
.find(|target| target.pool_index == target_pool_index)
.map(|target| {
target.remaining_reserved_physical_bytes(reservation.temporary_copies)
/ 1usize.saturating_add(reservation.temporary_copies)
})
.unwrap_or_default();
let remaining_total_physical_bytes = reservation
.predicted_physical_bytes
.saturating_sub(reservation.consumed_target_physical_bytes);
let remaining_data_bytes = reservation
.source_data_equivalent_bytes
.saturating_sub(reservation.committed_data_bytes);
if expected_target_physical_bytes > remaining_target_physical_bytes
|| expected_target_physical_bytes > remaining_total_physical_bytes
|| expected_data_bytes > remaining_data_bytes
{
return Err(decommission_capacity_blocked_error(format!(
"source pool {source_pool_index} target pool {target_pool_index} lacks reservation capacity for the exact object"
)));
}
matches.push(ExactDeleteCapacityReconciliation {
source_pool_index,
target_pool_index,
mutation_id,
expected_data_bytes,
expected_target_physical_bytes,
});
}
Ok(matches)
}
fn ensure_exact_delete_capacity_namespace_fences(opts: &ObjectOptions, bucket: &str, object: &str) -> Result<()> {
let object_fence = opts.namespace_lock_fence.as_ref().ok_or_else(|| {
decommission_capacity_blocked_error("exact delete capacity reconciliation requires an object namespace fence")
})?;
if object_fence.is_lock_lost() {
return Err(StorageError::NamespaceLockQuorumUnavailable {
mode: "exact_delete_capacity_reconciliation",
bucket: bucket.to_string(),
object: decode_dir_object(object),
required: 1,
achieved: 0,
});
}
if opts
.bucket_lifecycle_lock_fence
.as_ref()
.is_some_and(crate::object_api::NamespaceLockFence::is_lock_lost)
{
return Err(StorageError::NamespaceLockQuorumUnavailable {
mode: "exact_delete_capacity_bucket_generation",
bucket: bucket.to_string(),
object: decode_dir_object(object),
required: 1,
achieved: 0,
});
}
Ok(())
}
pub(crate) fn ensure_decommission_capacity_mutation_id(bucket: &str, object: &str, opts: &mut ObjectOptions) {
if opts
.decommission_capacity
@@ -8419,117 +8222,6 @@ impl ECStore {
.await
}
pub(crate) async fn reconcile_decommission_capacity_before_exact_delete(
&self,
bucket: &str,
object: &str,
opts: &ObjectOptions,
exact: &ObjectInfo,
) -> Result<()> {
if exact.bucket != bucket || exact.name != decode_dir_object(object) {
return Err(decommission_capacity_blocked_error(
"exact delete object identity changed before capacity reconciliation",
));
}
let reconciliations = {
let mut save_guard = self.pool_meta_save_gate.lock().await;
let (_read_guard, snapshot) = self
.acquire_pool_meta_read_guard(&mut save_guard, "exact delete capacity reconciliation failed")
.await?;
plan_exact_delete_capacity_reconciliations(&snapshot, object, exact)?
};
if reconciliations.is_empty() {
return Ok(());
}
ensure_exact_delete_capacity_namespace_fences(opts, bucket, object)?;
let target_lookup_options = ObjectOptions {
versioned: opts.versioned,
version_suspended: opts.version_suspended,
version_id: opts.version_id.clone(),
metadata_chg: opts.version_id.is_some(),
no_lock: true,
..Default::default()
};
for reconciliation in &reconciliations {
let target_pool = self.pools.get(reconciliation.target_pool_index).ok_or_else(|| {
decommission_capacity_blocked_error(format!(
"source pool {} exact-delete capacity target pool {} is out of range",
reconciliation.source_pool_index, reconciliation.target_pool_index
))
})?;
let target = target_pool
.get_object_info(bucket, object, &target_lookup_options)
.await
.map_err(|err| {
decommission_capacity_blocked_error(format!(
"source pool {} target pool {} exact object evidence could not be read: {err}",
reconciliation.source_pool_index, reconciliation.target_pool_index
))
})?;
if !Self::is_equivalent_decommission_capacity_target(exact, &target) {
return Err(decommission_capacity_blocked_error(format!(
"source pool {} target pool {} does not contain an equivalent exact object for its pending capacity intent",
reconciliation.source_pool_index, reconciliation.target_pool_index
)));
}
}
ensure_exact_delete_capacity_namespace_fences(opts, bucket, object)?;
let mut save_guard = self.pool_meta_save_gate.lock().await;
let (write_guard, mut snapshot) = self
.acquire_pool_meta_write_guard(&mut save_guard, "exact delete capacity reconciliation failed")
.await?;
let current_reconciliations = plan_exact_delete_capacity_reconciliations(&snapshot, object, exact)?;
if current_reconciliations != reconciliations {
return Err(decommission_capacity_blocked_error(
"pending capacity changed while exact target evidence was being verified",
));
}
ensure_exact_delete_capacity_namespace_fences(opts, bucket, object)?;
let now = OffsetDateTime::now_utc();
let mut source_pool_indices = Vec::with_capacity(current_reconciliations.len());
for reconciliation in current_reconciliations {
resolve_decommission_target_pending(
&mut snapshot,
reconciliation.source_pool_index,
reconciliation.target_pool_index,
reconciliation.expected_target_physical_bytes,
reconciliation.mutation_id,
)?;
record_decommission_target_consumption(
&mut snapshot,
reconciliation.source_pool_index,
reconciliation.target_pool_index,
DecommissionTargetConsumption {
committed_data_bytes: reconciliation.expected_data_bytes,
target_physical_bytes: reconciliation.expected_target_physical_bytes,
observed_physical_bytes: 0,
},
reconciliation.mutation_id,
now,
)?;
source_pool_indices.push(reconciliation.source_pool_index);
}
source_pool_indices.sort_unstable();
source_pool_indices.dedup();
ensure_exact_delete_capacity_namespace_fences(opts, bucket, object)?;
let outcome = snapshot
.save_no_lock_armed(self.pools.clone(), &mut save_guard, write_guard.lock_lost_signal(), &source_pool_indices)
.await?;
ensure_pool_meta_write_fence(&write_guard, "exact delete capacity reconciliation save failed")?;
{
let mut pool_meta = self.pool_meta.write().await;
publish_pool_meta_updates(&mut pool_meta, &outcome.committed, &source_pool_indices);
}
ensure_pool_meta_write_fence(&write_guard, "exact delete capacity reconciliation save failed")?;
outcome.disarm();
Ok(())
}
pub(crate) async fn reconcile_decommission_capacity_after_equivalent_target(
&self,
owner: DecommissionCapacityOwner,
@@ -17457,8 +17149,7 @@ mod pools_tests {
use super::{
DecommissionCapacityOwner, DecommissionCapacityReservation, DecommissionCapacityTemporaryMutation,
decommission_capacity_mutation_id, ensure_decommission_target_owner_admission,
ensure_exact_delete_capacity_namespace_fences, ensure_external_decommission_target_admission,
is_decommission_capacity_blocked_error, plan_exact_delete_capacity_reconciliations,
ensure_external_decommission_target_admission, is_decommission_capacity_blocked_error,
record_decommission_target_consumption, reserve_decommission_target_pending, resolve_decommission_target_pending,
set_decommission_capacity_info_overrides_for_test,
};
@@ -17473,7 +17164,7 @@ mod pools_tests {
use crate::disk::{STORAGE_FORMAT_FILE, endpoint::Endpoint};
use crate::error::{Error, StorageError};
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
use crate::object_api::{ObjectInfo, ObjectOptions};
use crate::object_api::ObjectOptions;
use crate::runtime::instance::InstanceContext;
use crate::services::rebalance::{RebalStatus, RebalanceInfo, RebalanceMeta, RebalanceStats};
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
@@ -21594,135 +21285,6 @@ mod pools_tests {
assert_eq!(first, recovered, "a lease nonce rotation must not change the mutation identity");
}
#[test]
fn exact_delete_capacity_plan_requires_identity_and_exact_size() {
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(2);
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
let capacity_infos = vec![
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 30, 30),
DecommissionPoolCapacityInfo::for_test(1, layout, 60, 60, 0),
];
let mut meta = PoolMeta {
version: POOL_META_VERSION,
pools: vec![decommission_test_pool_status(0, None), decommission_test_pool_status(1, None)],
..Default::default()
};
meta.decommission(0, capacity_infos[0].space).unwrap();
reserve_decommission_start_target_capacity(&mut meta, &[0], &capacity_infos, uuid::Uuid::new_v4(), 1, now)
.expect("the exact-delete test reservation should fit");
let exact = ObjectInfo {
bucket: "bucket".to_string(),
name: "object".to_string(),
version_id: Some(uuid::Uuid::from_u128(7)),
mod_time: Some(now),
size: 10,
..Default::default()
};
let owner = {
let reservation = meta.pools[0]
.decommission
.as_ref()
.and_then(|info| info.capacity_reservation.as_ref())
.expect("the exact-delete test reservation should exist");
DecommissionCapacityOwner {
source_pool_index: 0,
operation_id: reservation.operation_id,
generation: reservation.generation,
owner_nonce: reservation.owner_nonce,
mutation_id: None,
}
};
let version_id = exact.version_id.map(|version_id| version_id.to_string());
let mutation_id = decommission_capacity_mutation_id(
owner,
&exact.bucket,
&exact.name,
version_id.as_deref(),
exact.delete_marker,
exact.mod_time,
);
reserve_decommission_target_pending(&mut meta, 0, 1, 10, mutation_id, now + Duration::seconds(1))
.expect("the exact-delete test intent should be reserved");
let plan = plan_exact_delete_capacity_reconciliations(&meta, &exact.name, &exact)
.expect("the exact identity should match the pending intent");
assert_eq!(plan.len(), 1);
assert_eq!(plan[0].source_pool_index, 0);
assert_eq!(plan[0].target_pool_index, 1);
assert_eq!(plan[0].expected_data_bytes, 10);
assert_eq!(plan[0].expected_target_physical_bytes, 10);
let mismatched_size = ObjectInfo {
size: 9,
..exact.clone()
};
let mismatched_size = plan_exact_delete_capacity_reconciliations(&meta, &mismatched_size.name, &mismatched_size)
.expect_err("a different exact size must not consume the pending intent");
assert!(mismatched_size.to_string().contains("does not match the exact object size"));
let directory_exact = ObjectInfo {
name: "directory/".to_string(),
..exact.clone()
};
let internal_directory = rustfs_utils::path::encode_dir_object(&directory_exact.name);
let internal_directory_mutation_id = decommission_capacity_mutation_id(
owner,
&directory_exact.bucket,
&internal_directory,
version_id.as_deref(),
directory_exact.delete_marker,
directory_exact.mod_time,
);
meta.pools[0]
.decommission
.as_mut()
.and_then(|info| info.capacity_reservation.as_mut())
.expect("the exact-delete test reservation should exist")
.targets[0]
.pending_mutation_id = Some(internal_directory_mutation_id);
let directory_plan = plan_exact_delete_capacity_reconciliations(&meta, &internal_directory, &directory_exact)
.expect("an internally encoded directory intent should match its logical exact object");
assert_eq!(directory_plan[0].mutation_id, internal_directory_mutation_id);
let logical_directory_mutation_id = decommission_capacity_mutation_id(
owner,
&directory_exact.bucket,
&directory_exact.name,
version_id.as_deref(),
directory_exact.delete_marker,
directory_exact.mod_time,
);
meta.pools[0]
.decommission
.as_mut()
.and_then(|info| info.capacity_reservation.as_mut())
.expect("the exact-delete test reservation should exist")
.targets[0]
.pending_mutation_id = Some(logical_directory_mutation_id);
let directory_plan = plan_exact_delete_capacity_reconciliations(&meta, &internal_directory, &directory_exact)
.expect("a logical directory intent should match its internally encoded delete path");
assert_eq!(directory_plan[0].mutation_id, logical_directory_mutation_id);
meta.pools[0]
.decommission
.as_mut()
.and_then(|info| info.capacity_reservation.as_mut())
.expect("the exact-delete test reservation should exist")
.targets[0]
.pending_mutation_id = None;
let unidentified = plan_exact_delete_capacity_reconciliations(&meta, &exact.name, &exact)
.expect_err("an unidentified pending intent must fail closed");
assert!(unidentified.to_string().contains("without an object identity"));
let mut opts = ObjectOptions::default();
let unfenced = ensure_exact_delete_capacity_namespace_fences(&opts, &exact.bucket, &exact.name)
.expect_err("capacity reconciliation must reject a missing object namespace fence");
assert!(unfenced.to_string().contains("requires an object namespace fence"));
opts.ensure_namespace_lock_fence();
ensure_exact_delete_capacity_namespace_fences(&opts, &exact.bucket, &exact.name)
.expect("a live object namespace fence should admit capacity reconciliation");
}
#[test]
fn ordinary_write_admission_cannot_race_into_a_reserved_target() {
let now = OffsetDateTime::UNIX_EPOCH + Duration::minutes(2);
+1 -193
View File
@@ -196,7 +196,7 @@ mod decommission_lock_order_tests {
use crate::bucket::lifecycle::lifecycle::TRANSITION_PENDING;
use crate::core::pools::{
DecommissionCapacityLockOrderBarrier, DecommissionCapacityOwner, DecommissionErasureLayout, DecommissionPoolCapacityInfo,
POOL_META_NAME, decommission_capacity_mutation_id, set_decommission_capacity_info_overrides_for_test,
POOL_META_NAME, set_decommission_capacity_info_overrides_for_test,
};
use crate::data_movement;
use crate::disk::RUSTFS_META_BUCKET;
@@ -3047,198 +3047,6 @@ mod decommission_lock_order_tests {
);
}
#[tokio::test]
#[serial_test::serial]
async fn exact_delete_reconciles_pending_capacity_before_removing_replicas() {
let (_temp_dirs, store, _other_store) = test_three_pool_stores_with_isolated_node_contexts(None).await;
let bucket = test_bucket("exact-delete-capacity");
let object = "published-before-exact-delete.bin";
let body = vec![0x55; 64 * 1024];
let version_id = uuid::Uuid::new_v4().to_string();
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create the exact-delete reconciliation bucket");
let incarnation = store
.bucket_incarnation_id(&bucket)
.await
.expect("load the exact-delete bucket incarnation");
let mut source_data = PutObjReader::from_vec(body.clone());
let source = store.pools[0]
.put_object(
&bucket,
object,
&mut source_data,
&ObjectOptions {
versioned: true,
version_id: Some(version_id.clone()),
expected_bucket_incarnation_id: Some(incarnation),
..Default::default()
},
)
.await
.expect("seed the exact source version");
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
let target_total = body.len().saturating_mul(4);
set_decommission_capacity_info_overrides_for_test(
store.id,
vec![vec![
DecommissionPoolCapacityInfo::for_test(0, layout, 0, body.len(), body.len()),
DecommissionPoolCapacityInfo::for_test(1, layout, 0, target_total, target_total),
DecommissionPoolCapacityInfo::for_test(2, layout, target_total, target_total, 0),
]],
);
store
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
.await
.expect("activate the exact-delete capacity reservation");
let owner = decommission_capacity_owner(&*store.pool_meta.read().await);
let target_pool_index = store.pool_meta.read().await.pools[0]
.decommission
.as_ref()
.and_then(|info| info.capacity_reservation.as_ref())
.expect("the exact-delete capacity reservation should exist")
.targets[0]
.pool_index;
assert_eq!(target_pool_index, 2);
let target_options = ObjectOptions {
versioned: true,
version_id: Some(version_id.clone()),
mod_time: source.mod_time,
preserve_etag: source.etag.clone(),
user_defined: (*source.user_defined).clone(),
data_movement: true,
src_pool_idx: 0,
expected_bucket_incarnation_id: Some(incarnation),
..Default::default()
};
let mut target_data = PutObjReader::from_vec(body.clone());
let target = store.pools[target_pool_index]
.put_object(&bucket, object, &mut target_data, &target_options)
.await
.expect("publish the target version before capacity progress");
assert_eq!(target.version_id, source.version_id);
assert_eq!(target.mod_time, source.mod_time);
assert_eq!(target.size, source.size);
let source_version_id = source.version_id.map(|version_id| version_id.to_string());
let mutation_id = decommission_capacity_mutation_id(
owner,
&source.bucket,
&source.name,
source_version_id.as_deref(),
source.delete_marker,
source.mod_time,
);
{
let mut pool_meta = store.pool_meta.write().await;
let source_pool = &mut pool_meta.pools[0];
let reservation = source_pool
.decommission
.as_mut()
.and_then(|info| info.capacity_reservation.as_mut())
.expect("the exact-delete capacity reservation should remain active");
let target = reservation
.targets
.iter_mut()
.find(|target| target.pool_index == target_pool_index)
.expect("the exact-delete target allocation should exist");
target.pending_physical_bytes = body.len();
target.pending_mutation_id = Some(mutation_id);
reservation.pending_target_physical_bytes = body.len();
source_pool.last_update = time::OffsetDateTime::now_utc();
}
store
.save_current_pool_meta_for_test(&[0])
.await
.expect("persist the simulated post-commit capacity intent");
let exact_delete_options = ObjectOptions {
versioned: true,
version_id: Some(version_id.clone()),
expected_bucket_incarnation_id: Some(incarnation),
..Default::default()
};
store.pools[target_pool_index]
.delete_object(&bucket, object, exact_delete_options.clone())
.await
.expect("remove the target evidence before the fail-closed exact delete");
let delete_err = store
.delete_object(&bucket, object, exact_delete_options.clone())
.await
.expect_err("exact delete must fail while its pending target evidence is absent");
assert!(matches!(delete_err, crate::error::Error::DecommissionCapacityBlocked { .. }));
store.pools[0]
.get_object_info(
&bucket,
object,
&ObjectOptions {
versioned: true,
version_id: Some(version_id.clone()),
no_lock: true,
..Default::default()
},
)
.await
.expect("a failed reconciliation must preserve the source evidence");
let mut failed = crate::core::pools::PoolMeta::default();
failed
.load_no_lock_from_replicas(store.pools.clone())
.await
.expect("the failed exact delete must preserve readable capacity metadata");
let failed_reservation = failed.pools[0]
.decommission
.as_ref()
.and_then(|info| info.capacity_reservation.as_ref())
.expect("the failed exact delete reservation should remain present");
assert_eq!(failed_reservation.pending_target_physical_bytes, body.len());
assert_eq!(failed_reservation.consumed_target_physical_bytes, 0);
let mut replacement_target_data = PutObjReader::from_vec(body.clone());
store.pools[target_pool_index]
.put_object(&bucket, object, &mut replacement_target_data, &target_options)
.await
.expect("restore the equivalent target evidence for the exact-delete retry");
store
.delete_object(&bucket, object, exact_delete_options)
.await
.expect("the exact delete should reconcile capacity before removing replicas");
let mut persisted = crate::core::pools::PoolMeta::default();
persisted
.load_no_lock_from_replicas(store.pools.clone())
.await
.expect("the exact-delete reconciliation should remain durable");
let reservation = persisted.pools[0]
.decommission
.as_ref()
.and_then(|info| info.capacity_reservation.as_ref())
.expect("the reconciled reservation should remain present");
assert_eq!(reservation.pending_target_physical_bytes, 0);
assert_eq!(reservation.consumed_target_physical_bytes, body.len());
assert_eq!(reservation.committed_data_bytes, body.len());
for pool_index in [0, target_pool_index] {
let err = store.pools[pool_index]
.get_object_info(
&bucket,
object,
&ObjectOptions {
versioned: true,
version_id: Some(version_id.clone()),
no_lock: true,
..Default::default()
},
)
.await
.expect_err("the exact version should be absent after reconciliation and delete");
assert!(crate::error::is_err_object_not_found(&err) || crate::error::is_err_version_not_found(&err));
}
}
#[tokio::test]
#[serial_test::serial]
async fn data_movement_equivalent_target_reconciles_published_capacity_after_restart() {
+2 -2
View File
@@ -717,8 +717,8 @@ fn is_equivalent_data_movement_part(source: &ObjectPartInfo, target: &ObjectPart
== target.checksums.as_ref().filter(|checksums| !checksums.is_empty()))
}
pub(crate) fn data_movement_parts_by_number(parts: &[ObjectPartInfo]) -> Option<HashMap<usize, &ObjectPartInfo>> {
let mut parts_by_number = HashMap::with_capacity(parts.len());
fn data_movement_parts_by_number(parts: &[ObjectPartInfo]) -> Option<BTreeMap<usize, &ObjectPartInfo>> {
let mut parts_by_number = BTreeMap::new();
for part in parts {
if parts_by_number.insert(part.number, part).is_some() {
return None;
-7
View File
@@ -6444,9 +6444,6 @@ impl LocalDisk {
.abort_reserved_version_delete(object_dir, rollback_dir, volume, path, "delete_versions_commit_intent", err)
.await);
}
if should_fail_after_delete_commit(self.root.as_path(), path) {
return Err(DiskError::Unexpected);
}
return Ok(());
}
@@ -6517,10 +6514,6 @@ impl LocalDisk {
.await);
}
if should_fail_after_delete_commit(self.root.as_path(), path) {
return Err(DiskError::Unexpected);
}
Ok(())
}
+1 -3
View File
@@ -37,9 +37,7 @@ use rustfs_filemeta::{FileInfo, MetaCacheEntriesSorted, ObjectPartInfo, RestoreS
use rustfs_rio::Checksum;
use rustfs_utils::CompressionAlgorithm;
use rustfs_utils::http::headers::AMZ_OBJECT_TAGGING;
use rustfs_utils::http::{
AMZ_BUCKET_REPLICATION_STATUS, AMZ_RESTORE, AMZ_STORAGE_CLASS, SUFFIX_PLAINTEXT_CHECKSUM, get_consistent_str,
};
use rustfs_utils::http::{AMZ_BUCKET_REPLICATION_STATUS, AMZ_RESTORE, AMZ_STORAGE_CLASS};
use rustfs_utils::path::decode_dir_object;
use std::collections::HashMap;
use std::fmt::Debug;
+2 -596
View File
@@ -19,7 +19,6 @@ use crate::storage_api_contracts::{
HTTPPreconditions, ObjectLockRetentionOptions, ObjectPreconditionError, ObjectPreconditionPart, ObjectPreconditionState,
},
};
use std::io;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use tokio::sync::{Mutex, Notify, OwnedRwLockReadGuard};
use tokio_util::sync::CancellationToken;
@@ -679,196 +678,6 @@ pub struct DecommissionCapacityOptions {
pub(crate) mutation_id: Option<Uuid>,
}
/// Opaque storage-owned collection point for post-commit tier free-version
/// cleanup receipts. This type is public only because workspace crates build
/// [`ObjectOptions`] with struct literals; callers outside `ecstore` must leave
/// the corresponding option unset.
#[doc(hidden)]
#[derive(Clone)]
pub struct TierFreeVersionReceiptSink {
inner: Arc<parking_lot::Mutex<TierFreeVersionReceiptSinkState>>,
}
struct TierFreeVersionReceiptSinkState {
receipts: Option<HashMap<TierFreeVersionReceiptIdentity, TierFreeVersionReceiptPayload>>,
}
#[derive(PartialEq, Eq, Hash)]
struct TierFreeVersionReceiptIdentity {
bucket: String,
logical_name: String,
tier: String,
remote_name: String,
remote_version_state: TierFreeVersionReceiptVersionState,
remote_version: String,
backend_identity: crate::services::tier::tier::TierDestinationId,
}
struct TierFreeVersionReceiptPayload {
local_free_version_id: Uuid,
mod_time: Option<OffsetDateTime>,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum TierFreeVersionReceiptVersionState {
KnownDisabled,
SuspendedNull,
Exact,
}
impl TierFreeVersionReceiptSink {
/// Only the delete wrapper may originate a sink. The public type exists so
/// workspace struct literals can carry it, but external crates cannot
/// create an undrainable collector accidentally.
pub(crate) fn new() -> Self {
Self {
inner: Arc::new(parking_lot::Mutex::new(TierFreeVersionReceiptSinkState {
receipts: Some(HashMap::new()),
})),
}
}
}
impl std::fmt::Debug for TierFreeVersionReceiptSink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let state = self.inner.lock();
f.debug_struct("TierFreeVersionReceiptSink")
.field("drained", &state.receipts.is_none())
.field("receipt_count", &state.receipts.as_ref().map(HashMap::len).unwrap_or_default())
.finish()
}
}
impl TierFreeVersionReceiptVersionState {
fn persisted(self) -> rustfs_filemeta::TransitionVersionState {
match self {
Self::KnownDisabled => rustfs_filemeta::TransitionVersionState::KnownDisabled,
Self::SuspendedNull => rustfs_filemeta::TransitionVersionState::SuspendedNull,
Self::Exact => rustfs_filemeta::TransitionVersionState::Exact,
}
}
}
impl TierFreeVersionReceiptIdentity {
fn into_object_info(self, payload: TierFreeVersionReceiptPayload) -> ObjectInfo {
let mut metadata = HashMap::with_capacity(2);
rustfs_utils::http::metadata_compat::insert_str(
&mut metadata,
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
rustfs_utils::crypto::hex(self.backend_identity),
);
ObjectInfo {
bucket: self.bucket,
name: self.logical_name,
mod_time: payload.mod_time,
user_defined: Arc::new(metadata),
version_id: Some(payload.local_free_version_id),
delete_marker: true,
transitioned_object: TransitionedObject {
name: self.remote_name,
version_id: self.remote_version,
tier: self.tier,
free_version: true,
status: String::new(),
},
transition_version_state: self.remote_version_state.persisted(),
..Default::default()
}
}
}
fn tier_free_version_scheduling_receipt_from_source(
source: &ObjectInfo,
local_free_version_id: Uuid,
) -> io::Result<Option<(TierFreeVersionReceiptIdentity, TierFreeVersionReceiptPayload)>> {
if source.transitioned_object.status != rustfs_filemeta::TRANSITION_COMPLETE
|| source.transitioned_object.free_version
|| source.delete_marker
|| source.bucket.is_empty()
|| source.name.is_empty()
|| source.transitioned_object.tier.is_empty()
|| source.transitioned_object.name.is_empty()
{
return Ok(None);
}
if local_free_version_id.is_nil() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"tier free-version receipt has a nil local version identity",
));
}
let remote_version = source.transitioned_object.version_id.as_str();
let remote_version_state = match source.transition_version_state {
rustfs_filemeta::TransitionVersionState::Unknown => return Ok(None),
rustfs_filemeta::TransitionVersionState::KnownDisabled if remote_version.is_empty() => {
TierFreeVersionReceiptVersionState::KnownDisabled
}
rustfs_filemeta::TransitionVersionState::SuspendedNull if remote_version == "null" => {
TierFreeVersionReceiptVersionState::SuspendedNull
}
rustfs_filemeta::TransitionVersionState::Exact if !remote_version.is_empty() && remote_version != "null" => {
TierFreeVersionReceiptVersionState::Exact
}
_ => return Ok(None),
};
let Some(backend_identity) = crate::services::tier::tier::tier_destination_id_from_metadata(&source.user_defined)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?
else {
return Ok(None);
};
Ok(Some((
TierFreeVersionReceiptIdentity {
bucket: source.bucket.clone(),
logical_name: decode_dir_object(&source.name),
tier: source.transitioned_object.tier.clone(),
remote_name: source.transitioned_object.name.clone(),
remote_version_state,
remote_version: source.transitioned_object.version_id.clone(),
backend_identity,
},
TierFreeVersionReceiptPayload {
local_free_version_id,
mod_time: source.mod_time,
},
)))
}
impl TierFreeVersionReceiptSink {
/// Record one committed free-version cleanup target. Cloned options share
/// this sink; tuple-equivalent physical copies collapse to one worker task.
/// `false` means the source cannot safely identify a destructive cleanup.
pub(crate) fn record(&self, source: &ObjectInfo, local_free_version_id: Uuid) -> io::Result<bool> {
let Some((identity, payload)) = tier_free_version_scheduling_receipt_from_source(source, local_free_version_id)? else {
return Ok(false);
};
let mut state = self.inner.lock();
let receipts = state
.receipts
.as_mut()
.ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "tier free-version receipt sink was already drained"))?;
receipts.entry(identity).or_insert(payload);
Ok(true)
}
/// Consume every receipt exactly once. A second drain is a caller bug: it
/// could otherwise make two outer wrappers believe they own the same tasks.
pub(crate) fn drain(&self) -> io::Result<Vec<ObjectInfo>> {
let mut state = self.inner.lock();
let receipts = state
.receipts
.take()
.ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "tier free-version receipt sink was already drained"))?;
drop(state);
Ok(receipts
.into_iter()
.map(|(identity, payload)| identity.into_object_info(payload))
.collect())
}
}
#[derive(Default, Clone)]
pub struct ObjectOptions {
// Use the maximum parity (N/2), used when saving server configuration files
@@ -907,12 +716,6 @@ pub struct ObjectOptions {
pub skip_rebalancing: bool,
pub skip_free_version: bool,
/// Storage-owned, per-request hand-off for committed tier free-version
/// cleanup work. The outer delete wrapper installs and drains it; clones
/// below that boundary share the same opaque sink.
#[doc(hidden)]
pub tier_free_version_receipt_sink: Option<TierFreeVersionReceiptSink>,
/// Cooperative cancellation for an owned PutObject before authoritative
/// rename begins. Storage ignores it after entering the durable commit.
#[doc(hidden)]
@@ -1048,7 +851,6 @@ impl std::fmt::Debug for ObjectOptions {
.field("skip_decommissioned", &self.skip_decommissioned)
.field("skip_rebalancing", &self.skip_rebalancing)
.field("skip_free_version", &self.skip_free_version)
.field("tier_free_version_receipt_sink", &self.tier_free_version_receipt_sink)
.field("put_object_cancellation", &self.put_object_cancellation.is_some())
.field("scanner_publication_commit_scope", &self.scanner_publication_commit_scope)
.field("data_movement", &self.data_movement)
@@ -1969,10 +1771,9 @@ impl ObjectInfo {
}
if let Some(data) = &self.checksum {
if self.is_encrypted() && get_consistent_str(&self.user_defined, SUFFIX_PLAINTEXT_CHECKSUM) != Some("true") {
if self.is_encrypted() {
// Object-level encrypted checksum bytes require SSE decrypt material,
// unless RustFS marked the stored bytes as plaintext. Do not expose
// unmarked bytes as checksum headers here. The
// so do not expose them as plaintext checksum headers here. The
// `false` multipart flag feeds the response-path COMPOSITE
// fallback; callers that need accurate multipart routing must
// consult `is_multipart()` instead of this value.
@@ -2678,31 +2479,6 @@ mod tests {
assert!(checksums.is_empty());
}
#[test]
fn decrypt_checksums_reads_marked_rustfs_encrypted_object_checksum() {
let checksum = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::CRC32, b"encrypted-object")
.expect("test checksum should be valid");
let checksum_key = checksum.checksum_type.to_string();
let expected_checksum = checksum.encoded.clone();
let mut user_defined =
HashMap::from([(rustfs_utils::http::headers::AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "AES256".to_string())]);
rustfs_utils::http::insert_str(&mut user_defined, SUFFIX_PLAINTEXT_CHECKSUM, "true".to_string());
assert_eq!(user_defined.get("x-rustfs-internal-plaintext-checksum").map(String::as_str), Some("true"));
assert_eq!(user_defined.get("x-minio-internal-plaintext-checksum").map(String::as_str), Some("true"));
let info = ObjectInfo {
checksum: Some(checksum.to_bytes(&[])),
user_defined: Arc::new(user_defined),
..Default::default()
};
let (checksums, is_multipart) = info
.decrypt_checksums(0, &HeaderMap::new())
.expect("marked RustFS checksum should decode");
assert!(!is_multipart);
assert_eq!(checksums.get(&checksum_key), Some(&expected_checksum));
}
#[test]
fn decrypt_checksums_keeps_encrypted_multipart_flag_false_for_response_paths() {
let checksum = rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::CRC32, b"encrypted-object")
@@ -2820,381 +2596,11 @@ mod tests {
assert!(default_cloned.parts.is_empty());
}
fn transitioned_receipt_source(
bucket: &str,
object: &str,
remote_version: &str,
version_state: rustfs_filemeta::TransitionVersionState,
identity_hex: Option<&str>,
) -> ObjectInfo {
let mut metadata = HashMap::new();
if let Some(identity_hex) = identity_hex {
rustfs_utils::http::metadata_compat::insert_str(
&mut metadata,
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
identity_hex.to_string(),
);
}
ObjectInfo {
bucket: bucket.to_string(),
name: object.to_string(),
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
user_defined: Arc::new(metadata),
transitioned_object: TransitionedObject {
name: format!("remote/{object}"),
version_id: remote_version.to_string(),
tier: "WARM".to_string(),
status: TRANSITION_COMPLETE.to_string(),
..Default::default()
},
transition_version_state: version_state,
..Default::default()
}
}
#[test]
fn tier_free_version_receipt_matches_persisted_free_version_worker_fields() {
let bucket = "receipt-bucket";
let object = "archive/object.bin";
let source_version_id = Uuid::from_u128(1);
let local_free_version_id = Uuid::from_u128(2);
let remote_version_id = Uuid::from_u128(3);
let source_mod_time = OffsetDateTime::UNIX_EPOCH + time::Duration::hours(4);
let identity_hex = "ab".repeat(32);
let mut source_metadata = HashMap::from([
("etag".to_string(), "source-etag".to_string()),
("x-amz-meta-private".to_string(), "must-not-enter-receipt".to_string()),
]);
rustfs_utils::http::metadata_compat::insert_str(
&mut source_metadata,
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
identity_hex.clone(),
);
let source_file_info = FileInfo {
volume: bucket.to_string(),
name: object.to_string(),
version_id: Some(source_version_id),
transition_status: TRANSITION_COMPLETE.to_string(),
transitioned_objname: "remote/receipt-object".to_string(),
transition_tier: "WARM".to_string(),
transition_version_id: Some(remote_version_id),
transition_version: Some(remote_version_id.to_string()),
transition_version_state: rustfs_filemeta::TransitionVersionState::Exact,
mod_time: Some(source_mod_time),
size: 8192,
data_dir: Some(Uuid::from_u128(4)),
metadata: source_metadata,
..Default::default()
};
let source = ObjectInfo::from_file_info(&source_file_info, bucket, object, true);
let mut persisted = FileMeta::new();
persisted
.add_version(source_file_info)
.expect("transitioned receipt source should be persisted");
let mut delete_file_info = FileInfo {
volume: bucket.to_string(),
name: object.to_string(),
version_id: Some(source_version_id),
mod_time: Some(source_mod_time + time::Duration::minutes(1)),
..Default::default()
};
delete_file_info.set_tier_free_version_id(&local_free_version_id.to_string());
persisted
.delete_version(&delete_file_info)
.expect("transitioned source delete should create a free-version");
let encoded = persisted.marshal_msg().expect("free-version metadata should encode");
let decoded = FileMeta::load(&encoded).expect("free-version metadata should decode");
let persisted_free_version = decoded
.get_all_file_info_versions(bucket, object, true)
.expect("decoded free-version should produce FileInfo")
.versions
.into_iter()
.find(|version| version.tier_free_version())
.expect("decoded metadata should contain the persisted free-version");
let persisted_object_info = ObjectInfo::from_file_info(&persisted_free_version, bucket, object, true);
let sink = TierFreeVersionReceiptSink::new();
assert!(
sink.record(&source, local_free_version_id)
.expect("valid transitioned source should produce a receipt")
);
let mut receipts = sink.drain().expect("receipt owner should drain exactly once");
assert_eq!(receipts.len(), 1);
let receipt = receipts.pop().expect("one receipt should be present");
assert_eq!(receipt.bucket, persisted_object_info.bucket);
assert_eq!(receipt.name, persisted_object_info.name);
assert_eq!(receipt.version_id, persisted_object_info.version_id);
assert_eq!(receipt.mod_time, persisted_object_info.mod_time);
assert_eq!(receipt.delete_marker, persisted_object_info.delete_marker);
assert_eq!(receipt.transitioned_object.name, persisted_object_info.transitioned_object.name);
assert_eq!(
receipt.transitioned_object.version_id,
persisted_object_info.transitioned_object.version_id
);
assert_eq!(receipt.transitioned_object.tier, persisted_object_info.transitioned_object.tier);
assert_eq!(
receipt.transitioned_object.free_version,
persisted_object_info.transitioned_object.free_version
);
assert_eq!(receipt.transitioned_object.status, persisted_object_info.transitioned_object.status);
assert_eq!(receipt.transition_version_state, persisted_object_info.transition_version_state);
assert_eq!(
crate::services::tier::tier::tier_destination_id_from_metadata(&receipt.user_defined)
.expect("receipt identity should decode"),
crate::services::tier::tier::tier_destination_id_from_metadata(&persisted_object_info.user_defined)
.expect("persisted identity should decode")
);
assert_eq!(
receipt.user_defined.len(),
2,
"receipt should carry only the two compatibility identity keys"
);
assert_eq!(
receipt.user_defined.get("x-rustfs-internal-transition-tier-destination-id"),
Some(&identity_hex)
);
assert_eq!(
receipt.user_defined.get("x-minio-internal-transition-tier-destination-id"),
Some(&identity_hex)
);
assert!(!receipt.user_defined.contains_key("x-amz-meta-private"));
assert_eq!(receipt.size, 0);
assert_eq!(receipt.actual_size, 0);
assert!(receipt.parts.is_empty());
assert!(receipt.etag.is_none());
assert!(receipt.checksum.is_none());
assert!(receipt.data_dir.is_none());
}
#[test]
fn tier_free_version_receipt_sink_deduplicates_remote_target_and_drains_once() {
let identity_hex = "11".repeat(32);
let source = transitioned_receipt_source(
"bucket",
"object",
"remote-version",
rustfs_filemeta::TransitionVersionState::Exact,
Some(&identity_hex),
);
let other_object = transitioned_receipt_source(
"bucket",
"other-object",
"remote-version",
rustfs_filemeta::TransitionVersionState::Exact,
Some(&identity_hex),
);
let sink = TierFreeVersionReceiptSink::new();
let clone = sink.clone();
assert!(
sink.record(&source, Uuid::from_u128(10))
.expect("first physical receipt should record")
);
assert!(
clone
.record(&source, Uuid::from_u128(11))
.expect("tuple-equivalent physical receipt should be represented")
);
assert!(
clone
.record(&other_object, Uuid::from_u128(12))
.expect("a different logical key should retain its own task")
);
let mut receipts = sink.drain().expect("owner should drain shared receipts");
receipts.sort_by(|left, right| left.name.cmp(&right.name));
assert_eq!(receipts.len(), 2);
assert_eq!(receipts[0].name, "object");
assert_eq!(receipts[0].version_id, Some(Uuid::from_u128(10)));
assert_eq!(receipts[1].name, "other-object");
assert_eq!(receipts[1].version_id, Some(Uuid::from_u128(12)));
assert_eq!(
clone.drain().expect_err("a shared sink must drain only once").kind(),
io::ErrorKind::BrokenPipe
);
assert_eq!(
clone
.record(&source, Uuid::from_u128(13))
.expect_err("recording after drain must fail")
.kind(),
io::ErrorKind::BrokenPipe
);
}
#[test]
fn tier_free_version_receipt_identity_covers_every_destructive_dimension() {
let identity_hex = "44".repeat(32);
let baseline = transitioned_receipt_source(
"bucket",
"directory/",
"remote-version",
rustfs_filemeta::TransitionVersionState::Exact,
Some(&identity_hex),
);
let mut encoded_duplicate = baseline.clone();
encoded_duplicate.name = "directory__XLDIR__".to_string();
let mut variants = Vec::new();
let mut changed = baseline.clone();
changed.bucket = "other-bucket".to_string();
variants.push(changed);
let mut changed = baseline.clone();
changed.name = "other-directory/".to_string();
variants.push(changed);
let mut changed = baseline.clone();
changed.transitioned_object.tier = "COLD".to_string();
variants.push(changed);
let mut changed = baseline.clone();
changed.transitioned_object.name = "remote/other-directory/".to_string();
variants.push(changed);
let mut changed = baseline.clone();
changed.transitioned_object.version_id = "other-remote-version".to_string();
variants.push(changed);
let mut changed = baseline.clone();
changed.transition_version_state = rustfs_filemeta::TransitionVersionState::SuspendedNull;
changed.transitioned_object.version_id = "null".to_string();
variants.push(changed);
let mut changed = baseline.clone();
rustfs_utils::http::metadata_compat::insert_str(
Arc::make_mut(&mut changed.user_defined),
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
"55".repeat(32),
);
variants.push(changed);
let sink = TierFreeVersionReceiptSink::new();
assert!(
sink.record(&baseline, Uuid::from_u128(20))
.expect("baseline receipt should record")
);
assert!(
sink.record(&encoded_duplicate, Uuid::from_u128(21))
.expect("the encoded spelling of one logical key should deduplicate")
);
for (offset, variant) in variants.iter().enumerate() {
assert!(
sink.record(variant, Uuid::from_u128(30 + offset as u128))
.expect("each distinct cleanup identity should record")
);
}
let receipts = sink.drain().expect("identity matrix should drain once");
assert_eq!(receipts.len(), 8, "every destructive identity dimension must prevent deduplication");
let baseline_receipt = receipts
.iter()
.find(|receipt| {
receipt.bucket == "bucket"
&& receipt.name == "directory/"
&& receipt.transitioned_object.tier == "WARM"
&& receipt.transitioned_object.name == "remote/directory/"
&& receipt.transitioned_object.version_id == "remote-version"
&& receipt.transition_version_state == rustfs_filemeta::TransitionVersionState::Exact
&& crate::services::tier::tier::tier_destination_id_from_metadata(&receipt.user_defined)
.is_ok_and(|identity| identity == Some([0x44; 32]))
})
.expect("baseline cleanup identity should remain present");
assert_eq!(
baseline_receipt.version_id,
Some(Uuid::from_u128(20)),
"deduplication must retain the first UUID"
);
}
#[test]
fn tier_free_version_receipt_source_validation_fails_closed() {
let identity_hex = "22".repeat(32);
for (state, remote_version) in [
(rustfs_filemeta::TransitionVersionState::KnownDisabled, ""),
(rustfs_filemeta::TransitionVersionState::SuspendedNull, "null"),
(rustfs_filemeta::TransitionVersionState::Exact, "opaque-version"),
] {
let source = transitioned_receipt_source("bucket", "object", remote_version, state, Some(&identity_hex));
assert!(
TierFreeVersionReceiptSink::new()
.record(&source, Uuid::new_v4())
.expect("canonical remote-version state should be eligible"),
"state={state:?} remote_version={remote_version:?}"
);
}
let unknown = transitioned_receipt_source(
"bucket",
"object",
"opaque-version",
rustfs_filemeta::TransitionVersionState::Unknown,
Some(&identity_hex),
);
assert!(
!TierFreeVersionReceiptSink::new()
.record(&unknown, Uuid::new_v4())
.expect("unknown remote version state should defer to recovery")
);
let missing_identity = transitioned_receipt_source(
"bucket",
"object",
"opaque-version",
rustfs_filemeta::TransitionVersionState::Exact,
None,
);
assert!(
!TierFreeVersionReceiptSink::new()
.record(&missing_identity, Uuid::new_v4())
.expect("missing durable identity should defer to recovery")
);
let invalid_exact = transitioned_receipt_source(
"bucket",
"object",
"",
rustfs_filemeta::TransitionVersionState::Exact,
Some(&identity_hex),
);
assert!(
!TierFreeVersionReceiptSink::new()
.record(&invalid_exact, Uuid::new_v4())
.expect("conflicting remote state should defer to recovery")
);
let mut conflicting = transitioned_receipt_source(
"bucket",
"object",
"opaque-version",
rustfs_filemeta::TransitionVersionState::Exact,
Some(&identity_hex),
);
Arc::make_mut(&mut conflicting.user_defined)
.insert("x-minio-internal-transition-tier-destination-id".to_string(), "33".repeat(32));
assert_eq!(
TierFreeVersionReceiptSink::new()
.record(&conflicting, Uuid::new_v4())
.expect_err("conflicting identity aliases must fail closed")
.kind(),
io::ErrorKind::InvalidData
);
let valid = transitioned_receipt_source(
"bucket",
"object",
"opaque-version",
rustfs_filemeta::TransitionVersionState::Exact,
Some(&identity_hex),
);
assert_eq!(
TierFreeVersionReceiptSink::new()
.record(&valid, Uuid::nil())
.expect_err("nil local free-version identity must be rejected")
.kind(),
io::ErrorKind::InvalidInput
);
}
#[test]
fn object_options_default_does_not_allocate_lifecycle_delete_all_journal() {
let mut opts = ObjectOptions::default();
assert!(opts.lifecycle_delete_all_journal().is_none());
assert!(opts.tier_free_version_receipt_sink.is_none());
opts.ensure_lifecycle_delete_all_journal();
assert!(opts.lifecycle_delete_all_journal().is_some());
}
+1 -1
View File
@@ -127,7 +127,7 @@ use rustfs_filemeta::{
};
use rustfs_heal_contracts::heal_channel::{
DriveState, HealAdmissionResult, HealChannelPriority, HealItemType, HealOpts, HealRequestSource, HealScanMode,
send_heal_replacement_disk, send_heal_request_with_admission,
send_heal_disk, send_heal_request_with_admission,
};
use rustfs_io_metrics::{
record_object_lock_diag_acquire_duration, record_object_lock_diag_enabled, record_object_lock_diag_hold_duration,
+8 -146
View File
@@ -22,10 +22,12 @@
use super::super::{
Arc, DiskError, DiskInfo, DiskInfoOptions, DiskOption, DiskStore, Endpoint, Error, FormatV3, HealChannelPriority, LockResult,
NamespaceLock, NamespaceLockWrapper, ObjectKey, Result, SetDisks, StorageError, debug, disk, info, load_format_erasure,
send_heal_replacement_disk, warn,
send_heal_disk, warn,
};
use crate::disk::DiskAPI;
use crate::disk::health_state::DriveMembershipSnapshot;
use crate::disk::{DiskAPI, new_disk};
#[cfg(test)]
use crate::disk::new_disk;
use crate::runtime::sources as runtime_sources;
use rand::prelude::SliceRandom;
#[cfg(test)]
@@ -354,28 +356,11 @@ impl SetDisks {
Ok(res) => res,
Err(e) => {
warn!("renew_disk: connect_endpoint err {:?}", &e);
if !matches!(e, DiskError::UnformattedDisk | DiskError::Io(_)) {
return;
if ep.is_local && e == DiskError::UnformattedDisk {
info!("renew_disk unformatteddisk will trigger heal_disk, {:?}", ep);
let set_disk_id = format!("pool_{}_set_{}", ep.pool_idx, ep.set_idx);
let _ = send_heal_disk(set_disk_id, Some(HealChannelPriority::Normal)).await;
}
let attached = match self.attach_unformatted_replacement_disk(ep).await {
Ok(attached) => attached,
Err(err) => {
warn!(endpoint = %ep, error = ?err, "renew_disk: unformatted replacement probe failed");
return;
}
};
if !attached {
return;
}
info!("renew_disk attached unformatted replacement and will trigger heal_disk, {:?}", ep);
let (Ok(pool_index), Ok(set_index)) = (usize::try_from(ep.pool_idx), usize::try_from(ep.set_idx)) else {
warn!("renew_disk: replacement target has invalid pool or set index, {:?}", ep);
return;
};
let _ =
send_heal_replacement_disk(pool_index, set_index, ep.to_string(), Some(HealChannelPriority::Normal)).await;
return;
}
};
@@ -427,60 +412,6 @@ impl SetDisks {
disk_lock[disk_idx] = Some(new_disk);
}
/// Attach a replacement target only after proving that the exact local slot
/// is present and unformatted. A health-checked reconnect may reject a
/// blank target before it reaches the format-heal path; that target still
/// has to be visible in this set for the formatter to claim it safely.
async fn attach_unformatted_replacement_disk(&self, ep: &Endpoint) -> disk::error::Result<bool> {
if !ep.is_local
|| usize::try_from(ep.pool_idx).ok() != Some(self.pool_index)
|| usize::try_from(ep.set_idx).ok() != Some(self.set_index)
{
return Ok(false);
}
let Some(disk_idx) = self.set_endpoints.iter().position(|candidate| candidate == ep) else {
return Ok(false);
};
let replacement = new_disk(
ep,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await?;
match load_format_erasure(&replacement, false).await {
Err(DiskError::UnformattedDisk) => {}
Ok(_) => return Ok(false),
Err(err) => return Err(err),
}
{
let mut disks = self.disks.write().await;
if disks[disk_idx].as_ref().is_some_and(|existing| existing.endpoint() != *ep) {
warn!(endpoint = %ep, disk_idx, "renew_disk rejected unformatted replacement for an occupied foreign slot");
return Ok(false);
}
disks[disk_idx] = Some(replacement.clone());
}
let local_disk_map = runtime_sources::local_disk_map_handle();
local_disk_map
.write()
.await
.insert(replacement.endpoint().to_string(), Some(replacement.clone()));
if runtime_sources::setup_is_dist_erasure().await {
let local_disk_set_drives = runtime_sources::local_disk_set_drives_handle();
let mut local_set_drives = local_disk_set_drives.write().await;
local_set_drives[self.pool_index][self.set_index][disk_idx] = Some(replacement);
}
Ok(true)
}
pub(in crate::set_disk) fn find_disk_index(&self, fm: &FormatV3) -> Result<(usize, usize)> {
self.format.check_other(fm)?;
@@ -848,75 +779,6 @@ mod tests {
drop(temp_dirs);
}
#[tokio::test]
async fn renew_disk_attaches_only_a_verified_local_unformatted_replacement() {
let disk_count = 4;
let format = FormatV3::new(1, disk_count);
let mut temp_dirs = Vec::with_capacity(disk_count);
let mut endpoints = Vec::with_capacity(disk_count);
let mut disks = Vec::with_capacity(disk_count);
for disk_idx in 0..disk_count - 1 {
let (temp_dir, endpoint, disk) = make_formatted_local_disk(disk_idx, &format).await;
temp_dirs.push(temp_dir);
endpoints.push(endpoint);
disks.push(Some(disk));
}
let replacement_dir = tempfile::tempdir().expect("replacement tempdir should be created");
let mut replacement_endpoint =
Endpoint::try_from(replacement_dir.path().to_str().expect("replacement path should be utf8"))
.expect("replacement endpoint should parse");
replacement_endpoint.set_pool_index(0);
replacement_endpoint.set_set_index(0);
replacement_endpoint.set_disk_index(disk_count - 1);
temp_dirs.push(replacement_dir);
endpoints.push(replacement_endpoint.clone());
disks.push(None);
let set_disks = SetDisks::new(
"test-owner".to_string(),
Arc::new(RwLock::new(disks)),
disk_count,
disk_count / 2,
0,
0,
endpoints,
format,
Vec::new(),
)
.await;
assert!(
set_disks
.attach_unformatted_replacement_disk(&replacement_endpoint)
.await
.expect("a blank local replacement should be admitted")
);
let attached = set_disks.get_disks_internal().await;
let replacement = attached[disk_count - 1]
.as_ref()
.expect("the verified replacement must occupy its exact set slot");
assert_eq!(replacement.endpoint(), replacement_endpoint);
assert!(
!replacement.health_check_enabled_for_test(),
"the blank replacement must not start health checks before it receives a format"
);
assert_eq!(
load_format_erasure(replacement, false).await.unwrap_err(),
DiskError::UnformattedDisk,
"only a still-unformatted replacement may be attached by the fallback"
);
runtime_sources::local_disk_map_handle()
.write()
.await
.remove(&replacement_endpoint.to_string());
drop(set_disks);
drop(temp_dirs);
}
#[tokio::test]
async fn renew_disk_rejects_a_format_from_another_slot_or_cluster() {
let disk_count = 3;
+16 -296
View File
@@ -217,8 +217,7 @@ use crate::bucket::lifecycle::{
use crate::bucket::quota::reservation;
use crate::bucket::replication::{
DeleteReplicationConfigSnapshot, ReplicationLifecycleBridge, ReplicationStatusType, VersionPurgeStatusType,
replication_state_to_filemeta, replication_status_from_filemeta, version_purge_status_from_filemeta,
version_purge_status_to_filemeta,
replication_state_to_filemeta, replication_status_from_filemeta, version_purge_status_to_filemeta,
};
use crate::data_usage::quota_object_size;
use crate::diagnostics::get::GetObjectFailureReason;
@@ -284,95 +283,12 @@ fn record_transitioned_delete_cleanup_owner(bucket: &str, object: &str, batch: b
);
}
/// A causal free-version receipt is only valid when the delete request really
/// removes the locked transitioned source. In particular, replication may turn
/// an otherwise successful delete into a metadata-only purge-state update, and
/// a versioned delete without a version ID writes a new delete marker instead
/// of removing the source selected by `goi`.
fn transitioned_delete_publishes_free_version(source: &ObjectInfo, delete_request: &FileInfo, skip_free_version: bool) -> bool {
if source.delete_marker
|| source.transitioned_object.status != TRANSITION_COMPLETE
|| skip_free_version
|| delete_request.skip_tier_free_version()
|| delete_request.expire_restored
|| delete_request.transition_status == TRANSITION_COMPLETE
|| delete_file_info_version_id(source.version_id) != delete_request.version_id
{
return false;
}
// Keep this predicate aligned with FileMeta::delete_version's Object
// branch: a non-delete-marker request with a nonterminal purge status (or
// mark_deleted with no purge status) updates replication metadata in place
// and never calls MetaObject::init_free_version.
let purge_status = version_purge_status_from_filemeta(delete_request.version_purge_status());
let metadata_only = !delete_request.deleted
&& ((purge_status.is_empty() && delete_request.mark_deleted)
|| (!purge_status.is_empty() && purge_status != VersionPurgeStatusType::Complete));
!metadata_only
}
fn record_committed_tier_free_version_receipt(
opts: &ObjectOptions,
async fn acquire_single_tier_delete_lease(
bucket: &str,
object: &str,
opts: &ObjectOptions,
source: &ObjectInfo,
free_version_id: Uuid,
batch: bool,
) {
if let Some(sink) = opts.tier_free_version_receipt_sink.as_ref()
&& let Err(err) = sink.record(source, free_version_id)
{
warn!(
event = EVENT_LIFECYCLE_TRANSITIONED_DELETE_CLEANUP_OWNER,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
bucket,
object,
batch,
error = ?err,
"Failed to retain the in-memory tier free-version scheduling receipt"
);
}
record_transitioned_delete_cleanup_owner(bucket, object, batch);
}
struct TierFreeVersionReceiptCandidate {
source: ObjectInfo,
free_version_id: Uuid,
}
fn committed_tier_free_version_receipt_indices(
versions: &[FileInfoVersions],
delete_errors: &[Option<Error>],
candidates: &HashMap<usize, TierFreeVersionReceiptCandidate>,
) -> Vec<usize> {
if candidates.is_empty() {
return Vec::new();
}
let mut committed = Vec::with_capacity(candidates.len());
for group in versions {
let should_rollback = group
.versions
.iter()
.any(|version| delete_errors.get(version.idx).is_none_or(|error| error.is_some()));
if should_rollback {
continue;
}
committed.extend(
group
.versions
.iter()
.map(|version| version.idx)
.filter(|idx| candidates.contains_key(idx)),
);
}
committed
}
async fn acquire_single_tier_delete_lease(opts: &ObjectOptions, source: &ObjectInfo) -> Result<Option<TierOperationLease>> {
) -> Result<Option<TierOperationLease>> {
let Some(api) = opts.tier_delete_journal_api.as_ref() else {
return Ok(None);
};
@@ -392,6 +308,7 @@ async fn acquire_single_tier_delete_lease(opts: &ObjectOptions, source: &ObjectI
None => TierConfigMgr::acquire_operation_lease(&api.tier_config_mgr(), &source.transitioned_object.tier).await,
}
.map_err(Error::other)?;
record_transitioned_delete_cleanup_owner(bucket, object, false);
Ok(Some(lease))
}
@@ -467,168 +384,6 @@ mod scanner_publication_lease_fence_tests {
}
}
#[cfg(test)]
mod tier_free_version_receipt_eligibility_tests {
use super::*;
use crate::bucket::replication::ReplicationState;
fn transitioned_source(version_id: Option<Uuid>) -> ObjectInfo {
let mut source = ObjectInfo {
version_id,
..Default::default()
};
source.transitioned_object.status = TRANSITION_COMPLETE.to_string();
source.transitioned_object.tier = "WARM".to_string();
source.transitioned_object.name = "remote/object".to_string();
source
}
fn delete_request(version_id: Option<Uuid>) -> FileInfo {
let mut request = FileInfo {
version_id,
..Default::default()
};
request.set_tier_free_version_id(&Uuid::new_v4().to_string());
request
}
#[test]
fn accepts_exact_transitioned_source_removal_and_suspended_null_replacement() {
let version_id = Uuid::new_v4();
assert!(transitioned_delete_publishes_free_version(
&transitioned_source(Some(version_id)),
&delete_request(Some(version_id)),
false,
));
let mut suspended_null_delete = delete_request(None);
suspended_null_delete.deleted = true;
suspended_null_delete.mark_deleted = true;
assert!(transitioned_delete_publishes_free_version(
&transitioned_source(Some(Uuid::nil())),
&suspended_null_delete,
false,
));
}
#[test]
fn rejects_new_marker_version_and_non_transitioned_or_delete_marker_sources() {
let source_id = Uuid::new_v4();
assert!(!transitioned_delete_publishes_free_version(
&transitioned_source(Some(source_id)),
&delete_request(Some(Uuid::new_v4())),
false,
));
let mut ordinary = transitioned_source(Some(source_id));
ordinary.transitioned_object.status.clear();
assert!(!transitioned_delete_publishes_free_version(
&ordinary,
&delete_request(Some(source_id)),
false,
));
let mut delete_marker = transitioned_source(Some(source_id));
delete_marker.delete_marker = true;
assert!(!transitioned_delete_publishes_free_version(
&delete_marker,
&delete_request(Some(source_id)),
false,
));
}
#[test]
fn rejects_skip_restore_and_transition_metadata_updates() {
let version_id = Uuid::new_v4();
let source = transitioned_source(Some(version_id));
assert!(!transitioned_delete_publishes_free_version(
&source,
&delete_request(Some(version_id)),
true,
));
let mut skip_request = delete_request(Some(version_id));
skip_request.set_skip_tier_free_version();
assert!(!transitioned_delete_publishes_free_version(&source, &skip_request, false));
let mut restore_request = delete_request(Some(version_id));
restore_request.expire_restored = true;
assert!(!transitioned_delete_publishes_free_version(&source, &restore_request, false));
let mut transition_update = delete_request(Some(version_id));
transition_update.transition_status = TRANSITION_COMPLETE.to_string();
assert!(!transitioned_delete_publishes_free_version(&source, &transition_update, false));
}
#[test]
fn rejects_nonterminal_replication_metadata_only_update_but_accepts_complete_purge() {
let version_id = Uuid::new_v4();
let source = transitioned_source(Some(version_id));
let mut pending = delete_request(Some(version_id));
pending.replication_state_internal = Some(replication_state_to_filemeta(&ReplicationState {
version_purge_status_internal: Some("PENDING".to_string()),
..Default::default()
}));
assert!(!transitioned_delete_publishes_free_version(&source, &pending, false));
let mut mark_deleted = delete_request(Some(version_id));
mark_deleted.mark_deleted = true;
assert!(!transitioned_delete_publishes_free_version(&source, &mark_deleted, false));
let mut complete = delete_request(Some(version_id));
complete.replication_state_internal = Some(replication_state_to_filemeta(&ReplicationState {
version_purge_status_internal: Some("COMPLETE".to_string()),
..Default::default()
}));
assert!(transitioned_delete_publishes_free_version(&source, &complete, false));
}
#[test]
fn whole_physical_object_group_must_commit_before_any_receipt_is_retained() {
let candidate = || TierFreeVersionReceiptCandidate {
source: transitioned_source(Some(Uuid::new_v4())),
free_version_id: Uuid::new_v4(),
};
let candidates = HashMap::from([(0, candidate()), (2, candidate())]);
let versions = vec![
FileInfoVersions {
versions: vec![
FileInfo {
idx: 0,
..Default::default()
},
FileInfo {
idx: 1,
..Default::default()
},
],
..Default::default()
},
FileInfoVersions {
versions: vec![FileInfo {
idx: 2,
..Default::default()
}],
..Default::default()
},
];
let one_sibling_failed = vec![None, Some(Error::other("injected quorum failure")), None];
assert_eq!(
committed_tier_free_version_receipt_indices(&versions, &one_sibling_failed, &candidates),
vec![2],
"a sibling failure must suppress every receipt from the rolled-back xl.meta group"
);
assert_eq!(
committed_tier_free_version_receipt_indices(&versions, &[None, None, None], &candidates),
vec![0, 2],
"independent fully committed groups should retain their sparse receipts"
);
}
}
struct PutObjectCommitCancellation {
token: CancellationToken,
armed: bool,
@@ -7222,7 +6977,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
};
let mut vers_map: HashMap<&String, FileInfoVersions> = HashMap::new();
let mut tier_reference_leases: Vec<(usize, String, Option<TierDestinationId>)> = Vec::new();
let mut tier_free_version_receipt_candidates: HashMap<usize, TierFreeVersionReceiptCandidate> = HashMap::new();
let mut transitioned_cleanup_items = vec![false; objects.len()];
for (i, dobj) in objects.iter().enumerate() {
if del_errs[i].is_some() {
@@ -7314,6 +7069,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
{
match tier_destination_id_from_metadata(&goi.user_defined) {
Ok(identity) => {
transitioned_cleanup_items[i] = true;
tier_reference_leases.push((i, goi.transitioned_object.tier.clone(), identity));
}
Err(err) => {
@@ -7354,8 +7110,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
..Default::default()
};
let tier_free_version_id = Uuid::new_v4();
vr.set_tier_free_version_id(&tier_free_version_id.to_string());
vr.set_tier_free_version_id(&Uuid::new_v4().to_string());
// Delete
// del_objects[i].object_name.clone_from(&vr.name);
@@ -7440,19 +7195,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
});
}
if opts.tier_free_version_receipt_sink.is_some()
&& !dobj.synthetic_version_id
&& transitioned_delete_publishes_free_version(&goi, &vr, opts.skip_free_version)
{
tier_free_version_receipt_candidates.insert(
i,
TierFreeVersionReceiptCandidate {
source: goi,
free_version_id: tier_free_version_id,
},
);
}
// Only add to vers_map if we hold the lock
if locked_objects.contains(&dobj.object_name) {
vers_map.insert(&dobj.object_name, v);
@@ -7529,6 +7271,12 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
}
}
for (idx, transitioned) in transitioned_cleanup_items.into_iter().enumerate() {
if transitioned && del_errs[idx].is_none() {
record_transitioned_delete_cleanup_owner(bucket, &decode_dir_object(&objects[idx].object_name), true);
}
}
// Keep backend generations pinned through the source mutation, its
// free-version write quorum, and any local rollback. Ordinary
// single/batch deletes never transfer cleanup ownership to a journal.
@@ -7640,8 +7388,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
self.record_capacity_scope_if_needed(opts.capacity_scope_token, &disks);
let mut rollback_futures = Vec::new();
let committed_receipt_indices =
committed_tier_free_version_receipt_indices(&vers, &del_errs, &tier_free_version_receipt_candidates);
for fi_vers in &vers {
// delete_versions commits one xl.meta per object group, so rollback must use the same boundary.
let should_rollback = fi_vers.versions.iter().any(|fi| del_errs[fi.idx].is_some());
@@ -7716,20 +7462,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
join_all(rollback_futures).await;
for idx in committed_receipt_indices {
let Some(candidate) = tier_free_version_receipt_candidates.remove(&idx) else {
continue;
};
record_committed_tier_free_version_receipt(
&opts,
bucket,
&decode_dir_object(&objects[idx].object_name),
&candidate.source,
candidate.free_version_id,
true,
);
}
// TODO(backlog): support partial object deletion for multi-part objects
if dist_erasure {
@@ -8081,7 +7813,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
ensure_delete_commit_locks_held(_lock_guard.as_ref(), bucket, object, &opts)?;
begin_scanner_publication_delete_mutation(scanner_publication_commit_scope.as_ref())?;
let _tier_delete_lease = acquire_single_tier_delete_lease(&opts, &goi).await?;
let _tier_delete_lease = acquire_single_tier_delete_lease(bucket, object, &opts, &goi).await?;
if opts.skip_free_version {
fi.set_skip_tier_free_version();
}
@@ -8091,12 +7823,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
#[cfg(test)]
pause_delete_object_commit_after_publish(bucket, object).await;
if opts.tier_free_version_receipt_sink.is_some()
&& transitioned_delete_publishes_free_version(&goi, &fi, opts.skip_free_version)
{
record_committed_tier_free_version_receipt(&opts, bucket, object, &goi, find_vid, false);
}
let disks = self.disk_inventory().await;
self.record_capacity_scope_if_needed(opts.capacity_scope_token, &disks);
@@ -8129,7 +7855,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
ensure_delete_commit_locks_held(_lock_guard.as_ref(), bucket, object, &opts)?;
begin_scanner_publication_delete_mutation(scanner_publication_commit_scope.as_ref())?;
let _tier_delete_lease = acquire_single_tier_delete_lease(&opts, &goi).await?;
let _tier_delete_lease = acquire_single_tier_delete_lease(bucket, object, &opts, &goi).await?;
if opts.skip_free_version {
dfi.set_skip_tier_free_version();
}
@@ -8139,12 +7865,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
#[cfg(test)]
pause_delete_object_commit_after_publish(bucket, object).await;
if opts.tier_free_version_receipt_sink.is_some()
&& transitioned_delete_publishes_free_version(&goi, &dfi, opts.skip_free_version)
{
record_committed_tier_free_version_receipt(&opts, bucket, object, &goi, find_vid, false);
}
let disks = self.disk_inventory().await;
self.record_capacity_scope_if_needed(opts.capacity_scope_token, &disks);
File diff suppressed because it is too large Load Diff
+13 -638
View File
@@ -14,8 +14,8 @@
use super::*;
use crate::core::pools::{
PoolMetaBootstrapAuthority, PoolMetaReplicaState, PoolMetaWriteState, load_pool_meta_identity_observing,
local_decommission_queue_prefix, persist_pool_meta_identity_for_startup, pool_meta_has_active_decommission,
PoolMetaReplicaState, PoolMetaWriteState, load_pool_meta_identity_observing, local_decommission_queue_prefix,
persist_pool_meta_identity_for_startup, pool_meta_has_active_decommission,
};
use crate::runtime::instance::InstanceContext;
use crate::runtime::sources as runtime_sources;
@@ -173,7 +173,7 @@ async fn establish_pool_meta_bootstrap_identity_if_proven<S>(
where
S: EcstoreObjectIO,
{
if elected_writer && write_state.bootstrap_identity_proven() {
if elected_writer && write_state.fresh_bootstrap_proven() {
persist_pool_meta_identity_for_startup(pools, write_state, false).await?;
}
Ok(())
@@ -215,7 +215,7 @@ where
}
let mut committed = meta.clone();
if should_write {
if write_state.bootstrap_identity_proven() || write_state.identity_is_pending() {
if write_state.fresh_bootstrap_proven() || write_state.identity_is_pending() {
persist_pool_meta_identity_for_startup(pools.clone(), write_state, false)
.await
.map_err(|err| Error::other(format!("store init failed during prepare_pool_meta_identity: {err}")))?;
@@ -402,7 +402,7 @@ impl ECStore {
preflight_startup_rpc_secret(&endpoint_pools)?;
let mut deployment_id = None;
let mut pool_meta_bootstrap_authority = None;
let mut fresh_bootstrap_proven = true;
// let (endpoint_pools, _) = EndpointServerPools::create_server_endpoints(address.as_str(), &layouts)?;
@@ -518,12 +518,7 @@ impl ECStore {
}
}
}?;
pool_meta_bootstrap_authority = Some(pool_meta_bootstrap_authority.map_or(
loaded_format.pool_meta_bootstrap_authority,
|authority: PoolMetaBootstrapAuthority| {
authority.combine_across_pools(loaded_format.pool_meta_bootstrap_authority)
},
));
fresh_bootstrap_proven &= loaded_format.fresh_bootstrap_proven;
let fm = loaded_format.format;
// Format loading succeeded, enable health monitoring on all disks
@@ -564,10 +559,6 @@ impl ECStore {
let peer_sys = S3PeerSys::new_with_instance_ctx(&endpoint_pools, instance_ctx.clone());
let mut pool_meta = PoolMeta::new(&pools, &PoolMeta::default());
pool_meta.dont_save = true;
let pool_meta_write_state = PoolMetaWriteState::for_startup_with_bootstrap_authority(
deployment_id,
pool_meta_bootstrap_authority.unwrap_or_default(),
);
let decommission_cancelers = RwLock::new(vec![None; pools.len()]);
let ec = Arc::new(ECStore {
@@ -579,7 +570,7 @@ impl ECStore {
rebalance_meta: RwLock::new(None),
decommission_cancelers,
start_gate: Mutex::new(()),
pool_meta_save_gate: Mutex::new(pool_meta_write_state),
pool_meta_save_gate: Mutex::new(PoolMetaWriteState::for_startup(deployment_id, fresh_bootstrap_proven)),
decommission_capacity_entry_gate: Mutex::default(),
// Adopt the caller's context (the process bootstrap one on the
// legacy path) so startup writes (erasure type recorded before
@@ -800,7 +791,6 @@ mod tests {
run_local_decommission_watchdog, save_validated_pool_meta_for_startup, should_auto_start_rebalance_after_init,
should_defer_rebalance_auto_start, should_retry_format_load, wait_for_local_decommission_resume_delay,
};
use crate::core::pools::PoolMetaBootstrapAuthority;
#[cfg(feature = "test-util")]
use crate::disk::DiskAPI;
#[cfg(feature = "test-util")]
@@ -1161,65 +1151,6 @@ mod tests {
.expect("successful startup publication must disarm the transaction guard");
}
#[tokio::test]
async fn test_legacy_adoption_pool_meta_bootstrap_reaches_v3_cas() {
let deployment_id = Uuid::new_v4();
let storage = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
let mut write_state =
PoolMetaWriteState::for_startup_with_bootstrap_authority(deployment_id, PoolMetaBootstrapAuthority::LegacyAdoption);
establish_pool_meta_bootstrap_identity_if_proven(vec![storage.clone()], &mut write_state, true)
.await
.expect("verified legacy adoption should persist a nonce-bound identity before loading pool metadata");
let (loaded, replica_state) = load_pool_meta_for_startup(vec![storage.clone()], &mut write_state)
.await
.expect("verified legacy adoption should authorize initially missing pool metadata");
assert!(loaded.pools.is_empty());
let committed = persist_pool_meta_for_startup_if_safe(
&init_test_pool_meta(None),
vec![storage.clone()],
replica_state,
&mut write_state,
true,
true,
)
.await
.expect("verified legacy adoption should publish initial pool metadata");
assert_eq!(committed.pools[0].cmd_line, "pool-0");
let objects = storage.objects.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
assert!(objects.contains_key(POOL_META_NAME));
let identity = objects
.get(POOL_META_IDENTITY_NAME)
.map(|(payload, _)| payload.clone())
.expect("legacy adoption should commit the bootstrap identity");
assert!(pool_meta_identity_initialized_for_test(&identity).expect("decode committed identity"));
}
#[tokio::test]
async fn test_non_elected_legacy_adoption_cannot_initialize_pool_meta() {
let storage = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
let mut write_state =
PoolMetaWriteState::for_startup_with_bootstrap_authority(Uuid::new_v4(), PoolMetaBootstrapAuthority::LegacyAdoption);
establish_pool_meta_bootstrap_identity_if_proven(vec![storage.clone()], &mut write_state, false)
.await
.expect("a non-elected distributed node must not create legacy adoption authority");
let err = load_pool_meta_for_startup(vec![storage.clone()], &mut write_state)
.await
.expect_err("legacy adoption still requires the elected writer to create a durable identity");
assert!(err.to_string().contains("no durable bootstrap identity"));
assert!(
!storage
.objects
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains_key(POOL_META_NAME),
"classification must not create pool metadata on non-elected nodes"
);
}
#[tokio::test]
async fn test_unproven_pending_identity_cannot_authorize_all_missing_pool_meta() {
let deployment_id = Uuid::new_v4();
@@ -3611,7 +3542,6 @@ mod tests {
assert_eq!(body, multipart_target_body);
let retry_object = "multipart-retry-object";
let retry_object_etag = "0123456789abcdef0123456789abcdef".to_string();
let retry_first_part_size = 5 * 1024 * 1024;
let retry_object_mod_time =
OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixed retry timestamp should be valid");
@@ -3701,7 +3631,6 @@ mod tests {
&ObjectOptions {
mod_time: Some(retry_object_mod_time),
want_checksum: Some(retry_object_checksum),
preserve_etag: Some(retry_object_etag.clone()),
..Default::default()
},
)
@@ -3734,7 +3663,7 @@ mod tests {
part.checksums = Some(HashMap::from([(ChecksumType::CRC32C.to_string(), checksum.encoded.clone())]));
}
retry_source_info.parts = Arc::new(retry_source_parts);
assert_eq!(retry_source_info.etag.as_deref(), Some(retry_object_etag.as_str()));
retry_source_info.etag = Some("0123456789abcdef0123456789abcdef".to_string());
assert!(!retry_source_info.is_multipart());
assert!(retry_source_info.parts.iter().all(|part| part.checksums.is_some()));
assert_eq!(retry_source_info.checksum.as_deref(), Some(retry_object_checksum_bytes.as_ref()));
@@ -10496,8 +10425,6 @@ mod tests {
bucket: &str,
object: &str,
restore_before_delete: bool,
causal_enqueue: bool,
delete_with_journal: bool,
) {
let temp_dir = tempfile::tempdir().expect("create transitioned delete store dir");
let (ctx, store, _shutdown) =
@@ -10561,51 +10488,11 @@ mod tests {
);
}
if causal_enqueue {
ExpiryState::resize_workers(1, store.clone()).await;
}
backend.set_remove_failure(!causal_enqueue);
if delete_with_journal {
store
.delete_object_with_tier_delete_journal(bucket, object, ObjectOptions::default())
.await
.expect("transitioned source journal-wrapper delete should commit");
} else {
store
.delete_object(bucket, object, ObjectOptions::default())
.await
.expect("transitioned source plain object-layer delete should commit");
}
if causal_enqueue {
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let metadata_absent = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("causal free-version cleanup metadata should remain readable")
.is_none();
if metadata_absent && backend.remove_versions().await.len() == 1 {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
backend.set_remove_failure(true);
store
.delete_object_with_tier_delete_journal(bucket, object, ObjectOptions::default())
.await
.expect("committed free-version should be cleaned without a recovery scan");
assert_eq!(backend.object_count().await, 0, "causal cleanup should remove the remote object");
assert_eq!(
tier_delete_journal_count(store.clone()).await,
0,
"ordinary causal cleanup must not create a journal"
);
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("bucket delete should succeed after causal free-version cleanup");
return;
}
.expect("transitioned source delete should commit");
let local_versions = store.pools[0]
.get_disks_by_key(object)
@@ -10684,8 +10571,6 @@ mod tests {
"transitioned-delete-journal-owner-bucket",
"transition/archive.bin",
false,
false,
true,
)
.await;
}
@@ -10700,40 +10585,6 @@ mod tests {
"restored-transitioned-delete-journal-owner-bucket",
"transition/archive.bin",
true,
false,
true,
)
.await;
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn transitioned_delete_causally_enqueues_free_version() {
run_transitioned_delete_free_version_owner_case(
"transitioned-delete-causal-enqueue",
"DELETE-CAUSAL",
"transitioned-delete-causal-enqueue-bucket",
"transition/archive.bin",
false,
true,
false,
)
.await;
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn restored_transitioned_delete_causally_enqueues_free_version() {
run_transitioned_delete_free_version_owner_case(
"restored-transitioned-delete-causal-enqueue",
"RESTORE-DELETE-CAUSAL",
"restored-transitioned-delete-causal-enqueue-bucket",
"transition/archive.bin",
true,
true,
true,
)
.await;
}
@@ -12413,262 +12264,12 @@ mod tests {
.is_none(),
"free-version recovery must remove the exact cleanup owner"
);
let causal = "causal.bin";
let mut causal_reader = PutObjReader::from_vec(vec![b'c'; 1024 * 1024]);
let causal_source = store
.put_object(bucket, causal, &mut causal_reader, &ObjectOptions::default())
.await
.expect("causal batch source should be written");
store
.transition_object(
bucket,
causal,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: causal_source.etag.clone().expect("causal batch source should have an etag"),
..Default::default()
},
mod_time: causal_source.mod_time,
..Default::default()
},
)
.await
.expect("causal batch source transition should commit");
let (_deleted, errors) = store
.delete_objects(
bucket,
vec![
ObjectToDelete {
object_name: causal.to_string(),
..Default::default()
},
ObjectToDelete {
object_name: causal.to_string(),
..Default::default()
},
],
ObjectOptions::default(),
)
.await;
assert!(
errors.iter().all(Option::is_none),
"duplicate causal batch deletes should remain idempotent: {errors:?}"
);
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let metadata_absent = store.pools[0]
.get_disks_by_key(causal)
.load_file_info_versions_exact(bucket, causal)
.await
.expect("causal batch cleanup metadata should remain readable")
.is_none();
if metadata_absent && backend.remove_versions().await.len() >= 2 {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("batch free-version receipt should converge without another recovery scan");
assert_eq!(
backend.remove_versions().await.len(),
2,
"duplicate batch requests must cause only one remote delete for the causal object"
);
crate::bucket::metadata_sys::update_in(
&ctx,
bucket,
BUCKET_VERSIONING_CONFIG,
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
)
.await
.expect("causal batch bucket versioning should be enabled");
let versioned_causal = "versioned-causal.bin";
let mut versioned_reader = PutObjReader::from_vec(vec![b'v'; 1024 * 1024]);
let versioned_source = store
.put_object(
bucket,
versioned_causal,
&mut versioned_reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("versioned causal batch source should be written");
let versioned_source_id = versioned_source
.version_id
.expect("versioned causal batch source should have an identity");
store
.transition_object(
bucket,
versioned_causal,
&ObjectOptions {
version_id: Some(versioned_source_id.to_string()),
versioned: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: versioned_source
.etag
.clone()
.expect("versioned causal batch source should have an etag"),
..Default::default()
},
mod_time: versioned_source.mod_time,
..Default::default()
},
)
.await
.expect("versioned causal batch source transition should commit");
let (_deleted, errors) = store
.delete_objects(
bucket,
vec![ObjectToDelete {
object_name: versioned_causal.to_string(),
version_id: Some(versioned_source_id),
..Default::default()
}],
ObjectOptions::default(),
)
.await;
assert!(
errors.iter().all(Option::is_none),
"explicit-version causal batch delete should commit: {errors:?}"
);
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let metadata_absent = store.pools[0]
.get_disks_by_key(versioned_causal)
.load_file_info_versions_exact(bucket, versioned_causal)
.await
.expect("versioned causal batch cleanup metadata should remain readable")
.is_none();
if metadata_absent && backend.remove_versions().await.len() == 3 {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("explicit-version batch receipt should converge without a recovery scan");
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("batch source bucket should be physically empty");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn batch_transitioned_delete_aggregate_error_still_enqueues_committed_free_version() {
let temp_dir = tempfile::tempdir().expect("create aggregate-error batch delete store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "batch-transitioned-aggregate-error", &[4, 4]))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "BATCH-AGGREGATE-ERROR";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "batch-transitioned-aggregate-error-bucket";
let object = "archive.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("aggregate-error source bucket should be created");
let mut reader = PutObjReader::from_vec(vec![b'a'; 1024 * 1024]);
let source = store.pools[0]
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("aggregate-error source should be written");
store.pools[0]
.transition_object(
bucket,
object,
&ObjectOptions {
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: source.etag.clone().expect("aggregate-error source should have an etag"),
..Default::default()
},
mod_time: source.mod_time,
..Default::default()
},
)
.await
.expect("aggregate-error source should transition");
// Model a data-movement copy: both pools own the same logical source
// and exact remote tuple, but each batch delete creates its own local
// free-version UUID in the shared request sink.
for disk_index in 0..4 {
let source_meta = temp_dir
.path()
.join(format!("pool0/set0/disk{disk_index}/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
let target_meta = temp_dir
.path()
.join(format!("pool1/set0/disk{disk_index}/{bucket}/{object}/{STORAGE_FORMAT_FILE}"));
tokio::fs::create_dir_all(target_meta.parent().expect("target xl.meta should have a parent"))
.await
.expect("second-pool object directory should be created");
tokio::fs::copy(&source_meta, &target_meta)
.await
.expect("transitioned xl.meta should copy exactly to the second pool");
}
ExpiryState::resize_workers(1, store.clone()).await;
let injection = crate::store::object::BatchDeletePoolErrorInjection::install(
bucket,
1,
vec![(object.to_string(), StorageError::ErasureWriteQuorum)],
);
let (deleted, errors) = store
.delete_objects(
bucket,
vec![ObjectToDelete {
object_name: object.to_string(),
..Default::default()
}],
ObjectOptions::default(),
)
.await;
assert_eq!(injection.observed(), 1, "the second pool should inject one post-commit aggregate error");
assert_eq!(errors, vec![Some(StorageError::ErasureWriteQuorum)]);
assert!(deleted[0].found, "the aggregate error must retain the committed pool result");
drop(injection);
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let mut metadata_absent = true;
for pool in &store.pools {
metadata_absent &= pool
.get_disks_by_key(object)
.load_file_info_versions_exact(bucket, object)
.await
.expect("aggregate-error cleanup metadata should remain readable")
.is_none();
}
if metadata_absent && backend.remove_count().await == 1 {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("aggregate failure must not suppress committed receipt dispatch");
assert_eq!(backend.object_count().await, 0, "the shared remote object should be removed exactly once");
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("aggregate-error bucket should be physically empty");
shutdown.cancel();
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
@@ -12900,217 +12501,6 @@ mod tests {
.expect("retry should leave the source bucket empty");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn batch_transitioned_delete_post_commit_failures_roll_back_without_free_version_receipt() {
let temp_dir = tempfile::tempdir().expect("create failed batch delete store dir");
let (ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "batch-delete-local-failure", &[4])).await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let tier_name = "BATCH-DELETE-LOCAL-FAIL";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let bucket = "batch-delete-local-failure-bucket";
let object = "archive.bin";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("failed batch source bucket should be created");
crate::bucket::metadata_sys::update_in(
&ctx,
bucket,
BUCKET_VERSIONING_CONFIG,
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
)
.await
.expect("failed batch bucket versioning should be enabled");
let mut transitioned_reader = PutObjReader::from_vec(vec![b't'; 1024 * 1024]);
let transitioned_source = store
.put_object(
bucket,
object,
&mut transitioned_reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("failed batch transitioned version should be written");
let transitioned_version_id = transitioned_source
.version_id
.expect("failed batch transitioned source should have a version identity");
store
.transition_object(
bucket,
object,
&ObjectOptions {
version_id: Some(transitioned_version_id.to_string()),
versioned: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name.to_string(),
etag: transitioned_source
.etag
.clone()
.expect("failed batch transitioned source should have an etag"),
..Default::default()
},
mod_time: transitioned_source.mod_time,
..Default::default()
},
)
.await
.expect("failed batch source version should transition");
let mut ordinary_reader = PutObjReader::from_vec(vec![b'o'; 1024 * 1024]);
let ordinary_source = store
.put_object(
bucket,
object,
&mut ordinary_reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("failed batch ordinary sibling should be written");
let ordinary_version_id = ordinary_source
.version_id
.expect("failed batch ordinary sibling should have a version identity");
let delete_requests = || {
vec![
ObjectToDelete {
object_name: object.to_string(),
version_id: Some(transitioned_version_id),
..Default::default()
},
ObjectToDelete {
object_name: object.to_string(),
version_id: Some(ordinary_version_id),
..Default::default()
},
]
};
let set = store.pools[0].get_disks_by_key(object);
let disks = set.disks.read().await;
assert_eq!(disks.len(), 4, "the rollback fixture must use four disks");
// Keep discovery fully online, then make a quorum of disks report an
// error only after their batch metadata commit has completed.
for disk in disks.iter().take(3) {
let disk = disk.as_ref().expect("injected rollback disks should be online");
crate::disk::local::set_delete_version_fail_after_commit(disk.path().as_path(), object);
}
drop(disks);
let receipt_sink = crate::object_api::TierFreeVersionReceiptSink::new();
let (_deleted, errors) = store
.delete_objects(
bucket,
delete_requests(),
ObjectOptions {
tier_free_version_receipt_sink: Some(receipt_sink.clone()),
..Default::default()
},
)
.await;
assert_eq!(
errors,
vec![Some(StorageError::Unexpected), Some(StorageError::Unexpected),],
"three post-commit disk errors must fail batch delete before receipts publish"
);
assert!(
receipt_sink
.drain()
.expect("the test-owned failed-batch sink should drain exactly once")
.is_empty(),
"a rolled-back physical group must publish no cleanup receipt"
);
let retained_transitioned = store
.get_object_info(
bucket,
object,
&ObjectOptions {
version_id: Some(transitioned_version_id.to_string()),
versioned: true,
..Default::default()
},
)
.await
.expect("failed batch delete must restore the transitioned sibling");
assert_eq!(retained_transitioned.transitioned_object.status, rustfs_filemeta::TRANSITION_COMPLETE);
let retained_ordinary = store
.get_object_info(
bucket,
object,
&ObjectOptions {
version_id: Some(ordinary_version_id.to_string()),
versioned: true,
..Default::default()
},
)
.await
.expect("failed batch delete must restore the ordinary sibling");
assert_ne!(retained_ordinary.transitioned_object.status, rustfs_filemeta::TRANSITION_COMPLETE);
let retained_versions = set
.load_file_info_versions_exact(bucket, object)
.await
.expect("rolled-back batch metadata should decode")
.expect("rolled-back batch source should remain on disk");
assert_eq!(
retained_versions
.versions
.iter()
.chain(retained_versions.free_versions.iter())
.filter(|version| version.tier_free_version())
.count(),
0,
"failed batch quorum must not retain a free-version owner"
);
let retained_version_ids = retained_versions
.versions
.iter()
.filter_map(|version| version.version_id)
.collect::<std::collections::HashSet<_>>();
assert_eq!(
retained_version_ids,
std::collections::HashSet::from([transitioned_version_id, ordinary_version_id]),
"the physical-group rollback must restore both explicit siblings"
);
assert_eq!(backend.object_count().await, 1, "failed batch commit must retain the remote object");
assert_eq!(backend.remove_count().await, 0, "failed batch commit must not dispatch remote cleanup");
ExpiryState::resize_workers(1, store.clone()).await;
let (_deleted, retry_errors) = store
.delete_objects(bucket, delete_requests(), ObjectOptions::default())
.await;
assert!(
retry_errors.iter().all(Option::is_none),
"retry after disk recovery should commit: {retry_errors:?}"
);
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let metadata_absent = set
.load_file_info_versions_exact(bucket, object)
.await
.expect("retry cleanup metadata should remain readable")
.is_none();
if metadata_absent && backend.remove_count().await == 1 {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("successful batch retry should converge without a recovery scan");
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("successful batch retry should leave the bucket empty");
shutdown.cancel();
}
#[cfg(feature = "test-util")]
async fn run_multi_pool_same_remote_tuple_delete_case(batch: bool) {
let temp_dir = tempfile::tempdir().expect("create shared-tuple multi-pool store dir");
@@ -13184,10 +12574,8 @@ mod tests {
);
assert_eq!(backend.object_count().await, 1);
let receipt_sink = crate::object_api::TierFreeVersionReceiptSink::new();
let mut delete_opts = ObjectOptions {
tier_delete_journal_api: Some(store.clone()),
tier_free_version_receipt_sink: Some(receipt_sink.clone()),
..Default::default()
};
if batch {
@@ -13315,20 +12703,7 @@ mod tests {
);
backend.set_remove_failure(false);
let receipts = receipt_sink
.drain()
.expect("the simulated outer multi-pool wrapper should drain exactly once");
assert_eq!(
receipts.len(),
1,
"the same physical key and remote tuple must collapse to one causal task"
);
assert_eq!(
crate::bucket::lifecycle::bucket_lifecycle_ops::enqueue_committed_free_versions(&store, receipts).await,
1,
"the committed shared-tuple task should enter the running worker"
);
wait_for_expiry_workers_idle(&store).await;
wait_for_tier_free_version_recovery(store.clone(), &backend, 1).await;
assert_eq!(backend.remove_count().await, 1, "shared remote tuple should be deleted exactly once");
for pool_idx in 0..2 {
assert!(
+14 -75
View File
@@ -14,7 +14,6 @@
use crate::cluster::rpc::client::is_network_like_disk_error;
use crate::config::storageclass;
use crate::core::pools::PoolMetaBootstrapAuthority;
use crate::disk::error_reduce::{count_errs, reduce_write_quorum_errs};
use crate::disk::{self, DiskAPI};
use crate::error::{Error, Result};
@@ -85,7 +84,7 @@ pub async fn connect_load_init_formats(
pub(crate) struct LoadedFormat {
pub(crate) format: FormatV3,
pub(crate) pool_meta_bootstrap_authority: PoolMetaBootstrapAuthority,
pub(crate) fresh_bootstrap_proven: bool,
}
pub(crate) async fn connect_load_init_formats_with_instance_ctx(
@@ -134,7 +133,7 @@ pub(crate) async fn connect_load_init_formats_with_instance_ctx(
retain_format_quorum_members(instance_ctx, disks, &format, &quorum_members, set_drive_count).await?;
return Ok(LoadedFormat {
format: *format,
pool_meta_bootstrap_authority: PoolMetaBootstrapAuthority::LegacyAdoption,
fresh_bootstrap_proven: false,
});
}
Ok(LegacyFormatOutcome::Incompatible) => {
@@ -154,7 +153,7 @@ pub(crate) async fn connect_load_init_formats_with_instance_ctx(
let fm = init_format_erasure(instance_ctx, disks, set_count, set_drive_count, deployment_id).await?;
return Ok(LoadedFormat {
format: fm,
pool_meta_bootstrap_authority: PoolMetaBootstrapAuthority::Fresh,
fresh_bootstrap_proven: true,
});
}
}
@@ -183,29 +182,10 @@ pub(crate) async fn connect_load_init_formats_with_instance_ctx(
Ok(LoadedFormat {
format: fm,
pool_meta_bootstrap_authority: verified_legacy_adoption_source(disks, &formats, set_count, set_drive_count).await?,
fresh_bootstrap_proven: false,
})
}
async fn verified_legacy_adoption_source(
disks: &[Option<DiskStore>],
rustfs_formats: &[Option<FormatV3>],
set_count: usize,
set_drive_count: usize,
) -> Result<PoolMetaBootstrapAuthority> {
match try_migrate_format(disks, rustfs_formats, set_count, set_drive_count).await {
Ok(LegacyFormatOutcome::Migrated { .. }) => Ok(PoolMetaBootstrapAuthority::LegacyAdoption),
Ok(LegacyFormatOutcome::None | LegacyFormatOutcome::Incompatible) => Ok(PoolMetaBootstrapAuthority::None),
Err(err) => {
debug!(
error = %err,
"legacy adoption proof skipped because legacy format verification failed"
);
Ok(PoolMetaBootstrapAuthority::None)
}
}
}
async fn retain_format_quorum_members(
instance_ctx: &Arc<InstanceContext>,
disks: &mut [Option<DiskStore>],
@@ -1331,7 +1311,10 @@ mod tests {
let loaded = connect_load_init_formats_with_instance_ctx(&current_ctx(), true, &mut disks, 1, 3, None)
.await
.expect("fresh disks should receive a storage format");
assert_eq!(loaded.pool_meta_bootstrap_authority, PoolMetaBootstrapAuthority::Fresh);
assert!(
loaded.fresh_bootstrap_proven,
"every configured disk explicitly reporting unformatted should establish fresh topology proof"
);
let format = loaded.format;
let (formats, errors) = load_format_erasure_all(&disks, false).await;
@@ -1375,11 +1358,12 @@ mod tests {
let mut expected = legacy;
expected.erasure.this = Uuid::nil();
let loaded = connect_load_init_formats_with_instance_ctx(&current_ctx(), true, &mut disks, 1, 3, None)
.await
.expect("compatible legacy format should migrate");
assert_eq!(loaded.format, expected);
assert_eq!(loaded.pool_meta_bootstrap_authority, PoolMetaBootstrapAuthority::LegacyAdoption);
assert_eq!(
connect_load_init_formats(true, &mut disks, 1, 3, None)
.await
.expect("compatible legacy format should migrate"),
expected
);
let (formats, errors) = load_format_erasure_all(&disks, false).await;
assert!(
errors.iter().all(Option::is_none),
@@ -1394,51 +1378,6 @@ mod tests {
}
}
#[tokio::test]
async fn compatible_single_drive_legacy_format_marks_adoption_proof() {
let (_temp_dir, mut disks) = local_disks(1).await;
let legacy = FormatV3::new(1, 1);
write_legacy_majority(&disks, &legacy).await;
let loaded = connect_load_init_formats_with_instance_ctx(&current_ctx(), true, &mut disks, 1, 1, None)
.await
.expect("single-drive MinIO format should migrate");
assert_eq!(loaded.pool_meta_bootstrap_authority, PoolMetaBootstrapAuthority::LegacyAdoption);
}
#[tokio::test]
async fn existing_migrated_format_keeps_legacy_adoption_proof() {
let (_temp_dir, mut disks) = local_disks(1).await;
let legacy = FormatV3::new(1, 1);
write_legacy_majority(&disks, &legacy).await;
connect_load_init_formats_with_instance_ctx(&current_ctx(), true, &mut disks, 1, 1, None)
.await
.expect("first run should migrate the MinIO format");
let loaded = connect_load_init_formats_with_instance_ctx(&current_ctx(), true, &mut disks, 1, 1, None)
.await
.expect("retry after a partial adoption should reload the migrated RustFS format");
assert_eq!(loaded.pool_meta_bootstrap_authority, PoolMetaBootstrapAuthority::LegacyAdoption);
}
#[tokio::test]
async fn existing_rustfs_format_without_legacy_source_is_not_legacy_adoption() {
let (_temp_dir, mut disks) = local_disks(1).await;
let mut format = FormatV3::new(1, 1);
format.erasure.this = format.erasure.sets[0][0];
save_format_file(&disks[0], &Some(format))
.await
.expect("existing RustFS format should be written");
let loaded = connect_load_init_formats_with_instance_ctx(&current_ctx(), true, &mut disks, 1, 1, None)
.await
.expect("existing RustFS format should load");
assert_eq!(loaded.pool_meta_bootstrap_authority, PoolMetaBootstrapAuthority::None);
}
#[tokio::test]
async fn compatible_legacy_format_migrates_when_the_file_is_missing() {
let (_temp_dir, mut disks) = local_disks(3).await;
+1 -42
View File
@@ -97,8 +97,6 @@ pub(crate) struct BucketDeleteDiagnosticBudget {
deadline: Option<tokio::time::Instant>,
max_elapsed: Duration,
entries_remaining: usize,
#[cfg(test)]
first_io_delay: Option<(Duration, Arc<std::sync::atomic::AtomicBool>)>,
}
impl BucketDeleteDiagnosticBudget {
@@ -111,17 +109,9 @@ impl BucketDeleteDiagnosticBudget {
deadline: None,
max_elapsed: elapsed,
entries_remaining: entries,
#[cfg(test)]
first_io_delay: None,
}
}
#[cfg(test)]
fn with_first_io_delay(mut self, delay: Duration, started: Arc<std::sync::atomic::AtomicBool>) -> Self {
self.first_io_delay = Some((delay, started));
self
}
fn deadline(&mut self) -> tokio::time::Instant {
let max_elapsed = self.max_elapsed;
*self.deadline.get_or_insert_with(|| tokio::time::Instant::now() + max_elapsed)
@@ -147,20 +137,7 @@ impl BucketDeleteDiagnosticBudget {
if tokio::time::Instant::now() >= deadline {
return Ok(None);
}
#[cfg(test)]
let first_io_delay = self.first_io_delay.take();
#[cfg(test)]
let timeout_result = tokio::time::timeout_at(deadline, async move {
if let Some((delay, started)) = first_io_delay {
started.store(true, std::sync::atomic::Ordering::SeqCst);
tokio::time::sleep(delay).await;
}
future.await
})
.await;
#[cfg(not(test))]
let timeout_result = tokio::time::timeout_at(deadline, future).await;
match timeout_result {
match tokio::time::timeout_at(deadline, future).await {
Ok(result) => result.map(Some),
Err(_) => Ok(None),
}
@@ -198,22 +175,6 @@ impl BucketDeleteBlockerKind {
Self::DiagnosticBudgetExceeded => "diagnostic_budget_exceeded",
}
}
/// Whether the blocking residue is something the caller can still see and
/// remove through the S3 API.
///
/// A live version or a tier free-version is ordinary: the bucket really is
/// not empty, the client can list and delete what is left, and the 409 it
/// receives is a complete answer.
///
/// The remaining kinds are not. They are on-disk state that no S3 request
/// can reach: the caller has drained every version the API will show and
/// `DeleteBucket` still refuses, with no way to find out why. That is a
/// server-side integrity problem, and it is the reason this classification
/// exists — see [`bucket_delete_blocker_level`].
pub(crate) const fn is_client_visible(self) -> bool {
matches!(self, Self::VisibleVersion | Self::TierFreeVersion)
}
}
impl BucketMetadataLessResidue {
@@ -425,8 +386,6 @@ pub(crate) mod init_format;
pub(crate) mod list_objects;
mod multipart;
mod object;
#[cfg(any(test, feature = "test-util"))]
pub use object::DeleteAfterObjectLockSnapshotBarrier;
pub(crate) use object::{
DecommissionFixedReadAnchor, ObjectLockDiagGuard, RemoteTuplePublicationCommitGuard, RemoteTuplePublicationFence,
SourceCleanupMutationFence, tiered_data_movement_source_matches,
+33 -392
View File
@@ -14,7 +14,7 @@
use super::*;
use crate::bucket::lifecycle::{
bucket_lifecycle_ops::{enqueue_committed_free_versions, eval_action_from_lifecycle},
bucket_lifecycle_ops::eval_action_from_lifecycle,
get_expiry_configs,
tier_delete_journal::{
ActiveTierDeleteDispatch, EVENT_LIFECYCLE_TIER_DELETE_JOURNAL, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_LIFECYCLE,
@@ -39,7 +39,6 @@ use crate::core::pools::{DecommissionCapacityOwner, ensure_decommission_capacity
use crate::disk::OldCurrentSize;
use crate::object_api::{
NamespaceLockFence, ObjectLockConfigSnapshot, ScannerPublicationCommitScopeGuard, ScannerPublicationCommitState,
TierFreeVersionReceiptSink,
};
use crate::services::notification_sys::acquire_tier_delete_journal_fleet_proof;
use crate::services::tier::tier::{TierConfigMgr, TierDestinationId, TierOperationLease, tier_destination_id_from_metadata};
@@ -53,7 +52,6 @@ use crate::storage_api_contracts::{
object::{DeleteAccounting, ObjectIO as _, ObjectOperations as _},
};
use parking_lot::Mutex as ParkingMutex;
use rustfs_filemeta::ObjectPartInfo;
use rustfs_io_metrics::{
record_object_lock_diag_acquire_duration, record_object_lock_diag_hold_duration, record_object_lock_diag_slow_acquire,
record_object_lock_diag_slow_hold,
@@ -72,26 +70,6 @@ const RECURSIVE_DELETE_VERSION_SCAN_PAGE_SIZE: i32 = 1000;
#[cfg(test)]
const RECURSIVE_DELETE_VERSION_SCAN_PAGE_SIZE: i32 = 2;
fn install_tier_free_version_receipt_sink(opts: &mut ObjectOptions) -> Option<TierFreeVersionReceiptSink> {
if opts.tier_free_version_receipt_sink.is_some() || opts.skip_free_version || opts.delete_prefix {
return None;
}
let sink = TierFreeVersionReceiptSink::new();
opts.tier_free_version_receipt_sink = Some(sink.clone());
Some(sink)
}
async fn enqueue_recorded_tier_free_versions(store: &ECStore, sink: Option<TierFreeVersionReceiptSink>) -> usize {
let Some(sink) = sink else {
return 0;
};
let Ok(receipts) = sink.drain() else {
return 0;
};
enqueue_committed_free_versions(store, receipts).await
}
fn build_tier_delete_journal_entry(
bucket: &str,
object: &str,
@@ -1314,7 +1292,7 @@ fn should_create_delete_marker_for_missing_object(opts: &ObjectOptions) -> bool
(opts.versioned || opts.version_suspended) && opts.version_id.is_none() && !opts.delete_marker && !opts.data_movement
}
#[cfg(any(test, feature = "test-util"))]
#[cfg(test)]
struct DeleteAfterObjectLockSnapshotBarrierState {
bucket: String,
arrived: tokio::sync::Notify,
@@ -1323,19 +1301,19 @@ struct DeleteAfterObjectLockSnapshotBarrierState {
namespace_acquired: AtomicBool,
}
#[cfg(any(test, feature = "test-util"))]
pub struct DeleteAfterObjectLockSnapshotBarrier {
#[cfg(test)]
pub(crate) struct DeleteAfterObjectLockSnapshotBarrier {
state: Arc<DeleteAfterObjectLockSnapshotBarrierState>,
}
#[cfg(any(test, feature = "test-util"))]
#[cfg(test)]
static DELETE_AFTER_OBJECT_LOCK_SNAPSHOT_BARRIER: std::sync::OnceLock<
std::sync::Mutex<Option<Arc<DeleteAfterObjectLockSnapshotBarrierState>>>,
> = std::sync::OnceLock::new();
#[cfg(any(test, feature = "test-util"))]
#[cfg(test)]
impl DeleteAfterObjectLockSnapshotBarrier {
pub fn install(bucket: &str) -> Self {
pub(crate) fn install(bucket: &str) -> Self {
let state = Arc::new(DeleteAfterObjectLockSnapshotBarrierState {
bucket: bucket.to_string(),
arrived: tokio::sync::Notify::new(),
@@ -1352,15 +1330,15 @@ impl DeleteAfterObjectLockSnapshotBarrier {
Self { state }
}
pub async fn wait_until_paused(&self) {
pub(crate) async fn wait_until_paused(&self) {
self.state.arrived.notified().await;
}
pub fn release(&self) {
pub(crate) fn release(&self) {
self.state.release.notify_one();
}
pub async fn release_and_wait_until_namespace_pending(&self) {
pub(crate) async fn release_and_wait_until_namespace_pending(&self) {
let namespace_pending = self.state.namespace_pending.notified();
self.release();
tokio::time::timeout(Duration::from_secs(5), namespace_pending)
@@ -1368,12 +1346,12 @@ impl DeleteAfterObjectLockSnapshotBarrier {
.expect("delete should proceed to its namespace lock after leaving the snapshot barrier");
}
pub fn namespace_acquired(&self) -> bool {
pub(crate) fn namespace_acquired(&self) -> bool {
self.state.namespace_acquired.load(Ordering::Acquire)
}
}
#[cfg(any(test, feature = "test-util"))]
#[cfg(test)]
impl Drop for DeleteAfterObjectLockSnapshotBarrier {
fn drop(&mut self) {
self.state.release.notify_one();
@@ -1386,7 +1364,7 @@ impl Drop for DeleteAfterObjectLockSnapshotBarrier {
}
}
#[cfg(any(test, feature = "test-util"))]
#[cfg(test)]
async fn pause_delete_after_object_lock_snapshot(bucket: &str) {
let state = DELETE_AFTER_OBJECT_LOCK_SNAPSHOT_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
@@ -1398,24 +1376,11 @@ async fn pause_delete_after_object_lock_snapshot(bucket: &str) {
if let Some(state) = state {
state.arrived.notify_one();
state.release.notified().await;
}
}
#[cfg(any(test, feature = "test-util"))]
fn notify_delete_namespace_pending(bucket: &str) {
let state = DELETE_AFTER_OBJECT_LOCK_SNAPSHOT_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("delete snapshot barrier mutex should not poison")
.as_ref()
.filter(|state| state.bucket == bucket)
.cloned();
if let Some(state) = state {
state.namespace_pending.notify_one();
}
}
#[cfg(any(test, feature = "test-util"))]
#[cfg(test)]
fn notify_delete_namespace_acquired(bucket: &str) {
let state = DELETE_AFTER_OBJECT_LOCK_SNAPSHOT_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
@@ -2149,6 +2114,18 @@ fn remote_tuple_publication_object_source_matches(expected: &ObjectInfo, current
let (Ok(expected_actual_size), Ok(current_actual_size)) = (expected.get_actual_size(), current.get_actual_size()) else {
return false;
};
let parts_match = expected.parts.len() == current.parts.len()
&& expected.parts.iter().all(|expected_part| {
current
.parts
.iter()
.find(|current_part| current_part.number == expected_part.number)
.is_some_and(|current_part| {
current_part.size == expected_part.size
&& current_part.actual_size == expected_part.actual_size
&& current_part.etag == expected_part.etag
})
});
expected.data_dir.is_some_and(|data_dir| !data_dir.is_nil())
&& expected.data_dir == current.data_dir
@@ -2156,7 +2133,6 @@ fn remote_tuple_publication_object_source_matches(expected: &ObjectInfo, current
&& expected.delete_marker == current.delete_marker
&& expected.size == current.size
&& expected_actual_size == current_actual_size
&& expected.etag == current.etag
&& expected.checksum == current.checksum
&& expected.mod_time == current.mod_time
&& expected.storage_class == current.storage_class
@@ -2178,24 +2154,7 @@ fn remote_tuple_publication_object_source_matches(expected: &ObjectInfo, current
&& expected.transitioned_object.free_version == current.transitioned_object.free_version
&& expected.transitioned_object.status == current.transitioned_object.status
&& expected.transition_version_state == current.transition_version_state
&& remote_tuple_publication_parts_match(&expected.parts, &current.parts)
}
fn remote_tuple_publication_parts_match(expected: &[ObjectPartInfo], current: &[ObjectPartInfo]) -> bool {
if expected.len() != current.len() {
return false;
}
let Some(mut current_parts) = crate::data_movement::data_movement_parts_by_number(current) else {
return false;
};
expected.iter().all(|expected_part| {
current_parts.remove(&expected_part.number).is_some_and(|current_part| {
current_part.size == expected_part.size
&& current_part.actual_size == expected_part.actual_size
&& current_part.etag == expected_part.etag
})
})
&& parts_match
}
impl RemoteTuplePublicationFence {
@@ -2394,16 +2353,6 @@ impl ECStore {
})
}
pub(crate) fn is_equivalent_decommission_capacity_target(source: &ObjectInfo, target: &ObjectInfo) -> bool {
source.bucket == target.bucket
&& source.name == decode_dir_object(&target.name)
&& if source.delete_marker {
is_equivalent_data_movement_delete_marker(source, target)
} else {
crate::data_movement::is_equivalent_data_movement_object_identity(source, target, true, false)
}
}
/// Captures Object Lock state once for a batch of PUTs to the same bucket.
/// `handle_put_object` only reuses the token for the same store, bucket,
/// bucket incarnation, and Object Lock configuration revision.
@@ -2598,10 +2547,6 @@ impl ECStore {
let diag_enabled = is_object_lock_diag_enabled();
let ns_lock = self.handle_new_ns_lock(bucket, object).await?;
let acquire_start = Instant::now();
#[cfg(any(test, feature = "test-util"))]
if matches!(op, "delete_object" | "delete_objects") {
notify_delete_namespace_pending(bucket);
}
let guard = ns_lock
.get_write_lock(get_lock_acquire_timeout())
.await
@@ -4193,16 +4138,7 @@ impl ECStore {
opts: ObjectOptions,
tier_journal_api: Option<Arc<ECStore>>,
) -> Result<ObjectInfo> {
Box::pin(async move {
let mut opts = opts;
let receipt_sink = install_tier_free_version_receipt_sink(&mut opts);
let result = self
.handle_delete_object_with_journal_inner(bucket, object, opts, tier_journal_api)
.await;
enqueue_recorded_tier_free_versions(self, receipt_sink).await;
result
})
.await
Box::pin(self.handle_delete_object_with_journal_inner(bucket, object, opts, tier_journal_api)).await
}
async fn handle_delete_object_with_journal_inner(
@@ -4276,7 +4212,7 @@ impl ECStore {
if opts.delete_prefix && opts.expected_bucket_incarnation_id.is_none() {
opts.expected_bucket_incarnation_id = current_bucket_incarnation_id;
}
#[cfg(any(test, feature = "test-util"))]
#[cfg(test)]
pause_delete_after_object_lock_snapshot(bucket).await;
if opts.delete_prefix && !opts.delete_prefix_object {
@@ -4290,7 +4226,7 @@ impl ECStore {
} else {
None
};
#[cfg(any(test, feature = "test-util"))]
#[cfg(test)]
if _object_lock_guard.is_some() {
notify_delete_namespace_acquired(bucket);
}
@@ -4504,9 +4440,7 @@ impl ECStore {
}
if should_delete_from_all_pools(&opts, errs.len()) {
let mut obj = self
.delete_object_from_all_pools(bucket, object, &opts, &pinfo.object_info, errs)
.await?;
let mut obj = self.delete_object_from_all_pools(bucket, object, &opts, errs).await?;
obj.name = decode_dir_object(object);
return Ok(obj);
}
@@ -4580,25 +4514,6 @@ impl ECStore {
objects: Vec<ObjectToDelete>,
opts: ObjectOptions,
tier_journal_api: Option<Arc<ECStore>>,
) -> (Vec<DeletedObject>, Vec<Option<Error>>, Vec<Option<DeleteAccounting>>) {
Box::pin(async move {
let mut opts = opts;
let receipt_sink = install_tier_free_version_receipt_sink(&mut opts);
let result = self
.handle_delete_objects_with_journal_and_accounting_inner(bucket, objects, opts, tier_journal_api)
.await;
enqueue_recorded_tier_free_versions(self, receipt_sink).await;
result
})
.await
}
async fn handle_delete_objects_with_journal_and_accounting_inner(
&self,
bucket: &str,
objects: Vec<ObjectToDelete>,
opts: ObjectOptions,
tier_journal_api: Option<Arc<ECStore>>,
) -> (Vec<DeletedObject>, Vec<Option<Error>>, Vec<Option<DeleteAccounting>>) {
// encode object name
let objects: Vec<ObjectToDelete> = objects
@@ -4683,7 +4598,7 @@ impl ECStore {
StorageError::BucketNotFound(bucket.to_string()),
);
}
#[cfg(any(test, feature = "test-util"))]
#[cfg(test)]
if current_bucket_incarnation_id.is_some() {
pause_delete_after_object_lock_snapshot(bucket).await;
}
@@ -4691,7 +4606,7 @@ impl ECStore {
Ok(guards) => guards,
Err(err) => return return_batch_delete_lock_error_with_accounting(objects.as_slice(), err),
};
#[cfg(any(test, feature = "test-util"))]
#[cfg(test)]
if !_object_lock_guards.is_empty() {
notify_delete_namespace_acquired(bucket);
}
@@ -5908,271 +5823,6 @@ mod tests {
);
}
#[test]
fn exact_delete_capacity_target_requires_matching_namespace_and_object_identity() {
let source = ObjectInfo {
bucket: "bucket".to_string(),
name: "directory/".to_string(),
version_id: Some(Uuid::from_u128(1)),
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
size: 10,
etag: Some("source-etag".to_string()),
..Default::default()
};
let target = ObjectInfo {
name: rustfs_utils::path::encode_dir_object(&source.name),
..source.clone()
};
assert!(ECStore::is_equivalent_decommission_capacity_target(&source, &target));
let mismatched_identity = ObjectInfo {
etag: Some("different-etag".to_string()),
..target.clone()
};
assert!(!ECStore::is_equivalent_decommission_capacity_target(&source, &mismatched_identity));
let wrong_bucket = ObjectInfo {
bucket: "other-bucket".to_string(),
..target
};
assert!(!ECStore::is_equivalent_decommission_capacity_target(&source, &wrong_bucket));
}
fn publication_part(number: usize) -> ObjectPartInfo {
ObjectPartInfo {
number,
size: 100 + number,
actual_size: i64::try_from(200 + number).expect("test part size should fit in i64"),
etag: format!("part-etag-{number}"),
..Default::default()
}
}
fn publication_source(parts: Vec<ObjectPartInfo>) -> ObjectInfo {
ObjectInfo {
data_dir: Some(Uuid::new_v4()),
version_id: Some(Uuid::new_v4()),
size: 4096,
actual_size: 4096,
etag: Some("object-etag".to_string()),
checksum: Some(Bytes::from_static(b"object-checksum")),
mod_time: Some(time::OffsetDateTime::UNIX_EPOCH),
parts: Arc::new(parts),
..Default::default()
}
}
#[test]
fn publication_parts_match_is_order_independent_and_bijective() {
let ordered = vec![publication_part(1), publication_part(2), publication_part(3)];
let mut reversed = ordered.clone();
reversed.reverse();
assert!(remote_tuple_publication_parts_match(&[], &[]));
assert!(remote_tuple_publication_parts_match(&ordered, &ordered));
assert!(remote_tuple_publication_parts_match(&ordered, &reversed));
assert!(!remote_tuple_publication_parts_match(&ordered[..2], &ordered));
let expected_duplicate = vec![publication_part(1), publication_part(1)];
let current_unique = vec![publication_part(1), publication_part(2)];
assert!(
!remote_tuple_publication_parts_match(&expected_duplicate, &current_unique),
"two expected entries must not reuse the same current part"
);
assert!(!remote_tuple_publication_parts_match(&current_unique, &expected_duplicate));
assert!(!remote_tuple_publication_parts_match(&expected_duplicate, &expected_duplicate));
}
#[test]
fn publication_parts_match_preserves_exact_part_identity_contract() {
let expected = vec![publication_part(1)];
let mut different_number = expected.clone();
different_number[0].number = 2;
assert!(!remote_tuple_publication_parts_match(&expected, &different_number));
let mut different_size = expected.clone();
different_size[0].size += 1;
assert!(!remote_tuple_publication_parts_match(&expected, &different_size));
let mut different_actual_size = expected.clone();
different_actual_size[0].actual_size += 1;
assert!(!remote_tuple_publication_parts_match(&expected, &different_actual_size));
let mut different_etag = expected.clone();
different_etag[0].etag.push_str("-changed");
assert!(!remote_tuple_publication_parts_match(&expected, &different_etag));
let mut ignored_fields = expected.clone();
ignored_fields[0].index = Some(Bytes::from_static(b"different-index"));
ignored_fields[0].checksums = Some(HashMap::from([("CRC32C".to_string(), "different".to_string())]));
ignored_fields[0].mod_time = Some(time::OffsetDateTime::UNIX_EPOCH);
assert!(
remote_tuple_publication_parts_match(&expected, &ignored_fields),
"the publication fence must retain its existing checksum/index/mod-time compatibility contract"
);
let zero_actual_size = vec![ObjectPartInfo {
actual_size: 0,
..publication_part(1)
}];
assert!(
!remote_tuple_publication_parts_match(&zero_actual_size, &expected),
"the publication fence compares raw part actual sizes without the broader comparator's fallback"
);
}
#[test]
fn publication_source_match_rejects_object_etag_and_identity_mutations() {
let expected = publication_source(vec![publication_part(1)]);
assert!(remote_tuple_publication_object_source_matches(&expected, &expected));
let mut current = expected.clone();
current.etag = Some("changed-object-etag".to_string());
assert!(!remote_tuple_publication_object_source_matches(&expected, &current));
let mut current = expected.clone();
current.checksum = Some(Bytes::from_static(b"changed-checksum"));
assert!(!remote_tuple_publication_object_source_matches(&expected, &current));
let mut current = expected.clone();
current.actual_size += 1;
assert!(!remote_tuple_publication_object_source_matches(&expected, &current));
let mut current = expected.clone();
current.data_dir = Some(Uuid::new_v4());
assert!(!remote_tuple_publication_object_source_matches(&expected, &current));
let mut current = expected.clone();
current.version_id = Some(Uuid::new_v4());
assert!(!remote_tuple_publication_object_source_matches(&expected, &current));
let mut current = expected.clone();
current.mod_time = current.mod_time.map(|mod_time| mod_time + time::Duration::SECOND);
assert!(!remote_tuple_publication_object_source_matches(&expected, &current));
}
#[test]
fn publication_source_match_preserves_effective_actual_size_compatibility() {
let expected = publication_source(vec![publication_part(1)]);
let current = ObjectInfo {
actual_size: 0,
..expected.clone()
};
assert_eq!(
expected.get_actual_size().expect("expected size should be valid"),
current.get_actual_size().expect("current size should be valid")
);
assert!(remote_tuple_publication_object_source_matches(&expected, &current));
}
#[test]
fn publication_source_match_rejects_duplicate_parts_through_the_full_fence() {
let expected = publication_source(vec![publication_part(1), publication_part(1)]);
let current = ObjectInfo {
parts: Arc::new(vec![publication_part(1), publication_part(2)]),
..expected.clone()
};
assert!(
!remote_tuple_publication_object_source_matches(&expected, &current),
"the full source fence must reject the original non-bijective false-positive"
);
}
#[test]
fn publication_source_match_handles_10_000_reversed_parts_without_payload_cloning() {
let parts = Arc::new((1..=10_000).map(publication_part).collect::<Vec<_>>());
let mut reversed = parts.as_ref().clone();
reversed.reverse();
let expected = publication_source(Vec::new());
let expected = ObjectInfo {
parts: Arc::clone(&parts),
..expected
};
let current = ObjectInfo {
parts: Arc::new(reversed),
..expected.clone()
};
let expected_parts = Arc::clone(&expected.parts);
assert!(remote_tuple_publication_object_source_matches(&expected, &current));
assert!(Arc::ptr_eq(&expected.parts, &expected_parts));
}
#[tokio::test]
async fn publication_commit_guard_rejects_an_etag_only_source_change() {
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
let (_first_dirs, first_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
let (_second_dirs, second_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
let store =
Arc::new(new_prepared_reader_test_store_with_ctx(&[Arc::clone(&first_set), Arc::clone(&second_set)], ctx).await);
let bucket = "publication-etag-source-change";
let object = "source.bin";
for set in [&first_set, &second_set] {
set.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("publication ETag test bucket should be created");
}
let mut source_body = PutObjReader::from_vec(b"source body".to_vec());
first_set
.put_object(bucket, object, &mut source_body, &ObjectOptions::default())
.await
.expect("publication source should be written");
let source = first_set
.get_object_info(
bucket,
object,
&ObjectOptions {
include_part_checksums: true,
..Default::default()
},
)
.await
.expect("publication source should be readable");
let publication = store
.acquire_remote_tuple_publication_fence(bucket, 0, &source, false)
.await
.expect("the source snapshot should produce a publication capability");
let changed = first_set
.put_object_metadata(
bucket,
object,
&ObjectOptions {
eval_metadata: Some(HashMap::from([("etag".to_string(), "changed-etag".to_string())])),
..Default::default()
},
)
.await
.expect("the source ETag should be updated in place");
assert_ne!(source.etag, changed.etag);
let unchanged_except_etag = ObjectInfo {
etag: source.etag.clone(),
..changed.clone()
};
assert!(
remote_tuple_publication_object_source_matches(&source, &unchanged_except_etag),
"the persisted source update fixture must differ only by object ETag"
);
let encoded = encode_dir_object(object);
let err = match publication.into_commit_guard(1, bucket, &encoded).await {
Ok(_) => panic!("the publication guard must reject an ETag-only source change"),
Err(err) => err,
};
assert!(matches!(err, Error::DataMovementOverwriteErr(_, _, _)));
assert!(
second_set
.get_object_info(bucket, object, &ObjectOptions::default())
.await
.is_err(),
"a rejected publication must not create a target object"
);
}
#[tokio::test]
async fn generic_data_movement_put_rejects_transition_ownership_without_capability() {
let (_dirs, set) = make_local_set_disks(4, 2).await;
@@ -7594,15 +7244,6 @@ mod tests {
);
drop(unified_future);
let batch_future =
store.handle_delete_objects_with_journal_and_accounting("bucket", Vec::new(), ObjectOptions::default(), None);
let batch_future_size = std::mem::size_of_val(&batch_future);
assert!(
batch_future_size <= 4 * 1024,
"batch delete handler future must remain stack-bounded; measured {batch_future_size} bytes"
);
drop(batch_future);
let outer_future = store.handle_delete_object("bucket", "object", ObjectOptions::default());
let outer_future_size = std::mem::size_of_val(&outer_future);
assert!(
-4
View File
@@ -939,12 +939,8 @@ impl ECStore {
bucket: &str,
object: &str,
opts: &ObjectOptions,
exact: &ObjectInfo,
errs: Vec<PoolErr>,
) -> Result<ObjectInfo> {
self.reconcile_decommission_capacity_before_exact_delete(bucket, object, opts, exact)
.await?;
let mut results = Vec::with_capacity(errs.len());
for pe in errs.iter() {
+4 -72
View File
@@ -347,9 +347,6 @@ pub struct HealChannelRequest {
pub id: String,
/// Disk ID for heal disk/erasure set task
pub disk: Option<String>,
/// Exact endpoints of replacement disks for an automatic erasure-set
/// rebuild. An empty list retains the generic erasure-set heal behavior.
pub heal_endpoints: Vec<String>,
/// Bucket name
pub bucket: String,
/// Object prefix (optional)
@@ -597,7 +594,6 @@ pub fn create_heal_request(
timeout_seconds: None,
source: HealRequestSource::Internal,
disk: None,
heal_endpoints: Vec::new(),
}
}
@@ -638,13 +634,12 @@ pub fn create_heal_response(
}
}
fn create_auto_heal_disk_request(set_disk_id: String, priority: Option<HealChannelPriority>) -> HealChannelRequest {
HealChannelRequest {
pub async fn send_heal_disk(set_disk_id: String, priority: Option<HealChannelPriority>) -> Result<(), String> {
let req = HealChannelRequest {
id: Uuid::new_v4().to_string(),
bucket: "".to_string(),
object_prefix: None,
disk: Some(set_disk_id),
heal_endpoints: Vec::new(),
object_version_id: None,
force_start: false,
priority: priority.unwrap_or(HealChannelPriority::Low),
@@ -659,71 +654,8 @@ fn create_auto_heal_disk_request(set_disk_id: String, priority: Option<HealChann
no_lock: None,
timeout_seconds: None,
source: HealRequestSource::AutoHeal,
}
}
fn create_auto_replacement_disk_request(
pool_index: usize,
set_index: usize,
replacement_endpoint: String,
priority: Option<HealChannelPriority>,
) -> HealChannelRequest {
let mut request = create_auto_heal_disk_request(format!("pool_{pool_index}_set_{set_index}"), priority);
request.heal_endpoints = vec![replacement_endpoint];
request.pool_index = Some(pool_index);
request.set_index = Some(set_index);
request
}
/// Submit the legacy generic erasure-set auto-heal request.
pub async fn send_heal_disk(set_disk_id: String, priority: Option<HealChannelPriority>) -> Result<(), String> {
send_heal_request(create_auto_heal_disk_request(set_disk_id, priority)).await
}
/// Submit an automatic replacement heal for one known disk endpoint.
///
/// The endpoint makes the request eligible for the durable replacement intent
/// and completion-proof path in the heal task.
pub async fn send_heal_replacement_disk(
pool_index: usize,
set_index: usize,
replacement_endpoint: String,
priority: Option<HealChannelPriority>,
) -> Result<(), String> {
send_heal_request(create_auto_replacement_disk_request(
pool_index,
set_index,
replacement_endpoint,
priority,
))
.await
}
#[cfg(test)]
mod auto_heal_disk_request_tests {
use super::*;
#[test]
fn replacement_disk_request_carries_its_exact_endpoint() {
let request =
create_auto_replacement_disk_request(2, 3, "http://node2:9000/drive3".to_string(), Some(HealChannelPriority::Normal));
assert_eq!(request.disk.as_deref(), Some("pool_2_set_3"));
assert_eq!(request.heal_endpoints, ["http://node2:9000/drive3"]);
assert_eq!(request.pool_index, Some(2));
assert_eq!(request.set_index, Some(3));
assert_eq!(request.source, HealRequestSource::AutoHeal);
}
#[test]
fn legacy_auto_heal_disk_request_has_no_replacement_endpoint() {
let request = create_auto_heal_disk_request("pool_2_set_3".to_string(), None);
assert!(request.heal_endpoints.is_empty());
assert_eq!(request.pool_index, None);
assert_eq!(request.set_index, None);
assert_eq!(request.source, HealRequestSource::AutoHeal);
}
};
send_heal_request(req).await
}
#[cfg(test)]
+5 -25
View File
@@ -646,12 +646,10 @@ impl HealChannelProcessor {
/// Convert channel request to heal request
fn convert_to_heal_request(&self, request: HealChannelRequest) -> Result<HealRequest> {
let recursive = request.recursive.unwrap_or(false);
let mut inferred_set_scope = None;
let heal_type = if let Some(disk_id) = &request.disk {
let set_disk_id = utils::normalize_set_disk_id(disk_id).ok_or_else(|| Error::InvalidHealType {
heal_type: format!("erasure-set({disk_id})"),
})?;
inferred_set_scope = utils::parse_set_disk_id(&set_disk_id).ok();
HealType::ErasureSet {
buckets: vec![],
set_disk_id,
@@ -714,14 +712,13 @@ impl HealChannelProcessor {
dry_run: request.dry_run.unwrap_or(false),
no_lock,
timeout: request.timeout_seconds.map(std::time::Duration::from_secs),
pool_index: request.pool_index.or_else(|| inferred_set_scope.map(|(pool, _)| pool)),
set_index: request.set_index.or_else(|| inferred_set_scope.map(|(_, set)| set)),
pool_index: request.pool_index,
set_index: request.set_index,
};
let mut heal_request = HealRequest::new(heal_type, options, priority);
heal_request.id = request.id;
heal_request.source = request.source;
heal_request.heal_endpoints = request.heal_endpoints;
// force_start controls admission/queue semantics only. Do not reinterpret it as
// destructive heal options: admin clients commonly pass forceStart=true together
// with remove=false, and turning that into remove_corrupted=true can delete the
@@ -909,7 +906,6 @@ mod tests {
object_prefix: None,
object_version_id: None,
disk: None,
heal_endpoints: Vec::new(),
priority: HealChannelPriority::Normal,
scan_mode: None,
remove_corrupted: None,
@@ -942,7 +938,6 @@ mod tests {
object_prefix: None,
object_version_id: None,
disk: None,
heal_endpoints: Vec::new(),
priority: HealChannelPriority::High,
scan_mode: Some(HealScanMode::Normal),
remove_corrupted: Some(false),
@@ -975,7 +970,6 @@ mod tests {
object_prefix: Some("test-object".to_string()),
object_version_id: None,
disk: None,
heal_endpoints: Vec::new(),
priority: HealChannelPriority::High,
scan_mode: Some(HealScanMode::Deep),
remove_corrupted: Some(true),
@@ -1029,7 +1023,6 @@ mod tests {
object_prefix: Some("test-object".to_string()),
object_version_id: None,
disk: None,
heal_endpoints: Vec::new(),
priority: HealChannelPriority::Low,
scan_mode: None,
remove_corrupted: None,
@@ -1067,7 +1060,6 @@ mod tests {
object_prefix: Some("test-object".to_string()),
object_version_id: None,
disk: None,
heal_endpoints: Vec::new(),
priority: HealChannelPriority::Normal,
scan_mode: None,
remove_corrupted: None,
@@ -1107,7 +1099,6 @@ mod tests {
object_prefix: Some("test-object".to_string()),
object_version_id: None,
disk: None,
heal_endpoints: Vec::new(),
priority: HealChannelPriority::Normal,
scan_mode: None,
remove_corrupted: None,
@@ -1140,7 +1131,6 @@ mod tests {
object_prefix: Some("logs/".to_string()),
object_version_id: None,
disk: None,
heal_endpoints: Vec::new(),
priority: HealChannelPriority::High,
scan_mode: Some(HealScanMode::Normal),
remove_corrupted: Some(false),
@@ -1176,10 +1166,7 @@ mod tests {
object_prefix: None,
object_version_id: None,
disk: Some("pool_0_set_1".to_string()),
heal_endpoints: vec!["http://node0:9000/drive1".to_string()],
priority: HealChannelPriority::Critical,
pool_index: Some(0),
set_index: Some(1),
scan_mode: None,
remove_corrupted: None,
recreate_missing: None,
@@ -1188,16 +1175,15 @@ mod tests {
dry_run: None,
no_lock: None,
timeout_seconds: None,
pool_index: None,
set_index: None,
force_start: false,
source: HealRequestSource::AutoHeal,
source: HealRequestSource::Internal,
};
let heal_request = processor.convert_to_heal_request(channel_request).unwrap();
assert!(matches!(heal_request.heal_type, HealType::ErasureSet { .. }));
assert_eq!(heal_request.priority, HealPriority::Urgent);
assert_eq!(heal_request.heal_endpoints, ["http://node0:9000/drive1"]);
assert_eq!(heal_request.options.pool_index, Some(0));
assert_eq!(heal_request.options.set_index, Some(1));
}
#[tokio::test]
@@ -1211,7 +1197,6 @@ mod tests {
object_prefix: None,
object_version_id: None,
disk: Some("invalid-disk-id".to_string()),
heal_endpoints: Vec::new(),
priority: HealChannelPriority::Normal,
scan_mode: None,
remove_corrupted: None,
@@ -1250,7 +1235,6 @@ mod tests {
object_prefix: None,
object_version_id: None,
disk: None,
heal_endpoints: Vec::new(),
priority: channel_priority,
scan_mode: None,
remove_corrupted: None,
@@ -1282,7 +1266,6 @@ mod tests {
object_prefix: None,
object_version_id: None,
disk: None,
heal_endpoints: Vec::new(),
priority: HealChannelPriority::Normal,
scan_mode: None,
remove_corrupted: Some(false),
@@ -1316,7 +1299,6 @@ mod tests {
object_prefix: Some("".to_string()), // Empty prefix should be treated as bucket heal
object_version_id: None,
disk: None,
heal_endpoints: Vec::new(),
priority: HealChannelPriority::Normal,
scan_mode: None,
remove_corrupted: None,
@@ -1354,7 +1336,6 @@ mod tests {
object_prefix: Some("object".to_string()),
object_version_id: None,
disk: None,
heal_endpoints: Vec::new(),
priority: HealChannelPriority::Low,
scan_mode: Some(HealScanMode::Normal),
remove_corrupted: None,
@@ -1633,7 +1614,6 @@ mod tests {
object_prefix: None,
object_version_id: None,
disk: Some("invalid".to_string()),
heal_endpoints: Vec::new(),
priority: HealChannelPriority::Normal,
scan_mode: None,
remove_corrupted: None,
+7 -3
View File
@@ -26,10 +26,12 @@ pub mod utils;
use storage_api::owner::{
ECSTORE_BUCKET_META_PREFIX, ECSTORE_DATA_USAGE_CACHE_NAME, ECSTORE_HEALING_MARKER_PATH, ECSTORE_RUSTFS_META_BUCKET,
EcstoreConditionalFileUpdate, EcstoreDeleteOptions, EcstoreDiskAPI, EcstoreDiskBytes, EcstoreDiskError, EcstoreDiskOption,
EcstoreDiskResult, EcstoreDiskStore, EcstoreEndpoint, EcstoreErrorType, EcstoreStorageError, EcstoreStore, ObjectIO,
ObjectOperations, ecstore_local_disk_map_read, ecstore_new_disk,
EcstoreConditionalFileUpdate, EcstoreDeleteOptions, EcstoreDiskAPI, EcstoreDiskBytes, EcstoreDiskError, EcstoreDiskResult,
EcstoreDiskStore, EcstoreEndpoint, EcstoreErrorType, EcstoreStorageError, EcstoreStore, ObjectIO, ObjectOperations,
ecstore_local_disk_map_read,
};
#[cfg(test)]
use storage_api::owner::{EcstoreDiskOption, ecstore_new_disk};
pub use erasure_healer::ErasureSetHealer;
pub use manager::{HealManager, HealOperationsSnapshot, HealPriorityCounts, HealSourceCounts};
@@ -245,8 +247,10 @@ pub(crate) async fn local_disk_map_read() -> tokio::sync::OwnedRwLockReadGuard<L
ecstore_local_disk_map_read().await
}
#[cfg(test)]
pub(crate) type DiskOption = EcstoreDiskOption;
#[cfg(test)]
pub(crate) async fn new_disk(ep: &Endpoint, opt: &DiskOption) -> DiskResult<DiskStore> {
ecstore_new_disk(ep, opt).await
}
+7 -60
View File
@@ -14,9 +14,9 @@
use std::{fs, path::Path};
use super::{
DiskOption, DiskStore, Endpoint, HealDiskExt as _, local_disk_map_read, new_disk, resume::ReplacementTargetIdentity,
};
#[cfg(test)]
use super::Endpoint;
use super::{DiskStore, HealDiskExt as _, local_disk_map_read, resume::ReplacementTargetIdentity};
pub(crate) async fn auto_replacement_target_ready(disk: &DiskStore, local_disks: &[DiskStore]) -> bool {
auto_replacement_target_identity(disk, local_disks).await.is_some()
@@ -72,38 +72,8 @@ pub(crate) async fn auto_replacement_target_identity(
.flatten()
}
fn local_replacement_endpoint(target: &str, local_grid_hosts: &[String]) -> Option<Endpoint> {
let mut endpoint = Endpoint::try_from(target).ok()?;
if endpoint.is_local {
return Some(endpoint);
}
let grid_host = endpoint.grid_host();
if grid_host.is_empty() || !local_grid_hosts.iter().any(|local_host| local_host == &grid_host) {
return None;
}
endpoint.is_local = true;
Some(endpoint)
}
async fn replacement_target_disk(target: &str, local_disks: &[DiskStore]) -> Option<DiskStore> {
if let Some(disk) = local_disks.iter().find(|disk| disk.endpoint().to_string() == target) {
return Some(disk.clone());
}
let local_grid_hosts = local_disks.iter().map(|disk| disk.endpoint().grid_host()).collect::<Vec<_>>();
let endpoint = local_replacement_endpoint(target, &local_grid_hosts)?;
new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.ok()
pub(crate) async fn auto_replacement_targets_ready(targets: &[String]) -> bool {
auto_replacement_target_identities(targets).await.is_some()
}
pub(crate) async fn auto_replacement_target_identities(targets: &[String]) -> Option<Vec<ReplacementTargetIdentity>> {
@@ -118,8 +88,8 @@ pub(crate) async fn auto_replacement_target_identities(targets: &[String]) -> Op
let mut identities = Vec::with_capacity(targets.len());
for target in targets {
let disk = replacement_target_disk(target, &local_disks).await?;
identities.push(auto_replacement_target_identity(&disk, &local_disks).await?);
let disk = local_disks.iter().find(|disk| disk.endpoint().to_string() == *target)?;
identities.push(auto_replacement_target_identity(disk, &local_disks).await?);
}
identities.sort_by(|left, right| left.endpoint.cmp(&right.endpoint));
identities.dedup_by(|left, right| left.endpoint == right.endpoint);
@@ -161,29 +131,6 @@ mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn local_replacement_endpoint_accepts_a_url_on_a_registered_local_grid_host() {
let local_grid_hosts = vec!["http://127.0.0.1:9000".to_owned()];
let endpoint = local_replacement_endpoint("http://127.0.0.1:9000/replacement", &local_grid_hosts)
.expect("matching local grid host should be accepted");
assert!(endpoint.is_local);
}
#[test]
fn local_replacement_endpoint_rejects_a_url_on_an_unregistered_grid_host() {
let local_grid_hosts = vec!["http://127.0.0.1:9000".to_owned()];
assert!(local_replacement_endpoint("http://127.0.0.1:9001/replacement", &local_grid_hosts).is_none());
}
#[test]
fn local_replacement_endpoint_keeps_a_local_path_local() {
let endpoint = local_replacement_endpoint("/replacement", &[]).expect("local path should be accepted");
assert!(endpoint.is_local);
}
#[tokio::test]
async fn runtime_environment_cannot_bypass_mount_admission() {
temp_env::async_with_vars(
+9
View File
@@ -394,6 +394,11 @@ pub trait HealStorageAPI: Send + Sync {
Err(Error::other("target-scoped replacement format is unsupported"))
}
/// Recheck admitted replacement targets immediately before destructive work.
async fn replacement_targets_ready(&self, _targets: &[String]) -> Result<bool> {
Ok(false)
}
/// Read target-specific physical evidence for one replacement version.
///
/// This is only used by automatic replacement healing after the normal
@@ -1166,6 +1171,10 @@ impl HealStorageAPI for ECStoreHealStorage {
.map_err(Error::Storage)
}
async fn replacement_targets_ready(&self, targets: &[String]) -> Result<bool> {
Ok(super::replacement_readiness::auto_replacement_targets_ready(targets).await)
}
async fn replacement_targets_have_version(
&self,
bucket: &str,
+2
View File
@@ -24,6 +24,7 @@ pub(crate) use rustfs_ecstore::api::disk::{
DiskStore as EcstoreDiskStore, HEALING_MARKER_PATH as ECSTORE_HEALING_MARKER_PATH,
RUSTFS_META_BUCKET as ECSTORE_RUSTFS_META_BUCKET,
};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::disk::{DiskOption as EcstoreDiskOption, new_disk as ecstore_new_disk};
pub(crate) use rustfs_ecstore::api::error::{Error as EcstoreErrorType, StorageError as EcstoreStorageError};
pub(crate) use rustfs_ecstore::api::runtime::local_disk_map_read as ecstore_local_disk_map_read;
@@ -42,6 +43,7 @@ pub(crate) mod owner {
EcstoreStorageError, EcstoreStore, ecstore_load_admin_data_usage_from_backend_cached, ecstore_local_disk_map_read,
};
#[cfg(test)]
pub(crate) use super::{EcstoreDiskOption, ecstore_new_disk};
}
@@ -93,6 +93,16 @@ impl HealTask {
None
};
if is_auto_replacement
&& !self
.await_with_control(self.storage.replacement_targets_ready(&self.heal_endpoints))
.await?
{
return Err(Error::TaskExecutionFailed {
message: format!("Replacement target is no longer ready for automatic heal {set_disk_id}"),
});
}
let replacement_resume_disk = if is_auto_replacement {
Some(match replacement_resume_disk {
Some(disk) => disk,
+12 -8
View File
@@ -84,7 +84,7 @@ async fn automatic_replacement_uses_target_scoped_format() {
let temp = TempDir::new().expect("temporary resume disk directory should be created");
let disk = make_resume_disk(&temp).await;
let storage = Arc::new(MockStorage {
replacement_target_identities_ready: Mutex::new(true),
replacement_targets_ready: Mutex::new(true),
resume_disk: Mutex::new(Some(disk)),
..Default::default()
});
@@ -123,7 +123,7 @@ async fn automatic_replacement_uses_target_scoped_format() {
#[tokio::test]
async fn automatic_replacement_persists_intent_before_format() {
let storage = Arc::new(MockStorage {
replacement_target_identities_ready: Mutex::new(true),
replacement_targets_ready: Mutex::new(true),
..Default::default()
});
let mut request = HealRequest::new(
@@ -155,7 +155,7 @@ async fn automatic_replacement_persists_intent_before_format() {
#[tokio::test]
async fn recovered_replacement_never_uses_a_fresh_resume_disk() {
let storage = Arc::new(MockStorage {
replacement_target_identities_ready: Mutex::new(true),
replacement_targets_ready: Mutex::new(true),
..Default::default()
});
let mut request = HealRequest::new(
@@ -193,7 +193,7 @@ async fn automatic_replacement_rejects_a_new_identity_after_format() {
let first_identity = replacement_identity("replacement-a", "device-a", "filesystem-a");
let second_identity = replacement_identity("replacement-a", "device-b", "filesystem-b");
let storage = Arc::new(MockStorage {
replacement_target_identities_ready: Mutex::new(true),
replacement_targets_ready: Mutex::new(true),
replacement_target_identity_sequences: Mutex::new(VecDeque::from([
vec![first_identity.clone()],
vec![first_identity.clone()],
@@ -259,7 +259,7 @@ async fn automatic_replacement_reuses_an_existing_non_target_resume_anchor() {
.await
.expect("existing intent should be stored on the non-target anchor");
let storage = Arc::new(MockStorage {
replacement_target_identities_ready: Mutex::new(true),
replacement_targets_ready: Mutex::new(true),
replacement_resume_disk: Mutex::new(Some(anchor.clone())),
..Default::default()
});
@@ -493,7 +493,7 @@ async fn verified_recovery_keeps_state_when_marker_clear_fails() {
let storage = Arc::new(MockStorage {
replacement_resume_disk: Mutex::new(Some(anchor.clone())),
replacement_target_identities_ready: Mutex::new(true),
replacement_targets_ready: Mutex::new(true),
..Default::default()
});
let mut request = HealRequest::new(
@@ -551,7 +551,7 @@ struct MockStorage {
format_error: Mutex<Option<Error>>,
global_format_calls: Mutex<u32>,
replacement_format_calls: Mutex<Vec<(usize, usize, Vec<String>)>>,
replacement_target_identities_ready: Mutex<bool>,
replacement_targets_ready: Mutex<bool>,
replacement_target_identity_sequences: Mutex<VecDeque<Vec<crate::heal::resume::ReplacementTargetIdentity>>>,
listed_prefixes: Mutex<Vec<String>>,
truncate_without_token: Mutex<bool>,
@@ -943,6 +943,10 @@ impl HealStorageAPI for MockStorage {
))
}
async fn replacement_targets_ready(&self, _targets: &[String]) -> Result<bool> {
Ok(*self.replacement_targets_ready.lock().unwrap())
}
async fn list_objects_for_heal_page(
&self,
bucket: &str,
@@ -1024,7 +1028,7 @@ impl HealStorageAPI for MockStorage {
&self,
targets: &[String],
) -> Result<Vec<crate::heal::resume::ReplacementTargetIdentity>> {
if !*self.replacement_target_identities_ready.lock().unwrap() {
if !*self.replacement_targets_ready.lock().unwrap() {
return Err(Error::other("replacement target is not ready"));
}
if let Some(identities) = self.replacement_target_identity_sequences.lock().unwrap().pop_front() {
+1
View File
@@ -60,6 +60,7 @@ rustfs-storage-api.workspace = true
s3s = { workspace = true, features = ["minio"] }
time = { workspace = true, features = ["parsing", "formatting", "macros", "serde"] }
tracing.workspace = true
url.workspace = true
uuid = { workspace = true, features = ["v4", "serde", "fast-rng", "macro-diagnostics"] }
[dev-dependencies]
+37 -115
View File
@@ -4,78 +4,23 @@ Swift-compatible object storage API implementation for RustFS.
## Features
The lists below are bounded to what `router.rs` / `handler.rs` dispatch and
what the test suite exercises. A module existing under `src/swift/` does not
by itself mean the feature is reachable over HTTP.
This implementation provides **Phase 1 Swift API support** (~25% of full Swift API):
### Wired through the router and handler
- ✅ Container CRUD operations (create, list, delete, metadata)
- ✅ Object CRUD with streaming downloads (upload, get, head, delete)
- ✅ Keystone token authentication
- ✅ Multi-tenant isolation with secure SHA256-based bucket prefixing
- ✅ Server-side object copy (COPY method)
- ✅ HTTP Range requests for partial downloads (206, 416 responses)
- ✅ Custom metadata support (X-Object-Meta-*, X-Container-Meta-*)
- ✅ Account listing (`GET /v1/AUTH_{project}`, JSON) and additive account
metadata updates (`POST`, `X-Account-Meta-*` / `X-Remove-Account-Meta-*`)
- ✅ Container CRUD (create, list, head, update metadata, delete)
- Object CRUD with streaming downloads, HTTP Range requests (206 / 416),
and server-side copy via the `COPY` method
- ✅ Keystone token authentication and multi-tenant isolation with
SHA256-based bucket prefixing
- ✅ Custom metadata (`X-Object-Meta-*`, `X-Container-Meta-*`); container and
account POSTs are additive, object POSTs replace the set
- ✅ Container ACLs (`X-Container-Read` / `X-Container-Write`, set and remove on
container POST, reported on HEAD). Enforcement is account-level plus
referrer checks; per-user grants are not evaluated because credentials
carry no user id
- ✅ CORS: `OPTIONS` preflight on container and object routes, and response
header injection driven by `X-Container-Meta-Access-Control-*`
- ✅ TempURL (`temp_url_sig` / `temp_url_expires` on object GET, HEAD, PUT;
key stored as account metadata; optional client-IP restriction)
- ✅ FormPost (container POST with `multipart/form-data`, signed with the
account TempURL key)
- ✅ Large objects: Static Large Objects (`?multipart-manifest=put|get|delete`)
and Dynamic Large Objects (`X-Object-Manifest`)
- ✅ Bulk operations: `DELETE /v1/AUTH_{project}?bulk-delete` and
`PUT /v1/AUTH_{project}/{container}?extract-archive=tar|tar.gz|tar.bz2`
- ✅ Object versioning in the Swift `X-Versions-Location` style: the previous
copy is archived on PUT / DELETE and restored on DELETE
- ✅ Symlinks (`X-Symlink-Target` on PUT, resolved on GET / HEAD with loop and
depth checks)
- ✅ Container quotas (`X-Container-Meta-Quota-Bytes` / `-Quota-Count`),
enforced on object PUT
- ✅ Static website serving on object GET when `web-index` / `web-listings`
container metadata is set
- ✅ Object expiration headers: `X-Delete-At` / `X-Delete-After` are validated,
stored, and returned on GET / HEAD
### Not yet wired, or partially wired
- ⏳ Account `HEAD` returns `501 Not Implemented`; no account-level usage
statistics are exposed
- ⏳ Automatic deletion of expired objects: `expiration_worker.rs` exists but
the server never starts it, so objects past `X-Delete-At` are not removed
- ⏳ Container sync (`sync.rs`): no `X-Container-Sync-*` header handling and no
background worker; the module is unit-tested only
- ⏳ `X-Copy-From` on object PUT (only the `COPY` method is supported)
- ⏳ `X-History-Location` versioning mode
- ⏳ Static website index / listing pages at the container root (only the
object GET route consults static-web settings)
- ⏳ XML / plain-text listing formats; the `format=` query parameter is
ignored and listings are always JSON
### Test coverage
- Unit tests live next to each module (`acl.rs`, `bulk.rs`, `cors.rs`,
`dlo.rs`, `slo.rs`, `tempurl.rs`, `formpost.rs`, `staticweb.rs`,
`symlink.rs`, `quota.rs`, `expiration.rs`, `versioning.rs`, `router.rs`,
`handler.rs`, and others) and run in the CI `swift` feature lane
- `crates/protocols/tests/swift_metadata_persistence.rs` runs account,
container, ACL, TempURL-key, and versioning metadata writes against a real
ECStore and reloads them from disk
- `crates/protocols/tests/swift_versioning_integration.rs`,
`swift_listing_symlink_tests.rs`, `swift_simple_integration.rs`, and
`swift_phase4_integration.rs` cover version naming, listing parameters,
symlink parsing, and module-level helpers without a server
- `rustfs/tests/swift_container_integration_test.rs` and
`swift_object_integration_test.rs` exercise the HTTP surface end to end but
are `#[ignore]` and need a running server (`TEST_RUSTFS_SERVER`); they are
not part of CI
**Not yet implemented:**
- ⏳ Account-level operations (statistics, metadata)
- ⏳ Large object support (multi-part uploads >5GB)
- Object versioning
- ⏳ Container ACLs and CORS
- ⏳ Temporary URLs (TempURL)
- ⏳ XML/plain-text response formats (JSON only)
## Enable Feature
@@ -97,52 +42,38 @@ cargo build --features full
## Configuration
Swift API uses Keystone for authentication. The variables below are read by
`crates/keystone/src/config.rs`; that file is the authoritative list.
Swift API uses Keystone for authentication. Configure the following environment variables:
| Variable | Description |
|----------|-------------|
| `RUSTFS_KEYSTONE_ENABLE` | Set to `true` to enable Keystone authentication (default `false`; nothing else is read while disabled) |
| `RUSTFS_KEYSTONE_AUTH_URL` | Keystone authentication endpoint URL (required once enabled) |
| `RUSTFS_KEYSTONE_VERSION` | Keystone API version, `v3` or `v2.0` (default `v3`) |
| `RUSTFS_KEYSTONE_ADMIN_USER` | Admin username (optional) |
| `RUSTFS_KEYSTONE_ADMIN_PASSWORD` | Admin password (optional) |
| `RUSTFS_KEYSTONE_ADMIN_PROJECT` | Admin project name (optional) |
| `RUSTFS_KEYSTONE_ADMIN_DOMAIN` | Admin domain name (optional) |
| `RUSTFS_KEYSTONE_VERIFY_SSL` | Verify the Keystone TLS certificate (default `true`) |
| `RUSTFS_KEYSTONE_ENABLE_CACHE` / `RUSTFS_KEYSTONE_CACHE_SIZE` / `RUSTFS_KEYSTONE_CACHE_TTL` | Token cache toggle, entry count, and TTL in seconds (defaults `true`, `10000`, `300`) |
| `RUSTFS_KEYSTONE_TENANT_PREFIX` | Prefix bucket names with the tenant hash (default `true`) |
| `RUSTFS_KEYSTONE_IMPLICIT_TENANTS` | Allow implicit tenant creation (default `true`) |
| `RUSTFS_KEYSTONE_TIMEOUT` | Keystone request timeout in seconds (default `30`) |
| `RUSTFS_KEYSTONE_URL` | Keystone authentication endpoint URL |
| `RUSTFS_KEYSTONE_ADMIN_TENANT` | Admin tenant/project name |
| `RUSTFS_KEYSTONE_ADMIN_USER` | Admin username |
| `RUSTFS_KEYSTONE_ADMIN_PASSWORD` | Admin password |
## API Endpoints
Swift API endpoints follow the pattern: `/v1/AUTH_{project_id}/...`
### Account Operations
- `GET /v1/AUTH_{project}` - List containers (JSON)
- `HEAD /v1/AUTH_{project}` - Get account metadata (returns 501, not yet implemented)
- `POST /v1/AUTH_{project}` - Update account metadata and TempURL key
- `DELETE /v1/AUTH_{project}?bulk-delete` - Bulk delete
- `GET /v1/AUTH_{project}` - List containers
- `HEAD /v1/AUTH_{project}` - Get account metadata (not yet implemented)
- `POST /v1/AUTH_{project}` - Update account metadata (not yet implemented)
### Container Operations
- `PUT /v1/AUTH_{project}/{container}` - Create container (`?extract-archive=` for bulk upload)
- `GET /v1/AUTH_{project}/{container}` - List objects (JSON; `limit`, `marker`, `end_marker`, `prefix`, `delimiter`)
- `PUT /v1/AUTH_{project}/{container}` - Create container
- `GET /v1/AUTH_{project}/{container}` - List objects
- `HEAD /v1/AUTH_{project}/{container}` - Get container metadata
- `POST /v1/AUTH_{project}/{container}` - Update container metadata, ACLs, versioning location; FormPost when `multipart/form-data`
- `POST /v1/AUTH_{project}/{container}` - Update container metadata
- `DELETE /v1/AUTH_{project}/{container}` - Delete container
- `OPTIONS /v1/AUTH_{project}/{container}` - CORS preflight
### Object Operations
- `PUT /v1/AUTH_{project}/{container}/{object}` - Upload object (SLO manifest with `?multipart-manifest=put`, DLO with `X-Object-Manifest`, symlink with `X-Symlink-Target`)
- `GET /v1/AUTH_{project}/{container}/{object}` - Download object (Range, SLO/DLO assembly, symlink resolution, `?multipart-manifest=get`)
- `PUT /v1/AUTH_{project}/{container}/{object}` - Upload object
- `GET /v1/AUTH_{project}/{container}/{object}` - Download object
- `HEAD /v1/AUTH_{project}/{container}/{object}` - Get object metadata
- `POST /v1/AUTH_{project}/{container}/{object}` - Update object metadata
- `DELETE /v1/AUTH_{project}/{container}/{object}` - Delete object (`?multipart-manifest=delete` removes SLO segments)
- `DELETE /v1/AUTH_{project}/{container}/{object}` - Delete object
- `COPY /v1/AUTH_{project}/{container}/{object}` - Server-side copy
- `OPTIONS /v1/AUTH_{project}/{container}/{object}` - CORS preflight
Object GET, HEAD, and PUT also accept TempURL query parameters without an auth token.
## Architecture
@@ -165,18 +96,11 @@ Handler (fallback)
### Key Components
- **handler.rs** - Main service implementing Tower's Service trait and method dispatch
- **handler.rs** - Main service implementing Tower's Service trait
- **router.rs** - URL routing and parsing for Swift paths
- **container.rs** - Container operations with tenant isolation
- **object.rs** - Object operations including copy and range requests
- **account.rs** - Account validation, tenant access control, account metadata and TempURL key
- **acl.rs**, **cors.rs** - Container ACL evaluation and CORS config
- **slo.rs**, **dlo.rs** - Static and dynamic large objects
- **tempurl.rs**, **formpost.rs** - Signed URL and form upload validation
- **bulk.rs** - Bulk delete and archive extraction
- **versioning.rs**, **symlink.rs**, **quota.rs**, **staticweb.rs**, **expiration.rs** - Per-feature helpers called from the handler
- **expiration_worker.rs**, **sync.rs** - Background workers that are not started by the server (see above)
- **metadata_update.rs** - Additive account/container metadata merge
- **account.rs** - Account validation and tenant access control
- **errors.rs** - Swift-specific error types
- **types.rs** - Data structures for Swift API responses
@@ -197,15 +121,13 @@ This ensures:
## Documentation
There is no separate Swift reference document in the repository. Use these
sources instead:
See the `docs/` directory for detailed documentation:
- Module-level `//!` comments in each `crates/protocols/src/swift/*.rs` file
describe the headers and metadata keys that feature reads
- `crates/protocols/tests/swift_*.rs` and `rustfs/tests/swift_*_integration_test.rs`
show the expected request and response shapes
- `docs/testing/ci-gates.md` describes the CI lane that builds and tests with
`--features swift`
- `SWIFT_API.md` - Complete API reference
- `TESTING_GUIDE.md` - Manual testing procedures
- `COMPLETION_ANALYSIS.md` - Protocol coverage tracking
- `COPY_IMPLEMENTATION.md` - Server-side copy documentation
- `RANGE_REQUESTS.md` - Range request implementation details
## License
+1 -11
View File
@@ -88,8 +88,6 @@ impl From<Priority> for HealChannelPriority {
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StartCommand {
disk: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
heal_endpoints: Vec<String>,
bucket: String,
object_prefix: Option<String>,
object_version_id: Option<String>,
@@ -115,7 +113,6 @@ impl TryFrom<HealChannelRequest> for StartCommand {
fn try_from(request: HealChannelRequest) -> Result<Self, Self::Error> {
Ok(Self {
disk: request.disk,
heal_endpoints: request.heal_endpoints,
bucket: request.bucket,
object_prefix: request.object_prefix,
object_version_id: request.object_version_id,
@@ -149,7 +146,6 @@ impl StartCommand {
Ok(HealChannelRequest {
id: request_id,
disk: self.disk,
heal_endpoints: self.heal_endpoints,
bucket: self.bucket,
object_prefix: self.object_prefix,
object_version_id: self.object_version_id,
@@ -636,12 +632,6 @@ mod tests {
}
}
fn replacement_test_request(request_id: String) -> HealChannelRequest {
let mut request = test_request(request_id);
request.heal_endpoints = vec!["http://node1:9000/drive2".to_string()];
request
}
fn metadata(byte: u8, epoch: u64) -> RequestMetadata {
RequestMetadata::new([byte; 16], 1_000, 2_000, epoch)
}
@@ -649,7 +639,7 @@ mod tests {
#[test]
fn round_trips_all_commands_and_results() {
let request_id = uuid::Uuid::new_v4().to_string();
let start = Envelope::start(replacement_test_request(request_id), metadata(1, 7)).unwrap();
let start = Envelope::start(test_request(request_id), metadata(1, 7)).unwrap();
let query = Envelope::query(
uuid::Uuid::new_v4().to_string(),
metadata(2, 7),
+2 -3
View File
@@ -59,9 +59,8 @@ pub use mrf::{
MrfV2Envelope, MrfV2Error, MrfV2Reader, MrfV2Readiness, decode_mrf_file, encode_mrf_file,
};
pub use multipart::{
REPLICATION_MAX_SINGLE_PUT_SIZE, ReplicationMultipartPartInput, ReplicationMultipartPartPlan, ReplicationMultipartPlanError,
ReplicationMultipartRange, replication_multipart_complete_actual_size, replication_multipart_part_plan,
replication_single_put_size_error,
ReplicationMultipartPartInput, ReplicationMultipartPartPlan, ReplicationMultipartPlanError, ReplicationMultipartRange,
replication_multipart_complete_actual_size, replication_multipart_part_plan,
};
pub use object::{
ReplicationSourceObject, ReplicationTargetObject, SsecPassthroughCapability, SsecPassthroughGate, content_matches_by_etag,
+2 -76
View File
@@ -109,49 +109,15 @@ pub fn replication_multipart_complete_actual_size(user_defined: &HashMap<String,
get_internal_metadata(user_defined, SUFFIX_ACTUAL_SIZE).unwrap_or_default()
}
/// Largest body S3 accepts on a single `PutObject`. Anything above this has to
/// be uploaded as multipart; the limit is part of the S3 API, not a RustFS
/// tunable, so every generic S3 target enforces it.
pub const REPLICATION_MAX_SINGLE_PUT_SIZE: i64 = 5 * 1024 * 1024 * 1024;
/// Reject a single-`PutObject` replication transfer the target can never accept.
///
/// Replication mirrors the object's *source-side storage shape*: an object
/// written to the source with one `PutObject` replicates with one `PutObject`
/// whatever its size, and a multipart object replays the source's own part
/// layout. So a source object larger than [`REPLICATION_MAX_SINGLE_PUT_SIZE`]
/// that was not written as multipart can never reach a generic S3 target — the
/// remote rejects it with `EntityTooLarge`, but only after the whole body has
/// been streamed to it (rustfs#6825).
///
/// Returning the failure up front turns an unbounded wasted transfer plus an
/// opaque remote error into a stated, diagnosable limit. RustFS deliberately
/// does not re-chunk such an object into multipart on the replication side:
/// the target's part layout is the source's, and rewriting it would break the
/// ETag/part identity that heal and delete convergence address.
pub fn replication_single_put_size_error(is_multipart: bool, transfer_size: i64) -> Option<String> {
if is_multipart || transfer_size <= REPLICATION_MAX_SINGLE_PUT_SIZE {
return None;
}
Some(format!(
"object of {transfer_size} bytes was not written as multipart on the source and exceeds the \
{REPLICATION_MAX_SINGLE_PUT_SIZE} byte single-PutObject limit of an S3 target; \
re-upload it with multipart to make it replicable"
))
}
#[cfg(test)]
mod tests {
use super::{
REPLICATION_MAX_SINGLE_PUT_SIZE, ReplicationMultipartPartInput, ReplicationMultipartPartPlan,
ReplicationMultipartPlanError, ReplicationMultipartRange, replication_multipart_complete_actual_size,
replication_multipart_part_plan, replication_single_put_size_error,
ReplicationMultipartPartInput, ReplicationMultipartPartPlan, ReplicationMultipartPlanError, ReplicationMultipartRange,
replication_multipart_complete_actual_size, replication_multipart_part_plan,
};
use crate::http::{SUFFIX_ACTUAL_SIZE, insert_internal_metadata};
use std::collections::HashMap;
const MIB: i64 = 1024 * 1024;
#[test]
fn multipart_part_plan_builds_range_and_next_offset() {
assert_eq!(
@@ -253,44 +219,4 @@ mod tests {
assert_eq!(replication_multipart_complete_actual_size(&user_defined), "123");
assert!(replication_multipart_complete_actual_size(&HashMap::new()).is_empty());
}
#[test]
fn single_put_size_guard_admits_transfers_a_target_can_accept() {
for size in [
0,
1,
MIB,
REPLICATION_MAX_SINGLE_PUT_SIZE - 1,
REPLICATION_MAX_SINGLE_PUT_SIZE,
] {
assert_eq!(
replication_single_put_size_error(false, size),
None,
"single PUT of {size} bytes is within the S3 limit and must not be rejected"
);
}
}
#[test]
fn single_put_size_guard_rejects_an_oversized_single_put() {
let size = REPLICATION_MAX_SINGLE_PUT_SIZE + 1;
let err = replication_single_put_size_error(false, size).expect("oversized single PUT must be rejected");
// The message is the operator's diagnosis: it has to name the actual
// size, the limit, and the reason the object is on this route at all.
assert!(err.contains(&size.to_string()), "message must name the object size: {err}");
assert!(
err.contains(&REPLICATION_MAX_SINGLE_PUT_SIZE.to_string()),
"message must name the limit: {err}"
);
assert!(err.contains("multipart"), "message must name the remedy: {err}");
}
#[test]
fn single_put_size_guard_never_rejects_the_multipart_route() {
// Multipart replays the source part layout, so object size alone says
// nothing about whether the target will accept it; the per-part limits
// are the target's to enforce.
assert_eq!(replication_single_put_size_error(true, REPLICATION_MAX_SINGLE_PUT_SIZE * 1024), None);
}
}
+109 -167
View File
@@ -40,15 +40,14 @@ use uuid::Uuid;
mod storage_api;
use storage_api::lifecycle::{
BUCKET_LIFECYCLE_CONFIG, BucketOperations, BucketOptions, BucketVersioningSys, CompletePart,
DeleteAfterObjectLockSnapshotBarrier, DiskOption, ECStore, EcstoreError, Endpoint, EndpointServerPools, Endpoints,
ExpiryState, IlmAction, LcEvent, LcEventSrc, ListOperations as _, MakeBucketOptions, MockWarmBackend,
MultipartOperations as _, ObjectIO as _, ObjectOperations as _, PoolEndpoints, STORAGE_FORMAT_FILE, TRANSITION_PENDING,
TransitionCleanupStoreBarrier, TransitionOptions, assert_transition_meta_consistent, enqueue_transition_for_existing_objects,
expire_transitioned_object, free_version_count, get_bucket_metadata, get_global_tier_config_mgr, init_background_expiry,
init_bucket_metadata_sys, init_local_disks, is_err_object_not_found, is_err_version_not_found, new_disk,
path2_bucket_object_with_base_path, recover_transition_transaction_records, register_mock_tier_util, update_bucket_metadata,
wait_for_free_version_absence,
BUCKET_LIFECYCLE_CONFIG, BucketOperations, BucketOptions, BucketVersioningSys, CompletePart, DiskOption, ECStore,
EcstoreError, Endpoint, EndpointServerPools, Endpoints, IlmAction, LcEvent, LcEventSrc, ListOperations as _,
MakeBucketOptions, MockWarmBackend, MultipartOperations as _, ObjectIO as _, ObjectOperations as _, PoolEndpoints,
STORAGE_FORMAT_FILE, TRANSITION_PENDING, TransitionCleanupStoreBarrier, TransitionOptions, assert_transition_meta_consistent,
enqueue_transition_for_existing_objects, expire_transitioned_object, free_version_count, get_bucket_metadata,
get_global_tier_config_mgr, init_background_expiry, init_bucket_metadata_sys, init_local_disks, is_err_object_not_found,
is_err_version_not_found, new_disk, path2_bucket_object_with_base_path, recover_transition_transaction_records,
register_mock_tier_util, update_bucket_metadata, wait_for_free_version_absence,
};
static GLOBAL_ENV: OnceLock<(Vec<PathBuf>, Arc<ECStore>)> = OnceLock::new();
@@ -602,21 +601,24 @@ mod serial_tests {
/// persisted free-version recovery -- so no live local metadata ever points
/// at an already-removed remote version.
///
/// This test pins the fixed contract with deterministic GET and DELETE
/// barriers (reverting to remote-first ordering turns it red):
/// This test pins the FIXED contract two complementary ways, both
/// revert-proof (reverting to remote-first ordering turns them red):
///
/// 1. A GET that already resolved the transitioned metadata keeps its read
/// lock and returns the complete remote body while expiry waits.
/// 2. Expiry returns after committing the local free-version without
/// waiting for the post-commit worker's remote DELETE. While that DELETE
/// is paused, the durable marker and remote body must both still exist.
/// 3. A later GET observes a clean object/version-not-found, never a tier
/// fetch or read-quorum failure.
/// 1. Ordering (deterministic): immediately after
/// `expire_transitioned_object` returns, the remote tier object is still
/// present and the mock recorded **zero** remote `remove` calls --
/// proving the local delete happened with no synchronous remote removal
/// (local-first). Remote-first ordering loses the object and records a
/// `remove`.
/// 2. Concurrent GET (user-visible): a tight GET loop runs concurrently
/// with the expiry; every observation must be either a full, correct
/// body (GET won) or a clean object/version-not-found (expiry won). A
/// tier-fetch failure -- the #3491 symptom -- is never tolerated.
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial]
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-2)"]
async fn test_expire_transitioned_object_never_races_concurrent_get() {
let (disk_paths, ecstore) = setup_isolated_test_env(false).await;
let (_disk_paths, ecstore) = setup_isolated_test_env(false).await;
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
let backend = register_mock_tier(&tier_name).await;
@@ -662,7 +664,59 @@ mod serial_tests {
"the regression must exercise an unversioned remote tier"
);
ExpiryState::resize_workers(1, ecstore.clone()).await;
// Concurrent GET loop: hammer GET while the expiry runs. Every outcome
// must be a full correct body or a clean not-found -- never a tier-fetch
// failure.
let get_store = ecstore.clone();
let get_bucket = bucket_name.clone();
let get_object = object_name.to_string();
let expected = payload.clone();
let get_loop = tokio::spawn(async move {
let mut saw_full_body = 0usize;
let mut saw_not_found = 0usize;
for _ in 0..400 {
match get_store
.get_object_reader(
get_bucket.as_str(),
get_object.as_str(),
None,
http::HeaderMap::new(),
&ObjectOptions::default(),
)
.await
{
Ok(mut reader) => {
let mut data = Vec::new();
match reader.stream.read_to_end(&mut data).await {
Ok(_) => {
assert_eq!(
data, expected,
"a successful GET during expiry must return the complete, correct body"
);
saw_full_body += 1;
}
Err(err) => {
panic!("GET during expiry streamed a truncated/failed body (expire/GET race regression): {err:?}")
}
}
}
Err(err) => {
let ec: &EcstoreError = &err;
assert!(
is_err_object_not_found(ec) || is_err_version_not_found(ec),
"GET during expiry may only fail with a clean object/version-not-found (expiry won \
the race); a tier-fetch failure is the #3491 regression: {err:?}"
);
saw_not_found += 1;
}
}
tokio::task::yield_now().await;
}
(saw_full_body, saw_not_found)
});
// Run the exact expiry action the scanner drives for a transitioned
// current version.
let lc_event = LcEvent {
action: IlmAction::DeleteAction,
..Default::default()
@@ -671,127 +725,39 @@ mod serial_tests {
.bucket_incarnation_id(bucket_name.as_str())
.await
.expect("read bucket incarnation");
// Pause one real tier GET after it has resolved local transition
// metadata. The reader still owns the object read lock, so local expiry
// cannot commit until this GET finishes.
let get_barrier = backend.arm_get_barrier().await;
let get_store = ecstore.clone();
let get_bucket = bucket_name.clone();
let get_object = object_name.to_string();
let in_flight_get = tokio::spawn(async move {
let mut reader = get_store
.get_object_reader(
get_bucket.as_str(),
get_object.as_str(),
None,
http::HeaderMap::new(),
&ObjectOptions::default(),
)
.await
.map_err(|err| format!("in-flight GET failed before streaming: {err:?}"))?;
let mut data = Vec::new();
reader
.stream
.read_to_end(&mut data)
.await
.map_err(|err| format!("in-flight GET returned a failed or truncated stream: {err:?}"))?;
Ok::<_, String>(data)
});
tokio::time::timeout(TRANSITION_WAIT_TIMEOUT, get_barrier.wait_until_paused())
expire_transitioned_object(ecstore.clone(), &oi, &lc_event, &LcEventSrc::Scanner, bucket_incarnation_id)
.await
.expect("the in-flight GET should reach the remote read barrier");
// The next remote DELETE pauses and then fails. A correct local-first
// expiry returns while this barrier is still held; synchronous cleanup
// (remote-first or local-first) instead times out here.
let delete_start_barrier = DeleteAfterObjectLockSnapshotBarrier::install(bucket_name.as_str());
let remove_barrier = backend.arm_failing_remove_barrier().await;
let expiry_store = ecstore.clone();
let expiry_oi = oi.clone();
let expiry_event = lc_event.clone();
let mut expiry = tokio::spawn(async move {
expire_transitioned_object(expiry_store, &expiry_oi, &expiry_event, &LcEventSrc::Scanner, bucket_incarnation_id).await
});
tokio::time::timeout(TRANSITION_WAIT_TIMEOUT, delete_start_barrier.wait_until_paused())
.await
.expect("expiry should reach the store delete path while the GET remains paused");
delete_start_barrier.release_and_wait_until_namespace_pending().await;
assert!(
!delete_start_barrier.namespace_acquired() && !expiry.is_finished(),
"expiry must wait for the in-flight GET's object read lock before committing the local delete"
);
get_barrier.release();
let expiry_outcome = tokio::time::timeout(TRANSITION_WAIT_TIMEOUT, &mut expiry).await;
let delete_lock_acquired_after_get = delete_start_barrier.namespace_acquired();
drop(delete_start_barrier);
let remove_arrival = tokio::time::timeout(TRANSITION_WAIT_TIMEOUT, remove_barrier.wait_until_paused()).await;
// Snapshot only lock-free observables while the cleanup worker holds
// the object write lock. Store API reads wait until the barrier is
// released below.
let free_version_persisted = free_version_count(&disk_paths[0], bucket_name.as_str(), object_name).await > 0;
let remote_present_at_cleanup = backend.contains(&remote_object).await;
remove_barrier.release();
let remove_operation_dropped = if remove_arrival.is_ok() {
Some(tokio::time::timeout(TRANSITION_WAIT_TIMEOUT, remove_barrier.wait_until_operation_dropped()).await)
} else {
None
};
if expiry_outcome.is_err() && tokio::time::timeout(TRANSITION_WAIT_TIMEOUT, &mut expiry).await.is_err() {
expiry.abort();
let _ = expiry.await;
}
let in_flight_get_outcome = tokio::time::timeout(TRANSITION_WAIT_TIMEOUT, in_flight_get).await;
let post_expiry_get = tokio::time::timeout(
TRANSITION_WAIT_TIMEOUT,
ecstore.get_object_reader(bucket_name.as_str(), object_name, None, http::HeaderMap::new(), &ObjectOptions::default()),
)
.await;
expiry_outcome
.expect("expire_transitioned_object must not wait for asynchronous remote-tier cleanup")
.expect("the expiry task should not panic")
.expect("expire_transitioned_object should succeed");
assert!(
delete_lock_acquired_after_get,
"expiry must acquire the object write lock only after the in-flight GET releases its read lock"
);
remove_arrival.expect("the post-commit free-version worker should reach the remote DELETE barrier");
remove_operation_dropped
.expect("the remote DELETE should have reached the barrier")
.expect("the injected remote DELETE should finish after release");
assert!(
free_version_persisted,
"the durable free-version marker must exist before asynchronous remote cleanup"
);
assert!(
remote_present_at_cleanup,
"the remote object must remain readable until the paused cleanup DELETE is released"
);
let in_flight_body = in_flight_get_outcome
.expect("the in-flight GET should finish within the test deadline")
.expect("the in-flight GET task should not panic")
.expect("a GET that wins the expiry race must return a complete body");
// --- Ordering contract (deterministic revert-proof) ----------------
// #3491 defers remote cleanup to free-version recovery, so immediately
// after expiry the remote object is still present and NO synchronous
// remote `remove` was issued. Reverting to remote-first ordering makes
// both assertions fail.
assert_eq!(
in_flight_body, payload,
"a GET that resolved transitioned metadata before expiry must return the complete, correct body"
backend.remove_count().await,
0,
"expire_transitioned_object must NOT issue a synchronous remote-tier removal (local-first \
ordering, #3491); remote cleanup is deferred to free-version recovery"
);
assert!(
backend.contains(&remote_object).await,
"remote tier object must still exist immediately after expiry (deferred cleanup, #3491)"
);
match post_expiry_get.expect("the post-expiry GET should finish within the test deadline") {
Ok(_) => panic!("the locally expired transitioned object must no longer be readable"),
Err(err) => {
let ec: &EcstoreError = &err;
assert!(
is_err_object_not_found(ec) || is_err_version_not_found(ec),
"a GET after expiry may only fail with a clean object/version-not-found; \
a tier-fetch or read-quorum failure is the #3491 regression: {err:?}"
);
}
}
// Local metadata is gone: the object is atomically unreachable.
assert!(
wait_for_object_absence(&ecstore, bucket_name.as_str(), object_name, Duration::from_secs(5)).await,
"local metadata for the expired transitioned object should be gone"
);
// Drain the concurrent GET loop; its internal asserts already guarantee
// no #3491-style tier-fetch failure was ever observed.
let (saw_full_body, saw_not_found) = get_loop.await.expect("concurrent GET loop task panicked");
assert!(
saw_full_body + saw_not_found > 0,
"the concurrent GET loop should have observed at least one GET outcome"
);
}
#[test]
@@ -1503,18 +1469,10 @@ mod serial_tests {
let stale_remote_object = transitioned.transitioned_object.name.clone();
assert!(backend.contains(&stale_remote_object).await);
ExpiryState::resize_workers(1, ecstore.clone()).await;
let remove_barrier = backend.arm_failing_remove_barrier().await;
tokio::time::timeout(
Duration::from_secs(5),
ecstore.delete_object(bucket_name.as_str(), object_name, ObjectOptions::default()),
)
.await
.expect("DeleteObject must not wait for asynchronous remote-tier cleanup")
.expect("Failed to delete transitioned object before scanner fallback");
tokio::time::timeout(Duration::from_secs(5), remove_barrier.wait_until_paused())
ecstore
.delete_object(bucket_name.as_str(), object_name, ObjectOptions::default())
.await
.expect("the immediate free-version worker should reach the injected remote DELETE barrier");
.expect("Failed to delete transitioned object without expiry workers");
assert!(
free_version_count(&disk_paths[0], bucket_name.as_str(), object_name).await > 0,
@@ -1525,12 +1483,8 @@ mod serial_tests {
"stale transitioned remote object should still exist before scanner fallback runs"
);
// Queue the scanner fallback while the causal task is still blocked.
// Releasing the barrier fails only that first task, so the queued
// scanner task can prove durable-marker recovery on a healthy backend.
init_background_expiry(ecstore.clone()).await;
scan_object_metadata(&disk_paths[0], bucket_name.as_str(), object_name).await;
remove_barrier.release();
remove_barrier.wait_until_operation_dropped().await;
assert!(
backend
@@ -1577,18 +1531,10 @@ mod serial_tests {
let stale_remote_object = transitioned.transitioned_object.name.clone();
assert!(backend.contains(&stale_remote_object).await);
ExpiryState::resize_workers(1, ecstore.clone()).await;
let remove_barrier = backend.arm_failing_remove_barrier().await;
tokio::time::timeout(
Duration::from_secs(5),
ecstore.delete_object(bucket_name.as_str(), object_name, ObjectOptions::default()),
)
.await
.expect("DeleteObject must not wait for asynchronous remote-tier cleanup")
.expect("Failed to delete transitioned object after compensation-driven transition");
tokio::time::timeout(Duration::from_secs(5), remove_barrier.wait_until_paused())
ecstore
.delete_object(bucket_name.as_str(), object_name, ObjectOptions::default())
.await
.expect("the immediate free-version worker should reach the injected remote DELETE barrier");
.expect("Failed to delete transitioned object after compensation-driven transition");
assert!(
free_version_count(&disk_paths[0], bucket_name.as_str(), object_name).await > 0,
@@ -1599,12 +1545,8 @@ mod serial_tests {
"stale transitioned remote object should still exist before scanner cleanup runs"
);
// Enqueue the scanner fallback before the first, causal cleanup task is
// released into its injected failure. This keeps attribution
// deterministic and proves the durable marker drives convergence.
init_background_expiry(ecstore.clone()).await;
scan_object_metadata(&disk_paths[0], bucket_name.as_str(), object_name).await;
remove_barrier.release();
remove_barrier.wait_until_operation_dropped().await;
assert!(
backend
+8 -11
View File
@@ -15,9 +15,7 @@
pub(crate) use rustfs_ecstore::api::bucket::lifecycle::transition_transaction::recover_transition_transaction_records;
pub(crate) use rustfs_ecstore::api::bucket::lifecycle::{
bucket_lifecycle_audit::LcEventSrc,
bucket_lifecycle_ops::{
ExpiryState, enqueue_transition_for_existing_objects, expire_transitioned_object, init_background_expiry,
},
bucket_lifecycle_ops::{enqueue_transition_for_existing_objects, expire_transitioned_object, init_background_expiry},
lifecycle::{Event as LcEvent, IlmAction, TRANSITION_PENDING, TransitionOptions},
};
pub(crate) use rustfs_ecstore::api::bucket::metadata::BUCKET_LIFECYCLE_CONFIG;
@@ -29,7 +27,6 @@ pub(crate) use rustfs_ecstore::api::capacity::path2_bucket_object_with_base_path
pub(crate) use rustfs_ecstore::api::disk::{DiskOption, STORAGE_FORMAT_FILE, endpoint::Endpoint, new_disk};
pub(crate) use rustfs_ecstore::api::error::{Error as EcstoreError, is_err_object_not_found, is_err_version_not_found};
pub(crate) use rustfs_ecstore::api::layout::{EndpointServerPools, Endpoints, PoolEndpoints};
pub(crate) use rustfs_ecstore::api::object::test_util::DeleteAfterObjectLockSnapshotBarrier;
pub(crate) use rustfs_ecstore::api::runtime::global_tier_config_mgr as get_global_tier_config_mgr;
pub(crate) use rustfs_ecstore::api::storage::{ECStore, init_local_disks};
// Shared lifecycle/tier test utilities (rustfs/backlog#1148 ilm-6). The mock
@@ -48,12 +45,12 @@ pub(crate) mod lifecycle {
};
pub(crate) use super::{
BUCKET_LIFECYCLE_CONFIG, BucketVersioningSys, DeleteAfterObjectLockSnapshotBarrier, DiskOption, ECStore, EcstoreError,
Endpoint, EndpointServerPools, Endpoints, ExpiryState, IlmAction, LcEvent, LcEventSrc, MockWarmBackend, PoolEndpoints,
STORAGE_FORMAT_FILE, TRANSITION_PENDING, TransitionCleanupStoreBarrier, TransitionOptions,
assert_transition_meta_consistent, enqueue_transition_for_existing_objects, expire_transitioned_object,
free_version_count, get_bucket_metadata, get_global_tier_config_mgr, init_background_expiry, init_bucket_metadata_sys,
init_local_disks, is_err_object_not_found, is_err_version_not_found, new_disk, path2_bucket_object_with_base_path,
recover_transition_transaction_records, register_mock_tier_util, update_bucket_metadata, wait_for_free_version_absence,
BUCKET_LIFECYCLE_CONFIG, BucketVersioningSys, DiskOption, ECStore, EcstoreError, Endpoint, EndpointServerPools,
Endpoints, IlmAction, LcEvent, LcEventSrc, MockWarmBackend, PoolEndpoints, STORAGE_FORMAT_FILE, TRANSITION_PENDING,
TransitionCleanupStoreBarrier, TransitionOptions, assert_transition_meta_consistent,
enqueue_transition_for_existing_objects, expire_transitioned_object, free_version_count, get_bucket_metadata,
get_global_tier_config_mgr, init_background_expiry, init_bucket_metadata_sys, init_local_disks, is_err_object_not_found,
is_err_version_not_found, new_disk, path2_bucket_object_with_base_path, recover_transition_transaction_records,
register_mock_tier_util, update_bucket_metadata, wait_for_free_version_absence,
};
}
-5
View File
@@ -50,11 +50,6 @@ pub const SUFFIX_ACTUAL_OBJECT_SIZE: &str = "actual-object-size";
/// Used by replication; key stored with capital A
pub const SUFFIX_ACTUAL_OBJECT_SIZE_CAP: &str = "Actual-Object-Size";
pub const SUFFIX_CRC: &str = "crc";
/// Marks checksum bytes produced by RustFS as plaintext checksum metadata.
///
/// MinIO encrypts the same on-disk field for SSE objects, so readers must not
/// decode encrypted-object checksums unless this marker is present.
pub const SUFFIX_PLAINTEXT_CHECKSUM: &str = "plaintext-checksum";
/// JSON-encoded per-part S3 checksum maps retained across raw data movement.
pub const SUFFIX_PART_CHECKSUMS: &str = "part-checksums";
pub const SUFFIX_TRANSITION_STATUS: &str = "transition-status";
+52 -43
View File
@@ -1,57 +1,66 @@
# Architecture Documentation
**Use this when:** you need the contract, invariant, or boundary rule that governs a change, and you want the one document that owns it.
**Source of truth:** the code and the guards. `scripts/check_architecture_migration_rules.sh` enforces the CI-anchored documents below; `scripts/check_doc_paths.sh` fails the pre-commit gate when any doc under `docs/` cites a repository path that no longer exists.
Durable architecture reference for RustFS: migration guardrails, runtime
contracts, boundary rules, and support matrices.
Two rules keep this directory healthy:
1. **Durable reference only.** One-shot plans, task trackers, dated analyses, status snapshots, and PR-scoped notes do not belong in the repository; keep them in the issue tracker or a local worktree and delete them when the work closes.
2. **No copies of other sources of truth.** Crate lists come from `Cargo.toml`, CI steps from `.github/workflows/`, code structure from the code. Cite a file path plus a symbol name, never a line number, and never paste counts or tables that a command can regenerate.
1. **Durable reference only.** One-shot implementation plans, task trackers,
and PR templates do not belong in the repository — keep them in the issue
tracker or your local worktree. When their work closes, delete them rather
than archiving them here.
2. **No copies of other sources of truth.** Crate lists come from
`Cargo.toml`, CI steps from `.github/workflows/ci.yml`, code structure from
the code. `scripts/check_doc_paths.sh` fails the pre-commit gate when a
doc here references a file path that no longer exists.
Every document starts with a `**Use this when:**` line so an agent can decide in one glance whether to read further. The index below repeats those lines.
## Start here
## CI-anchored core
- [overview.md](overview.md) — migration baseline, phase order, core principles
Required headings and strings in these files are asserted by `scripts/check_architecture_migration_rules.sh`; rename a heading only together with the guard.
## CI-enforced core (required by `scripts/check_architecture_migration_rules.sh`)
| Document | Use this when |
|---|---|
| [crate-boundaries.md](crate-boundaries.md) | you add a crate dependency, move code across crates, touch a `storage_api.rs` boundary file, or need the change-type vocabulary the architecture guard enforces |
| [runtime-lifecycle.md](runtime-lifecycle.md) | moving or reordering anything in `rustfs/src/startup_*.rs`, changing readiness publication, or touching shutdown ordering |
| [readiness-matrix.md](readiness-matrix.md) | changing what a request surface does before storage or IAM is ready, changing probe semantics, or adding a runtime dependency that readiness must wait for |
| [storage-control-data-plane.md](storage-control-data-plane.md) | adding a storage API surface, a cluster read model, or a background-service status/reconcile surface, and you need to know which layer owns it |
| [global-state-crate-split-plan.md](global-state-crate-split-plan.md) | business logic needs runtime state (object store, endpoints, lock clients, lifecycle state, config) and you must pick the right boundary, or you are evaluating a crate split out of ECStore |
| [global-state-inventory.md](global-state-inventory.md) | you meet a `GLOBAL_*` static or an `OnceLock` and need to know whether it is a runtime ownership handle, an owner-local static, or process-global by design |
| [ecstore-module-split-plan.md](ecstore-module-split-plan.md) | you add lifecycle or replication logic and need to know which crate it belongs in, plan to move an operation family out of `SetDisks`, or the guard fails on one of the split rules |
| [ecstore-api-facade-inventory.md](ecstore-api-facade-inventory.md) | you need something from `rustfs_ecstore` in another crate, you are narrowing a `rustfs_ecstore::api` facade group, or the guard reports a facade bypass |
| [obs-ecstore-dependency-inventory.md](obs-ecstore-dependency-inventory.md) | adding, removing, or moving any `rustfs_ecstore` or `rustfs_storage_api` reference inside `crates/obs` |
| [compat-cleanup-register.md](compat-cleanup-register.md) | you add, review, or remove a temporary compatibility path and need the `RUSTFS_COMPAT_TODO` marker format and its removal condition |
| [overview.md](overview.md) | you need the historical framing of the architecture-migration program or the phase names that other contracts refer to |
- [crate-boundaries.md](crate-boundaries.md) — dependency direction, PR types, re-export contracts
- [runtime-lifecycle.md](runtime-lifecycle.md) — startup/shutdown sequencing, readiness guarantees
- [readiness-matrix.md](readiness-matrix.md) — request/dependency behavior, probe semantics
- [storage-control-data-plane.md](storage-control-data-plane.md) — storage API contracts, control-plane boundaries
- [global-state-crate-split-plan.md](global-state-crate-split-plan.md) — remaining global-state owners and split evaluation
- [ecstore-module-split-plan.md](ecstore-module-split-plan.md) — ECStore decomposition rules and facade contracts
## Contracts and invariants
## Contracts & invariants
| Document | Use this when |
|---|---|
| [erasure-coding.md](erasure-coding.md) | changing anything under `crates/ecstore/src/erasure/`, `crates/filemeta/`, `crates/ecstore/src/set_disk/`, storage-class or layout code, or any decode, quorum, or heal boundary (normative spec) |
| [placement-repair-invariants.md](placement-repair-invariants.md) | changing anything that resolves an object to a pool, set, or disk, or that admits scanner or heal work |
| [heal-concurrency-model.md](heal-concurrency-model.md) | changing heal, PUT/multipart commit, delete, lifecycle expiry, or data-movement code that shares the `(bucket, object)` commit surface, or asking whether RustFS needs a persistent healing marker |
| [unified-object-generation.md](unified-object-generation.md) | adding or changing anything that fences a commit, scopes a read lease, gates old-directory cleanup, binds prepared pool reads, or settles quota against the current object version |
| [decommission-compatibility.md](decommission-compatibility.md) | changing pool decommission or rebalance behavior, its admin API shape, the persisted `PoolMeta` fields, or how tier free versions move between pools |
| [ecstore-layout-boundary.md](ecstore-layout-boundary.md) | touching endpoint expansion, `FormatV3`, pool/set layout, or moving files between ECStore's internal directories |
| [runtime-capability-contracts.md](runtime-capability-contracts.md) | changing the read-only observability or topology snapshot contracts in `rustfs-storage-api`, their providers, or the `storage_classes` payload of `GET /rustfs/admin/v4/runtime/capabilities` |
| [workload-admission-contracts.md](workload-admission-contracts.md) | adding a workload class or snapshot provider, or consuming admission state from a background job |
| [background-controller-contract.md](background-controller-contract.md) | adding a status snapshot or reconcile surface for a background service, or being tempted to fold several services into a generic controller |
| [config-model-boundary-adr.md](config-model-boundary-adr.md) | touching the server-config model (`Config`, `KV`, `KVS`) or its persistence, or asking which crate owns which part of server configuration |
| [admin-route-action-snapshot.md](admin-route-action-snapshot.md) | adding, moving, or re-authorizing an admin route and needing to know where the route → handler → `AdminAction` contract is enforced |
| [kms-bulk-rekey-contract.md](kms-bulk-rekey-contract.md) | changing the bulk envelope re-wrap sweep, its admin endpoints, the re-wrap primitive, or which objects a rekey may touch |
- [erasure-coding.md](erasure-coding.md) — normative erasure-coding algorithm and on-disk (`xl.meta`) compatibility contract; the frozen invariants for all user-data read/write, encode/decode, quorum, heal, and decode tolerance
- [placement-repair-invariants.md](placement-repair-invariants.md)
- [unified-object-generation.md](unified-object-generation.md) — single per-object generation authority (fencing epoch, transport/encoding/proto/mixed-version contracts)
- [runtime-capability-contracts.md](runtime-capability-contracts.md)
- [workload-admission-contracts.md](workload-admission-contracts.md)
- [background-controller-contract.md](background-controller-contract.md)
- [config-model-boundary-adr.md](config-model-boundary-adr.md)
- [ecstore-layout-boundary.md](ecstore-layout-boundary.md)
- [decommission-compatibility.md](decommission-compatibility.md)
- [kms-bulk-rekey-contract.md](kms-bulk-rekey-contract.md) — object-side DEK re-wrap job: work unit, idempotency model, exclusion rules, and the never-destroy-old-key-versions constraint
## Support and compatibility matrices (release-facing, keep current)
## Support matrices (release-facing, keep current)
| Document | Use this when |
|---|---|
| [s3-compatibility-matrix.md](s3-compatibility-matrix.md) | writing or checking a user-facing S3 compatibility claim, or moving a Ceph s3tests case between lists |
| [s3-tables-support-matrix.md](s3-tables-support-matrix.md) | writing a release note or client-compatibility statement about S3 Tables / Iceberg REST Catalog (cutover procedure: [../operations/s3-tables-cutover-runbook.md](../operations/s3-tables-cutover-runbook.md)) |
| [minio-rustfs-router-compatibility.md](minio-rustfs-router-compatibility.md) | a client or `mc` call that works against MinIO fails against RustFS and you need to know whether the endpoint is missing, stubbed, or deliberately different |
| [minio-file-format-compat.md](minio-file-format-compat.md) | deciding whether a MinIO drive set, bucket-metadata blob, or SSE object can be read or imported by a given RustFS build, or before touching a listed version anchor |
- [s3-compatibility-matrix.md](s3-compatibility-matrix.md)
- [s3-tables-support-matrix.md](s3-tables-support-matrix.md)
- [minio-rustfs-router-compatibility.md](minio-rustfs-router-compatibility.md)
- [minio-file-format-compat.md](minio-file-format-compat.md)
Operations runbooks live in [../operations/](../README.md#operations) and testing references in [../testing/README.md](../testing/README.md).
## Inventories & baselines (snapshots that feed migration work)
- [global-state-inventory.md](global-state-inventory.md)
- [ecstore-api-facade-inventory.md](ecstore-api-facade-inventory.md)
- [ecstore-config-consumer-inventory.md](ecstore-config-consumer-inventory.md)
- [obs-ecstore-dependency-inventory.md](obs-ecstore-dependency-inventory.md)
- [background-services-inventory.md](background-services-inventory.md)
- [scanner-heal-admission.md](scanner-heal-admission.md)
- [admin-route-action-snapshot.md](admin-route-action-snapshot.md)
- [compat-cleanup-register.md](compat-cleanup-register.md)
Historical plans and trackers (rebalance/decommission phases,
migration-progress ledger, and the one-shot migration snapshots that fed it —
startup timeline, scheduler baseline, profiling/NUMA capability inventory, KMS
development defaults inventory) were retired in 2026-07 once the
architecture-review ledger they served closed out (backlog#660/#665). Planning
documents are no longer kept in the repository.
+133 -15
View File
@@ -1,27 +1,145 @@
# Admin Route Action Snapshot
**Use this when:** you add, move, or re-authorize an admin route and need to know where the route → handler → `AdminAction` contract is enforced.
**Source of truth:** `rustfs/src/admin/route_policy.rs` (the `AdminRouteSpec` matrix, checked by `validate_admin_route_policy_specs`), `rustfs/src/admin/route_registration_test.rs` (registration coverage), `rustfs/src/admin/router.rs` (dispatch and credential checks), `rustfs/src/admin/handlers/*.rs` (handler-level authorization calls).
This snapshot records the current admin routing and authorization surface before
directory moves or crate extraction. It is a migration guardrail: later pure
move PRs must preserve the route, handler, authorization action, public
exception, and compatibility alias semantics listed here unless the PR is
explicitly scoped as a behavior change.
This page is a pointer, not a route table. The machine-checked matrix in `route_policy.rs` lists every admin route with its `AdminAction` and `RouteRiskLevel`; routes that are registered but answered by policy instead of a handler are declared there too through `DeferredRoutePolicyReason`. The `AdminRouteSpec` type lives in `crates/security-governance/src/admin_matrix.rs`.
## Source Of Truth
- Router assembly: `rustfs/src/admin/mod.rs::make_admin_route`
- Route registration coverage: `rustfs/src/admin/route_registration_test.rs`
- Runtime dispatch: `rustfs/src/admin/router.rs`
- Admin auth helpers: `rustfs/src/admin/auth.rs`
- Handler route/action ownership: `rustfs/src/admin/handlers/*.rs`
The route registration test intentionally covers representative paths for every
registered route family. This document uses route patterns from the registration
functions and action names from the handler authorization calls.
## Prefix And Alias Contract
| Prefix | Behavior | Rule |
| Prefix | Current behavior | Migration rule |
|---|---|---|
| `/rustfs/admin` | Canonical admin prefix used by `make_admin_route` (`rustfs/src/admin/mod.rs`) | The only registered admin prefix |
| `/minio/admin` | Compatibility alias accepted by `S3Router::is_match`; `canonicalize_admin_path` rewrites it to `/rustfs/admin` immediately before route lookup (`rustfs/src/admin/router.rs`) | Never register routes twice; preserve canonicalization |
| `/iceberg/v1` | Table catalog prefix registered by `register_table_catalog_route` (`rustfs/src/admin/handlers/table_catalog/routes.rs`) and accepted by `is_admin_path` | Stays outside `/rustfs/admin`; table actions are authorized per handler |
| `/health`, `/health/ready` | Public health endpoints, registered only when `ENV_HEALTH_ENDPOINT_ENABLE` allows | Preserve the unauthenticated bypass |
| `/profile/cpu`, `/profile/memory` | Registered by the health handler but guarded by profile authorization | Never couple to health-endpoint enablement |
| `/rustfs/admin` | Canonical admin API prefix used by route registration | Keep as the single registered admin prefix |
| `/minio/admin` | Compatibility alias accepted by `S3Router::is_match`; dispatch canonicalizes it to `/rustfs/admin` | Do not duplicate registrations; preserve canonicalization |
| `/iceberg/v1` table catalog prefix | Registered through `table_catalog::register_table_catalog_route` and accepted by `is_admin_path` | Keep outside `/rustfs/admin` and document auth separately |
| `/health` and `/health/ready` | Public health endpoints when `ENV_HEALTH_ENDPOINT_ENABLE` allows registration | Preserve unauthenticated health bypass |
| `/profile/cpu` and `/profile/memory` | Registered by health handler but guarded by profile auth | Do not couple to health endpoint enablement |
The compatibility alias is not a second route table. `canonicalize_admin_path`
maps `/minio/admin/...` to `/rustfs/admin/...` immediately before route lookup.
## Dispatch And Auth Shape
```mermaid
flowchart LR
A["Incoming request"] --> B{"S3Router::is_match"}
B -->|"Replication or misc extension"| X["Extension handler"]
B -->|"Health path"| H["Public health"]
B -->|"OIDC public path"| O["OIDC public handler"]
B -->|"POST / STS form"| S["STS handler"]
B -->|"Admin or console path"| C{"S3Router::check_access"}
C -->|"public exception"| P["No SigV4 required"]
C -->|"admin route"| D["Credential required"]
D --> E["canonicalize /minio/admin to /rustfs/admin"]
E --> F["matchit route lookup"]
F --> G["AdminOperation handler"]
G --> I["handler-level validate_admin_request"]
```
Route-level credential presence and handler-level policy authorization are
separate contracts. The router enforces credential presence for ordinary admin
routes. Handler rows below record whether the current handler performs a
precise `AdminAction` or `S3Action` check, or only repeats a credential
presence check.
## Public Exceptions
Router-level credential checks (`S3Router::check_access`) are bypassed only for:
| Method | Path pattern | Handler | Auth contract |
|---|---|---|---|
| `GET`, `HEAD` | `/health` | `HealthCheckHandler` | Public when health routes are registered |
| `GET`, `HEAD` | `/health/ready` | `HealthCheckHandler` | Public when health routes are registered |
| Registered as `GET`; auth bypass is path-based | `/rustfs/admin/v3/oidc/providers` and `/minio/admin/v3/oidc/providers` | `ListOidcProvidersHandler` | Public OIDC bootstrap path; `check_access` bypasses SigV4 for any method matching this path |
| Registered as `GET`; auth bypass is path-prefix-based | `/rustfs/admin/v3/oidc/authorize/{provider_id}` and `/minio/admin/v3/oidc/authorize/{provider_id}` | `OidcAuthorizeHandler` | Public OIDC bootstrap path; `check_access` bypasses SigV4 for any method matching this path prefix |
| Registered as `GET`; auth bypass is path-prefix-based | `/rustfs/admin/v3/oidc/callback/{provider_id}` and `/minio/admin/v3/oidc/callback/{provider_id}` | `OidcCallbackHandler` | Public OIDC bootstrap path; `check_access` bypasses SigV4 for any method matching this path prefix |
| Registered as `GET`; auth bypass is path-based | `/rustfs/admin/v3/oidc/logout` and `/minio/admin/v3/oidc/logout` | `OidcLogoutHandler` | Public OIDC logout path; `check_access` bypasses SigV4 for any method matching this path |
| `POST` | `/` with `application/x-www-form-urlencoded` | `AssumeRoleHandle` | Public only for unsigned STS web identity form requests; handler validates JWT/action |
| Any matched method | `/favicon.ico` and `/rustfs/console...` | Console router | Public only when `console_enabled` is true; router bypasses SigV4 before handing off to the console router |
- health routes, when they are registered;
- OIDC bootstrap paths matched by `is_oidc_path` (`providers`, `authorize/{provider_id}`, `callback/{provider_id}`, `logout`); the bypass is path-based, so it applies to any method on those paths;
- unsigned STS web-identity form posts to `/` with `application/x-www-form-urlencoded`, which the STS handler validates itself;
- console assets (`/favicon.ico`, `/rustfs/console...`), only while the console is enabled.
## Registered Route Families
Every other admin route requires credentials at the router and a precise `AdminAction` or `S3Action` check in the handler (metrics routes, for example, authorize `GetMetricsAction`). The MinIO alias contract is specified in [minio-rustfs-router-compatibility.md](minio-rustfs-router-compatibility.md).
All rows with `/rustfs/admin` also accept the `/minio/admin` compatibility alias
through router canonicalization unless the row explicitly says otherwise.
| Area | Methods and path patterns | Handler ownership | Authorization contract |
|---|---|---|---|
| STS and admin probe | `POST /`; `GET /rustfs/admin/v3/is-admin` | `sts.rs`, `is_admin.rs` | STS dispatch validates request action; is-admin checks `AllAdminActions` |
| User lifecycle | `GET /v3/list-users`; `GET /v3/user-info`; `PUT /v3/add-user`; `PUT /v3/set-user-status`; `DELETE /v3/remove-user` | `user_lifecycle.rs`, `user.rs` | `ListUsersAdminAction`, `GetUserAdminAction`, `CreateUserAdminAction`, `EnableUserAdminAction`, `DeleteUserAdminAction` |
| Group management | `GET /v3/groups`; `GET /v3/group`; `DELETE /v3/group/{group}`; `PUT /v3/set-group-status`; `PUT /v3/update-group-members` | `group.rs` | `ListGroupsAdminAction`, `GetGroupAdminAction`, `RemoveUserFromGroupAdminAction`, `EnableGroupAdminAction`, `AddUserToGroupAdminAction` |
| Service accounts | `PUT /v3/add-service-account(s)`; `POST /v3/update-service-account`; `GET /v3/info-service-account`; `GET /v3/temporary-account-info`; `GET /v3/info-access-key`; `GET /v3/list-service-accounts`; `GET /v3/list-access-keys-bulk`; `DELETE /v3/delete-service-account(s)` | `service_account.rs` | create/update/list/temp-info/user-list/remove service account actions as checked in handler context |
| IAM import/export | `GET /v3/export-iam`; `PUT /v3/import-iam` | `user_iam.rs`, `user.rs` | `ExportIAMAction`, `ImportIAMAction` |
| IAM policies | `GET /v3/list-canned-policies`; `GET /v3/info-canned-policy`; `PUT /v3/add-canned-policy`; `DELETE /v3/remove-canned-policy`; `PUT /v3/set-user-or-group-policy`; `PUT /v3/set-policy`; `POST /v3/idp/builtin/policy/attach`; `POST /v3/idp/builtin/policy/detach`; `GET /v3/idp/builtin/policy-entities` | `policies.rs` | list/create/get/delete/attach policy actions; policy-entities combines list groups, users, and policies |
| Account info | `GET /v3/accountinfo` | `account_info.rs` | S3 action checks for account-scoped bucket and object probes |
| System info | `GET /v3/info`; `GET /v3/storageinfo`; `GET /v3/datausageinfo` | `system.rs` | `ServerInfoAdminAction`, `StorageInfoAdminAction`, `DataUsageInfoAdminAction` plus `ListBucketAction` for data usage |
| Metrics stream | `GET /v3/metrics` | `metrics.rs` through `system.rs` | Router credential presence plus handler credential check; no handler-level `AdminAction` is currently enforced |
| System service placeholders | `POST /v3/service`; `GET|POST /v3/inspect-data` | `system.rs` | Currently registered but handler returns `NotImplemented`; migration must preserve this unless behavior changes |
| Pools | `GET /v3/pools/list`; `GET /v3/pools/status`; `POST /v3/pools/decommission`; `POST /v3/pools/cancel` | `pools.rs` | list/status accept server-info or decommission; decommission/cancel use `DecommissionAdminAction` |
| Rebalance | `POST /v3/rebalance/start`; `GET /v3/rebalance/status`; `POST /v3/rebalance/stop` | `rebalance.rs` | `RebalanceAdminAction` |
| Heal | `POST /v3/heal/`; `POST /v3/heal/{bucket}`; `POST /v3/heal/{bucket}/{prefix}`; `POST /v3/background-heal/status`; `GET /v4/heal/replacement-recovery` | `heal.rs` | `HealAdminAction` |
| Tier | `GET /v3/tier`; `GET /v3/tier-stats`; `GET /v3/tier/{tier}`; `DELETE /v3/tier/{tiername}`; `PUT /v3/tier`; `POST /v3/tier/{tiername}`; `POST /v3/tier/clear` | `tier.rs` | `ListTierAction` for reads/status; `SetTierAction` for add/edit/remove/clear |
| Quota legacy and bucket-scoped | `PUT /v3/set-bucket-quota`; `GET /v3/get-bucket-quota`; `PUT|GET|DELETE /v3/quota/{bucket}`; `GET /v3/quota-stats/{bucket}`; `POST /v3/quota-check/{bucket}` | `quota.rs` | `SetBucketQuotaAdminAction` for writes; `GetBucketQuotaAction` for bucket-scoped reads/stats/checks |
| Bucket metadata | `GET /export-bucket-metadata`; `GET /v3/export-bucket-metadata`; `PUT /import-bucket-metadata`; `PUT /v3/import-bucket-metadata` | `bucket_meta.rs` | `ExportBucketMetadataAction`, `ImportBucketMetadataAction` |
| Server config | `GET /v3/get-config-kv`; `PUT /v3/set-config-kv`; `DELETE /v3/del-config-kv`; `GET /v3/help-config-kv`; `GET /v3/list-config-history-kv`; `DELETE /v3/clear-config-history-kv`; `PUT /v3/restore-config-history-kv`; `GET|PUT /v3/config` | `config_admin.rs` | `ConfigUpdateAdminAction` helper path; read/write handlers preserve current per-handler checks |
| Scanner | `GET /v3/scanner/status` | `scanner.rs` | `ServerInfoAdminAction` |
| Notification targets | `GET /v3/target/list`; `GET /v3/target/arns`; `PUT /v3/target/{target_type}/{target_name}`; `DELETE /v3/target/{target_type}/{target_name}/reset` | `event.rs` through `user_policy_binding.rs` | `GetBucketTargetAction` for list/ARNs; `SetBucketTargetAction` for put/delete |
| Audit targets | `GET /v3/audit/target/list`; `PUT /v3/audit/target/{target_type}/{target_name}`; `DELETE /v3/audit/target/{target_type}/{target_name}/reset` | `audit.rs` | `GetBucketTargetAction` for list; `SetBucketTargetAction` for put/delete |
| Module switches | `GET|PUT /v3/module-switches` | `module_switch.rs` | `ServerInfoAdminAction` for get; `ConfigUpdateAdminAction` for update |
| Plugin catalog | `GET /v4/plugins/catalog` | `plugins_catalog.rs` | `ServerInfoAdminAction` |
| Plugin instances | `GET /v4/plugins/instances`; `GET|PUT|DELETE /v4/plugins/instances/{id}` | `plugins_instances.rs` | read uses `GetBucketTargetAction`; write/delete use `SetBucketTargetAction` |
| Replication target list | `GET /v3/list-remote-targets` | `replication.rs` | Router credential presence plus handler credential check; no handler-level `AdminAction` is currently enforced |
| Replication target metrics/mutation | `GET /v3/replicationmetrics`; `PUT /v3/set-remote-target`; `DELETE /v3/remove-remote-target` | `replication.rs` | `GetReplicationMetricsAction` for metrics; `SetBucketTargetAction` for target mutation |
| Site replication | `PUT /v3/site-replication/add`; `PUT /v3/site-replication/remove`; `GET /v3/site-replication/info`; `GET /v3/site-replication/metainfo`; `GET /v3/site-replication/status`; `POST /v3/site-replication/devnull`; `POST /v3/site-replication/netperf`; `PUT /v3/site-replication/edit`; `PUT /v3/site-replication/peer/join`; `PUT /v3/site-replication/peer/bucket-ops`; `PUT /v3/site-replication/peer/iam-item`; `PUT /v3/site-replication/peer/bucket-meta`; `GET /v3/site-replication/peer/idp-settings`; `PUT /v3/site-replication/peer/edit`; `PUT /v3/site-replication/peer/remove`; `PUT /v3/site-replication/resync/op`; `PUT /v3/site-replication/state/edit` | `site_replication.rs` | add/remove/info/operation/resync actions selected per handler |
| Admin profiling | `GET /rustfs/admin/debug/pprof/profile`; `GET /rustfs/admin/debug/pprof/status` | `profile_admin.rs`, `profile.rs` | `ProfilingAdminAction` |
| TLS debug | `GET /rustfs/admin/debug/tls/status` | `tls_debug.rs`, `profile.rs` | `ProfilingAdminAction` via shared profile authorization |
| KMS legacy management | `POST /v3/kms/create-key`; `POST /v3/kms/key/create`; `GET /v3/kms/describe-key`; `GET /v3/kms/key/status`; `GET /v3/kms/list-keys`; `POST /v3/kms/generate-data-key`; `GET|POST /v3/kms/status`; `GET /v3/kms/config`; `POST /v3/kms/clear-cache` | `kms_management.rs`, `kms_keys.rs` | dedicated `kms:*` actions throughout; `kms:ServiceControl` for the status paths, `kms:Configure` for config, `kms:ClearCache` for cache. No `ServerInfoAdminAction` fallback remains on any KMS route |
| KMS dynamic control | `POST /v3/kms/configure`; `POST /v3/kms/start`; `POST /v3/kms/stop`; `GET /v3/kms/service-status`; `POST /v3/kms/reconfigure` | `kms_dynamic.rs` | `kms:Configure` for configure/reconfigure; `kms:ServiceControl` for start/stop/service-status |
| KMS keys | `POST /v3/kms/keys`; `DELETE /v3/kms/keys/delete`; `POST /v3/kms/keys/cancel-deletion`; `GET /v3/kms/keys`; `GET /v3/kms/keys/{key_id}` | `kms_keys.rs` | dedicated `kms:*` actions per handler |
| OIDC public | `GET /v3/oidc/providers`; `GET /v3/oidc/authorize/{provider_id}`; `GET /v3/oidc/callback/{provider_id}`; `GET /v3/oidc/logout` | `oidc.rs` | Public OIDC exception in `is_oidc_path` |
| OIDC config | `GET /v3/oidc/config`; `PUT|DELETE /v3/oidc/config/{provider_id}`; `POST /v3/oidc/validate` | `oidc.rs` | `ServerInfoAdminAction` for read/validate; `ConfigUpdateAdminAction` for mutation |
## Table Catalog Routes
The table catalog API is registered by the admin router but is not under
`/rustfs/admin`. It has its own prefix and Iceberg-style route shape.
| Method | Path pattern | Handler | Authorization action |
|---|---|---|---|
| `GET` | `/iceberg/v1/config` | `GET_CONFIG_HANDLER` | `GetTableCatalogAction` |
| `GET` | `/iceberg/v1/{warehouse}/namespaces` | `LIST_NAMESPACES_HANDLER` | `GetTableNamespaceAction` |
| `POST` | `/iceberg/v1/{warehouse}/namespaces` | `CREATE_NAMESPACE_HANDLER` | `SetTableNamespaceAction` |
| `GET` | `/iceberg/v1/{warehouse}/namespaces/{namespace}` | `GET_NAMESPACE_HANDLER` | `GetTableNamespaceAction` |
| `DELETE` | `/iceberg/v1/{warehouse}/namespaces/{namespace}` | `DROP_NAMESPACE_HANDLER` | `DeleteTableNamespaceAction` |
| `GET` | `/iceberg/v1/{warehouse}/namespaces/{namespace}/tables` | `LIST_TABLES_HANDLER` | `GetTableAction` |
| `POST` | `/iceberg/v1/{warehouse}/namespaces/{namespace}/tables` | `CREATE_TABLE_HANDLER` | `CreateTableAction` |
| `POST` | `/iceberg/v1/{warehouse}/namespaces/{namespace}/register` | `REGISTER_TABLE_HANDLER` | `RegisterTableAction` |
| `GET` | `/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}` | `LOAD_TABLE_HANDLER` | `GetTableAction` |
| `POST` | `/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}` | `COMMIT_TABLE_HANDLER` | `CommitTableAction` |
| `DELETE` | `/iceberg/v1/{warehouse}/namespaces/{namespace}/tables/{table}` | `DROP_TABLE_HANDLER` | `DeleteTableAction` |
## Migration Rules
1. Pure move PRs may move handler modules, but must not change registered
methods, patterns, handler ownership, alias canonicalization, or public
exception behavior.
2. If an admin handler is wrapped to cut a dependency direction, the wrapper
must preserve the same `AdminAction` or `S3Action` check and keep response
compatibility unchanged.
3. Do not duplicate `/minio/admin` registrations. The alias remains a router
canonicalization concern.
4. Do not move table catalog routes under `/rustfs/admin` during route cleanup.
5. Registered-but-`NotImplemented` routes are behavior contracts too. Removing
or implementing them requires a behavior-change PR type.
6. Future route matrix automation should compare against this document and
`route_registration_test.rs` before crate extraction begins.
@@ -1,55 +1,187 @@
# Background Controller Contract
**Use this when:** you add a status snapshot or reconcile surface for a background service (scanner, heal, lifecycle, replication, config reload, capacity, metrics, memory observability, allocator reclaim, auto-tuner), or you are tempted to fold several of them into a generic controller.
**Source of truth:** the shipped reference surfaces — `MemoryObservabilityReconcilePlan` and `reconcile()` in `rustfs/src/memory_observability.rs`, `AllocatorReclaimControllerSnapshot` and `AllocatorReclaimReconcilePlan` in `rustfs/src/allocator_reclaim.rs`, `MetricsRuntimeReconcilePlan` in `crates/obs/src/metrics/scheduler.rs`. Startup and shutdown ordering is owned by [runtime-lifecycle.md](runtime-lifecycle.md); the plane-level overview is in [storage-control-data-plane.md](storage-control-data-plane.md).
This document defines `BGC-002` for
[`rustfs/backlog#660`](https://github.com/rustfs/backlog/issues/660). It turns
the background service inventory into a shared vocabulary for future read-only
status work. It does not add a Rust trait, a scheduler, a service registry, or
any worker start/stop behavior.
There is no `BackgroundController` trait, scheduler, or service registry. Each service exposes its own typed snapshot and reconcile plan; this page fixes the vocabulary and the rules those surfaces follow.
## Scope
## Vocabulary
- PR type: `docs-only`.
- Baseline: `upstream/main` at
`f9a5e6d7e67322ac6f626b6f437a5e722fbe22e2`.
- Applies to future controller work for scanner, heal, lifecycle, replication,
dynamic config reload, capacity, metrics, memory observability, allocator
reclaim, and auto-tuning.
- Out of scope: worker creation, worker shutdown, queue resizing, storage
writes, readiness changes, peer signaling changes, scheduler replacement, and
crate splitting.
| Term | Meaning | Boundary |
## Contract Vocabulary
| Term | Meaning | BGC-002 boundary |
|---|---|---|
| Desired | Static intent from env, persisted config, module switches, feature flags, bucket config, or admin configuration. | Read only; collecting desired state never normalizes or mutates config. |
| Current | Observed local runtime state: configured, disabled, running, degraded, stopping, or unknown. | Read only; never inferred by probes that create storage or network side effects. |
| Status | Machine-checkable snapshot of counters, worker counts, queue pressure, last cycle, last error, cancellation source, and shutdown-handle shape. | Side-effect-free; a missing surface is reported as `unknown`, never guessed. |
| Reconcile | Comparison of desired, current, and status that yields a plan. | Shipped plans only report; the only worker mutation they may request is `none`. |
| Side effects | Writes, deletes, queue admission, target activation, external I/O, metrics emission, readiness publication, peer signals, config reload fanout. | Declared per service before any controller touches it. |
| Desired | Static intent from env, persisted config, module switches, feature flags, bucket config, or admin configuration. | Read only. Do not normalize or mutate config while collecting desired state. |
| Current | Observed local runtime state such as configured, disabled, running, degraded, stopping, or unknown. | Read only. Do not infer state by starting probes that create storage or network side effects. |
| Status | Human-readable and machine-checkable snapshot of runtime counters, worker counts, queue pressure, last successful cycle, last error, cancellation source, and shutdown handle shape. | Side-effect-free. Missing status surfaces must be reported as `unknown`, not guessed. |
| Reconcile | Future comparison between desired, current, and status that can produce a recommendation. | No action in `BGC-002`; future reconcile must not start or stop workers until a tested pilot PR allows it. |
| Side effects | Writes, deletes, queue admission, target activation, external I/O, metrics emission, readiness publication, peer signal, or config reload fanout. | Must be declared before any controller migration touches that service. |
## State Model
Snapshots use the narrowest state the code can prove:
Future status snapshots should use the narrowest state that the current code can
prove:
| State | Meaning | Notes |
|---|---|---|
| NotConfigured | No valid desired source exists. | Config, module switches, or features make the service absent. |
| Disabled | A desired source exists and explicitly disables the service. | Not for missing config. |
| Starting | Start requested, steady state not reached. | Only where a start boundary exists. |
| Running | Active according to existing runtime state. | Not merely because config is enabled. |
| Degraded | Active with known error, partial, or stalled status. | No new failure classification is invented for a snapshot. |
| Stopping | Shutdown requested, not fully exited. | Only where shutdown is observable. |
| Stopped | Started earlier, now fully stopped. | Distinct from `Disabled` and `NotConfigured`. |
| Unknown | No safe status surface exists. | Preferred over speculation. |
| NotConfigured | No valid desired source exists for this service. | Use when config/module switches/features make the service absent. |
| Disabled | Desired source exists and explicitly disables the service. | Do not use for missing config. |
| Starting | Startup was requested and has not reached steady state. | Only expose when current code has a start boundary. |
| Running | The service is active according to existing runtime state. | Do not use merely because config is enabled. |
| Degraded | The service is active but current status exposes known error, partial, or stalled state. | Do not introduce new failure classification in docs-only work. |
| Stopping | Shutdown was requested and the service has not fully exited. | Only expose where shutdown can be observed. |
| Stopped | The service was started before and is now fully stopped. | Do not confuse with `Disabled` or `NotConfigured`. |
| Unknown | Current code lacks a safe status surface. | Preferred over speculative status. |
## Lifecycle Boundary
```mermaid
flowchart LR
D["Desired source"]
C["Current runtime state"]
S["Read-only status snapshot"]
R["Future reconcile recommendation"]
W["Workers and side effects"]
D --> S
C --> S
S --> R
R -. "future tested pilot only" .-> W
```
`BGC-002` stops at the read-only contract. The arrow from reconcile to workers is
intentionally dotted because this PR does not allow any implementation to start,
stop, resize, or reconfigure workers.
## Service Boundaries
| Service area | Desired source | Current/status inputs | Side effects to preserve |
|---|---|---|---|
| Data scanner | Scanner env and runtime scanner config. | Admin scanner status, scanner metrics, scanner cancellation token, checkpoint/yield/alert counters. | Data usage cache updates, lifecycle evaluation, replication heal admission, scanner heal admission, alerts, and scanner metrics. |
| Heal/AHM | Heal enablement and scanner-driven heal admission. | Heal manager global channel, active task atomics, queue length atomics, AHM cancellation token. | Heal queue consumption, heal storage writes, and channel close semantics. |
| Lifecycle expiry/transition | Bucket lifecycle config and scanner event source. | Lifecycle worker counts, active tasks, queue send timeouts, transition stats, expiry/transition queues. | Object deletes, transition queueing, stale multipart cleanup, and lifecycle metrics. |
| Replication pool | Bucket/site replication config and resync admin requests. | Global replication stats, worker pool sizes, queue counters, persisted resync state, per-bucket cancel tokens. | Object replication, delete replication, queue resizing by channel close, persisted resync metadata, and admin-triggered cancel paths. |
| Dynamic config reload | Persisted server config, admin config calls, and peer snapshot signals. | Last local reload result, per-subsystem reload errors, peer reload signal result. | Scanner/heal runtime config updates, audit reload, notification reload, peer signaling, and config snapshot fanout. |
| Capacity manager | Local disk inventory and capacity feature state. | Capacity manager cache age, scheduled refresh state, last refresh result, runtime summary loop. | Global capacity cache refresh and runtime summary metrics/logging. |
| Metrics runtime | Observability metrics feature state and collector configuration. | Collector intervals, last collection result, cancellation token state, collector grouping. | Metrics collection and emission only. |
| Memory observability | Observability feature state and memory sampling config. | Sampler loop state, last sample time, last sample error, runtime cancellation token. | Memory metric emission. This is the preferred first BGC-003 status candidate. |
| Allocator reclaim | Allocator reclaim env/config and backend support. | Enabled flag, idle streak, active request gauge, scanner/heal activity gauges, last reclaim result. | Backend-specific allocator reclaim and metrics. |
| Auto-tuner | `RUSTFS_AUTOTUNER_ENABLED` and tuning inputs. | Last tuning attempt, last tuning error, 60-second loop state. | Runtime concurrency tuning. Treat as behavior-sensitive. |
The following areas stay outside the first controller migrations:
- deferred IAM recovery, because it can publish readiness;
- optional protocol servers, because they already have protocol shutdown handles;
- ECStore endpoint monitor and disk health monitor, because they are storage-
adjacent and can affect disk state;
- notification and audit runtime coupling, because live streams, replay, target
activation, and reload behavior need dedicated preservation tests.
## Read-Only Snapshot Requirements
- Status collection never starts, stops, resizes, or wakes a worker.
- Status collection never writes storage data, object metadata, target state, queue entries, persisted config, or resync metadata.
- Status collection never publishes readiness or peer reload signals.
- Missing fields are `unknown` or omitted with a documented reason.
- Cancellation source and shutdown-handle shape are reported separately from desired enabled/disabled state.
- Repeated `reconcile` calls over the same snapshot return the same plan.
- Scanner, heal, lifecycle, and replication status must not hide their queue and admission coupling.
Any future `BGC-003` status implementation must satisfy all of these:
## Coupling Notes
- status collection must not start, stop, resize, or wake a worker;
- status collection must not write storage data, object metadata, target state,
queue entries, persisted config, or resync metadata;
- status collection must not publish readiness or peer reload signals;
- missing fields must be represented as `unknown` or omitted with a documented
reason;
- cancellation source and shutdown handle shape must be reported separately from
desired enabled/disabled state;
- scanner, heal, lifecycle, and replication status must not hide their queue and
admission coupling.
The services below share state or shutdown contracts and must not be folded into a generic controller without service-specific preservation tests:
## BGC-003 Snapshot Pilot
- Scanner implies heal: the loop started by `init_data_scanner` (`rustfs/src/startup_lifecycle.rs`) enqueues heal work, so scanner status must separate scheduler state from work-source accounting.
- Heal/AHM owns its own token: `create_ahm_services_cancel_token` and `init_heal_manager` run in `rustfs/src/startup_background.rs`; `shutdown_ahm_services` runs in `rustfs/src/startup_shutdown.rs`. Heal admission and channel-close semantics stay intact.
- Replication has two shutdown contracts: the pool started by `init_background_replication` (`rustfs/src/startup_storage.rs`) stops workers by closing channels, while resync started by `init_resync` (`rustfs/src/startup_bucket_metadata.rs`) uses cancellation tokens, and admin-triggered resync uses per-bucket tokens.
- Lifecycle expiry, transition, and stale-multipart cleanup are started by `ECStore::init` (`init_background_expiry`, `init_background_stale_multipart_upload_cleanup` in `crates/ecstore/src/store/init.rs`), which binds the runtime token through `bind_background_cancel_token`; the scanner is their event source, so they are not a separate periodic controller.
- Notification and audit share a runtime pattern but not a lifecycle: `init_event_notifier` and `start_audit_system` (`rustfs/src/startup_audit.rs`), `shutdown_event_notifier` and `stop_audit_system` (`rustfs/src/startup_shutdown.rs`). Live event streams stay separate from target-delivery enablement.
- Dynamic config reload is admin-triggered fanout (`apply_dynamic_config_for_subsystem`, `signal_dynamic_config_reload`, `signal_config_snapshot_reload` in `rustfs/src/admin/service/config.rs`), not a loop; per-subsystem validation and error boundaries are preserved.
- Capacity refresh tasks are owned through `CapacityBackgroundTasks` returned by `init_capacity_management_managed` (`rustfs/src/capacity/capacity_integration.rs`, called from `rustfs/src/startup_entrypoint.rs`); scheduled interval defaults and singleflight refresh stay unchanged.
- Storage-adjacent monitors (`monitor_and_connect_endpoints` in `crates/ecstore/src/core/sets.rs`, `enable_health_check` in `crates/ecstore/src/disk/disk_store.rs`) change disk state and stay outside controller work.
- Deferred IAM recovery (`spawn_iam_recovery_task`, `rustfs/src/startup_iam.rs`) publishes readiness; optional protocol servers already own `ShutdownHandle`s; the auto-tuner (`init_auto_tuner` in `rustfs/src/init.rs`) changes runtime concurrency. All three stay outside generic controllers.
The first read-only snapshot is memory observability status. It reports the
service name, whether observability metrics currently enable the sampler, the
configured sampler interval, runtime-token cancellation state, and the absence
of a dedicated shutdown handle.
This snapshot intentionally does not define an admin route, scheduler, service
registry, worker start/stop path, readiness signal, peer signal, storage write,
or metrics emission change.
## BGC-004 Controller Pilot
The first controller pilot is also memory observability. It converts the
existing desired inputs and status snapshot into a typed reconcile plan. The
pilot reports desired state, current state, and worker mutation intent.
The only allowed worker mutation for this pilot is `none`. Repeated reconcile
calls must return the same plan for the same snapshot and must not request a
worker start, stop, resize, wakeup, storage write, readiness signal, peer
signal, or metrics emission.
## BGC-005 Allocator Reclaim Status And Controller Surface
The second low-risk controller/status surface is allocator reclaim. It reports
the service name, desired enablement, configured force flag, backend-specific
effective force, idle interval settings, runtime-token cancellation state, and
the absence of a dedicated shutdown handle.
The only allowed worker mutation for this surface is `none`. Reconcile output is
read-only and must not start, stop, resize, wake, or otherwise drive the
allocator reclaim loop. Existing backend-specific force handling, idle-streak
logic, metrics emission, and runtime-token shutdown behavior remain owned by the
current loop.
## BGC-006 Metrics Runtime Status And Controller Surface
The third low-risk controller/status surface is metrics runtime. It reports the
service name, observability metrics enablement, collector task count, configured
collector intervals, replication bandwidth zero-tombstone cycle count,
runtime-token cancellation state, and the absence of a dedicated shutdown
handle.
The only allowed worker mutation for this surface is `none`. Reconcile output is
read-only and must not start, stop, resize, wake, or otherwise drive metrics
collector tasks. Existing collector grouping, interval parsing, metrics
emission, replication bandwidth tombstone handling, and runtime-token shutdown
behavior remain owned by the current loops.
## Future Reconcile Rules
Future reconcile work is allowed only after a read-only status snapshot exists.
The first reconcile pilot must:
- choose one low-risk service;
- compare desired/current/status without side effects;
- prove idempotence under repeated calls;
- prove no duplicate workers are created;
- preserve existing shutdown order and cancellation source;
- include rollback guidance that removes the pilot without changing existing
worker behavior.
Memory observability is the recommended first candidate because it already has a
simple runtime cancellation loop and no storage writes. Scanner, heal,
replication, lifecycle, disk health, deferred IAM recovery, and auto-tuning must
wait for focused preservation tests.
## Verification Expectations
For this docs-only contract:
- architecture migration guard scripts must pass;
- layer dependency and metrics reference guards must pass;
- no Rust source, Cargo metadata, CI workflow, Makefile, or runtime config file
may change.
For the next implementation PRs:
- add focused tests before changing behavior;
- do not modify production logic only to make tests pass;
- keep compatibility comments searchable with `RUSTFS_COMPAT_TODO(<task-id>)`
whenever temporary old paths are retained for later deletion.
@@ -0,0 +1,89 @@
# Background Services Inventory
This document records the current background service surface before
BackgroundController work. It is a behavior-preservation inventory only; it does
not define a new scheduler, controller framework, or shutdown contract.
## Scope
- Related migration task: `BGC-001`.
- PR type: `docs-only`.
- Baseline: `upstream/main` at
`03eb10b07f5f968c531151ae667dfe218050493d`.
- Out of scope: changing startup order, shutdown order, readiness, storage
writes, heal admission, scanner scheduling, replication queues, config reload
behavior, metrics intervals, or worker counts.
## Startup And Shutdown Owners
| Area | Startup owner | Shutdown owner | Current cancellation source |
|---|---|---|---|
| Main runtime token | `rustfs/src/main.rs::run` creates `ctx` after HTTP listeners start and before ECStore creation. | `rustfs/src/main.rs::handle_shutdown` calls `ctx.cancel()` before service-specific shutdown. | Shared `tokio_util::sync::CancellationToken`. |
| Scanner | `rustfs/src/main.rs::run` calls `init_data_scanner(ctx.clone(), store.clone())` after successful startup log and global init time. | Main shutdown calls `ctx.cancel()`; if scanner was enabled it also calls `shutdown_background_services()`. | Scanner loop receives the main runtime token. |
| Heal/AHM | Main creates `create_ahm_services_cancel_token()` before scanner/heal feature checks and calls `init_heal_manager(...)` when heal or scanner is enabled. | Main shutdown calls `shutdown_ahm_services()` when heal or scanner was enabled. | Global AHM token plus channel/worker-local state. |
| Replication pool | Main calls `init_background_replication(store.clone())` after global config init, then `pool.init_resync(ctx.clone(), buckets.clone())` after bucket listing. | No direct main shutdown call for the replication pool; resync receives the main runtime token. | Resync routine uses the main runtime token; per-bucket resync uses registered cancel tokens. |
| Lifecycle expiry/transition | `ECStore::init` calls `init_background_expiry(self.clone())` and `init_background_stale_multipart_upload_cleanup(self.clone())`. | Expiry workers read `get_background_services_cancel_token()` and fall back to a private token if none exists. Stale multipart cleanup exits when the weak ECStore reference cannot upgrade. | `ECStore::init` binds the main runtime token into the instance context with `bind_background_cancel_token(ctx)` before expiry starts, so the private-token fallback is a defensive path rather than the normal one. |
| Notification runtime | Main calls `init_event_notifier()` after buffer profile init. | Main shutdown calls `shutdown_event_notifier().await`. | Notification runtime owns target/replay shutdown internally. |
| Audit runtime | Main calls `start_audit_system().await`. | Main shutdown calls `stop_audit_system().await`. | Audit runtime owns target/replay shutdown internally. |
| Metrics and memory loops | Main calls `init_metrics_runtime(ctx.clone())`, `init_memory_observability(ctx.clone())`, and `init_auto_tuner(ctx.clone())` when observability metrics are enabled. | Main shutdown only cancels the shared runtime token. | Shared runtime token. |
| Allocator reclaim | Main calls `init_allocator_reclaim(ctx.clone())` unconditionally. | Main shutdown cancels the shared runtime token. | Shared runtime token. |
| Capacity manager | Main calls `init_capacity_management().await` before HTTP listener startup and ECStore creation. | No direct main shutdown call. | Current scheduled capacity and metrics loops do not receive a shutdown token. |
| Optional protocol servers | Main calls feature-gated FTP, FTPS, WebDAV, and SFTP init functions. | Main shutdown calls each stored `ShutdownHandle` and waits for all protocol shutdown futures. | Per-protocol broadcast shutdown handles. |
| Deferred IAM recovery | `bootstrap_or_defer_iam_init(...)` may spawn a deferred recovery loop. | Main shutdown cancels the shared runtime token. | Shared runtime token. |
## Service Inventory
| Service | Trigger and workers | Side effects | Status and metrics | Migration notes |
|---|---|---|---|---|
| Capacity background refresh | `rustfs/src/capacity/capacity_integration.rs::init_capacity_management` delegates to `init_capacity_management_for_local_disks`, then `crates/object-capacity/src/capacity_manager.rs::start_background_task` spawns a scheduled refresh loop and a runtime summary loop. | Refreshes global capacity cache from local disks and logs runtime summaries. | Uses the object-capacity manager state and log summaries; no explicit shutdown status surface is exposed here. | Add read-only status before any controller migration. A future controller must not change scheduled interval defaults or singleflight refresh behavior. |
| ECStore endpoint monitor | `crates/ecstore/src/core/sets.rs::new` spawns `monitor_and_connect_endpoints`. | Monitors endpoint connectivity and reconnect behavior for erasure sets. | Logs monitor start, cancellation, and exit. | This is storage-adjacent and must stay outside broad controller movement until storage shutdown semantics are explicitly covered. |
| Local disk health monitor | `crates/ecstore/src/store/init.rs::init` enables disk health checks after store initialization; `crates/ecstore/src/disk/disk_store.rs::enable_health_check` spawns writable and recovery monitors. | Periodically probes disk writability, can create test objects named `health-check-*`, and updates disk runtime health state. | Disk info includes runtime health metrics and waiting counts. | Do not merge this with scanner/heal controller work; probes affect disk health semantics. |
| Data scanner | `crates/scanner/src/scanner.rs::init_data_scanner` configures scanner defaults, applies runtime config, waits the initial scanner delay, then loops `run_data_scanner`. | Updates data usage cache, scans buckets/sets, evaluates lifecycle rules, queues replication heal, queues scanner heal, and emits scanner alerts. | Scanner runtime config/status is exposed through admin scanner status; scanner metrics record ILM, replication admission, heal admission, checkpoints, yields, and alerts. | Scanner implies heal because scanner can enqueue heal requests. Future controller status must separate scheduler state from scanner work-source accounting. |
| Heal/AHM | `crates/heal/src/lib.rs::init_heal_manager` starts `HealManager`, initializes the shared heal channel, and spawns `HealChannelProcessor`. | Consumes heal requests from the global heal channel and drives heal work through the configured heal storage API. | Global active-task and queue-length atomics track current heal pressure. | Keep heal admission and channel semantics intact. Controller work should first expose queue/active status and shutdown state. |
| Bucket replication pool | `crates/ecstore/src/bucket/replication/replication_pool.rs::init_background_replication` creates global replication stats and the global pool; pool resizing spawns regular, large-object, and failed-object workers. | Replicates object and delete operations, updates queue stats, and maintains replication worker pools. | Replication stats expose active worker counts and queue accounting. | Worker resize behavior currently closes channels to stop workers. Do not replace this with a generic controller until queue close semantics are captured by tests. |
| Bucket replication resync | Main calls `get_global_replication_pool().init_resync(ctx.clone(), buckets.clone())`; the pool spawns `start_resync_routine`. Admin site-replication handlers can start or cancel per-bucket resync with dedicated tokens. | Loads persisted resync state, starts bucket resync, persists status, and can cancel per-target resync. | Admin site-replication status surfaces resync state. | Preserve the split between startup resync and admin-triggered resync operations. |
| Lifecycle expiry and transition | `ECStore::init` calls `init_background_expiry(self.clone())`. Scanner evaluates lifecycle events and queues expiry/transition work through `apply_expiry_rule` and `apply_transition_rule`. | Deletes expired objects, queues transitions, updates lifecycle stats, and accounts scanner ILM actions only when work is queued. | Lifecycle state tracks worker counts, active tasks, queue send timeouts, compensation tasks, and transition stats. | This is not a separate periodic controller today; scanner is the main event source for object lifecycle evaluation. |
| Stale multipart cleanup | `ECStore::init` calls `init_background_stale_multipart_upload_cleanup(self.clone())`. | Periodically deletes stale multipart upload data. | Logs cleanup passes when objects are deleted. | Current loop has no explicit cancellation token and exits when ECStore is dropped. Future controller work needs an explicit lifecycle decision before changing it. |
| Notification runtime | `rustfs/src/server/event.rs::init_event_notifier` initializes live event stream support even when notification targets are disabled; when enabled, it loads server config and activates targets. Config reload uses `NotificationConfigManager::reload_config`. | Installs ECStore event dispatch hook, activates notification targets, manages replay/runtime target state, and supports live event streams. | Notification module state is refreshed from persisted module switches; target health is available through runtime target status. | Keep live event stream support separate from target delivery enablement. Reload must remain admin-triggered and peer-signaled. |
| Audit runtime | `rustfs/src/server/audit.rs::start_audit_system` starts audit only when module switches and configured targets allow it. `AuditSystem::reload_config` replaces runtime targets. | Dispatches audit events to configured targets and manages replay workers. | Audit observability records config reloads and target delivery metrics. | Do not couple audit lifecycle to notification lifecycle even though the runtime patterns are similar. |
| Dynamic config reload | Admin config handlers call `apply_dynamic_config_for_subsystem`, then `signal_dynamic_config_reload` or `signal_config_snapshot_reload` through the global notification system. | Applies scanner/heal runtime config, audit reloads, notification reloads, and peer reload signals. | Logs local and peer reload failures. Audit reload increments audit config reload metrics. | This is admin-triggered fanout, not a background scheduler. Controller work should preserve per-subsystem validation and error boundaries. |
| Metrics runtime | `crates/obs/src/metrics/scheduler.rs::init_metrics_runtime` spawns multiple interval loops for cluster, bucket, node, resource, audit, notification, and replication bandwidth metrics. | Periodically collects and reports metrics. | Reports through the metrics runtime, logs cancellation warnings, and exposes a typed read-only status snapshot plus a no-op reconcile plan for enablement, collector task count, intervals, replication bandwidth tombstone cycles, cancellation source, and shutdown handle shape. | Keep intervals and collector grouping stable. The current controller surface does not mutate workers. |
| Memory observability | `rustfs/src/memory_observability.rs::init_memory_observability` spawns a token-cancelled sampler. | Periodically records memory snapshots. | Emits memory observability metrics and exposes a read-only status snapshot plus a no-op reconcile plan for metrics enablement, interval, cancellation source, and shutdown handle shape. | This is the first low-risk pilot for controller status because it already has a simple token loop and the pilot does not mutate workers. |
| Allocator reclaim | `rustfs/src/allocator_reclaim.rs::init_allocator_reclaim` spawns a token-cancelled reclaim loop when enabled. | Observes reclaimable work and may run allocator reclaim after idle intervals. | Emits reclaim enabled/backend counters, active-request gauges, scanner/heal activity gauges, and reclaim result counters. Exposes a typed read-only status snapshot plus a no-op reconcile plan for enablement, backend, effective force, intervals, cancellation source, and shutdown handle shape. | A controller must preserve idle-streak logic and backend-specific force behavior. The current controller surface does not mutate workers. |
| Auto-tuner | `rustfs/src/init.rs::init_auto_tuner` optionally spawns a 60-second loop when `RUSTFS_AUTOTUNER_ENABLED` is true. | Tunes concurrency manager settings from performance metrics. | Logs iteration success/failure. | Treat as behavior-sensitive; a future controller needs explicit rollback because it can change runtime concurrency. |
| Update check | `rustfs/src/init.rs::init_update_check` spawns one async task with a 30-second timeout when update checks are enabled. | Performs version check network I/O and logs available updates. | Logs result only. | This is a one-shot task, not a controller candidate for the first BGC PRs. |
| Deferred IAM recovery | `rustfs/src/startup_iam.rs::spawn_iam_recovery_task` retries IAM init with backoff and finalizes readiness when successful. | Can initialize IAM later, initialize AppContext if needed, mark `IamReady`, and publish `FullReady`. | Readiness state reflects deferred recovery progress. | Keep this lifecycle-critical path separate from generic background controllers. |
| Optional protocol servers | `rustfs/src/init.rs` starts FTP, FTPS, WebDAV, and SFTP with per-protocol `ShutdownHandle`s when features and config enable them. | Serve protocol traffic in background tasks. | Shutdown logs per protocol. | Protocol servers already have explicit handles; do not fold them into BGC until the service registry owns shutdown ordering. |
## Current Gaps To Preserve Before Controller Work
- ECStore background-service cancellation has a public global token API, but this
inventory found no current startup call to create that token. Lifecycle expiry
workers therefore use their fallback token when no global token exists.
- Capacity manager loops do not receive the main runtime cancellation token.
- Replication worker pools stop some workers by closing channels, while resync
uses cancellation tokens. These are different shutdown contracts.
- Scanner, lifecycle, replication, and heal are coupled by work queues and
metrics. Moving one without status snapshots for the others risks hiding work
admission failures.
- Dynamic config reload is admin-triggered and peer-signaled, not a periodic
background loop.
## BGC-002 Contract Inputs
These inputs are formalized in
[`background-controller-contract.md`](background-controller-contract.md).
Future controller contract work should start with a read-only shape:
- `desired`: enabled/disabled plus static config source.
- `current`: started, stopped, running, degraded, or disabled.
- `status`: worker counts, queue lengths, last cycle/reload time, and last error.
- `shutdown`: cancellation source and whether the service has an explicit stop
handle.
- `side_effects`: storage writes, target activation, external I/O, metrics, and
readiness changes.
The first pilot should use a service with an existing simple cancellation loop
and no storage writes, such as memory observability. Scanner, heal, replication,
lifecycle, and disk health must wait for focused preservation tests.
+5 -4
View File
@@ -1,7 +1,8 @@
# Compatibility Cleanup Register
**Use this when:** you add, review, or remove a temporary compatibility path (fallback, wrapper, re-export, legacy codec) and need the required marker and its removal condition.
**Source of truth:** the `RUSTFS_COMPAT_TODO(<id>)` source markers, matched in both directions against `## Open Items` by `scripts/check_architecture_migration_rules.sh`. Entries exist only for compatibility paths planned for later deletion.
Use this file to track temporary compatibility code introduced by architecture
migration PRs. Entries are required only for compatibility paths that are planned
for later deletion.
## Required Source Marker
@@ -13,7 +14,7 @@
- `tokio-tar-extension-limits` bounded archive parser hardening: Snowball extraction depends on per-entry and cumulative GNU long-name, GNU long-link, and PAX extension limits; physical-entry, GNU sparse-map, and sparse-continuation limits; cancellation-safe sparse parsing; and fused entry streams after parser errors. The released tokio-tar API does not provide this complete boundary. Keep the reviewed fork pin until astral-sh/tokio-tar#118 is merged and one published tokio-tar release contains every listed capability with the Snowball regression fixtures passing against that release.
- `backlog-2102` rc.2/rc.3 empty scanner usage floor recovery: old DeleteBucket cleanup could synthesize an empty incomplete v2 usage primary/backup before leadership added an epoch, while newer scanners require a durable authoritative baseline identity. New scanners recognize only that exact serialized empty-fence shape, preserve its epoch through a CAS-protected recovery marker, and rebuild namespace coverage without treating zero usage as authoritative. Remove this recovery path and marker after rc.2 and rc.3 are no longer supported direct-upgrade sources.
- `s3gate-metadata-xml` persisted bucket XML migration: mixed-version site-replication peers, retained `.metadata.bin` objects, and backup archives can all carry XML written by the s3s codec, so the gateway migration must keep the legacy codec available until every stored form has crossed a verified rewrite boundary. Remove the legacy s3s parser and serializer only after the minimum supported direct-upgrade release reads and writes every persisted XML configuration family through the gateway codec, every supported mixed-version site-replication topology has completed its writer upgrade, and migration tooling has verified or rewritten every retained bucket metadata object and restorable backup archive.
- `s3gate-metadata-xml` persisted bucket XML migration: mixed-version site-replication peers, retained `.metadata.bin` objects, and backup archives can all carry XML written by the s3s codec, so the gateway migration must keep the legacy codec available until every stored form has crossed a verified rewrite boundary. Remove the legacy s3s parser and serializer only after the minimum supported direct-upgrade release reads and writes every persisted XML configuration family through the gateway codec, the four-way D1-D5 gate has remained clean for one full support window, every supported mixed-version site-replication topology has completed its writer upgrade, and migration tooling has verified or rewritten every retained bucket metadata object and restorable backup archive.
- `rustfs-6339` legacy bucket policy ID casing: earlier RustFS releases persisted the top-level policy identifier as "ID", while current writes use the S3-compatible "Id" spelling. Readers accept both spellings so retained bucket metadata remains usable after upgrade. Remove the legacy alias after migration tooling has rewritten every retained bucket policy using "ID".
- `table-publication-fence-v1` table publication fencing: nodes that predate table and table-bucket publication fences can mutate live files while a new node is publishing a catalog pointer. New nodes retain exact object guards until the operator confirms that every serving node uses the new fences. Fleet confirmation also requires non-overlapping active warehouse prefixes and lifecycle workers that exclude table buckets. Remove the exact live-file fallback and the fleet-confirmation gate after the minimum supported RustFS release acquires table fences for registered-table mutations and table-bucket fences for unresolved-prefix mutations.
- `table-catalog-strong-snapshot-v1` durable strong catalog snapshot compatibility: version 1 writes continue during mixed-version rollout until operators confirm that every serving node reads version 2, and version 1 table/view identifier collisions remain available only for cleanup. Remove version 1 writes and collision cleanup after the minimum supported RustFS release reads version 2 and every retained durable strong snapshot is collision-free and has been upgraded to version 2.
@@ -28,7 +29,7 @@
- `rustfs-5416-zero-retry-delay` startup retry-delay validation: releases before bounded topology convergence accept RUSTFS_STARTUP_TOPOLOGY_RETRY_MAX_DELAY values of 0 or 0ms. New servers replace those values with the safe nonzero default so a direct upgrade neither fails startup nor enters a busy loop. Reject zero after the minimum supported direct-upgrade release validates or rewrites this setting before rollout.
- `scanner-usage-v2` persisted scanner usage migration: pre-v2 scanners write `.usage.json`, so upgraded clusters read that primary/backup pair only while `.usage.v2.json` is absent and continue removing deleted buckets from legacy copies that still exist. The additive usage_snapshot_complete field in `.usage.v2.json` must remain optional while mixed-version clusters are supported; a missing field means the snapshot is not authoritative. The legacy read also feeds the degraded quota-admission baseline (issue #5716): while no authoritative usage exists, quota checks admit against the pre-discard sizes of the last loaded snapshot, including a legacy one. Remove the legacy object fallback and cleanup only after every supported direct-upgrade source writes `.usage.v2.json`; the baseline then feeds from incomplete v2 snapshots alone.
- `ns-scanner-rpc-v3` namespace scanner capability and activity handshake: old peers and legacy internode transports lack the authenticated startup-epoch handshake. The oldest peers send an empty activity request and receive a field-empty protocol-0 response. Protocol v4 binds the challenge and response topology but cannot authenticate distributed dirty-usage state. Protocol v5 binds the request version, acknowledgement target and generation, and the response dirty-usage state, but predates set-scoped scanner cache locks. Protocol v6 additionally fences scanner cache lock-domain changes. Current protocol v7 binds the storage-owned movement generation and publication-blocked state, so distributed scanner cycles publish usage only after every peer reports a complete v7 activity proof; v6 responses remain readable but are treated as unverified for publication. Servers retain protocol-0, protocol-v4, and protocol-v6 codecs alongside the current v7 codec for rolling upgrades, while protocol-v5 peers are treated as previous-version peers that cannot safely participate in the new cache lock domain. Scanner selection treats HTTP 404/405/426 and the legacy MethodNotAllowed default as an explicit lack of remote scanner v3 support and assigns those disks to coordinator-driven workers; transient capability failures remain incomplete and do not activate the fallback. Remove the coordinator fallback after the minimum supported RustFS peer version implements namespace scanner protocol v3, remove protocol-0 activity requests and responses after every supported peer implements authenticated scanner activity protocol v4, remove the protocol-v4 activity codec after every supported peer implements protocol v5, and remove protocol-v5 previous-version rejection after every supported peer implements protocol v6; future protocol revisions must keep the same dual-version server/codec window before changing the advertised version.
- `rustfs-4648` walk-dir stream completion capability: old clients can append fallback output to an already-used metacache writer after a terminal body error, so servers emit terminal walk errors only to clients that sign the `walk_dir_stream_completion=error-v1` query capability and its request-body digest. Remove the legacy clean-EOF path after the minimum supported RustFS peer version always advertises this capability.
- `#4648` walk-dir stream completion capability: old clients can append fallback output to an already-used metacache writer after a terminal body error, so servers emit terminal walk errors only to clients that sign the `walk_dir_stream_completion=error-v1` query capability and its request-body digest. Remove the legacy clean-EOF path after the minimum supported RustFS peer version always advertises this capability.
- `heal-rpc-auth-v2` internode gRPC authentication: servers temporarily accept legacy prefix signatures so old peers remain available during rolling upgrades. Remove the legacy fallback after the minimum supported RustFS peer version sends v2 authentication on every internode gRPC request.
- `put-file-auth-epoch-strict` internode put_file epoch compatibility: rc.2 peers can cache a remote put_file capability before that remote node restarts, then continue sending v1 authenticated uploads with the old server epoch; those peers cannot recover from the 409 conflict used by newer clients to trigger a re-probe. Servers temporarily accept signed, non-nil stale put_file epochs while legacy put_file auth remains non-strict so mixed-version rolling upgrades can finish multipart/object writes. Remove the stale-epoch fallback after the minimum supported RustFS peer version re-probes put_file capability after server-epoch conflicts and legacy put_file auth is no longer accepted.
- `disk-mutation-body-digest` internode mutating disk RPCs: servers temporarily accept mutating disk RPCs (RenameData, DeleteVersion, DeleteVersions, WriteMetadata, UpdateMetadata, WriteAll, Delete, DeletePaths, RenameFile, RenamePart, DeleteVolume, MakeVolume, MakeVolumes) that carry no signature-bound canonical body digest, so peers from releases that predate body-digest signing remain available during rolling upgrades. Accepted digestless mutations increment the internode body-digest fallback counter; that counter must read zero fleet-wide across a release window before RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT is enabled. Because body-bound requests now consume replay-cache nonces on the receiver, deploy the raised RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY default fleet-wide before enabling strict mode, and watch the internode replay-cache overflow counter for undersized capacity during the rollout. Remove the digestless fallback after the minimum supported RustFS peer version body-binds every mutating disk RPC.
+167 -31
View File
@@ -1,49 +1,185 @@
# Config Model Boundary ADR
**Use this when:** you touch the server-config model (`Config`, `KV`, `KVS`) or its persistence, or you need to know which crate owns which part of server configuration.
**Source of truth:** `crates/config/src/server_config.rs` (model, default registration, process-global snapshot) and `crates/ecstore/src/config/` (`ConfigSys`, persistence, migration, storage-class runtime state).
Related issue: [`rustfs/backlog#660`](https://github.com/rustfs/backlog/issues/660)
Task: `CFG-002`
## Decision
`rustfs-config` (`crates/config`) owns the pure server-config model and the process-global server-config snapshot. ECStore keeps config persistence, migration, default-registration wiring, startup initialization, and storage-class runtime state. There is no separate config-model crate, and `rustfs_ecstore::config` does not re-export the model or the snapshot accessors.
Use the existing `crates/config` package (`rustfs-config`) as the target owner
for the pure server-config model. Do not create a new config-model crate for
the first extraction.
Import path: `rustfs_config::server_config::{Config, KV, KVS}`. The model sits behind the `server-config-model` feature of `rustfs-config` (`crates/config/Cargo.toml`), which enables `serde` and `serde_json`.
The next model extraction PR should introduce the model under:
```text
crates/config/src/server_config.rs
```
The exported path should be:
```rust
rustfs_config::server_config::{Config, KV, KVS}
```
The extraction kept the existing path available through a temporary
compatibility re-export:
```rust
rustfs_ecstore::config::{Config, KV, KVS}
```
That re-export included `RUSTFS_COMPAT_TODO(CFG-004)` and a matching entry in
[`compat-cleanup-register.md`](compat-cleanup-register.md) until the model
consumers were migrated. The CFG-004 cleanup removed this old model path after
code scans showed consumers import the model directly from `rustfs-config`.
Follow-up `CFG-008` moved the process-global server-config snapshot accessors
to `rustfs_config::server_config` after the model path stabilized. Its temporary
`rustfs_ecstore::config::{get_global_server_config, set_global_server_config}`
compatibility re-export was removed after in-repo runtime consumers migrated to
the `rustfs-config` owner.
## Why `rustfs-config`
- It is already the lowest RustFS crate for configuration constants and subsystem identifiers used by ECStore, notify, audit, targets, scanner, IAM, and admin code, and the model needs only those constants.
- Moving the model upward removes the wrong-direction dependency (outer crates importing ECStore for a plain data type) without adding another crate or a second config namespace.
`rustfs-config` is already the lowest RustFS crate for configuration constants
and subsystem identifiers used by ECStore, notify, audit, targets, scanner, IAM,
and admin code. The current `ecstore::config::{Config, KV, KVS}` model already
uses `rustfs-config` constants, so moving the pure model upward to
`rustfs-config` cuts the wrong dependency direction without adding another crate.
## Ownership
Creating a new crate now would add a second config namespace before consumers
are migrated. That would increase re-export and compatibility surface while not
removing any storage or runtime dependency by itself.
| Item | Owner | Notes |
|---|---|---|
| `KV`, `KVS`, `Config` and their methods (`get_value`, `set_defaults`, `marshal`, `unmarshal`, `merge`) | `crates/config/src/server_config.rs` | Pure data model with serde roundtrip |
| `DEFAULT_KVS`, `register_default_kvs` | `crates/config/src/server_config.rs` | Registration surface; ECStore still calls it from `init()` in `crates/ecstore/src/config/mod.rs` |
| `GLOBAL_SERVER_CONFIG`, `get_global_server_config`, `set_global_server_config` | `crates/config/src/server_config.rs` | Process-global snapshot accessors |
| `ConfigSys`, `init()`, `try_migrate_server_config` | `crates/ecstore/src/config/mod.rs` | Startup order and caller unchanged |
| `read_config_without_migrate`, `save_server_config`, other config-object helpers | `crates/ecstore/src/config/com.rs` | Persistence over the object store |
| `GLOBAL_STORAGE_CLASS` and storage-class parsing | `crates/ecstore/src/config/mod.rs`, `crates/ecstore/src/config/storageclass.rs` | Storage behavior stays in ECStore |
## Allowed Dependencies
## Allowed Dependencies Of The Model Module
The server-config model module may use only:
- `std::collections::HashMap` and `std::sync::{LazyLock, OnceLock, RwLock}` for `DEFAULT_KVS` and `GLOBAL_SERVER_CONFIG`;
- `serde` for `KV`/`KVS` and `serde_json` for `Config::marshal` / `Config::unmarshal`, gated by `server-config-model`;
- existing `rustfs-config` constants and subsystem modules.
- `std::collections::HashMap`
- `std::sync::{LazyLock, OnceLock, RwLock}` for the default `KVS` registration
surface and process-global server-config snapshot
- `serde` for `KV` and `KVS` serialization compatibility
- `serde_json` for `Config::marshal` and `Config::unmarshal`
- existing `rustfs-config` constants and subsystem modules
## Forbidden Dependencies Of The Model Module
If `serde` and `serde_json` are added to `rustfs-config`, they should be attached
only to a model feature such as `server-config-model` unless the implementation
PR proves that making them non-optional is simpler and harmless for downstream
builds.
- `rustfs-ecstore`, `rustfs`, storage-api traits, or object persistence helpers;
- notify, audit, targets, IAM, scanner, KMS, or admin handler crates;
- async runtimes, HTTP/router crates, object-store crates, or runtime lifecycle state;
- `ConfigSys`, `read_config_without_migrate`, `save_server_config`, or any `com.rs` helper.
## Forbidden Dependencies
## Shape Preservation
The model module must not depend on:
Persisted server-config JSON must keep decoding unchanged:
- `rustfs-ecstore`
- `rustfs`
- `StorageAPI` or object persistence helpers
- notify, audit, targets, IAM, scanner, KMS, or admin handler crates
- async runtimes, HTTP/router crates, object-store crates, or runtime lifecycle
state
- unrelated runtime global state outside the process-global server-config
snapshot
- `ConfigSys`, `read_config_without_migrate`, `save_server_config`, or any
`com.rs` persistence helper
- `KV { key, value, hidden_if_empty }` with `#[serde(default, alias = "hiddenIfEmpty")]` on `hidden_if_empty`;
- `KVS(pub Vec<KV>)` and `Config(pub HashMap<String, HashMap<String, KVS>>)`;
- `KVS::{get, lookup, is_empty, keys, insert, extend}` and `Config::{get_value, set_defaults, marshal, unmarshal, merge}` keep their semantics;
- `Config::new()` applies the defaults registered by `ecstore::config::init()`;
- target, notify, audit, scanner, OIDC, and admin code keep interpreting `Config` and `KVS` the same way.
## Boundary Split
Move in the first extraction:
- `KV`
- `KVS`
- `Config`
- `DEFAULT_KVS`
- `register_default_kvs`
- `Config::new`
- `Config::get_value`
- `Config::set_defaults`
- `Config::marshal`
- `Config::unmarshal`
- `Config::merge`
Keep in `ecstore`:
- `ConfigSys`
- `init_global_config_sys`
- `try_migrate_server_config`
- `read_config_without_migrate`
- `save_server_config`
- generic `com.rs` config-object helpers
- storage-class runtime global state
Keep default registration wiring in `ecstore::config::init` until a later PR
extracts a dedicated default-registration contract. The values may be registered
through the moved `rustfs_config::server_config::register_default_kvs`, but the
startup order and caller remain unchanged.
Move in `CFG-008`:
- `GLOBAL_SERVER_CONFIG`
- `get_global_server_config`
- `set_global_server_config`
The temporary ECStore compatibility re-export for these accessors was removed
after code scans showed in-repo consumers use `rustfs_config::server_config`
directly.
## Required Shape Preservation
The extraction PR must preserve:
- `KV { key, value, hidden_if_empty }`
- `#[serde(default, alias = "hiddenIfEmpty")]` on `KV::hidden_if_empty`
- `KVS(pub Vec<KV>)`
- `Config(pub HashMap<String, HashMap<String, KVS>>)`
- `KVS::new`, `get`, `lookup`, `is_empty`, `keys`, `insert`, and `extend`
- `Config::new`, `get_value`, `set_defaults`, `marshal`, `unmarshal`, and
`merge`
- `Config::new()` default application after `ecstore::config::init()`
- existing persisted server-config JSON shape
- existing target, notify, audit, scanner, OIDC, and admin interpretation of
`Config` and `KVS`
## Next PR Requirements
`CFG-003` should be a pure model extraction or narrow `api-extraction` PR. It
must not migrate consumers, change persistence helpers, or alter runtime
behavior.
`CFG-004` kept the old `rustfs_ecstore::config::*` path as a temporary
compatibility shim, registered its removal condition, and removed the shim after
all in-repo consumers migrated.
`CFG-005` should migrate external consumers one group at a time after the model
and compatibility path are stable.
`CFG-008` moves only the global server-config snapshot accessors to
`rustfs-config` and migrates in-repo direct consumers. It must not move
`ConfigSys`, storage-class global state, persistence helpers, default
registration wiring, startup order, or storage behavior.
## Verification Gate
Before pushing an extraction PR, run:
- serde roundtrip tests for old and new paths
- tests for `hiddenIfEmpty` alias compatibility
- tests for `KVS` insertion, lookup, extension, and keys behavior
- tests for `Config::new`, `set_defaults`, `marshal`, `unmarshal`, and `merge`
- a cleanup scan proving in-repo consumers no longer use the old
`rustfs_ecstore::config::{Config, KV, KVS}` model path before removing the
compatibility shim
- `cargo tree -p rustfs-config --edges normal`
- `cargo tree -p rustfs-ecstore --edges normal`
- `./scripts/check_layer_dependencies.sh`
- `./scripts/check_architecture_migration_rules.sh`
- `cargo fmt --all --check`
- `make pre-commit`
## Non-Goals
- No consumer migration in `CFG-002`.
- No code movement in `CFG-002`.
- No new crate in `CFG-002`.
- No `com.rs` or `StorageAPI` movement in the first model extraction.
- No global server-config state migration until the model path is stable.
+264 -40
View File
@@ -1,7 +1,7 @@
# Crate Boundaries And Migration Guardrails
**Use this when:** you add a crate dependency, move code across crates, touch a `storage_api.rs` boundary file, or need the change-type vocabulary the architecture guard enforces.
**Source of truth:** `scripts/check_architecture_migration_rules.sh` (the enumerated rules; this file is its boundary document) and `scripts/check_layer_dependencies.sh` (layer and edge checks). Extend those guards instead of adding a parallel system.
These rules apply to architecture-migration PRs linked to
[`rustfs/backlog#660`](https://github.com/rustfs/backlog/issues/660).
## PR Types
@@ -22,51 +22,275 @@ Do not mix directory movement, security tightening, and behavior changes in one
## Dependency Direction
Contract crates stay below implementation crates. Forbidden edges:
Contract crates must stay below implementation crates. Initial forbidden edges:
| Edge | Why |
|---|---|
| `storage-api -> ecstore` | Storage contracts must not depend on the storage implementation |
| `security-governance -> rustfs` | Governance contracts stay below the binary crate |
| `extension-schema -> rustfs` | The extension schema is consumed by the binary, never the reverse |
| `extension-schema -> ecstore` | The extension schema must not reach storage internals |
- `storage-api -> ecstore`
- `security-governance -> rustfs`
- `extension-schema -> rustfs`
- `extension-schema -> ecstore`
- `rustfs-storage-api` exposes storage-facing replication status/state contracts only through `crates/storage-api/src/replication.rs`, so its temporary dependency on `rustfs-filemeta` wire types stays centralized and no `rustfs-replication` / `rustfs-storage-api` cycle appears.
- Leaf crates (`config`, `credentials`, `crypto`, `io-metrics`, `madmin`) may not depend on other `rustfs-*` crates, in either TOML spelling, except the adjudicated edges pinned in the guard's leaf allowlist: `io-metrics -> rustfs-s3-ops` (pure contract crates sharing the `S3Operation` vocabulary) and `madmin -> rustfs-signer` (the SigV4-signed admin SDK client). A new leaf exception must be a pure contract dependency (types and enums only, no I/O, no globals, no non-contract internal dependencies) and land together with its allowlist entry.
- Compile-time source reads follow the same direction: `include_str!` / `include!` of a `.rs` file must not resolve outside the including crate (`scripts/check_layer_dependencies.sh`). Shared source-text expectations belong in a contract surface such as `rustfs_protos::compat_manifest` (`crates/protos/src/compat_manifest.rs`) and are asserted by each owning crate.
`rustfs-storage-api` may only expose storage-facing replication status/state
contracts through `crates/storage-api/src/replication.rs` while the underlying
wire types still live in `rustfs-filemeta`. This keeps the temporary dependency
centralized until those wire contracts can move without introducing a
`rustfs-replication` / `rustfs-storage-api` cycle.
## ECStore Access Boundary
Leaf crates carry exactly one adjudicated allowed edge:
`io-metrics -> rustfs-s3-ops` (transitively `rustfs-s3-types`). Both are pure
contract crates — types and enums only, no I/O, no global state, no non-contract
internal dependencies — so `io-metrics` reuses the `S3Operation` vocabulary
instead of copying it. `madmin` is no longer counted a leaf: since #6166 it is
the SigV4-signed admin SDK client and deliberately depends on `rustfs-signer`;
the guard pins its internal dependency surface to exactly that edge so it cannot
quietly grow storage-side dependencies. The leaf-crate allowlist in
`scripts/check_architecture_migration_rules.sh` fails any other `rustfs-*`
dependency in `config`, `credentials`, `crypto`, `io-metrics`, or `madmin`, in
either TOML spelling (`rustfs-x = ...` or `rustfs-x.workspace = true`).
Adjudicated in
[`rustfs/backlog#1834`](https://github.com/rustfs/backlog/issues/1834); a further
leaf exception must meet the pure-contract criterion — types and enums only, no
I/O, no globals, no non-contract internal dependencies — and land its guard
allowlist entry alongside the dependency.
Outer crates reach ECStore only through `rustfs_ecstore::api`, and only from one local boundary file per owner (`storage_api.rs`). Boundary files and facade groups are inventoried in [ecstore-api-facade-inventory.md](ecstore-api-facade-inventory.md).
Dependency direction also applies to compile-time source reads:
`include_str!`/`include!` of a `.rs` file must not resolve outside the
including crate's own directory (`scripts/check_layer_dependencies.sh`
enforces this). A source-text tripwire belongs in the crate that owns the
asserted file; shared expectations move into a contract surface such as
`rustfs_protos::compat_manifest` and are asserted by each owning crate.
- Inside a boundary file, raw `rustfs_ecstore::api::...` paths are centralized behind local `ecstore_*` module aliases; code outside the boundary sees local type aliases, constants, traits, or wrapper functions, never the raw facade path.
- Non-trait ECStore surfaces (metadata, object-lock, lifecycle journal, monitor, notification types) stay behind local aliases; boundary function signatures do not expose raw ECStore facade types once narrowed. Object and error aliases anchor on storage-api associated object types and a local `StorageError`.
- Outer consumers use `rustfs-storage-api` operation traits (`ObjectIO`, `ObjectOperations`, `ListOperations`, `MultipartOperations`, `HealOperations`, `NamespaceLocking`) and generic list responses (`ListObjectsV2Info`, `ListObjectVersionsInfo`, `ObjectInfoOrErr`) directly; ECStore keeps concrete aliases only for internal implementation and compatibility.
- Bucket lifecycle, replication, versioning, object-lock, restore-request, disk, RPC peer client, and warm-backend trait methods are reached through owner-local compatibility traits or wrapper functions, not by importing ECStore traits outside the boundary.
- The old `StorageAPI` aggregate facade must not reappear in production `crates/ecstore/src` or `rustfs/src` code.
- Facade-covered ECStore root modules (layout, `endpoints`, `disks_layout`, bitrot, erasure, object DTO/reader, event, list, batch processor, `global`) stay crate-private; public access goes through the matching `rustfs_ecstore::api::*` group.
- Cluster control-plane read models stay owned by the crate-private `cluster` module and are published through `rustfs_ecstore::api::cluster`; pool-state, local-node storage, and peer-health projections are read-only.
- RustFS startup internals are crate-private: only `startup_entrypoint` is a public startup module of the `rustfs` library (`rustfs/src/lib.rs`), and items inside the other `startup_*` modules use crate visibility.
- The observability dependency baseline is [obs-ecstore-dependency-inventory.md](obs-ecstore-dependency-inventory.md); observability extraction updates it together with the guard.
Existing migration checks live in:
## Loss-Prevention Coverage
- `scripts/check_layer_dependencies.sh`
- `scripts/check_architecture_migration_rules.sh`
The guard pins specific public re-export lines (its `require_source_line` entries) so contract surfaces cannot silently disappear during cleanup. The canonical lists are the guard script and the owning files, not this page:
- `crates/storage-api/src/lib.rs`: admin, bucket, capability, error, multipart, observability, object, and topology contract re-exports;
- `crates/concurrency/src/lib.rs`: workload admission contract re-exports;
- `rustfs/src/lib.rs`: `pub mod startup_entrypoint;`.
ECStore keeps compile-time coverage for `StorageAdminApi`, `HealOperations`, and the separate `NamespaceLocking` operation group (`crates/ecstore/tests/ecstore_contract_compat_test.rs`), and its internal consumers use the `rustfs-storage-api` lifecycle DTOs `ExpirationOptions` and `TransitionedObject` directly.
## Temporary Compatibility Code
Every temporary compatibility path carries a `RUSTFS_COMPAT_TODO(<id>)` source marker with a removal condition and a matching entry in [compat-cleanup-register.md](compat-cleanup-register.md); the guard enforces the match in both directions. Compatibility layers are deleted in their own cleanup change, never bundled with new migration logic.
## Config Model
The server-config model (`Config`, `KV`, `KVS`) and the global server-config snapshot accessors are owned by `rustfs_config::server_config`; ECStore keeps persistence, storage-class state, and startup wiring, and its public facades must not re-export those symbols. See [config-model-boundary-adr.md](config-model-boundary-adr.md).
Extend these guardrails instead of adding a parallel system.
## Required Architecture Documents
The guard requires the documents and section headings listed in its `require_source_contains` entries (`scripts/check_architecture_migration_rules.sh`); the directory index is [README.md](README.md).
The migration guard must keep these baseline documents present and anchored to
their required sections:
- `docs/architecture/overview.md`: Baseline, Core Principle, Phase Order.
- `docs/architecture/runtime-lifecycle.md`: Startup And Readiness, Shutdown
Lifecycle Boundary, AppContext Foundation.
- `docs/architecture/storage-control-data-plane.md`: Storage API Contracts,
Cluster Control Plane, Background Controllers.
- `docs/architecture/crate-boundaries.md`: PR Types, Dependency Direction,
Required Architecture Documents.
- `docs/architecture/readiness-matrix.md`: Request Behavior Matrix, Runtime
Dependency Matrix, Probe Semantics.
- `docs/architecture/global-state-crate-split-plan.md`: Remaining Global
Owners, Runtime Source Boundaries, Fallback Removal Plan, Crate Split
Evaluation.
## Pre-Push Expert Review
Before pushing any PR branch, record three expert reviews in the task notes:
| Expert | Required focus |
|---|---|
| Quality/architecture | Structure, naming, dependency direction, PR type, scope, and over-abstraction risk |
| Migration preservation | Startup order, readiness, quorum, reader semantics, AppContext/global fallback, notify/audit lifecycle, IAM/KMS boundaries, and compatibility |
| Testing/verification | Focused tests, regression tests, commands run, missing coverage, and whether tests are forcing business-logic drift |
Push is allowed only when all three experts return `pass` or
`pass-with-nonblocking-follow-up`. Any `blocker` prevents push until the issue is
fixed and the relevant review is repeated.
## Temporary Compatibility Code
Temporary compatibility code that must be removed later must include a searchable
source comment and a cleanup-register entry.
Use this source-comment format:
```rust
// RUSTFS_COMPAT_TODO(API-005): keep old ecstore::store_api path during storage-api migration. Remove after all consumers use rustfs-storage-api.
```
Rules:
- Add the marker only to temporary compatibility paths, not permanent APIs.
- Include the task ID in the marker.
- State why the compatibility path exists and when it can be removed.
- Use this for temporary re-exports, wrappers, fallbacks, legacy action mappings,
and old endpoint compatibility layers.
- Delete compatibility layers in their own cleanup PR.
## Config Model First
`ecstore::config::{Config, KV, KVS}` should move before extension config adapters
or config-schema work. First inventory consumers, then decide whether existing
`crates/config` is enough or whether a smaller model crate is required.
The current decision is recorded in
[`config-model-boundary-adr.md`](config-model-boundary-adr.md): use the existing
`rustfs-config` package for the pure server-config model and global
server-config snapshot accessors, while ECStore keeps config persistence,
storage-class global state, default wiring, and startup initialization.
The old `rustfs_ecstore::config::{Config, KV, KVS, register_default_kvs,
get_global_server_config, set_global_server_config}` compatibility path must
not be restored after the Phase 1a cleanup. Consumers use
`rustfs_config::server_config` for the moved model and accessors; ECStore public
facades must not re-export those symbols.
## Loss-Prevention Coverage
Architecture migration checks must keep public contract re-exports and ECStore
compatibility coverage from silently drifting during cleanup PRs.
Required `rustfs-storage-api` public re-exports:
- `pub use admin::{DiskSetSelector, StorageAdminApi};`
- `pub use bucket::{BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp};`
- `pub use capability::{CapabilitySnapshotError, CapabilityState, CapabilityStatus};`
- `pub use error::{StorageErrorCode, StorageResult};`
- `pub use multipart::{CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartUploadResult, PartInfo};`
- `pub use observability::{MemorySamplingState, ObservabilitySnapshot, ObservabilitySnapshotProvider, PlatformSupport, UserspaceProfilingCapability};`
- `pub use object::{HTTPPreconditions, HTTPRangeError, HTTPRangeSpec, ObjectLockRetentionOptions};`
- `pub use object::{ExpirationOptions, TransitionedObject};`
- `pub use object::{HealOperations, MultipartOperations, NamespaceLocking, ObjectIO, ObjectOperations};`
- `pub use object::{ListObjectVersionsInfo, ListObjectsInfo, ListObjectsV2Info, ListOperations, ObjectInfoOrErr};`
- `pub use object::{ObjectPreconditionError, ObjectPreconditionPart, ObjectPreconditionState};`
- `pub use object::{VersionMarker, WalkOptions, WalkVersionsSortOrder};`
- `pub use topology::{DiskCapabilities, TopologyCapabilities, TopologyDisk, TopologyLabels, TopologyPool, TopologySet, TopologySnapshot, TopologySnapshotProvider};`
Required `rustfs-concurrency` public workload admission contract re-exports:
- `pub use workload::{AdmissionState, WorkloadAdmissionRegistrySnapshot, WorkloadAdmissionSnapshot, WorkloadAdmissionSnapshotProvider, WorkloadClass};`
ECStore must keep compile-time coverage for `StorageAdminApi`, `HealOperations`,
and the separate `NamespaceLocking` operation group.
The old `StorageAPI` aggregate facade must not reappear in production
`crates/ecstore/src` or `rustfs/src` code after the storage operation groups
have been made explicit.
Outer RustFS/IAM consumers must use `rustfs-storage-api` generic list response
contracts directly for `ListObjectsV2Info`, `ListObjectVersionsInfo`, and
`ObjectInfoOrErr`; ECStore keeps the concrete aliases only for internal
implementation and compatibility.
Outer RustFS/scanner consumers must use `rustfs-storage-api` operation traits
directly for `ObjectIO`, `ObjectOperations`, `ListOperations`,
`MultipartOperations`, `HealOperations`, and `NamespaceLocking`; ECStore keeps
the concrete compatibility traits only for internal implementation and
downstream compatibility.
Outer consumers must not import ECStore directly outside compatibility
boundaries except for temporary trait imports needed for method resolution or
local test trait implementations. Non-trait ECStore surfaces must stay behind
local aliases, constants, or wrapper functions.
Outer compatibility boundary modules must use `rustfs_ecstore::api` for ECStore
public facade surfaces such as layout, storage owner, admin, metrics,
notification, capacity, bucket/config helpers, disk/error contracts, global
state accessors, RPC constants/clients, reader helpers, tier helpers, and
rebalance status contracts. Any non-ECStore `storage_compat.rs` import from
`rustfs_ecstore` must route through the `rustfs_ecstore::api` facade.
The legacy ECStore root `endpoints` and `disks_layout` compatibility modules
must remain crate-private; public layout access goes through
`rustfs_ecstore::api::layout`.
Facade-covered ECStore root modules must remain crate-private after this
boundary is established; outer crates should use `rustfs_ecstore::api::*`
instead of legacy root module paths. This includes storage/layout surfaces as
well as remaining bitrot, erasure coding, object DTO/reader, event, list, and
batch processor root modules once their facade groups exist.
ECStore root `global` re-exports must also stay removed once consumers use
`rustfs_ecstore::api::global` or crate-internal `crate::global` paths.
RustFS root `storage_compat.rs` must expose bucket metadata and quota contracts
as explicit aliases only. Broad `metadata`, `metadata_sys`, and `quota` module
passthroughs are reserved to narrower app/admin/storage compatibility
boundaries that still need module-local owner cleanup.
Root runtime storage config initialization and disk endpoint contracts must also
stay explicit aliases. The root compatibility boundary must not restore `com`,
bare `init`, or grouped `endpoint::Endpoint` passthroughs.
RustFS root `storage_compat.rs` must not re-export ECStore API symbols directly;
remaining root runtime compatibility symbols must be local type aliases,
constants, traits, or wrapper functions so ownership stays visible at the
boundary.
RustFS admin `storage_compat.rs` must expose config IO and default
initialization through explicit aliases. The admin compatibility boundary must
not restore broad `com` or bare `init` passthroughs.
RustFS admin and app `storage_compat.rs` bucket-facing compatibility contracts
must stay explicitly whitelisted. They must not restore broad bucket module,
client object API, client transition API, or storage-class module passthroughs
once a local compatibility boundary has narrowed them to specific aliases.
RustFS storage `storage_compat.rs` must expose bucket metadata, object-lock,
policy, replication, tagging, versioning, object API, and test-only
storage-class config contracts through explicit aliases. The storage
compatibility boundary must not restore broad `metadata`, `metadata_sys`,
`object_lock`, `policy_sys`, `replication`, `tagging`, `utils`, `versioning`,
`versioning_sys`, `object_api_utils`, or `com` passthroughs.
RustFS storage owner `storage_compat.rs` must not re-export ECStore API symbols
directly except temporary trait imports needed for method resolution. Remaining
storage-owner compatibility symbols must be local constants, type aliases, or
wrapper functions so storage-owned global state and helper access stays visible
at the boundary.
RustFS app, admin, and storage outer `storage_compat.rs` object and error
facade aliases must stay anchored on storage-api associated object types and
local `StorageError` aliases. They must not reintroduce raw
`rustfs_ecstore::api::object::{ObjectInfo,ObjectOptions}` or
`rustfs_ecstore::api::error::{Error,Result}` references.
Outer compatibility function signatures must also use local aliases for ECStore
metadata, object-lock, lifecycle journal, monitor, and notification facade
types. The boundary may define the local alias, but call signatures must not
expose the raw ECStore facade path once narrowed.
The RustFS storage owner compatibility boundary must keep raw ECStore facade
paths centralized behind local `ecstore_*` module aliases rather than scattering
`rustfs_ecstore::api::...` references through its aliases and wrappers.
The RustFS app/admin storage compatibility boundaries must likewise route raw
ECStore facade access through their local `ecstore_*` module aliases instead of
scattering `rustfs_ecstore::api::...` paths through compatibility wrappers.
Peripheral consumer storage compatibility boundaries must follow the same
pattern. IAM, heal, scanner, notify, observability, Swift, S3 Select, test, and
fuzz storage compatibility modules keep raw ECStore facade access centralized
behind local `ecstore_*` module aliases.
RustFS root runtime and e2e storage compatibility boundaries must follow the
same pattern, keeping raw ECStore facade access centralized behind local
`ecstore_*` module aliases.
Outer bucket lifecycle, replication, versioning, object-lock, and
restore-request trait method access must stay behind local compatibility traits
or wrapper functions. Non-compat sources must not import those ECStore bucket
API traits directly after the wrapper boundary is established. Disk, RPC peer
client, and warm-backend method-resolution access must follow the same pattern:
non-compat sources use owner-local compatibility traits or test aliases instead
of importing ECStore traits directly.
Scanner, notify, observability, and e2e `storage_compat.rs` boundaries must
also stay narrow. Scanner must not restore grouped bucket compatibility exports
for target, lifecycle, metadata, replication, or versioning modules. Notify
must not restore broad `config`/`global` module imports. Observability must
consume data usage through a local DTO projection instead of re-exporting the
ECStore data-usage loader. The e2e harness must not restore grouped RPC
passthroughs.
Test and fuzz `storage_compat.rs` harnesses must also stay narrow. Heal and
scanner test harnesses must expose ECStore contracts through direct aliases or
local wrappers, and fuzz harnesses must wrap bucket utility entrypoints instead
of restoring grouped ECStore passthrough exports.
External ECStore API facade imports must stay inside local `storage_api`
boundary files after the external runtime, test, and fuzz consumers have been
narrowed. IAM, heal, scanner, notify, observability, Swift, S3 Select, e2e, and
fuzz code must not reintroduce direct `rustfs_ecstore::api::...` references
outside those boundary files.
The observability ECStore dependency baseline is tracked in
[`obs-ecstore-dependency-inventory.md`](obs-ecstore-dependency-inventory.md);
future observability extraction PRs must update that inventory with the guard.
ECStore ClusterControlPlane read models must stay owned by the crate-private
`cluster` module. Public access goes through `rustfs_ecstore::api::cluster` so
outer crates cannot depend on ECStore root control-plane internals.
Pool-state, local-node storage, and peer-health status projections are part of
the same facade boundary and must remain read-only until a later controller
slice explicitly wires dynamic health or membership behavior.
RustFS startup internals must stay crate-private after the startup owner split.
Only `startup_entrypoint` remains a public startup module for the binary
entrypoint; IAM bootstrap, optional runtime, and profiling startup shims must
not be re-exported as public library modules. Items inside crate-private
startup modules must also use crate visibility rather than bare public
visibility.
ECStore internal consumers must use `rustfs-storage-api` lifecycle helper DTOs
directly for `ExpirationOptions` and `TransitionedObject`; ECStore keeps the
old lifecycle paths only as downstream compatibility re-exports.
+268 -54
View File
@@ -1,31 +1,47 @@
# Decommission Compatibility Scope
**Use this when:** you change pool decommission or rebalance behavior, its admin API shape, the persisted `PoolMeta` decommission fields, or how tier free versions move between pools.
**Source of truth:** `crates/ecstore/src/core/pools.rs` (queue, recovery, cleanup predicates), `crates/ecstore/src/services/rebalance/worker.rs` (rebalance predicates), `rustfs/src/admin/handlers/pools.rs` plus the `pools/*` rows of `rustfs/src/admin/route_policy.rs` (admin surface), `crates/ecstore/src/data_movement/` and `crates/ecstore/src/set_disk/` (free-version movement).
This note records the current RustFS decommission contract for admin/API
compatibility reviews.
## Current Contract
RustFS supports queued multi-pool decommission start requests on multi-pool deployments. The admin handler accepts the MinIO-compatible request shape, including comma-separated pool targets. An empty target list is rejected; single-pool deployments reject decommission because there is no destination pool; on multi-pool deployments one or more valid target pools are accepted as a single queued operation.
RustFS supports queued multi-pool decommission start requests on multi-pool
deployments.
The admin handler accepts the request shape used by the MinIO-compatible admin
API, including comma-separated pool targets. An empty target list is rejected.
Single-pool deployments reject decommission because there is no destination pool.
On multi-pool deployments, one or more valid target pools are accepted as a
single queued operation.
### Request Semantics
`POST /v3/pools/decommission` with comma-separated pool targets is a queue submission:
`POST /v3/pools/decommission` with comma-separated pool targets is treated as a
queue submission:
- validate all requested pool identifiers before mutating metadata;
- reject duplicate target pools in the same request;
- reject active or queued target pools;
- reject completed decommission targets, because completion means the pool can be removed from the deployment configuration;
- reject completed decommission targets because completion means the pool can be
removed from the deployment configuration;
- allow failed or canceled targets to be retried;
- persist queued metadata before starting workers;
- start only the local-leader prefix of the queue on the receiving node.
The local-leader-prefix rule keeps the active worker on the leader for the pool being moved while still allowing a request to contain later targets whose leaders are different nodes. Later queued targets are recovered or promoted by the leader that owns that target.
The local-leader-prefix rule keeps the active worker on the leader for the pool
being moved while still allowing a request to contain later targets whose leaders
are different nodes. Later queued targets are recovered or promoted by the
leader that owns that target.
Start, cancel (`POST /v3/pools/cancel`), and clear (`POST /v3/pools/clear`) requests may arrive on any cluster node. When the target pool's first endpoint is remote, RustFS forwards the operation over the authenticated internode RPC channel to that endpoint; the receiving node still enforces the local-leader rule before mutating decommission state.
Admin start, cancel, and clear requests may arrive on any cluster node. When the
target pool first endpoint is remote, RustFS forwards the operation over the
authenticated internode RPC channel to that first endpoint. The receiving node
still enforces the local-leader rule before mutating decommission state.
### Persisted Metadata Shape
The queue is persisted in pool metadata and decoded with the rest of `PoolMeta`. Each pool entry can distinguish:
The queue is persisted in pool metadata and decoded with the rest of
`PoolMeta`. Each pool entry can distinguish:
- `active`: at most one pool currently moving data;
- `queued`: validated pools waiting for the active entry to finish;
@@ -33,112 +49,310 @@ The queue is persisted in pool metadata and decoded with the rest of `PoolMeta`.
- `failed`: pools whose worker reached terminal failure;
- `canceled`: pools canceled before or during execution.
Legacy metadata without queue fields decodes as a non-queued decommission entry, preserving restart behavior for already deployed clusters.
Legacy metadata without queue fields decodes as a non-queued decommission entry,
preserving restart behavior for already deployed clusters.
### Serial Scheduling And Recovery
Only one queued entry may own a decommission worker at a time. Startup recovery:
- loads pool metadata before rebalance recovery;
- computes the resumable entries with `resumable_decommission_queue_indices` (`crates/ecstore/src/core/pools.rs`): every pool that has decommission state and is not terminal (`complete`, `failed`, or `canceled`). Terminal predecessors are skipped, not treated as barriers, so a queued pool behind a failed or canceled attempt is still resumable (`test_resumable_decommission_queue_indices_skip_terminal_predecessors`);
- starts workers only for the local-leader prefix of those entries; later queued pools stay out of worker scheduling until promotion while their state remains visible in admin status.
- resumes the first local non-terminal active/queued entry;
- skips a durably completed prefix and promotes the next queued entry only after
successful completion;
- treats failed or canceled terminal entries as an automatic-promotion barrier,
leaving later queued pools visible but stopped until an operator retries,
clears, or otherwise resolves the terminal entry;
- keeps queued pools out of active worker scheduling until promotion, while still
making their future state visible in admin status.
Promotion is persisted before worker execution. If cancellation is already requested immediately after promotion, RustFS persists a canceled terminal state instead of leaving the promoted pool active without a worker.
Promotion is persisted before worker execution. If cancellation is already
requested immediately after promotion, RustFS persists a canceled terminal state
instead of leaving the promoted pool active without a worker.
### Cancel Semantics
Cancel separates active and queued behavior:
- canceling the active entry requests worker cancellation and persists terminal metadata;
- canceling the active entry requests worker cancellation and persists terminal
metadata;
- canceling a queued entry marks that entry canceled before it becomes active;
- failed or canceled terminal entries can be cleared explicitly (`POST /v3/pools/clear`) when the operator abandons the decommission attempt;
- peer reload failures during cancel are surfaced in status and logs.
- failed or canceled terminal entries can be cleared explicitly when the operator
chooses to abandon the decommission attempt;
- peer reload failures during cancel must be surfaced in status and logs.
Cancel requests can be accepted on non-leader nodes as remote cancel intent; the leader observes the pending cancel and applies it to the active worker.
Cancel requests can be accepted on non-leader nodes as remote cancel intent; the
leader observes the pending cancel and applies it to the active worker.
### Status Response Shape
`GET /v3/pools/list` and `GET /v3/pools/status?pool=...` expose per-pool machine-readable decommission state. The `status` field can report `active`, `running`, `queued`, `complete`, `failed`, or `canceled`.
`GET /v3/pools/list` and `GET /v3/pools/status?pool=...` expose per-pool
machine-readable decommission state. The `status` field can report `active`,
`running`, `queued`, `complete`, `failed`, or `canceled`.
When decommission metadata is present, `decommissionInfo` includes:
- queue and terminal flags: `queued`, `complete`, `failed`, `canceled`;
- progress counters: `objectsDecommissioned`, `objectsDecommissionedFailed`, `bytesDecommissioned`, and `bytesDecommissionedFailed`;
- progress counters: `objectsDecommissioned`,
`objectsDecommissionedFailed`, `bytesDecommissioned`, and
`bytesDecommissionedFailed`;
- current location: `bucket`, `prefix`, and `object`;
- queue/history lists: `queuedBuckets` and `decommissionedBuckets`;
- `waitingReason`: `queued` for queued entries and `waiting_for_worker` when metadata exists but no worker has started.
- `waitingReason`, currently `queued` for queued entries and
`waiting_for_worker` when metadata exists but no worker has started.
This makes queued pools and stalled metadata visible without requiring operators to inspect pool metadata files directly.
This makes queued pools and stalled metadata visible without requiring operators
to inspect pool metadata files directly.
## MinIO Divergence Decisions
Behavior that is close to MinIO but not byte-for-byte identical. Changing either decision requires an operator compatibility note and updated characterization tests.
This section records the current product decisions for behavior that is close to
MinIO but not always byte-for-byte identical.
### Empty Delete Markers
MinIO decommission documentation states that empty delete markers (delete markers with no successor object versions) are not transitioned to another pool. RustFS follows that behavior for decommission when the bucket has no replication configuration: a lone remaining delete marker is cleanup-only metadata and is skipped. When replication is configured, RustFS keeps the delete marker eligible for movement so delete-marker replication and purge state are not lost.
MinIO decommission documentation states that empty delete markers, meaning delete
markers with no successor object versions, are not transitioned to another pool.
Rebalance uses the same predicate as decommission (`should_skip_decommission_delete_marker` in `crates/ecstore/src/core/pools.rs`, `should_skip_rebalance_delete_marker` in `crates/ecstore/src/services/rebalance/worker.rs`), even though MinIO's public documentation calls out the decommission case more explicitly than the rebalance case.
RustFS follows that behavior for decommission when the bucket has no replication
configuration: a lone remaining delete marker is treated as cleanup-only metadata
and is skipped. When replication is configured, RustFS intentionally keeps the
delete marker eligible for movement so delete-marker replication and purge state
are not lost.
RustFS rebalance uses the same predicate as decommission: skip only a lone delete
marker without replication. This is intentional even though MinIO's public
documentation calls out the decommission case more explicitly than the rebalance
case.
Regression guards:
- `should_skip_decommission_delete_marker_characterizes_empty_marker_without_replication`
- `should_skip_decommission_delete_marker_characterizes_replication_configured`
- `test_should_skip_rebalance_delete_marker_characterizes_empty_marker_without_replication`
- `test_should_skip_rebalance_delete_marker_characterizes_replication_configured`
### Lifecycle-Expired Versions During Cleanup
MinIO decommission ignores versions already expired by lifecycle rules. RustFS applies the same rule to decommission and rebalance: a source entry is cleanup-complete when moved versions plus safely expired versions equal the total version count (`should_cleanup_decommission_source_entry` in `crates/ecstore/src/core/pools.rs`, `should_cleanup_rebalance_source_entry` in `crates/ecstore/src/services/rebalance/worker.rs`). Versions retained by object lock or pending replication are not counted as safely expired by the callers, so an entry with such versions is retained. Both predicates accept an entry whose versions are all lifecycle-expired (`test_should_cleanup_decommission_source_entry_accepts_versions_only_safely_expired_by_lifecycle`, `test_should_cleanup_rebalance_source_entry_accepts_versions_only_expired_by_lifecycle`).
MinIO decommission ignores versions that are already expired by lifecycle rules.
RustFS follows that decommission behavior by allowing safely expired versions to
count toward source cleanup completion.
RustFS rebalance is intentionally stricter. Expired versions do not prove that a
target pool received an equivalent version, so rebalance cleanup requires actual
rebalance completion for the source entry instead of treating lifecycle-expired
versions as moved.
Regression guards:
- `test_should_cleanup_decommission_source_entry_accepts_migrated_and_safely_expired_versions`
- `test_should_cleanup_decommission_source_entry_accepts_versions_only_safely_expired_by_lifecycle`
- `test_should_cleanup_rebalance_source_entry_rejects_versions_only_expired_by_lifecycle`
No migration step is required for these decisions because this note documents the
current RustFS behavior. Changing either decision later requires an operator
compatibility note and updated characterization tests.
## Tier Free Versions During Decommission
A tier free version is an internal xl.meta record (`rustfs_filemeta::FREE_VERSION`, flagged `XL_FLAG_FREE_VERSION`) shaped like a delete marker. It is created by `MetaObject::init_free_version` when a version whose remote transition completed is deleted locally: the visible version is removed and the record keeps the remote-tier identity (tier, object name, version id, state, destination id) needed for an idempotent remote delete. Free versions are not user-visible versions; `num_versions` and all listing/GET paths exclude them.
A tier free version is an internal xl.meta record (`rustfs_filemeta::FREE_VERSION`,
flagged `XL_FLAG_FREE_VERSION`) shaped like a delete marker. It is created by
`MetaObject::init_free_version` when a version whose remote transition completed is
deleted locally: the visible version is removed and the record keeps the remote-tier
identity (tier, object name, version id, state, destination id) needed for an
idempotent remote delete. Free versions are not user-visible versions; `num_versions`
and all listing/GET paths exclude them.
### Lifecycle And Consumers
Creation: a local delete that removes a version whose transition status is `complete` normally appends the record via `MetaObject::delete_version``init_free_version` (skipped only when `skip_tier_free_version` is set, as on data-movement copies). User-facing single and batch deletes always retain that historical owner when they actually remove a transitioned source; they do not create a tier journal, probe a fleet capability, or issue a peer mutation RPC. `TransitionVersionState::Unknown` and incomplete destination identities stay on the same conservative free-version path. Delete-marker creation on an Enabled bucket is unchanged and does not schedule remote deletion.
Creation: a local delete that removes a version whose transition status is
`complete` normally appends the record via `MetaObject::delete_version`
`init_free_version`. User-facing single and batch deletes always retain that
historical owner when they actually remove a transitioned source; they do not
create a tier journal, probe a fleet capability, or issue a peer mutation RPC.
`TransitionVersionState::Unknown` and incomplete destination identities remain
on the same conservative free-version path. Delete-marker creation on an Enabled
bucket remains unchanged and does not schedule remote deletion.
Recursive prefix/delete-all cannot preserve per-object markers across its physical directory purge, so it requires a v6 recoverable journal for every transitioned visible source plus a durable dispatch manifest for the whole operation. It fails closed before mutation on legacy metadata or on any existing hidden tier free-version under the prefix. Its internal streaming walk discovers logical keys, then exact-loads every key from its authoritative set in every pool, including free versions; the S3 listing merge is never treated as a complete physical-owner inventory. Tier-operation leases stay held from that preflight through journal prepare and physical deletion. Once physical deletion starts, any error is mutation-ambiguous: authorized/dispatched journals remain for recovery to commit owners only after all physical sets prove both the source and the exact free-version identity absent; uncertain owners are retained. If a retry discovers a later transitioned source after the manifest reached `DispatchAuthorized`, it replays only the manifest's immutable predecessor set, completes that operation, and leaves the newcomer for a successor dispatch. Operators may retry after the legacy free-version worker has durably completed remote and local cleanup. Journal-less internal deletes and older nodes keep their established marker behavior.
Recursive prefix/delete-all cannot preserve per-object markers across its
physical directory purge, so it requires a v6 recoverable journal for every
transitioned visible source plus a durable dispatch manifest for the complete
operation. It fails closed before mutation on legacy metadata or any existing
hidden tier free-version under the prefix. Its internal streaming walk
discovers logical keys, then exact-loads every key from its authoritative set in
every pool, including free versions; the S3 listing merge is never treated as a
complete physical-owner inventory. Tier-operation leases remain held from that
preflight through journal prepare and physical deletion. Once physical deletion
starts, any error is mutation-ambiguous: authorized/dispatched journals remain
for recovery to commit owners only after all physical sets prove both the source
and exact free-version identity absent; uncertain owners are retained.
If a retry discovers a later transitioned source after the manifest reached
`DispatchAuthorized`, it replays only the manifest's immutable predecessor set,
completes that operation, and leaves the newcomer for a successor dispatch.
Operators may retry after the legacy free-version worker has durably completed
remote and local cleanup. Journal-less internal deletes and older nodes retain
their established marker behavior.
Consumption while the record exists: the background recovery loop started by `init_background_expiry` (spawned by `spawn_tier_free_version_recovery_once`, enabled by default) scans disks for pending records and re-enqueues them; the usage scanner does the same; the lifecycle worker then deletes the remote tier object idempotently and only afterwards removes the local record. Heal walks include free-version records in metadata healing. Transition planning, replication, restore, GET, listings, and usage aggregation never depend on them.
Consumption while the record exists: the background recovery loop started by
`init_background_expiry` (spawned by `spawn_tier_free_version_recovery_once`,
enabled by default) scans disks for pending records and re-enqueues them; the
usage scanner does the same; the lifecycle worker then deletes the remote tier
object idempotently and only afterwards removes the local record. Heal walks
include free-version records in metadata healing. Transition planning,
replication, restore, GET, listings, and usage aggregation never depend on
them.
### Decommission Handling
The exact decommission inventory loader (`load_file_info_versions_exact` via `get_all_file_info_versions`) keeps free-version records inline in `versions`. The migration loop handles them before lifecycle expiry and delete-marker shortcuts. It selects a target pool using the free-version-aware lookup, then writes the original free record to every target disk with the normal metadata write quorum. The free-version marker, local version id, transition identity, transition state, and destination id are preserved at the FileInfo/metadata boundary.
The exact decommission inventory loader (`load_file_info_versions_exact` via
`get_all_file_info_versions`) keeps free-version records inline in `versions`.
The migration loop handles them before lifecycle expiry and delete-marker
shortcuts. It selects a target pool using the free-version-aware lookup, then
writes the original free record to every target disk with the normal metadata
write quorum. The free-version marker, local version id, transition identity,
transition state, and destination id are preserved at the FileInfo/metadata
boundary.
The source record is physically removed only after the target write quorum has committed and the source cleanup preflight still matches the exact inventory. If the lifecycle worker has already completed the remote delete and removed the source record before decommission acquires the source lock, decommission records that identity as already consumed and treats the missing source record as safe. If target capacity, metadata validation, lock fencing, or quorum fails, the source record remains and the entry records `state = "free_version_retained"` with reason `tier_free_version_migration_failed`; the worker retries the operation on a later pass. A target record with the same version id is accepted only when its free-version identity matches; a conflicting ordinary version or different free record is an overwrite error. This makes retries idempotent and prevents a free record from replacing a user-visible version.
`TransitionVersionState::Unknown` records are migrated unchanged rather than discarded; the lifecycle worker retains them if remote identity validation cannot make a delete request. Only an authorized recursive prefix/delete-all v6 transaction may use a per-source journal as the sole retry source; ordinary single/batch deletes never take that path, and a journal discovered alongside an older or fallback free-version never authorizes dropping the xl.meta record.
The source record is physically removed only after the target write quorum has
committed and the source cleanup preflight still matches the exact inventory.
If the lifecycle worker has already completed the remote delete and removed the
source record before decommission acquires the source lock, decommission records
that identity as already consumed and treats the missing source record as safe.
If target capacity, metadata validation, lock fencing, or quorum fails, the
source record remains and the entry records `state = "free_version_retained"`
with reason `tier_free_version_migration_failed`; the worker retries the
operation on a later pass. A target record with the same version id is accepted
only when its free-version identity matches; a conflicting ordinary version or
different free record is an overwrite error. This makes retries idempotent and
prevents a free record from replacing a user-visible version.
### Remote-Tuple Publication Fence
Cross-pool capability v3 adds a commit-late publication contract for every path that can copy an existing transition tuple to a new physical owner. This capability version is independent of the tier-mutation RPC protocol version; a mixed fleet whose minimum cross-pool capability is below v3 cannot authorize journal-v6 remote deletion.
Cross-pool capability v3 includes a commit-late publication contract for every
path that can copy an existing transition tuple to a new physical owner. This
capability version is independent of the tier-mutation RPC protocol version.
A mixed fleet whose minimum cross-pool capability is below v3 cannot authorize
journal-v6 remote deletion.
Data movement captures a non-cloneable, process-local source capability before copying, but it does not hold a namespace write lock or tier-operation lease while reading a large body or uploading multipart parts (`NewMultipartUpload` and `UploadPart` are staging only). Immediately before single-PUT rename, Multipart Complete, or a pure-remote/free-version metadata quorum write, the final consumer acquires the exact tier generation (when a remote tuple exists), then the fixed/source/target write domains in stable order. The fixed domain is used only for a real remote-tuple decommission publisher; an ordinary local object keeps the lighter source/target commit scope.
Data movement captures a non-cloneable, process-local source capability before
copying, but it does not hold a namespace write lock or tier-operation lease
while reading a large body or uploading multipart parts. `NewMultipartUpload`
and `UploadPart` are staging only. Immediately before single-PUT rename,
Multipart Complete, or a pure-remote/free-version metadata quorum write, the
final consumer acquires the exact tier generation (when a remote tuple exists),
then fixed/source/target write domains in stable order. The fixed domain is used
only for a real remote-tuple decommission publisher; an ordinary local object
keeps the lighter source/target commit scope.
While that owned scope is held, the publisher re-reads the exact source pool and compares version, data directory, modification time, ETag, checksums, transition tuple, transition-version state, and destination identity. A missing or changed source, a changed or revoked tier generation, a bucket incarnation change, or a lost lock fails before target rename. The scope stays owned through rename quorum and the rename-tail guard handoff, so recovery-first ordering cannot delete the remote object and then let a stale restored-transitioned rebalance recreate its tuple, and publisher-first ordering makes recovery wait and rescan the newly committed owner.
While that owned scope is held, the publisher re-reads the exact source pool and
compares version, data directory, modification time, ETag, checksums, transition
tuple, transition-version state, and destination identity. A missing or changed
source, changed/revoked tier generation, bucket incarnation change, or lost lock
fails before target rename. The scope remains owned through rename quorum and
the existing rename-tail guard handoff. Consequently, recovery-first ordering
cannot delete the remote object and then have a stale restored-transitioned
rebalance recreate its tuple; publisher-first ordering makes recovery wait and
rescan the newly committed owner.
Full cross-key S3 Copy is not an ownership-sharing operation: it materializes local data and strips transition, destination, transaction, and free-version keys. Same-key metadata/version-only updates preserve the protected state. Admin heal keeps the legacy `nolock` request field for wire compatibility but ignores it as lock authority; final heal writes enter the normal locked path. Restore likewise ignores ambient `ObjectOptions.no_lock`, acquires its own commit-late PUT/Complete lock, validates the restore operation id, and keeps an exact tier generation lease through the local commit.
Full cross-key S3 Copy is not an ownership-sharing operation: it materializes
local data and strips transition, destination, transaction, and free-version
keys. Same-key metadata/version-only updates preserve the existing protected
state. Admin heal keeps the legacy `nolock` request field for wire compatibility
but ignores it as lock authority; final heal writes enter the normal locked
path. Restore similarly ignores ambient `ObjectOptions.no_lock`, acquires its
own commit-late PUT/Complete lock, validates the restore operation id, and keeps
an exact tier generation lease through the local commit.
### Tier Mutation Protocol And Journal v6 Rollout
### Reference-Audit Result
Tier edit/remove/clear reference proof uses the internal walk with `include_free_versions = true`, in addition to persisted journal and transition-transaction checks. Protocol v3 peer Prepare blocks new reference creators and drains existing tier-operation leases before this proof; protocol v4 preserves that state machine and adds a signed failure classification. Abort carries the canonical Prepare intent, so a peer can create an identity-bound `Aborted` tombstone even when Abort overtakes Prepare; a delayed matching Prepare then converges on `Aborted` instead of reinstalling the block, and a conflicting intent with the same mutation id fails closed. The tombstone stays durable until intent expiry plus the configured clock-skew allowance, including across reload and coordinator-record cleanup. After expiry, a missing-record replay of the original signed Prepare is rejected and cannot recreate a peer-only runtime fence. Abort checks an existing same-identity terminal record before consulting mutable current-config proof, and recovery reconstructs the original Prepared revision for Abort fanout.
After migration, user-facing GET/list/transition/replication/restore paths still
exclude the record. Recovery, usage scanning, lifecycle tier cleanup, and heal
continue to see a legacy/fallback record when they request free versions, so an
unresolved remote delete remains actionable on the target pool. Only an
authorized recursive prefix/delete-all v6 transaction may instead use a
per-source journal as the sole retry source; ordinary single/batch deletes never
take that path. A journal discovered
alongside an older or fallback free-version does not authorize dropping the
record. In particular, `Unknown` transition state records are migrated unchanged
rather than discarded: the lifecycle worker retains them if remote identity
validation cannot make a delete request.
A new server accepts both v3 and v4 requests and selects the matching canonical response proof. During a mixed rollout an older v3 server rejects a v4 request with an authenticated, byte-exact unsupported-version status before dispatch; the v4 coordinator treats only that exact rejection as definitely-not-installed, fails the admin mutation, and does not send the peer an incompatible Abort. There is deliberately no automatic v3 retry: `Unimplemented`, near-text, timeouts, missing or unknown failure classes, and other ambiguous outcomes still receive Abort and retain the coordinator retry record if Abort cannot be proven. Operators must pause and drain tier edit/remove/clear operations before starting a rolling upgrade, leave them disabled while any v3-only peer remains, and resume only after every topology member advertises the v4-capable release. Ordinary object I/O and free-version cleanup stay available; `xl.meta` is unchanged by a rejected mutation.
Tier edit/remove/clear reference proof uses the internal walk with
`include_free_versions = true`, in addition to persisted journal and transition
transaction checks. Protocol v3 peer Prepare blocks new reference creators and
drains existing tier-operation leases before this proof; protocol v4 preserves
that state machine and adds a signed failure classification. Abort carries the
canonical Prepare intent, so a peer can create an identity-bound `Aborted`
tombstone even when Abort overtakes Prepare. A delayed matching Prepare then
converges on `Aborted` instead of reinstalling the block; a conflicting intent
with the same mutation id fails closed. The tombstone remains durable until the
intent expiry plus the configured clock-skew allowance, including across reload
and coordinator-record cleanup. After
expiry, a missing-record replay of the original signed Prepare is rejected and
cannot recreate a peer-only runtime fence. Abort checks an existing same-identity
terminal record before consulting mutable current-config proof, and recovery
reconstructs the original Prepared revision for Abort fanout.
Sole-owner transactions use journal v6: v5-and-older readers reject and retain those records, so an old recovery worker cannot bypass the all-pool proof. Older nodes may keep creating fallback free-versions until the rollout is homogeneous. Do not downgrade every v6-aware recovery worker while any v6 record remains; drain the journal first or keep at least one v6-aware worker until cleanup converges.
A new server accepts both v3 and v4 requests and selects the matching canonical
response proof. During a mixed rollout, an older v3 server rejects a v4 request
with an authenticated, byte-exact unsupported-version status before dispatch;
the v4 coordinator treats only that exact rejection as definitely not installed,
fails the admin mutation, and does not send the peer an incompatible Abort.
There is deliberately no automatic v3 retry. `Unimplemented`, near-text,
timeouts, missing/unknown failure classes, and other ambiguous outcomes still
receive Abort and retain the coordinator retry record if Abort cannot be proven.
Operators must pause and drain tier edit/remove/clear operations before starting
the rolling upgrade, leave them disabled while any v3-only peer remains, and
resume only after every topology member advertises the v4-capable release.
Ordinary object I/O and free-version cleanup remain available; xl.meta is
unchanged by the rejected mutation.
### Disposition Events
Sole-owner transactions use journal v6: v5-and-older readers reject and retain
those records, so an old recovery worker cannot bypass the all-pool proof. Older
nodes may continue to create fallback free-versions until the rollout is
homogeneous. A deployment must not downgrade every v6-aware recovery worker
while any v6 record remains; drain the journal first or keep at least one v6-aware
worker until cleanup converges.
Free versions remain internal, so no S3-visible version or admin response field is added. The structured `decommission_entry` events are the operational status surface:
Each migrated record emits `state = "free_version_migrated"` with reason
`tier_free_version_migrated`. A record consumed before migration emits
`state = "free_version_consumed"` with reason
`tier_free_version_already_consumed`. Each failed record emits the retained state
and failure reason above. The entry also emits a disposition summary with
migrated, consumed, retained, and total counts. The final decommission sweep uses
the exact loader, counts free records still present, and emits one retained
record/reason for each unresolved free version before failing the sweep. This
makes successful migration, completed cleanup, and retained cleanup obligations
visible instead of silently omitting free records.
| Outcome | `state` | `reason` |
|---|---|---|
| Record migrated to the target pool | `free_version_migrated` | `tier_free_version_migrated` |
| Record consumed by the lifecycle worker before migration | `free_version_consumed` | `tier_free_version_already_consumed` |
| Migration failed, source retained for retry | `free_version_retained` | `tier_free_version_migration_failed` |
No new S3-visible version or admin response field is needed: free versions remain
internal and are never counted as user-visible versions. The structured
`decommission_entry` events are the operational status surface for the
free-version disposition; the existing decommission item/failed counters still
report the enclosing object migration result.
The entry also emits a disposition summary with migrated, consumed, retained, and total counts. The final decommission sweep uses the exact loader, counts free records still present, and emits one retained record/reason per unresolved free version before failing the sweep. The existing decommission item/failed counters still report the enclosing object migration result.
Regression guard:
## Regression Guards
- `decommission_tier_free_version_preserves_remote_identity`
- `decommission_tier_free_version_resume_requires_write_quorum`
- `decommission_tier_free_version_commit_rejects_lost_fence`
- `test_decommission_cleanup_preflight_accepts_migrated_free_version_consumed_from_source`
- `decommission_entry_skips_cleanup_only_marker_when_free_version_is_present`
- `decommission_entry_rejects_subquorum_free_version_conflict_and_retains_source`
Test names drift; locate the current guards instead of copying them:
## Regression Guard
```bash
rg -n 'fn [a-z_]*decommission[a-z_]*\(' crates/ecstore/src/core/pools.rs crates/ecstore/src/set_disk/mod.rs crates/ecstore/src/data_movement/mod.rs crates/ecstore/src/store/init.rs rustfs/src/admin/handlers/pools.rs rustfs/src/app/admin_usecase.rs
rg -n 'fn test_should_[a-z_]*rebalance[a-z_]*\(' crates/ecstore/src/services/rebalance/rebalance_unit_tests.rs
```
The queued multi-pool contract is guarded by:
- `test_contextualized_decommission_start_request_allows_multiple_target_pools`
- `test_decommission_start_local_leader_allows_remote_queued_pool`
- `test_local_decommission_queue_prefix_stops_at_remote_leader`
- `test_decommission_peer_target_returns_none_for_local_first_endpoint`
- `test_pool_meta_queued_decommission_is_not_suspended_until_promoted`
- `test_pool_meta_promoted_queued_decommission_can_be_canceled`
- `test_first_resumable_decommission_queue_indices_stops_at_failed_or_canceled_state`
- `test_first_resumable_decommission_queue_indices_allows_after_completed_prefix`
- `admin_pool_list_item_exposes_queued_decommission_state`
These tests live in `crates/ecstore/src/core/pools.rs` and
`rustfs/src/app/admin_usecase.rs`.
@@ -1,65 +1,76 @@
# ECStore API Facade Inventory
**Use this when:** you need something from `rustfs_ecstore` in another crate, you are narrowing a `rustfs_ecstore::api` facade group, or the architecture guard reports a facade bypass.
**Source of truth:** `crates/ecstore/src/api/mod.rs` (facade groups), the boundary files listed below, and the facade rules in `scripts/check_architecture_migration_rules.sh`.
The broad `rustfs_ecstore::api` facade is a compatibility boundary, not an architecture target. It shrinks monotonically and only through guarded changes; it is never approval to move lifecycle, replication, or `SetDisks` runtime behavior.
This inventory records the current `rustfs_ecstore::api` compatibility surface
before any ECStore split PR removes or narrows re-exports. It is a planning and
guardrail document only. It must not be used as approval to move lifecycle,
replication, or `SetDisks` runtime behavior.
## Facade Group Inventory
| Facade group | Role | Shrink posture |
| Facade group | Current role | Shrink posture |
|---|---|---|
| `storage`, `layout`, `error`, `runtime`, `cluster`, `rpc` | Compatibility spine for storage, topology, runtime handles, cluster control, and internode calls. | Keep until replacement contracts compile in downstream boundary files. |
| `bucket` | Domain facade consumed through owner-local `storage_api` boundaries; explicit submodules and symbol lists, never whole bucket owner modules. | Keep lists aligned with boundary consumers; never restore whole-module passthroughs. |
| `config`, `disk`, `tier` | Compatibility paths with explicit nested submodules and symbol lists. | Same as `bucket`. |
| `data_usage`, `capacity`, `notification`, `metrics`, `rebalance` | Domain and service facades consumed through owner-local boundaries. | Narrow one group at a time after explicit aliases or wrappers exist. |
| `set_disk`, `object`, `object_api_utils`, `rio`, `bitrot`, `erasure`, `compression`, `cache`, `store_list` | Low-level object IO, reader, erasure, cache, and migration helper compatibility. | Keep stable while `SetDisks` remains the shared state carrier. |
| `admin`, `event`, `global` | Admin, event hook, and bootstrap-global compatibility. | `global` is limited to bootstrap writes and lifecycle controls; read-only runtime access goes through `runtime`. |
The S3 client is no longer a facade group: it lives in `crates/s3-client` (`rustfs_s3_client`). Regenerate the group list with:
```bash
rg -n '^pub mod ' crates/ecstore/src/api/mod.rs
```
| `bucket` | Domain facade consumed through owner-local `storage_api` boundaries. The public API keeps compatibility paths but exposes explicit submodules and symbol lists instead of whole bucket owner modules. | Keep explicit lists aligned with owner-boundary consumers; do not restore whole-module passthroughs. |
| `client`, `config`, `disk`, `tier` | Compatibility paths consumed through owner-local `storage_api` boundaries. The public API keeps existing path names but exposes explicit nested submodules and symbol lists instead of whole owner modules. | Keep explicit lists aligned with owner-boundary consumers; do not restore whole-module passthroughs. |
| `data_usage`, `capacity`, `notification`, `metrics`, `rebalance` | Domain and service facades still consumed through owner-local `storage_api` boundaries. | Narrow one group at a time after explicit aliases or wrappers exist. |
| `set_disk`, `object`, `rio`, `bitrot`, `erasure`, `compression`, `cache`, `store_list` | Low-level object IO, reader, erasure, cache, and migration helper compatibility. | Keep stable while `SetDisks` remains the shared state carrier. |
| `admin`, `event`, `global` | Admin, event hook, and legacy global compatibility. | Keep `global` limited to bootstrap writes and lifecycle controls; read-only runtime access must use runtime-source contracts. |
## External Consumer Boundaries
External `rustfs_ecstore::api` imports stay in these local boundary files:
External `rustfs_ecstore::api` imports must stay in these local boundary files:
| Boundary file | Facade families consumed |
| Boundary file | Current facade families |
|---|---|
| `rustfs/src/storage/storage_api.rs` | Broad storage-owner bridge: admin, bucket submodules, capacity, compression, cluster, config, data usage, disk, error, event, global bootstrap controls, runtime getters, layout, metrics, notification, rebalance, rio, rpc, set disk, storage, tier. Replication pool/stat handles are projected into RustFS-local wrapper types here. |
| `rustfs/src/storage_api.rs`, `rustfs/src/admin/storage_api.rs`, `rustfs/src/app/storage_api.rs` | Root, admin, and app owner boundaries: explicit aliases only, no `metadata`, `metadata_sys`, `quota`, `com`, or bare `init` module passthroughs; object and error aliases anchor on storage-api associated types and a local `StorageError`. |
| `crates/scanner/src/storage_api.rs` | Bucket lifecycle, replication, metadata, capacity, config, data usage, disk, error, runtime, set disk, storage, tier. Replication queue config, admission, and heal object DTOs are projected into scanner-local types. |
| `crates/obs/src/metrics/storage_api.rs` | Bucket bandwidth, lifecycle, replication, quota, capacity, data usage, error, runtime, storage; data usage is consumed as a local DTO projection. |
| `crates/iam/src/storage_api.rs` | Config, error, notification, runtime, storage. |
| `crates/heal/src/heal/storage_api.rs` | Data usage, disk, error, runtime, storage. |
| `crates/notify/src/storage_api.rs` | Config, runtime, storage; no broad `config` or `global` module imports. |
| `crates/protocols/src/swift/storage_api.rs` | Bucket metadata, bucket metadata system, error, runtime, storage. |
| `crates/s3select-api/src/storage_api.rs` | Error, runtime, set disk, storage. |
| `crates/e2e_test/src/storage_api.rs` | E2E harness bridge for bucket targets, disk walking, and RPC helpers; no grouped RPC passthroughs. |
| `crates/ecstore/tests/storage_api.rs`, `crates/heal/tests/storage_api.rs`, `crates/scanner/tests/storage_api/mod.rs`, `fuzz/fuzz_targets/*_storage_api.rs` | Test and fuzz bridges: direct aliases or local wrappers; fuzz harnesses wrap bucket utility entrypoints instead of grouped passthroughs. |
| `crates/test-utils/src/ecstore_test_compat.rs`, `crates/iam/tests/ecstore_test_compat/mod.rs`, `crates/protocols/tests/ecstore_test_compat/mod.rs` | Test-only compatibility harnesses that import the facade directly for fixture setup. |
| `rustfs/src/storage/storage_api.rs` | Broad RustFS storage owner bridge for admin, explicit bucket facade submodules, capacity, client, compression, cluster, config, data usage, disk, error, event, global bootstrap controls, runtime-source getters, layout, metrics, notification, rebalance, rio, rpc, set disk, storage, and tier. Replication pool/stat handles are projected into RustFS-local wrapper types here. |
| `crates/scanner/src/storage_api.rs` | Scanner bridge for bucket lifecycle, replication, metadata, capacity, config, data usage, disk, error, runtime, set disk, storage, and tier. Replication queue config, admission, and heal object DTOs are projected into scanner-local types here. |
| `crates/obs/src/metrics/storage_api.rs` | Metrics bridge for bucket bandwidth, lifecycle, replication, quota, capacity, data usage, error, runtime, and storage. |
| `crates/iam/src/storage_api.rs` | IAM bridge for config, error, notification, runtime, and storage. |
| `crates/heal/src/heal/storage_api.rs` | Heal bridge for data usage, disk, error, runtime, and storage. |
| `crates/notify/src/storage_api.rs` | Notification bridge for config, runtime, and storage. |
| `crates/protocols/src/swift/storage_api.rs` | Swift bridge for bucket metadata, bucket metadata system, error, runtime, and storage. |
| `crates/s3select-api/src/storage_api.rs` | S3 Select bridge for error, runtime, set disk, and storage. |
| `crates/e2e_test/src/storage_api.rs` | E2E harness bridge for bucket targets, disk walking, and RPC helpers. |
| `crates/heal/tests/*/storage_api.rs`, `crates/scanner/tests/storage_api/mod.rs`, `fuzz/fuzz_targets/*_storage_api.rs` | Test and fuzz bridges for the same compatibility seams under test. |
`crates/replication/src/storage_api.rs` shares the file name but is not an ECStore boundary: it owns the delete work DTOs of `rustfs-replication`, which imports neither `rustfs_ecstore` nor `rustfs-storage-api`.
Regenerate the boundary list with:
```bash
rg -l 'rustfs_ecstore::api' crates rustfs/src fuzz -g '*.rs' -g '!crates/ecstore/src/**'
```
New production imports outside these files are migration drift. Do not add direct `rustfs_ecstore::api` imports outside the boundary files; add a local boundary or a storage-api contract first, then route consumers through it.
New production imports outside these boundary files are migration drift. Add a
local boundary or storage-api contract first, then route consumers through it.
## Split Dependency Inventory
Lifecycle, replication, and `SetDisks` split blockers, extracted contracts, and guard rule names are tracked in [ecstore-module-split-plan.md](ecstore-module-split-plan.md) and the module inventories `crates/ecstore/src/bucket/lifecycle/README.md` and `crates/ecstore/src/bucket/replication/README.md`. `crates/ecstore/tests/ecstore_contract_compat_test.rs` keeps compile-time coverage for `ECStore` and `SetDisks` storage-api trait compatibility before any facade shrink or operation-family movement.
| Candidate | ECStore dependencies that block a crate split | Required owner contracts before movement |
|---|---|---|
| Lifecycle | Object API, `ECStore`, `SetDisks`, runtime sources/globals, bucket metadata/versioning/object lock/replication, disk, config, notification, audit, and tier services. | `LifecycleObjectStore`, `LifecycleMetadataStore`, `LifecycleRuntime`, `LifecycleReplicationSink`, and `LifecycleAuditSink`. |
| Replication | Bucket target and metadata systems, bucket target client config, disk, object API, runtime sources, notification, and SetDisks lock timing. | `ReplicationStorage`, `ReplicationMetadataStore`, `ReplicationRuntime`, `ReplicationEventSink`, and `ReplicationLifecycleBridge`. |
| SetDisks | Shared disks, endpoints, format state, namespace locks, cache, and implementations for object IO, namespace locking, bucket, object, list, multipart, and heal operations. | Pure shard source, disk error, bitrot IO, namespace lock, metrics label, and file metadata contracts before any operation family moves. |
`crates/ecstore/tests/ecstore_contract_compat_test.rs` keeps compile-time
coverage for `ECStore` and `SetDisks` storage-api trait compatibility before
any facade shrink or operation-family movement.
## Shrink Rules
1. Do not remove a facade item until its downstream boundary has compile-time coverage or a documented replacement.
2. Do not add direct `rustfs_ecstore::api` imports outside the boundary files listed above.
3. Do not split lifecycle or replication into crates while they depend on ECStore runtime state, queues, notification, audit, scanner, or `SetDisks` internals.
4. Do not replace `SetDisks` with multiple runtime structs in one change; move one operation family only after contracts and focused tests exist.
5. Remove or narrow one facade group per change so rollback preserves object IO, quorum, lifecycle/replication queues, scanner repair, notification/audit events, and metadata compatibility.
6. Keep `api::bucket`, `api::config`, `api::disk`, and `api::tier` on explicit submodules and symbol lists; do not restore `pub use crate::<owner>::{...}` whole-module passthroughs for those groups.
1. Do not remove a facade item until its downstream boundary has compile-time
coverage or a documented replacement.
2. Do not add direct `rustfs_ecstore::api` imports outside the boundary files
listed above.
3. Do not split lifecycle or replication into crates while they depend on
ECStore runtime state, queues, notification, audit, scanner, or SetDisks
internals.
4. Do not replace `SetDisks` with multiple runtime structs in one PR. Move one
operation family only after contracts and focused tests exist.
5. Remove or narrow one facade group per PR so rollback preserves object IO,
quorum, lifecycle/replication queues, scanner repair, notification/audit
events, and metadata compatibility.
6. Keep `api::bucket`, `api::client`, `api::config`, `api::disk`, and
`api::tier` on explicit submodules and symbol lists; do not restore
`pub use crate::<owner>::{...}` whole-module passthroughs for those groups.
## First PR Checklist
- inventory the facade group and all external boundary consumers;
- add explicit aliases or wrappers before deleting any broad passthrough;
- run `./scripts/check_architecture_migration_rules.sh`;
- run focused compile or tests for the touched owner boundary;
- keep runtime behavior unchanged unless the PR is explicitly a code-bearing
follow-up with its own rollback plan.
@@ -0,0 +1,199 @@
# ECStore Config Consumer Inventory
This inventory is the Phase 0 baseline for moving
`rustfs_ecstore::config::{Config, KV, KVS}` safely. It records the current
definitions, persistence helpers, global accessors, and direct consumers before
any contract extraction, global-state migration, or crate split.
Related issue: [`rustfs/backlog#660`](https://github.com/rustfs/backlog/issues/660)
## Scope
In scope:
- `rustfs_ecstore::config::KV`
- `rustfs_ecstore::config::KVS`
- `rustfs_ecstore::config::Config`
- `rustfs_ecstore::config::DEFAULT_KVS`
- `rustfs_ecstore::config::{get_global_server_config, set_global_server_config}`
- `rustfs_ecstore::config::com::{read_config_without_migrate, save_server_config}`
- Consumers that persist, clone, inspect, mutate, or pass these types across
runtime boundaries.
- Selected adjacent users of `rustfs_ecstore::config::com::{read_config,
save_config, delete_config}` and related helper variants are listed separately
when they appear outside the core `Config`, `KV`, and `KVS` consumer map.
This is not a complete `com.rs` move inventory; any future `com.rs` move must
first inventory ECStore-internal persistence helper users too.
Out of scope:
- Unrelated `Config` types from `rustfs::config`, SDKs, TLS, SSH, KMS, OIDC
client libraries, or local module-specific config structs.
- Storage-class-only imports are not treated as `Config`, `KV`, or `KVS`
consumers unless they also use the server-config model.
- Pure route/action snapshot work already covered by
[`admin-route-action-snapshot.md`](admin-route-action-snapshot.md).
## Current Shape
Arrows show current source dependency or call direction: the left node imports
or calls the right node.
```mermaid
flowchart TB
EC["crates/ecstore/src/config"]
Store["crates/ecstore/src/store/mod.rs"]
AppCtx["rustfs/src/app/context.rs"]
Server["rustfs/src/server/{event,audit}.rs"]
Admin["rustfs/src/admin"]
Notify["crates/notify"]
Audit["crates/audit"]
Targets["crates/targets"]
IAM["crates/iam/src/oidc.rs"]
Scanner["crates/scanner/src/{runtime_config,scanner}.rs"]
Store --> EC
AppCtx --> EC
Server --> AppCtx
Admin --> EC
Notify --> EC
Audit --> EC
Targets --> EC
Notify --> Targets
Audit --> Targets
IAM --> EC
Scanner --> EC
```
The config model is currently both a persisted server-config representation and
the runtime carrier for notify, audit, target-plugin, scanner, and OIDC
settings. Any move must preserve that dual role until consumers are migrated
behind narrower contracts.
## Core Model And Global State
| Item | Current owner | Current role | Migration note |
|---|---|---|---|
| `KV` | `crates/ecstore/src/config/mod.rs` | Key/value entry with `hidden_if_empty` metadata and serde compatibility. | Preserve field names, aliases, defaults, and redaction behavior before any model move. |
| `KVS(Vec<KV>)` | `crates/ecstore/src/config/mod.rs` | Ordered key/value set used by server config, target factories, admin rendering, tests, and examples. | Preserve tuple shape and methods: `new`, `get`, `lookup`, `is_empty`, `keys`, `insert`, `extend`. |
| `Config(HashMap<String, HashMap<String, KVS>>)` | `crates/ecstore/src/config/mod.rs` | Server config map by subsystem and target. | Direct `.0` access is widespread; add wrappers only after preserving the current public shape. |
| `DEFAULT_KVS` | `crates/ecstore/src/config/mod.rs` | Registry for defaults across storage class, scanner, notify, audit, and OIDC. | Move defaults only after an explicit registration contract exists. |
| `GLOBAL_SERVER_CONFIG` | `crates/ecstore/src/config/mod.rs` | Process-wide mutable server config snapshot. | Migrate readers behind `AppContext` or a server-config provider before changing storage. |
| `ConfigSys::init` | `crates/ecstore/src/config/mod.rs` | Reads persisted config, looks up derived config, and stores the global snapshot. | Startup order must remain unchanged until the lifecycle contract owns this dependency. |
| `read_config_without_migrate` | `crates/ecstore/src/config/com.rs` | Loads persisted server config through ECStore-owned object I/O and storage-admin contracts. | Persistence stays in `ecstore` until pure model and persistence are separated. |
| `save_server_config` | `crates/ecstore/src/config/com.rs` | Persists the canonical server config object. | Preserve external object shape and config-history behavior. |
| `get_global_server_config` / `set_global_server_config` | `crates/ecstore/src/config/mod.rs` | Clone/read and replace the global server-config snapshot. | Do not remove until all runtime readers have an injected provider path. |
## Consumer Map
### ECStore Ownership, Persistence, And Defaults
| Files | Current usage |
|---|---|
| `crates/ecstore/src/config/mod.rs` | Defines `KV`, `KVS`, `Config`, defaults, global snapshot, initialization, and tests. |
| `crates/ecstore/src/config/com.rs` | Encodes, decodes, reads, writes, creates, and normalizes server config objects through ECStore-local persistence helpers. |
| `crates/ecstore/src/config/{notify,audit,oidc,scanner,storageclass}.rs` | Register default `KVS` values and subsystem-specific parsing helpers. |
| `crates/ecstore/src/store/mod.rs` | Exposes store-level server-config accessors that delegate to the global config snapshot. |
### App Context And Server Startup Consumers
| Files | Current usage |
|---|---|
| `rustfs/src/app/context.rs` | Defines `ServerConfigInterface`, keeps an `AppContext` server-config handle, and still falls back to `get_global_server_config`. |
| `rustfs/src/server/event.rs` | Resolves server config through app context/global fallback before starting the notification runtime. |
| `rustfs/src/server/audit.rs` | Resolves server config through app context/global fallback before starting the audit runtime. |
### Admin Control-Plane Readers And Writers
| Files | Current usage |
|---|---|
| `rustfs/src/admin/handlers/config_admin.rs` | Reads active/persisted server config, validates against `DEFAULT_KVS`, mutates `KVS`, saves config history, saves server config, and updates the global snapshot. |
| `rustfs/src/admin/handlers/oidc.rs` | Reads and writes OIDC provider `KVS`, saves server config, and compares persisted config against the global snapshot for restart signaling. |
| `rustfs/src/admin/handlers/audit_runtime_config.rs` | Reads persisted config, applies audit runtime target changes, saves server config, and reloads audit runtime state. |
| `rustfs/src/admin/handlers/notify_runtime_access.rs` | Reads notification runtime config snapshots and passes `KVS` target changes into the notification system. |
| `rustfs/src/admin/handlers/{event,audit}.rs` | Lists and validates notification/audit targets from `Config`; tests build `KV` and `KVS` fixtures. |
| `rustfs/src/admin/handlers/plugins_instances.rs` | Maps target plugin `KVS` to response payloads and applies runtime target edits. |
| `rustfs/src/admin/handlers/target_descriptor.rs` | Converts descriptor payloads into `KVS` for target plugin instances. |
| `rustfs/src/admin/handlers/site_replication.rs` | Reads global server config for LDAP settings and parses LDAP `KVS` fixtures. |
| `rustfs/src/admin/service/config.rs` | Reads persisted server config, validates storage-class `KVS`, derives target state, and updates global config/storage-class state. |
| `rustfs/src/admin/router.rs` | Reads persisted/global server config for admin route behavior; route tests construct `Config`, `KV`, and `KVS`. |
### Adjacent ECStore Config-Object Helper Users
| Files | Current usage |
|---|---|
| `rustfs/src/admin/handlers/kms_dynamic.rs` | Uses generic `read_config` and `save_config` for dynamic KMS config objects. |
| `rustfs/src/site_replication/state.rs` | Uses generic `read_config`, `save_config`, and `delete_config` (via the root storage facade) for site-replication state objects. |
| `rustfs/src/admin/service/site_replication.rs` | Uses generic `read_config` and `save_config` for site-replication state normalization. |
| `rustfs/src/server/module_switch.rs` | Uses generic `read_config` and `save_config` for module-switch config objects. |
| `crates/iam/src/store/object.rs` | Uses generic `read_config_no_lock`, `read_config_with_metadata`, `save_config`, `save_config_with_opts`, and `delete_config` helper variants for IAM object-store persistence paths. |
| `crates/scanner/src/{scanner,data_usage_define}.rs` | Uses generic `read_config` and `save_config` for scanner metadata and cache persistence paths. |
### Runtime Target, Notify, And Audit Crates
| Files | Current usage |
|---|---|
| `crates/notify/src/{global,integration,services,registry}.rs` | Carries `Config` into notification runtime startup/reload and target creation. |
| `crates/notify/src/config_manager.rs` | Mutates `Config`, reads persisted server config with `read_config_without_migrate`, persists changes with `save_server_config`, and applies per-target `KVS` updates. |
| `crates/notify/src/factory.rs` | Builds notification target arguments from `KVS`. |
| `crates/notify/examples/{full_demo,full_demo_one}.rs` | Constructs `Config`, `KV`, and `KVS` directly for examples. |
| `crates/audit/src/{global,system,registry}.rs` | Carries `Config` into audit runtime startup/reload and target creation. |
| `crates/audit/src/factory.rs` | Builds audit target arguments from `KVS`. |
| `crates/audit/tests/*.rs` | Constructs `Config` and `KVS` directly for runtime and parsing tests. |
| `crates/audit/README.md` | Documents current direct `Config` usage. |
| `crates/targets/src/plugin.rs` | Creates plugin targets from `Config` and merged `KVS`. |
| `crates/targets/src/catalog/builtin.rs` | Declares builtin target descriptors and default `KVS` fields. |
| `crates/targets/src/config/{common,target_args,loader,instance}.rs` | Collects, normalizes, redacts, and materializes target configs from `Config` and `KVS`, including environment overrides. |
### Identity, Scanner, Tests, And Fixtures
| Files | Current usage |
|---|---|
| `crates/iam/src/oidc.rs` | Reads global server config and parses OIDC provider `KVS`. |
| `crates/scanner/src/{runtime_config,scanner}.rs` | Reads the global server-config snapshot and resolves scanner runtime config from `Config` and `KVS`. |
| `rustfs/src/admin` handler/router tests, `crates/audit/tests/*.rs`, and selected in-crate tests in `crates/{targets,scanner}/src` | Build direct tuple-struct fixtures; use them as candidate regression guards during a pure model move. |
## Dependency Risk Classification
| Risk | Why it matters | Guardrail |
|---|---|---|
| `Config` is both persistence model and runtime input | A move can accidentally change persisted JSON/object shape or runtime target behavior. | Separate pure model contract from persistence helpers before moving `com.rs`. |
| Direct `.0` map access is common | Replacing the tuple struct too early would create broad churn and likely behavior drift. | Preserve tuple shape in the first move, then add typed readers in later PRs. |
| `KVS` is the effective target config carrier | Notify, audit, and target factories consume `KVS` after file/env merge. | Keep `KVS` API stable until target descriptor and runtime crates are behind a shared contract. |
| `DEFAULT_KVS` registration is global | Defaults are initialized centrally and used by admin validation/rendering. | Add a registration contract before changing initialization order. |
| Global snapshot readers still exist | Server, admin, IAM, scanner, and site-replication paths can still read global config. | Migrate readers through `AppContext`/provider paths in small steps after the model contract is stable. |
| Persistence helpers depend on ECStore storage contracts | Moving them with the pure model would pull storage implementation dependencies upward. | Keep read/write helpers in `ecstore` until a storage-facing persistence contract is explicit. |
## Recommended Migration Order
1. Keep this inventory current while Phase 0 guardrails land.
2. Add a focused contract surface for `KV`, `KVS`, and `Config` without changing
serialization, tuple-struct shape, or method names.
3. Add compile-time or scripted checks for temporary compatibility markers and
config-model re-export coverage.
4. Move only the pure model and defaults registration surface after targeted
regression checks cover unchanged persisted object shape, target `KVS` merge
behavior, and representative admin config rendering paths.
5. Migrate global `Config` readers behind `ServerConfigInterface` or a narrower
provider in small PRs.
6. Move persistence helpers only after object-I/O and storage-admin dependencies
can stay below the model contract.
7. Evaluate crate split only after consumers no longer need old paths except
explicit `RUSTFS_COMPAT_TODO(<task-id>)` compatibility shims.
## Do-Not-Change Contract
The first migration steps must preserve:
- `KV { key, value, hidden_if_empty }` serde behavior and redaction semantics.
- `KVS(Vec<KV>)` tuple shape and public methods.
- `Config(HashMap<String, HashMap<String, KVS>>)` tuple shape and public methods.
- `Config::set_defaults`, `Config::unmarshal`, `Config::marshal`, and
`Config::merge` behavior.
- `read_config_without_migrate` fallback/creation behavior for missing server
config objects.
- `save_server_config` external object shape and config-history compatibility.
- Existing notify, audit, scanner, OIDC, and target-plugin enable/disable
interpretation from `Config`/`KVS` inputs; business-rule changes stay out of
migration PRs.
- AppContext/global fallback behavior until all readers are explicitly migrated.
+42 -20
View File
@@ -1,39 +1,61 @@
# ECStore Layout Boundary
**Use this when:** you touch endpoint expansion, `FormatV3`, pool/set layout, or move files between ECStore's internal directories.
**Source of truth:** `crates/ecstore/src/layout/` (static layout), `crates/ecstore/src/core/sets.rs` (`Sets`) and `crates/ecstore/src/set_disk/mod.rs` (`SetDisks`) for runtime orchestration, `crates/ecstore/src/api/mod.rs` (`pub mod layout`) for the public surface.
This document records the `E-001` and `E-SET-001` foundation slice for the
architecture migration.
## Directory Ownership
## Directory Skeleton
Ownership buckets under `crates/ecstore/src` (a subset; list the rest with `ls crates/ecstore/src`):
The ECStore migration uses these internal ownership buckets before any pure
file moves:
| Directory | Owns |
|---|---|
| `api` | Facade and compatibility re-exports |
| `core` | Store facade, pools, sets, and object/bucket/list/multipart/heal orchestration |
| `layout` | Static endpoint, disk, pool, and set layout (`disks_layout`, `endpoint`, `endpoints`, `format`, `pool_space`, `set_heal`, `set_layout`) |
| `disk` | Local disk, format compatibility, health, disk errors |
| `erasure` | Erasure coding and bitrot |
| `metadata` | Bucket metadata, config object store, data usage |
| `cluster` | Remote disk, peer, lock, membership, health control plane |
| `services` | Lifecycle, replication, tier, notification, rebalance, metrics services |
| `set_disk`, `store`, `data_movement`, `data_usage`, `object_api`, `runtime` | Set-level operations, store init, pool data movement, usage accounting, object API helpers, runtime state owners |
- `api`: facade and compatibility re-export ownership.
- `core`: store facade, object, bucket, list, multipart, and heal paths.
- `layout`: static endpoint, disk, pool, and set layout descriptions.
- `disk`: local disk, format, health, and disk error ownership.
- `erasure`: erasure coding and bitrot ownership.
- `metadata`: bucket metadata, config object-store, and data-usage ownership.
- `cluster`: remote disk, peer, lock, membership, and health-control-plane
ownership.
- `services`: lifecycle, replication, tier, notification, rebalance, and
metrics service ownership.
## Static Set Layout
Static layout is derived from persisted `FormatV3` data (`crates/ecstore/src/layout/format.rs`) and endpoint expansion (`crates/ecstore/src/layout/disks_layout.rs`). It may describe the deployment id, set count and drives per set, disk UUID positions inside `format.erasure.sets`, the distribution algorithm, and endpoint grouping produced before runtime disk initialization. It must not own disk handles, lock clients, reconnect loops, repair state, or shutdown signaling.
Static layout is derived from persisted `FormatV3` data and input endpoint
expansion. It may describe:
## Visibility
- deployment id;
- set count and drives per set;
- disk UUID positions inside `format.erasure.sets`;
- distribution algorithm;
- endpoint grouping produced before runtime disk initialization.
`layout::*` modules are `pub(crate)`; public access goes through `rustfs_ecstore::api::layout` (`DisksLayout`, `EndpointServerPools`, `Endpoints`, `PoolEndpoints`, `SetupType`). `disk::format` re-exports `layout::format` for crate-internal callers. Outer crates must not reach the root `endpoints` or `disks_layout` modules.
Static layout must not own disk handles, lock clients, reconnect loops, repair
state, or shutdown signaling.
## Format And Disk Layout Ownership
`layout::format` owns persisted format structures and disk UUID position lookup.
`layout::disks_layout` owns command-line volume expansion into pool/set layout.
Compatibility paths remain available through `disk::format` and `disks_layout`
until downstream callers are moved or compatibility coverage allows removal.
## Runtime Set Orchestration
`Sets` and `SetDisks` own the flat disk index to `(set_index, disk_index)` mapping, per-set local disk replacement after distributed setup detection, per-set lock-client host deduplication, endpoint reconnect monitoring and runtime shutdown signaling, and read/write/heal/list orchestration over initialized disks.
Runtime orchestration remains owned by `Sets` and `SetDisks` until a later pure
move. It may describe:
- flat disk index to `(set_index, disk_index)` mapping;
- per-set local disk replacement after distributed setup detection;
- per-set lock-client host deduplication;
- endpoint reconnect monitoring and runtime shutdown signaling;
- read/write/heal/list orchestration over initialized disks.
## Preservation Rules
- Object-to-set hashing and distribution algorithm selection must not change.
- Format `sets` ordering and disk UUID position lookup must not change.
- Local disk replacement and lock-client mapping stay runtime-only.
- File moves keep old public paths or add explicit compatibility coverage before deleting them.
- Later file moves must keep old public paths or add explicit compatibility
coverage before deleting them.
+314 -46
View File
@@ -1,85 +1,353 @@
# ECStore Module Split Plan
**Use this when:** you add lifecycle or replication logic and need to know which crate it belongs in, you plan to move an operation family out of `SetDisks`, or the guard fails on one of the split rules named below.
**Source of truth:** `scripts/check_architecture_migration_rules.sh` (the rules), `crates/ecstore/src/bucket/lifecycle/README.md` and `crates/ecstore/src/bucket/replication/README.md` (module-level contract inventories, completion criteria, milestones), and [ecstore-api-facade-inventory.md](ecstore-api-facade-inventory.md) (facade groups and boundary files).
This plan records the remaining ECStore split work after the final audit
remediation pass. Runtime movement must still wait until each candidate
boundary has explicit contracts, compatibility coverage, dependency evidence,
and rollback steps.
## Current Shape
| Area | Owner | Split status |
|---|---|---|
| Bucket lifecycle | `crates/lifecycle/` (`rustfs-lifecycle`, pure contracts) + `crates/ecstore/src/bucket/lifecycle/` (runtime) | Core contracts extracted; runtime stays in ECStore |
| Bucket replication | `crates/replication/` (`rustfs-replication`, contracts and wire formats) + `crates/ecstore/src/bucket/replication/` (worker runtime) | Contracts extracted; runtime move pending |
| Set disks | `crates/ecstore/src/set_disk/` | Shared state carrier plus operation modules; stays in ECStore |
| Public facade | `crates/ecstore/src/api/mod.rs` | Shrinks only through guarded changes |
| S3 client | `crates/s3-client/` (`rustfs-s3-client`) | Extracted |
| Area | Current owner | Size | Split status |
|---|---|---:|---|
| Bucket lifecycle | `crates/lifecycle/` + `crates/ecstore/src/bucket/lifecycle/` | core contracts + ECStore runtime | Core contract extracted |
| Bucket replication | `crates/ecstore/src/bucket/replication/` | 15,619 lines | Contracts extracted; runtime move pending |
| Set disks | `crates/ecstore/src/set_disk/` | state carrier plus operation modules | Keep in ECStore |
| Public ECStore facade | `crates/ecstore/src/api/mod.rs` | broad compatibility surface | Shrink only through guarded PRs |
| Embedded S3 client | `crates/s3-client/` (`rustfs-s3-client`) | ~8.4K lines | Extracted (rustfs/backlog#1842) |
Measure size instead of trusting numbers in a document:
Measured 2026-08-12: the whole crate is 265 files / ~288K lines (roughly half
is inline `#[cfg(test)]` code). The largest single files are `disk/local.rs`
(21,063 lines), `bucket/lifecycle/bucket_lifecycle_ops.rs` (11,961 lines), and
`set_disk/mod.rs` (11,151 lines). Reproduce with:
```bash
find crates/ecstore/src -name '*.rs' | xargs wc -l | sort -rn | head
find crates/ecstore/src/bucket/replication -name '*.rs' | xargs wc -l | tail -1
```
Rule for new code: in a domain that already has a contract crate, new logic that does not need ECStore runtime state lands in that crate (`rustfs-lifecycle`, `rustfs-replication`), not under `crates/ecstore/src/bucket/`.
No split step has landed since the contract-extraction PRs of 2026-07-04,
while the `bucket/replication` runtime grew from 8,730 to 15,619 lines (+79%)
through feature work (e.g. SSE-C ciphertext passthrough replication #5898,
delete-marker purge retry/replay #5864). To keep the gap from widening: in
domains that already have a contract crate, new replication runtime logic that
does not need ECStore runtime state must land in `rustfs-replication`, not in
`crates/ecstore/src/bucket/replication/`.
The S3 client extraction is complete: the former `client/` directory moved to `crates/s3-client`, its two server-side modules moved to `crates/ecstore/src/object_api/object_api_utils.rs` and `crates/ecstore/src/bucket/lifecycle/object_handlers_common.rs`, and the remaining serving-side `s3s` references in ECStore are ratcheted shrink-only by `S3S_ECSTORE_FILES_BASELINE` in `scripts/check_s3s_footprint.sh`.
The file split inside `set_disk/` is already operation-oriented: read, write,
list, multipart, lock, heal, and replication code live in separate modules.
The remaining large surface is the shared `SetDisks` state and cross-cutting
contracts, not only file layout.
## Completed: S3 Client Extraction (rustfs/backlog#1842)
`crates/ecstore/src/client/` was a ~8.4K-line hand-written S3 HTTP client the engine uses to *consume* remote S3-compatible endpoints (ILM tier warm backends, transition targets). It was a legitimate engine capability misfiled inside the engine: it pulled `s3s`/`hyper` wire types into ecstore against ARCHITECTURE.md invariant 4, which distinguishes serving the S3 wire protocol (forbidden in ecstore) from consuming it (allowed, but in a dedicated crate).
The extraction landed as: pure move of the 21 client modules to `crates/s3-client` (`rustfs-s3-client`) with a temporary re-export shim, then direct `rustfs_s3_client::` imports and shim deletion. The two server-side modules historically misfiled under `client/` stayed in ecstore and moved to their real homes: `object_api_utils.rs` under `object_api/`, `object_handlers_common.rs` under `bucket/lifecycle/` (behind the `replication_sink` boundary). The remaining serving-side `s3s` references in ecstore are ratcheted shrink-only by the `S3S_ECSTORE_FILES_BASELINE` counter in `scripts/check_s3s_footprint.sh`; per-module conversions to storage-level types (first: `bucket/object_lock/`) lower the baseline in the same change.
## Non-Negotiable Rules
- Do not split crates in the same change that moves runtime state or changes startup behavior.
- Do not change object placement, quorum, reader semantics, lifecycle queues, replication queues, notification dispatch, audit events, or scanner repair behavior during inventory and contract work.
- Do not expose new direct ECStore internals to outer crates; use storage-api and owner-local facade boundaries.
- Keep `rustfs_ecstore::api` compatibility visible until each consumer path has compile coverage and an explicit replacement.
- Do not split crates in the same PR that moves runtime state or changes
startup behavior.
- Do not change object placement, quorum, reader semantics, lifecycle queues,
replication queues, notification dispatch, audit events, or scanner repair
behavior during inventory and contract PRs.
- Do not expose new direct ECStore internals to outer crates; use the existing
storage-api and owner-local facade boundaries.
- Keep `rustfs_ecstore::api` compatibility visible until each consumer path has
compile coverage and an explicit replacement.
## Guarded Split Rules
## SetDisks Split Direction
Each rule is enforced by `scripts/check_architecture_migration_rules.sh`; the name is the vocabulary used in reviews and guard failures.
Do not replace `SetDisks` with several runtime structs in one change. The safe
path is:
| Rule | What the guard checks |
|---|---|
| `LifecycleCrateCoreIndependence` | `crates/lifecycle` (rule validation, filtering, event evaluation, transition/expiration options, tag decoding, object-lock metadata checks, expiry-time rounding) imports no ECStore internals, `rustfs-filemeta`, or `rustfs-utils`; ECStore owns the `ObjectInfo` adapter in `crates/ecstore/src/bucket/lifecycle/core.rs`. |
| `ReplicationCrateFileMetaIndependence` | Replication status, decision, MRF, resync, and target-reset wire contracts live in `crates/replication/src/filemeta.rs`; `rustfs-replication` neither imports nor depends on `rustfs-filemeta`. |
| `ReplicationCrateStorageApiIndependence` | Delete work DTOs live in `crates/replication/src/storage_api.rs`; ECStore converts storage-api delete DTOs at its replication storage boundary; `rustfs-replication` does not depend on `rustfs-storage-api`. |
| `ReplicationCrateUtilsIndependence` | HTTP metadata keys, S3 header labels, ETag trimming, and prefix matching used by replication wire contracts live in `crates/replication/src/http.rs`; `rustfs-replication` does not depend on `rustfs-utils`. |
| `EcstoreReplicationBoundaryImports` | ECStore-side `rustfs_replication` imports are confined to the `*_boundary.rs` modules under `crates/ecstore/src/bucket/replication/`; grouped queue, stats, resync, and object-decision symbols each have one owning boundary file. |
| `RuntimeReplicationFacadeConsumers` | Scanner, admin, storage-owner, and app code consume replication status/DTO/helper contracts through the `rustfs_ecstore` facade; the `rustfs` and `rustfs-scanner` crates do not depend on `rustfs-replication` directly. |
| `StorageApiReplicationContracts` | Owner-facing storage-api delete DTO replication state/status helpers stay in `crates/storage-api/src/replication.rs`; replication worker DTOs stay in `rustfs-replication`. |
1. Keep `SetDisks` as the shared state carrier while operation modules continue
to own read/write/list/multipart/lock/heal/replication behavior.
2. Extract pure contracts first: shard source, disk error, bitrot IO, namespace
lock, metrics labels, and file metadata access.
3. Move one operation family only after its contracts are covered by focused
tests and the facade compatibility path is explicit.
4. Preserve the old `rustfs_ecstore::api::set_disk` surface until downstream
compatibility tests prove no caller depends on removed names.
## Lifecycle
The first executable SetDisks follow-up should be an inventory or guardrail PR,
not a runtime split PR.
`rustfs-lifecycle` owns the pure rule, event, evaluator, tag-filter, object-lock metadata check, and expiry-time contracts. ECStore keeps the object-store runtime, queues, tiering, audit/notification, metadata access (`crates/ecstore/src/bucket/lifecycle/metadata_boundary.rs`), and replication-delete scheduling adapters.
## Lifecycle Candidate
Coupling that still blocks a runtime move: lifecycle workers read ECStore runtime sources (object store, expiry and transition state, tier config, deployment id, local node name); stale multipart cleanup depends on `SetDisks` internals and bucket metadata; expiry schedules replication deletes through the replication lifecycle bridge; the lifecycle runtime coordinates scanner metrics and notification/audit side effects. The contract list and the next step live in `crates/ecstore/src/bucket/lifecycle/README.md`.
`rustfs-lifecycle` now owns the pure lifecycle rule, event, evaluator, tag
filtering, object-lock metadata check, and expiry-time contracts. ECStore keeps
the object-store runtime, queues, tiering, audit/notification, metadata, and
replication scheduling adapters.
## Replication
Current coupling:
`rustfs-replication` owns resync status contracts, the persisted resync status wire format, filemeta-derived wire contracts, delete work DTOs, and HTTP helper contracts. ECStore keeps the worker runtime, error mapping, MRF persistence, and global pool/stat initialization.
- lifecycle workers and transition state read ECStore runtime sources for
object-store handles, expiry state, transition state, tier config, deployment
IDs, and local node names;
- stale multipart cleanup depends on `SetDisks` internals and bucket metadata
through the lifecycle metadata boundary;
- lifecycle expiry schedules bucket replication delete work through the
replication lifecycle bridge contract;
- lifecycle evaluation uses S3 DTOs and replication status contracts from the
independent `rustfs-lifecycle`/`rustfs-replication` crates, while ECStore maps
`ObjectInfo` into lifecycle object options at the compatibility boundary;
- lifecycle runtime still coordinates scanner metrics, notification/audit side
effects, metadata access, replication delete scheduling, and tier services.
Boundary layout inside `crates/ecstore/src/bucket/replication/`: `*_boundary.rs` modules concentrate imports from `rustfs-replication`, storage-api, filemeta, config, target, error, lock, msgp, versioning, tagging, bandwidth, queue, stats, resync, and object-decision surfaces; `replication_*_bridge.rs` modules (lifecycle, scanner, object, migration, target-config) expose replication scheduling to other owners without leaking DTO construction; `replication_config_store.rs` exposes config persistence and storage-class labels. Modules inside the directory use relative self-imports, and the facade in `mod.rs` uses explicit symbol lists, never wildcard re-exports.
Current extracted contracts:
Consumers outside ECStore: RustFS runtime code receives pool/stat handles through storage-owner wrapper types in `rustfs/src/storage/storage_api.rs`; scanner code receives scanner-local config/admission/heal DTOs from `crates/scanner/src/storage_api.rs`; observability reads replication metrics through obs-local snapshot DTOs in `crates/obs/src/metrics/storage_api.rs`; app object and multipart writes call object-replication bridge helpers instead of constructing replication work DTOs.
- `LifecycleCrateCoreIndependence`: lifecycle rule validation, filtering,
event evaluation, transition/expiration options, tag decoding, object-lock
metadata checks, and ILM expiry-time rounding live in `rustfs-lifecycle`.
`rustfs-lifecycle` must not import ECStore internals, file metadata, or
`rustfs-utils`; ECStore owns the `ObjectInfo` adapter in
`crates/ecstore/src/bucket/lifecycle/core.rs`.
Completion criteria, the milestone order, and the per-dependency contract inventory live in `crates/ecstore/src/bucket/replication/README.md` (sections "Completion Criteria" and "Milestones"). Remaining work starts from moving resyncer pure decision logic.
Required contracts before crate movement:
## SetDisks
- `LifecycleObjectStore`: object stat, delete, transition, restore, multipart
cleanup, and version-aware metadata operations needed by lifecycle workers.
- `LifecycleMetadataStore`: lifecycle, object-lock, replication, bucket
versioning, and stale multipart metadata lookups without importing ECStore
implementation modules. Current lifecycle config reads are concentrated in
`crates/ecstore/src/bucket/lifecycle/metadata_boundary.rs`.
- `LifecycleRuntime`: expiry state, transition state, tier config, deployment
ID, local node name, queue metrics, cancellation, and worker sizing.
- `LifecycleReplicationSink`: schedule lifecycle-originated replication deletes
without depending on the replication implementation module.
- `LifecycleAuditSink`: lifecycle audit and notification emission boundary.
Do not replace `SetDisks` with several runtime structs in one change:
Next safe PR:
1. Keep `SetDisks` as the shared state carrier while operation modules own read/write/list/multipart/lock/heal/replication behavior.
2. Extract pure contracts first: shard source, disk error, bitrot IO, namespace lock, metrics labels, and file metadata access.
3. Move one operation family only after its contracts are covered by focused tests and the facade compatibility path is explicit.
4. Preserve the `rustfs_ecstore::api::set_disk` surface until downstream compatibility tests prove no caller depends on removed names.
- move one runtime-facing dependency behind a trait or adapter owned by
`rustfs-lifecycle` without changing queue, transition, or delete behavior;
- keep ECStore compatibility shims until scanner and RustFS app consumers stop
depending on `rustfs_ecstore::api::bucket::lifecycle` paths;
- add focused tests for the moved contract and keep architecture guard coverage.
## Facade Shrink
The module-level inventory lives in
`crates/ecstore/src/bucket/lifecycle/README.md`.
Facade groups, boundary files, and shrink rules are in [ecstore-api-facade-inventory.md](ecstore-api-facade-inventory.md). Shrinking is monotonic: inventory, add compile-time coverage, move consumers to storage-api or owner-local boundaries, then remove one group per change. Do not delete facade groups only because the underlying module moved.
Focused verification for the first code-bearing lifecycle PR:
- `cargo test -p rustfs-ecstore lifecycle --lib`
- `cargo check -p rustfs-ecstore --tests`
- `./scripts/check_architecture_migration_rules.sh`
- `git diff --check`
## Replication Candidate
`rustfs-replication` now owns the resync status contracts and persisted resync
status wire format. The remaining `bucket/replication` worker runtime is not
ready for a full standalone crate yet.
The completion criteria and milestone sequence for this candidate (when the
split counts as done, the target end state, and the order of the remaining
moves) live in the module inventory:
`crates/ecstore/src/bucket/replication/README.md`, sections "Completion
Criteria" and "Milestones". The originally proposed first code-bearing step
(event sink / runtime contracts) has landed; remaining work starts from moving
resyncer pure decision logic.
Current coupling:
- replication workers depend on `ReplicationStorage`, ECStore object APIs and
owner storage-api contracts through the replication storage boundary, bucket
target clients, bucket metadata, file metadata replication state through the
filemeta boundary, config-derived storage class labels through the config store, scanner repair
classification, runtime replication pool/stat handles, bucket monitor and
bandwidth reader access through local boundaries, local node names, and
notification events;
- resync and delete replication paths call metadata paths through the metadata
boundary, while bucket target system access, target config types, and target
operation types are concentrated behind the replication target boundary;
- lifecycle delete paths schedule replication work through
`ReplicationLifecycleBridge`, while scanner heal paths schedule replication
work through `ReplicationScannerBridge`, and app/SetDisks object write/delete
paths use `ReplicationObjectBridge`;
- bucket metadata migration and bucket target removal checks use local
replication bridges instead of importing resyncer codec or config helper
internals;
- resync options, bucket/target resync status DTOs, status display labels, and
the persisted resync status wire format live in `crates/replication`, with
ECStore retaining only error mapping and MRF persistence locally;
- `ReplicationCrateFileMetaIndependence`: replication status, decision, MRF,
resync, and target-reset wire contracts are owned inside `rustfs-replication`
instead of importing `rustfs-filemeta`;
- `ReplicationCrateStorageApiIndependence`: delete work DTOs are owned inside
`rustfs-replication`; ECStore converts storage-api delete DTOs at the
replication storage boundary instead of `rustfs-replication` importing
`rustfs-storage-api`;
- `ReplicationCrateUtilsIndependence`: HTTP metadata keys, S3 header labels,
ETag trimming, and case-insensitive prefix matching used by replication wire
contracts are owned inside `rustfs-replication` instead of importing
`rustfs-utils`;
- direct ECStore replication imports from `rustfs-replication` are limited to
`*_boundary.rs` modules;
- storage-api delete replication status/state helpers use the local
`crates/storage-api/src/replication.rs` contract boundary; ECStore converts
those owner DTOs at the replication storage boundary before queueing work;
- admin replication extension target filtering and resync request construction
stay behind the admin storage boundary instead of exposing replication work
DTO construction to handlers;
- scanner, admin, storage-owner, and app storage replication status/DTO/helper
consumers import those contracts through the ECStore replication facade;
- app object and multipart writes call object-replication boundary helpers
instead of constructing replication work DTOs or choosing object replication
operation types at the use-case layer;
- RustFS runtime consumers receive replication pool/stat handles through
storage-owner wrapper types instead of carrying ECStore replication handles
through app, admin, startup, or workload-admission layers;
- global replication pool/stat initialization still lives with ECStore runtime
compatibility state;
- modules inside `bucket/replication` use local relative paths rather than the
ECStore owner path for replication self-imports;
- replication runtime source access uses storage/bandwidth boundary aliases for
ECStore object store and bucket monitor implementation types;
- the ECStore replication facade in `mod.rs` uses explicit compatibility
exports instead of wildcard re-exports from implementation modules.
Required contracts before crate movement:
- `ReplicationObjectIO`: object read/write primitives for config, MRF, resync
status, and multipart replication paths. ECStore object API reader/writer
types and storage-api object IO contracts are concentrated in
`crates/ecstore/src/bucket/replication/replication_storage_boundary.rs`.
- `ReplicationStorage`: keep the existing trait as the starting point, then
split object read/write/delete, walk, and metadata update responsibilities
only when call sites prove a narrower shape. ECStore object API,
storage-api contracts, and read option types are concentrated in
`crates/ecstore/src/bucket/replication/replication_storage_boundary.rs`.
- `ReplicationMetadataStore`: replication config, target reset headers,
MRF/resync state, and status persistence. Metadata sys access and replication
metadata path constants are exposed through the contract type in
`crates/ecstore/src/bucket/replication/replication_metadata_boundary.rs`.
- `ReplicationConfigStore`: replication config persistence and config-derived
labels used by target options. Config read/save helpers and storage class
labels are exposed through the contract type in
`crates/ecstore/src/bucket/replication/replication_config_store.rs`.
- `ReplicationFileMeta`: replication status, decisions, MRF entries, resync
decisions, and target reset helpers. ECStore concentrates filemeta-to-
replication compatibility conversions in
`crates/ecstore/src/bucket/replication/replication_filemeta_boundary.rs`,
while `FileInfo` remains in the storage boundary for storage trait bindings
and walk options.
- `ReplicationCrateFileMetaIndependence`: filemeta wire contracts consumed by
replication workers are owned in `crates/replication/src/filemeta.rs`, and
`rustfs-replication` must not import or depend on `rustfs-filemeta`.
- `ReplicationCrateStorageApiIndependence`: delete work DTOs consumed by
replication delete/queue/operation helpers are owned in
`crates/replication/src/storage_api.rs`, and `rustfs-replication` must not
import or depend on `rustfs-storage-api`.
- `ReplicationCrateUtilsIndependence`: replication-specific HTTP metadata,
header, ETag, and prefix helper contracts are owned in
`crates/replication/src/http.rs`, and `rustfs-replication` must not import or
depend on `rustfs-utils`.
- `EcstoreReplicationBoundaryImports`: ECStore-side imports from
`rustfs-replication` are concentrated in replication `*_boundary.rs` modules.
- `RuntimeReplicationFacadeConsumers`: scanner, admin, storage-owner, and app
storage replication status/DTO/helper consumers import through
`rustfs-ecstore`; runtime code under `rustfs/src` does not import
`rustfs-replication` directly, and the RustFS runtime/scanner crates do not
depend on it.
- `StorageApiReplicationContracts`: owner-facing storage-api delete DTO
replication state/status helpers remain concentrated in
`crates/storage-api/src/replication.rs`, while replication worker DTOs live in
`rustfs-replication`.
- `ReplicationErrorBoundary`: ECStore error/result contracts and
replication-specific error classifiers. `crate::error` imports are
concentrated in
`crates/ecstore/src/bucket/replication/replication_error_boundary.rs`.
- `ReplicationTargetStore`: bucket target listing, target client lookup,
target offline checks, target config types, and target operation option
types. Bucket target sys access, `BucketTargets`, and target operation types
are exposed through the contract type in
`crates/ecstore/src/bucket/replication/replication_target_boundary.rs`.
- `ReplicationRuntime`: pool, stats, worker admission, bucket monitor, local
node identity, cancellation, and queue sizing. Concrete ECStore object store
and bucket monitor types stay behind local storage/bandwidth boundaries.
- `ReplicationBandwidthLimiter`: target reader wrapping for replication
bandwidth accounting and throttling.
- `ReplicationVersioningStore`, `ReplicationLockTiming`, `ReplicationMsgpCodec`,
and `ReplicationTagFilter`: smaller state/codec/filter contracts that keep
bucket versioning, SetDisks lock timing, MessagePack helpers, and bucket
tagging helper access behind local replication boundary types.
- `ReplicationEventSink`: notification/audit events for skipped, failed, and
completed replication operations, including local event host selection.
- `ReplicationLifecycleBridge`: lifecycle-originated delete and version-purge
scheduling is exposed through the contract type in
`crates/ecstore/src/bucket/replication/replication_lifecycle_bridge.rs`.
- `ReplicationMigrationBridge`: persisted resync status decode/encode access
for bucket metadata migration is exposed through the contract type in
`crates/ecstore/src/bucket/replication/replication_migration_bridge.rs`.
- `ReplicationResyncContracts`: resync options, target/bucket resync status,
status labels, and persisted status encoding live in `crates/replication`.
- `ReplicationObjectBridge`: app and SetDisks object write/delete replication
decisions and scheduling are exposed through the contract type in
`crates/ecstore/src/bucket/replication/replication_object_bridge.rs`.
- `ObsReplicationStatsSnapshot`: observability reads replication bucket/site
metrics through obs-local snapshot DTOs in
`crates/obs/src/metrics/storage_api.rs` instead of carrying the ECStore
replication stats handle through collectors.
- `StorageReplicationPoolHandle` / `StorageReplicationStatsHandle`: RustFS app, admin,
startup, and workload-admission code use storage-owner wrapper types from
`rustfs/src/storage/storage_api.rs` for pool activity, resync, queue counts,
proxy stats, and site metrics snapshots.
- `ReplicationScannerBridge`: scanner-originated replication heal scheduling is
exposed through the contract type in
`crates/ecstore/src/bucket/replication/replication_scanner_bridge.rs`.
Scanner consumers receive scanner-local replication config/admission/heal
object DTOs from `crates/scanner/src/storage_api.rs` instead of constructing
or inspecting replication queue DTOs directly.
- `ReplicationTargetConfigBridge`: bucket target removal checks against
replication target rules are exposed through the contract type in
`crates/ecstore/src/bucket/replication/replication_target_config_bridge.rs`.
- `ReplicationFacade`: the current `rustfs_ecstore::api::bucket::replication`
compatibility surface is an explicit symbol list guarded against wildcard
re-exports while downstream owners migrate to narrower contracts.
First safe PR:
- add a replication extraction inventory section or module-level README;
- list current ECStore/runtime dependencies and the target contract owner for
each dependency;
- keep global pool/stat initialization and queue behavior unchanged.
The module-level inventory lives in
`crates/ecstore/src/bucket/replication/README.md`.
Focused verification for the first code-bearing replication PR:
- `cargo test -p rustfs-ecstore replication --lib`
- `cargo check -p rustfs-ecstore --tests`
- `./scripts/check_architecture_migration_rules.sh`
- `git diff --check`
## Facade Shrink Plan
The broad `rustfs_ecstore::api` facade remains a compatibility boundary, not a
new architecture target. The current facade groups and external consumers are
recorded in
[`ecstore-api-facade-inventory.md`](ecstore-api-facade-inventory.md).
Shrinking it must be monotonic:
1. Inventory every public facade group and consumer.
2. Add compile-time coverage before removing or narrowing a facade item.
3. Move outer consumers to storage-api or owner-local compatibility boundaries.
4. Remove one facade group per PR only after downstream compatibility tests pass.
Do not delete facade groups only because the underlying module moved. Keep the
facade stable until the replacement path is visible and tested.
## Ready-To-Split Checklist
A candidate is ready for code movement only when all of these hold:
A candidate split is ready for code movement only when all items below are true:
- the dependency graph shows no cycle with ECStore, storage-api, runtime sources, or owner-local compatibility modules;
- dependency graph shows no cycle with ECStore, storage-api, runtime sources, or
owner-local compatibility modules;
- contract traits compile without importing ECStore implementation modules;
- old facade names have compatibility tests or explicit deprecation coverage;
- focused tests cover the changed owner path before any full gate is attempted;
- rollback preserves object IO, quorum, lifecycle/replication queues, scanner repair, notification/audit events, and metadata compatibility.
- rollback preserves object IO, quorum, lifecycle/replication queues, scanner
repair, notification/audit events, and metadata compatibility.
+19 -11
View File
@@ -1,13 +1,10 @@
# Erasure Coding — Normative Algorithm & On-Disk Compatibility Contract
**Use this when:** changing anything under `crates/ecstore/src/erasure/`, `crates/filemeta/`, `crates/ecstore/src/set_disk/`, storage-class or layout code, or any decode, quorum, or heal boundary; read §12 and §13 before editing.
**Source of truth:** this document is normative for the algorithm and the on-disk / on-wire compatibility contract; the cited symbols are where the code enforces each rule.
Status: normative. This document is the source of truth for how RustFS erasure-codes, stores, reads, reconstructs, and heals user data, and for the on-disk / on-wire compatibility contract that every future change must preserve. It governs the highest-risk code in the system: a regression here can silently corrupt or lose all user data, or make existing (and MinIO-migrated) objects permanently unreadable.
Erasure coding, quorum/heal, and metadata/on-disk formats are **High-risk** per [AGENTS.md](../../AGENTS.md) ("Broad or High-Risk Changes"). Any behavior-affecting change to code this document governs requires adversarial review with the `adversarial-validation` skill and, for anything touching decode or the on-disk format, a regression test against real on-disk and MinIO-migrated samples before merge.
Erasure coding, quorum/heal, and metadata/on-disk formats are **High-risk** per [AGENTS.md](../../AGENTS.md) ("Risk tiers"). Any behavior-affecting change to code this document governs requires the full seven-role adversarial validation and, for anything touching decode or the on-disk format, a regression test against real on-disk and MinIO-migrated samples before merge.
This document describes the algorithm as implemented on `main`. The *invariant* stated is always the rule the code must satisfy; where the code enforces it, the enforcing symbol is cited.
This document describes the baseline (`main`) algorithm. Where the baseline has a known defect that a specific change corrects, that is called out inline; the *invariant* stated is always the correct rule the code must converge to, never the defect.
## How to use this document
@@ -37,6 +34,7 @@ This document describes the algorithm as implemented on `main`. The *invariant*
11. Compatibility contract and decode tolerance
12. Invariants checklist (the frozen contract)
13. Change procedure and guardrails
14. References
---
@@ -82,7 +80,7 @@ Two storage classes: `STANDARD` (SC) and `REDUCED_REDUNDANCY` (RRS) ([storagecla
- **INVARIANT — parity bounds.** Parity must satisfy `parity ≤ N/2` for both classes, and `SC parity ≥ RRS parity` when both are non-zero ([storageclass.rs](../../crates/ecstore/src/config/storageclass.rs), `validate_parity` / `validate_parity_inner`). Enforcement nuance to be aware of: `validate_parity_inner` (the path a user-configured `EC:<parity>` storage class flows through) only applies the `parity ≤ N/2` check for `N > 2`, so degenerate small-set values (e.g. `EC:2` on `N = 2`, giving `data_blocks = 0`) are not caught there; the standalone `validate_parity` enforces the bound unconditionally but is applied only to the resolved default parity. A change that lets user-configured parity reach a write path must not assume the `≤ N/2` bound was enforced for `N ≤ 2`. Parity `0` is permitted (single-drive / capacity setups); there is no non-zero minimum.
- **INVARIANT — per-pool validity.** Each pool's resolved parity must be valid for **that pool's own drive count**. A heterogeneous deployment (pools of different widths) must resolve parity per pool; applying one pool's parity to a narrower pool can drive `data_blocks = N parity` to `0` and make encoding impossible.
- Implemented by `resolve_write_layout` ([set_disk/mod.rs](../../crates/ecstore/src/set_disk/mod.rs)), which takes the pool index and resolves parity against that pool's own drive count; the default parity for a pool without explicit config comes from `ec_drives_no_config` ([store/init_format.rs](../../crates/ecstore/src/store/init_format.rs)).
- Baseline defect: `main` computes `common_parity_drives` from the **first** pool only and applies it to every pool ([store/init.rs](../../crates/ecstore/src/store/init.rs), `ec_drives_no_config` at [store/init_format.rs](../../crates/ecstore/src/store/init_format.rs)); this is issue #4801 (a smaller later pool panics with `TooFewDataShards`). The correct rule is per-pool resolution.
Per-write layout (the numbers that go into `xl.meta`), from the storage class or `default_parity_count`, with `opts.max_parity` forcing `N/2` for internal writes ([set_disk/ops/object.rs](../../crates/ecstore/src/set_disk/ops/object.rs)):
@@ -221,7 +219,7 @@ Fields: `version_id`, `mod_time`, `signature: [u8;4]`, `version_type`, `flags: u
### 6.6 Inline data
Small objects store their payload inline after the container CRC ([filemeta_inline.rs](../../crates/filemeta/src/filemeta_inline.rs)): 1 version byte (`INLINE_DATA_VER = 1`) then a msgpack map of `version-key → bin`. **INVARIANT — the map key** is the version-id string, `"null"` (`NULL_VERSION_ID`, [fileinfo.rs](../../crates/filemeta/src/fileinfo.rs)) for the null/None version, else the lowercase hyphenated UUID. Presence is determined **on read** solely by the `meta_sys[inline-data]` body marker (`FileInfo::inline_data`); the read path gates inline extraction on that marker alone. The header `InlineData` flag is **written** (mirrored from the body on marshal) but is **not** consulted on read, and a disagreement is tolerated — MinIO may leave the header flag unset while inline data is present, so a reader must **not** require the flag and the marker to agree. The inline threshold is `should_inline` ([storageclass.rs](../../crates/ecstore/src/config/storageclass.rs)): inline if `shard_size ≤ inline_block/8` for versioned buckets, else `≤ inline_block`; `DEFAULT_INLINE_BLOCK = 128 KiB`.
Small objects store their payload inline after the container CRC ([filemeta_inline.rs](../../crates/filemeta/src/filemeta_inline.rs)): 1 version byte (`INLINE_DATA_VER = 1`) then a msgpack map of `version-key → bin`. **INVARIANT — the map key** is the version-id string, `"null"` (`NULL_VERSION_ID`) for the null/None version, else the lowercase hyphenated UUID. Presence is determined **on read** solely by the `meta_sys[inline-data]` body marker (`FileInfo::inline_data`); the read path gates inline extraction on that marker alone. The header `InlineData` flag is **written** (mirrored from the body on marshal) but is **not** consulted on read, and a disagreement is tolerated — MinIO may leave the header flag unset while inline data is present, so a reader must **not** require the flag and the marker to agree. The inline threshold is `should_inline` ([storageclass.rs](../../crates/ecstore/src/config/storageclass.rs)): inline if `shard_size ≤ inline_block/8` for versioned buckets, else `≤ inline_block`; `DEFAULT_INLINE_BLOCK = 128 KiB`.
---
@@ -232,7 +230,7 @@ Small objects store their payload inline after the container CRC ([filemeta_inli
- Encode-time gates: writable disks `< write_quorum``ErasureWriteQuorum`; committed shards `< write_quorum` after encode ⇒ error ([set_disk/ops/object.rs](../../crates/ecstore/src/set_disk/ops/object.rs)).
- **INVARIANT — atomic commit with best-effort rollback.** Commit is `rename_data` (per-disk temp → final) fanned across all disks ([core/io_primitives.rs](../../crates/ecstore/src/set_disk/core/io_primitives.rs)). If write quorum is not met (`reduce_write_quorum_errs`), every successful disk is undone (`delete_version{undo_write:true}`) and the original quorum error is returned. **Baseline:** the rollback is **best-effort** — undo failures are counted and `warn!`-logged, never propagated or retried — so a write that both misses quorum *and* whose rollback partially fails can leave shards on some disks; that partial residue is reconciled later by heal/scanner, not by the commit path. The guarantee the commit path enforces is "never *reports* success below quorum", not "never leaves any bytes behind".
- On success the newly committed dir is `fi.data_dir`. Separately, `reduce_common_data_dir` votes over each disk's **`old_data_dir`** (the *superseded* dir being dereferenced) and returns it when it reaches write_quorum, so the old dir can be reclaimed (`commit_rename_data_dir`) — it is a GC input, **not** the new `data_dir`. `classify_rename_convergence` classifies the commit (`PartialCommit` / `SignatureDivergent`), but **only the multipart-complete path consumes it** (`convergence.needs_heal()``send_heal_request`); the regular `put_object` path discards the convergence result and relies on the old-data-dir cleanup / `add_partial` heal enqueue instead.
- The write layout (per-pool parity, storage class, `max_parity`) is resolved once by `resolve_write_layout` into a `WriteLayout` ([set_disk/mod.rs](../../crates/ecstore/src/set_disk/mod.rs)) and consumed by the object and multipart write paths ([set_disk/ops/object.rs](../../crates/ecstore/src/set_disk/ops/object.rs), [set_disk/ops/multipart.rs](../../crates/ecstore/src/set_disk/ops/multipart.rs)); resolution is per pool (§2.2).
- The write layout (per-pool parity, storage class, `max_parity`) is computed **inline** in the write path ([set_disk/ops/object.rs](../../crates/ecstore/src/set_disk/ops/object.rs)); on `main` there is **no** `WriteLayout` type or `resolve_write_layout` function — do not cite either as if it exists (§13's symbol-citation rule). A future refactor may centralize this; add the symbol to the spec only once it lands in code.
---
@@ -240,7 +238,7 @@ Small objects store their payload inline after the container CRC ([filemeta_inli
- **INVARIANT — read quorum = `data_blocks`.** `object_quorum_from_meta` returns `(read_quorum = data_blocks, write_quorum)` ([set_disk/metadata.rs](../../crates/ecstore/src/set_disk/metadata.rs)); `parity_blocks = common_parity(...)` is the parity value held by the most disks that still reaches its own read quorum. When `default_parity_count == 0`, read = write = all shards.
- Authoritative FileInfo selection — `find_file_info_in_quorum` ([set_disk/metadata.rs](../../crates/ecstore/src/set_disk/metadata.rs)) groups valid metas by a content-identity SHA-256 (`file_info_quorum_hash`) that hashes size/flags/mod_time/transition/version_id/data_dir/parts and, for real objects, data/parity/distribution — **excluding replication-status keys** so replication noise never splits quorum. A meta counts only if its mod_time equals the common mod_time (or etag matches when mod_time is absent). The winning hash must reach quorum, else `ErasureReadQuorum`. Latest-version reads may escalate to write_quorum to avoid resurrecting a partially-overwritten version.
- **INVARIANT — decode needs ≥ `data_blocks` shards.** The stripe reader requires `available_shards ≥ data_shards`; below that the read fails closed with a read-quorum error (never silent truncation) ([set_disk/read.rs](../../crates/ecstore/src/set_disk/read.rs), [set_disk/shard_source.rs](../../crates/ecstore/src/set_disk/shard_source.rs)). Codec geometry is validated at construction: `Erasure::try_new` / `try_new_with_options` ([erasure.rs](../../crates/ecstore/src/erasure/coding/erasure.rs)) return `ErasureConstructionError` for `data_shards == 0`, `block_size == 0`, shard-count overflow, or an unsupported shard configuration, so a read never reaches a `block_size` / `data_shards` division with invalid geometry (§13).
- **INVARIANT — decode needs ≥ `data_blocks` shards.** The stripe reader requires `available_shards ≥ data_shards`; below that the read fails closed with a read-quorum error (never silent truncation) ([set_disk/read.rs](../../crates/ecstore/src/set_disk/read.rs), [set_disk/shard_source.rs](../../crates/ecstore/src/set_disk/shard_source.rs)). Before any `block_size` / `data_shards` division, `has_valid_dimensions()` must hold (`block_size > 0 && data_shards > 0`) or the read fails instead of dividing by zero ([erasure.rs](../../crates/ecstore/src/erasure/coding/erasure.rs)); note this guard runs *after* codec construction and fully covers only `block_size == 0` — a `data_blocks == 0` geometry panics earlier in the constructor (§13).
- If `available ≥ data_blocks` but some shards are missing, the read is served **and** a background read-repair heal is enqueued.
- **INVARIANT — cross-stripe read verification.** When a data shard is missing and `available > data_blocks`, reconstruction regenerates parity and compares it to the surviving parity; a mismatch is `InvalidData "inconsistent read source shards"` (backlog#832), catching corruption that passed per-shard bitrot but disagrees across the stripe ([erasure.rs](../../crates/ecstore/src/erasure/coding/erasure.rs)).
@@ -326,12 +324,22 @@ Decode tolerance
## 13. Change procedure and guardrails
- **Risk tier.** All of the above is High-risk ([AGENTS.md](../../AGENTS.md) "Broad or High-Risk Changes"). Any behavior-affecting change requires adversarial review with the `adversarial-validation` skill.
- **Risk tier.** All of the above is High-risk ([AGENTS.md](../../AGENTS.md)). Any behavior-affecting change requires the full seven-role adversarial validation.
- **Keep this document in sync.** A change to any governed behavior, formula, format field, or invariant must update this spec in the same PR; renaming a cited symbol must update its reference here. The spec is normative and is the checklist the next change is reviewed against, so drift is a correctness defect. References are symbol-based (not line numbers) specifically so ordinary refactors do not invalidate them — but semantic changes still must.
- **Adding an on-disk field** must be additive: new msgpack key or a `minor`/`meta_ver` bump with a read path for the old value; keep decoders skipping unknown keys; write both internal-key prefixes; never repurpose or reorder existing keys or header array positions.
- **Never make a decode boundary stricter** than what §11 allows without (a) proving no legitimate older-RustFS or MinIO-migrated shape is rejected, and (b) a regression test against real on-disk and MinIO fixtures. New validation belongs at the trust boundary and must fail *open to a tolerant default*, not closed to `FileCorrupt`, for anything recoverable. (Concretely: rejecting a negative `actual_size`, or hard-failing a non-16-byte `transitioned-versionID`, breaks existing data — see §11.)
- **Codec construction is fallible.** Read, heal, multipart, and object write paths construct the codec through `Erasure::try_new` / `try_new_with_options` ([erasure.rs](../../crates/ecstore/src/erasure/coding/erasure.rs)) and surface `ErasureConstructionError` instead of panicking on geometry decoded from untrusted metadata (`data_shards == 0`, `block_size == 0`, shard-count overflow, unsupported shard counts). Do not reintroduce a panicking constructor on any path reachable from on-disk metadata; `has_valid_dimensions()` remains only a `&self` preflight for already-built codecs.
- **Codec construction (baseline gap).** The baseline exposes panicking `Erasure::new` / `new_with_options`: the codec's shard-count validation surfaces as an `.expect` panic when `data_shards == 0 && parity_shards > 0` (`ReedSolomon::new` `TooFewDataShards`). `has_valid_dimensions()` (`block_size > 0 && data_shards > 0`) is a **`&self`** method, so it can only run *after* construction — the read path builds the codec from on-disk geometry first and checks the guard second ([erasure.rs](../../crates/ecstore/src/erasure/coding/erasure.rs), [set_disk/read.rs](../../crates/ecstore/src/set_disk/read.rs)). It therefore reliably catches only the `block_size == 0` case (block size is never passed to `ReedSolomon::new`, so construction succeeds and the guard rejects it before any division); a `data_blocks == 0` xl.meta with `parity > 0` **panics in the constructor before the guard can run**. The correct fix is a **fallible constructor** (returning `Result`, not `.expect`) on any path reachable from untrusted metadata; until then `has_valid_dimensions()` is a partial preflight, not a complete guard.
- **Guardrail scripts** (part of `make pre-commit` / `make pre-pr`):
- [check_architecture_migration_rules.sh](../../scripts/check_architecture_migration_rules.sh) keeps the erasure engine crate-private and under its owner module, and keeps erasure-cache / `GLOBAL_IS_ERASURE*` access behind ecstore helpers.
- [check_doc_paths.sh](../../scripts/check_doc_paths.sh) validates that every repo path this document cites exists — keep citations to real paths.
- **Tooling.** Inspect on-disk metadata with `dump_fileinfo` / `dump_versions` per [../operations/tier-ilm-debugging.md](../operations/tier-ilm-debugging.md) rather than guessing at bytes.
---
## 14. References
- ReedSolomon codes; MDS property and GF(2⁸) byte-oriented coding — the standard basis for `rs-vandermonde` (Vandermonde generator matrix over GF(2⁸)).
- `rustfs-erasure-codec` (RustFS fork of `reed-solomon-erasure`, GF(2⁸)) and `reed-solomon-simd` (GF(2¹⁶)) — declared in the workspace `Cargo.toml`.
- HighwayHash-256 — the bitrot checksum family; π-derived default key.
- MinIO `xl.meta` v1.3 format lineage — RustFS is byte-compatible for read + one-way migration; see [minio-file-format-compat.md](minio-file-format-compat.md) for the fixture-proven matrix and scope.
- Related invariants: [placement-repair-invariants.md](placement-repair-invariants.md), [ecstore-layout-boundary.md](ecstore-layout-boundary.md), [decommission-compatibility.md](decommission-compatibility.md), [../operations/tier-ilm-debugging.md](../operations/tier-ilm-debugging.md), and [AGENTS.md](../../AGENTS.md) Cross-Cutting Domain Invariants.
@@ -1,67 +1,202 @@
# Global State And Crate Split Plan
**Use this when:** business logic needs runtime state (object store, endpoints, lock clients, lifecycle state, config) and you must pick the right boundary, or you are evaluating a new crate split out of ECStore.
**Source of truth:** `crates/ecstore/src/runtime/global.rs` and `crates/ecstore/src/runtime/sources.rs` (ECStore-owned state and its adapter), `rustfs/src/app/context.rs` and the `runtime_sources.rs` owner modules under `rustfs/src` (RustFS resolvers), and the `rustfs_ecstore::api::global` boundary list in `scripts/check_architecture_migration_rules.sh`. The static inventory is [global-state-inventory.md](global-state-inventory.md).
This document records the late global-state cleanup plan after the AppContext
foundation, storage API contracts, ECStore layout, runtime lifecycle, and cluster
control-plane boundaries are stable.
Broad resolver-fallback removal is complete: runtime resolver fallbacks live in explicit owner-local boundaries, not in the root facade. What remains is ECStore-owned bootstrap state and crate-split decisions.
As of the Phase 7 closeout, runtime resolver fallbacks have been pushed out of
the root facade and into explicit owner-local boundaries. Future work should
therefore treat broad fallback removal as complete and use this document for the
remaining ECStore-owned bootstrap state and crate-split decisions.
The issue #730 global-state baseline and runtime migration target inventory are
recorded in [`global-state-inventory.md`](global-state-inventory.md).
## Remaining Global Owners
| Owner | Role | Stance |
| Owner | Current role | Migration stance |
|---|---|---|
| `rustfs/src/app/context.rs` | AppContext-first resolver facade. | Resolver helpers stay context-first and do not construct concrete no-AppContext defaults. |
| `rustfs/src/app/context/runtime_sources.rs` | Default adapters for KMS, IAM, object store, endpoints, config, metrics, and notification state used by AppContext construction. | Allowed adapter boundary, not a business-logic owner. |
| `rustfs/src/runtime_sources.rs`, `rustfs/src/admin/runtime_sources.rs`, `rustfs/src/app/runtime_sources.rs`, `rustfs/src/server/runtime_sources.rs`, `rustfs/src/storage/runtime_sources.rs` | Owner-local runtime-source boundaries. | Business modules use these instead of global state; owner facades decide when to apply no-AppContext compatibility defaults. |
| `rustfs/src/storage_api.rs`, `rustfs/src/admin/storage_api.rs`, `rustfs/src/app/storage_api.rs`, `rustfs/src/storage/storage_api.rs` | Owner-local storage contract/facade boundaries. | Storage helper and ECStore facade access stays visible at local owner boundaries. |
| `crates/*/storage_api.rs` | External crate-local storage facade boundaries (IAM, scanner, heal, notify, observability, Swift, S3 Select). | External runtime crates read ECStore runtime state through `rustfs_ecstore::api::runtime`, never the global facade. |
| `crates/ecstore/src/runtime/global.rs` | ECStore bootstrap/runtime state owner. | Internal until ECStore has explicit owner handles for all remaining bootstrap state. |
| `crates/ecstore/src/runtime/sources.rs` | ECStore runtime-source adapter over global state. | Preferred ECStore-internal access path while direct `runtime::global` reads shrink. |
| `rustfs/src/app/context/runtime_sources.rs` | Default adapters for KMS, IAM, object store, endpoints, config, metrics, and notification state used by AppContext construction. | This is an allowed adapter boundary, not a business logic owner. |
| `rustfs/src/*/runtime_sources.rs` | Root, admin, app, server, startup, and storage owner-local runtime-source boundaries. | Business modules use these boundaries instead of calling global state directly; owner facades own any remaining no-AppContext compatibility defaults. |
| `rustfs/src/*/storage_api.rs` | Root, admin, app, and storage owner-local storage contract/facade boundaries. | Storage helper and ECStore facade access remains visible at local owner boundaries. |
| `crates/*/storage_api.rs` | External crate-local storage facade boundaries for IAM, scanner, heal, notify, observability, Swift, and S3 Select. | External runtime crates consume ECStore runtime state through `rustfs_ecstore::api::runtime` instead of the direct global facade. |
| `crates/ecstore/src/runtime/global.rs` | ECStore bootstrap/runtime state owner. | Keep internal until ECStore has explicit owner handles for all remaining bootstrap state. |
| `crates/ecstore/src/runtime/sources.rs` | ECStore runtime-source adapter over global state. | Preferred ECStore-internal access path while shrinking direct `runtime::global` reads. |
## Runtime Source Boundaries
Runtime-source modules are the allowed compatibility layer between migrated consumers and process-global state. They keep these properties:
Runtime-source modules are the allowed compatibility layer between migrated
consumers and process-global state. They must keep these properties:
- context-first lookup when an `AppContext` handle exists;
- explicit fallback to the existing global only where compatibility still requires it, decided by the owner facade;
- explicit fallback to the existing global only where compatibility still
requires it;
- no hidden service construction in business logic;
- the root `rustfs/src/runtime_sources.rs` is an entrypoint only: it composes no concrete fallback defaults (`unwrap_or`, `unwrap_or_else`, direct `init_global` or `new_global` calls);
- production callers outside runtime-source and `storage_api.rs` boundary modules do not import ECStore global state directly.
- no startup, readiness, IAM, KMS, lock, notification, or storage behavior
change in inventory or guardrail PRs.
### Guarded Boundary List
## Guarded Boundary List
The guard pins the production files allowed to reference `rustfs_ecstore::api::global` directly:
The architecture guard snapshots the files currently allowed to reference
`rustfs_ecstore::api::global` directly:
- `rustfs/src/storage/storage_api.rs`
That boundary keeps only bootstrap writes and lifecycle controls (`set_global_endpoints`, `set_global_region`, `set_global_rustfs_port`, `set_object_store_resolver`, `shutdown_background_services`, `update_erasure_type`). Read-only runtime getters are exported through `rustfs_ecstore::api::runtime` and consumed through the local storage facade. A new direct use either moves behind an existing owner-local boundary or updates this plan and the guard in the same reviewed change.
That boundary now keeps only bootstrap writes and lifecycle controls in the
global facade. Read-only runtime getters must be exported through
`rustfs_ecstore::api::runtime` and consumed through the local storage facade.
New direct uses must either move behind an existing owner-local boundary or
update this plan and the guard in the same reviewed migration PR.
## Fallback Removal Plan
1. AppContext-first lookup is the stable resolver contract.
2. Concrete no-AppContext compatibility defaults exist only at the owner-local runtime-source facades that consume them.
3. Business logic does not call `AppContext` or ECStore globals directly when an owner-local runtime-source boundary exists.
4. Embedded startup and tests keep working before any remaining owner fallback is deleted.
5. ECStore bootstrap globals stay until ownership handles exist for local disks, endpoint pools, lock clients, notification state, tier config, lifecycle state, and object-store publication.
1. Keep AppContext-first lookup as the stable resolver contract.
2. Keep concrete no-AppContext compatibility defaults only at owner-local
runtime-source facades that consume them.
3. Do not let business logic call `AppContext` or ECStore globals directly when
an owner-local runtime-source boundary exists.
4. Keep embedded startup and tests working before deleting any remaining owner
fallback.
5. Do not remove ECStore bootstrap globals until ownership handles exist for
local disks, endpoint pools, lock clients, notification state, tier config,
lifecycle state, and object-store publication.
## GLOB-007 Closeout Boundary
`GLOB-007` is complete when these invariants hold:
- root `rustfs/src/runtime_sources.rs` is an AppContext/root facade entrypoint
and no longer composes concrete fallback defaults with `unwrap_or`,
`unwrap_or_else`, direct `init_global`, or direct `new_global` calls;
- private AppContext resolver helpers are context-first and do not hide fallback
closure parameters;
- admin, app, storage, server, startup, and config owner facades decide when to
apply no-AppContext compatibility defaults;
- production callers outside runtime-source and storage-api boundary modules do
not import ECStore global state directly;
- the architecture guard keeps the direct `rustfs_ecstore::api::global`
boundary list explicit.
Allowed remaining fallbacks are owner compatibility decisions, not resolver
fallback families. They are kept so embedded startup, tests, and no-context
callers preserve the previous behavior while higher layers continue migrating
to explicit AppContext ownership.
## Crate Split Evaluation
`ecstore-erasure` and `storage-cluster` are proposal-only; neither is ready for code movement. Lifecycle and replication split status is tracked in [ecstore-module-split-plan.md](ecstore-module-split-plan.md).
`ecstore-erasure` and `storage-cluster` remain proposal-only until dependency
cycles and hot-path risks are proven safe. The Phase 7 evaluation is complete
for now: neither split is ready for code movement in this migration round.
The follow-up ECStore module split plan is recorded in
[`ecstore-module-split-plan.md`](ecstore-module-split-plan.md), including the
remaining `SetDisks`, lifecycle, replication, and facade-shrink boundaries.
### `ecstore-erasure`
### CRATE-001: `ecstore-erasure`
Coupling: erasure decoding depends on disk errors, disk read timeouts, and set-disk shard sources; set-disk read/write/heal paths construct codecs in hot object I/O paths; bitrot readers/writers live in ECStore IO support and serve both erasure and set-disk code; `rustfs_ecstore::api::erasure` is still a public compatibility surface.
Current coupling:
Decision: do not split. The boundary becomes a candidate only after shard-source, disk-error, bitrot, and metrics contracts are explicit enough to avoid a dependency cycle back into ECStore, backed by encode/decode/reconstruction benchmarks and a rollback plan that keeps read/write quorum and old-version decode unchanged.
- erasure decoding depends on disk errors, disk read timeouts, and set-disk
shard sources;
- set-disk read/write/heal paths construct erasure codecs in hot object I/O
paths;
- bitrot readers/writers live in ECStore IO support and are used by both
erasure and set-disk code;
- public compatibility still exposes erasure symbols through
`rustfs_ecstore::api::erasure`.
### `storage-cluster`
Decision: do not split in code yet. The erasure boundary is a candidate only
after the shard-source, disk-error, bitrot, and metrics contracts are explicit
enough to avoid a dependency cycle back into ECStore.
Coupling: cluster RPC remote-disk code depends on disk stores, disk health tracking, set-disk buffer sizing, local disk scan guards, internode metrics, and runtime credential/signature sources; peer S3 and peer REST clients share bucket metadata, disk quorum reduction, endpoint layout, local disk initialization, and store helpers; control-plane snapshots are separate from data-plane RPC, but remote disk and peer clients still own data-movement side effects inside ECStore.
Required evidence before proposing the split:
Decision: do not split. The boundary becomes a candidate only after remote disk, peer health, lock/quorum, runtime metrics, and endpoint layout contracts can stand below ECStore without cycles, with compatibility plans for `rustfs_ecstore::api::cluster` and `api::rpc` and focused tests for remote disk error classification, peer health recovery, per-pool quorum reduction, lock behavior, and data-stream request paths.
- `cargo tree -p rustfs-ecstore -e normal --depth 2` snapshot for dependency
impact;
- focused benchmarks for encode/decode, read reconstruction, bitrot verification,
and large-object streaming;
- contract sketch for shard sources, disk errors, bitrot IO, metrics, and file
metadata without importing ECStore implementation modules;
- compatibility plan for `rustfs_ecstore::api::erasure` and test harnesses;
- rollback plan that keeps object read/write quorum and old-version file decode
behavior unchanged.
### CRATE-002: `storage-cluster`
Current coupling:
- cluster RPC remote disk code depends on disk stores, disk health tracking,
set-disk buffer sizing, local disk scan guards, internode metrics, and runtime
credential/signature sources;
- peer S3 and peer REST clients share bucket metadata, disk quorum reduction,
endpoint layout, local disk initialization, and store helpers;
- control-plane snapshots are separated from data-plane RPC, but remote disk and
peer clients still own data movement side effects inside ECStore.
Decision: do not split in code yet. The storage-cluster boundary is a candidate
only after remote disk, peer health, lock/quorum, runtime metrics, and endpoint
layout contracts are explicit enough to stand below ECStore without circular
dependencies.
Required evidence before proposing the split:
- dependency graph showing no cycle with ECStore, `rustfs-storage-api`, runtime
source owners, or cluster control-plane owners;
- RPC contract sketch for remote disk, peer S3, peer REST, auth/signature,
internode metrics, and cancellation;
- compatibility plan for `rustfs_ecstore::api::cluster`, `api::rpc`, and test
fixtures that build local disks or endpoint pools;
- focused tests for remote disk error classification, peer health recovery,
per-pool quorum reduction, lock behavior, and data-stream request paths;
- rollback plan that preserves quorum, remote disk IO, lock, peer health, and
data movement behavior.
### CRATE-003: `bucket-lifecycle`
Decision: do not split in code yet. Lifecycle remains coupled to ECStore object
operations, bucket metadata, `SetDisks` stale multipart cleanup, tier config,
runtime lifecycle state, scanner metrics, notification/audit side effects, and
replication delete scheduling.
Required evidence before proposing the split:
- contract sketch for lifecycle object operations, metadata access, runtime
state, replication delete scheduling, and audit/notification sinks;
- dependency graph showing the candidate crate can avoid importing ECStore
implementation modules;
- focused tests for lifecycle evaluation, expiry, transition, stale multipart
cleanup, tier journal recovery, and lifecycle-originated replication deletes;
- compatibility plan for `rustfs_ecstore::api::bucket::lifecycle` consumers;
- rollback plan that preserves lifecycle queues, scanner repair accounting,
tier transitions, object deletion behavior, and notification/audit events.
### CRATE-004: `bucket-replication`
Decision: do not split in code yet. Replication remains coupled to ECStore
object APIs, bucket target clients, metadata systems, file metadata replication
state, ECStore-owned runtime replication pool/stat handles, bucket monitor
state, scanner repair classification, lifecycle-originated deletes, and
notification events. RustFS-facing runtime consumers should use storage-owner
wrapper handles while that state remains in ECStore.
Required evidence before proposing the split:
- contract sketch for replication storage operations, metadata/target access,
runtime pool and stats, event sinks, and lifecycle/heal bridges;
- dependency graph showing the candidate crate can avoid importing ECStore
implementation modules;
- focused tests for object replication, delete replication, resync state, heal
repair queueing, target error handling, and queue admission;
- compatibility plan for `rustfs_ecstore::api::bucket::replication` consumers;
- rollback plan that preserves replication queues, MRF/resync state, target
client behavior, scanner repair, and event emission.
## Preservation Rules
- Do not reintroduce AppContext resolver fallback families in broad cleanups.
- Do not introduce direct global reads in admin, app, server, storage, scanner, heal, IAM, notify, observability, Swift, or S3 Select business logic.
- Do not split crates in the same change that moves runtime state.
- Do not change startup order, readiness, KMS fatal boundaries, IAM recovery, lock quorum, object placement, reader behavior, or notification/audit lifecycle while shrinking global state.
- Do not reintroduce AppContext resolver fallback families in broad cleanup PRs.
- Do not introduce direct global reads in admin, app, server, storage, scanner,
heal, IAM, notify, observability, Swift, or S3 Select business logic.
- Do not split crates in the same PR that moves runtime state.
- Do not change startup order, readiness, KMS fatal boundaries, IAM recovery,
lock quorum, object placement, reader behavior, or notification/audit
lifecycle while shrinking global state.
+126 -44
View File
@@ -1,64 +1,146 @@
# Global State Inventory
**Use this when:** you meet a `GLOBAL_*` static or an `OnceLock` and need to know whether it is a runtime ownership handle (reach it through a boundary), an owner-local static (leave it inside its module), or process-global by design.
**Source of truth:** `crates/ecstore/src/api/mod.rs` (the `pub mod runtime` and `pub mod global` re-export lists), `crates/ecstore/src/runtime/global.rs`, `crates/ecstore/src/runtime/sources.rs`, and the statics themselves. Boundary rules are in [global-state-crate-split-plan.md](global-state-crate-split-plan.md).
This inventory records the issue #730 baseline for global runtime state after
the AppContext foundation and owner-local runtime-source boundaries were added.
It is intentionally documentation-only: it classifies migration targets without
changing startup, readiness, object IO, lifecycle, replication, or notification
behavior.
## Counting Baseline
The audit uses the current workspace Rust sources and keeps broad static
caches separate from runtime migration targets.
| Scope | Count | Command |
|---|---:|---|
| Rust source files | 1,252 | `rg --files -g '*.rs'` |
| `OnceLock` references | 221 lines | `rg -n --glob '*.rs' 'OnceLock'` |
| `GLOBAL_*` references | 273 lines | `rg -n --glob '*.rs' '\bGLOBAL_[A-Za-z0-9_]*\b'` |
| `static NAME:` definitions | 621 lines | `rg -n --glob '*.rs' '^\s*(pub(\([^)]*\))?\s+)?static(\s+mut)?\s+[A-Za-z_][A-Za-z0-9_]*\s*:'` |
| `lazy_static!` `static ref` definitions | 58 lines | `rg -n --glob '*.rs' '^\s*(pub\s+)?static\s+ref\s+[A-Za-z_][A-Za-z0-9_]*\s*:'` |
| `static mut` definitions | 0 lines | `rg -n --glob '*.rs' '^\s*(pub(\([^)]*\))?\s+)?static\s+mut\s+'` |
## Global State Classification
| Category | Rule | Representative owners |
|---|---|---|
| Process-global | Process identity, metrics registries, lock manager, audit guard, TLS material, or other state intentionally one per process. | `GLOBAL_LOCK_MANAGER` (`crates/lock`), `GLOBAL_CONN_MAP` (`crates/common`), `GLOBAL_RUSTFS_RPC_SECRET` (`crates/credentials`), `AUDIT_SYSTEM` (`crates/audit`), `crates/io-metrics`, `crates/obs`, `crates/tls-runtime` |
| Runtime migration target | Mutable runtime state describing the active object store, endpoints, local disks, lifecycle, replication, notification, config, or background controllers. | `crates/ecstore/src/runtime/global.rs`, `crates/ecstore/src/runtime/sources.rs`, `rustfs/src/app/context/` |
| Owner-local compatibility | Adapters allowed to read globals while callers migrate to AppContext-first or owner-local runtime-source APIs. | `rustfs/src/*/runtime_sources.rs`, `rustfs/src/*/storage_api.rs`, `crates/*/storage_api.rs` |
| Owner-local static | A static private to one module and reached only through that module's functions: caches, single-run guards, admission locks, module toggles. | The RustFS inventory below |
| Test or fixture state | Static setup that amortizes expensive ECStore setup or isolates harness state. | `rustfs/src/app/*_test.rs`, `crates/scanner/tests/`, `crates/test-utils/src/ecstore_test_compat.rs` |
| Cache or constant | Regexes, metrics descriptors, defaults, KVS registrations, headers, path constants. | `crates/config`, `crates/obs/src/metrics`, `crates/utils` |
| Process-global | Process identity, metrics registries, lock manager, audit guard, TLS material, or other state that is intentionally one per process. | `crates/credentials`, `crates/common`, `crates/io-metrics`, `crates/lock`, `crates/obs`, `crates/tls-runtime` |
| Runtime migration target | Mutable runtime state that describes the active object store, endpoints, local disks, lifecycle, replication, notification, config, or background controllers. | `crates/ecstore/src/runtime/global.rs`, `crates/ecstore/src/runtime/sources.rs`, `rustfs/src/app/context/*` |
| Owner-local compatibility | Existing compatibility adapters that are allowed to read globals while callers migrate to AppContext-first or owner-local runtime-source APIs. | `rustfs/src/*/runtime_sources.rs`, `rustfs/src/*/storage_api.rs`, `crates/*/storage_api.rs` |
| Test or fixture state | Static setup used by tests to amortize expensive ECStore setup or isolate compatibility harness state. | `rustfs/src/app/*_test.rs`, `crates/scanner/tests/*`, `crates/ecstore/src/**/tests` |
| Cache or constant | Regexes, metrics descriptors, defaults, KVS registrations, headers, path constants, and small process caches that are not runtime ownership handles. | `crates/config`, `crates/obs/src/metrics`, `crates/utils`, `rustfs/src/server/readiness.rs` |
| Legacy naming or review-needed | Old MinIO-port naming, stale comments, or names that need owner confirmation before code movement. | `GLOBAL_OBJECT_API` |
## Runtime Migration Inventory
Runtime ownership handles that exist today. Reads go through `rustfs_ecstore::api::runtime`, bootstrap writes go through `rustfs_ecstore::api::global`, and RustFS code reaches both only from `rustfs/src/storage/storage_api.rs` and the AppContext resolvers.
These are the issue #730 targets that should remain visible until an owner
migration PR removes or replaces each item.
| Handle (`rustfs_ecstore::api::runtime`) | Backing state | Stance |
|---|---|---|
| `object_store_handle` | `GLOBAL_OBJECT_API`, `GLOBAL_OBJECT_STORE_RESOLVER` (`crates/ecstore/src/runtime/global.rs`); the resolver is published from the AppContext owner path | Do not migrate first: tied to storage startup, IAM-after-storage AppContext publication, and data-plane resolver compatibility. |
| `endpoint_pools`, `setup_is_erasure`, `setup_is_dist_erasure`, `setup_is_erasure_sd`, `first_cluster_node_is_local` | `GLOBAL_ENDPOINTS` and setup-type state (`crates/ecstore/src/runtime/global.rs`) | Move endpoint ownership only after readiness and quorum behavior have explicit coverage. |
| `local_disk_map_read` | Local disk map and set-drive state (`crates/ecstore/src/runtime/sources.rs`) | Preserve disk lookup, remote/local classification, and test reset hooks. |
| `expiry_state_handle`, `transition_state_handle` | Lifecycle expiry and transition state, `GLOBAL_LIFECYCLE_SYS` (`crates/ecstore/src/runtime/global.rs`) | Lifecycle owner helpers and the AppContext `ExpiryStateInterface` (`rustfs/src/app/context/interfaces.rs`) are the caller boundary; the scanner still reads `expiry_state_handle` until it gets an injected provider. |
| `global_tier_config_mgr` | Tier config manager | Reads and reloads stay behind this helper. |
| `bucket_monitor` | Replication bandwidth monitor | Replication pool/stat handles are projected into RustFS wrapper types at the storage boundary. |
| `global_lock_client`, `global_lock_clients` | `GLOBAL_LOCAL_LOCK_CLIENT`, `GLOBAL_LOCK_CLIENTS` (`crates/ecstore/src/runtime/global.rs`) | Preserve lock quorum and client selection; the process-level `GLOBAL_LOCK_MANAGER` stays separate. |
| `boot_time`, `deployment_id`, `region`, `rustfs_port` | `GLOBAL_BOOT_TIME`, deployment id, region, and port state (`crates/ecstore/src/runtime/global.rs`) | Scalar writes remain behind the `api::global` setters (`set_global_endpoints`, `set_global_region`, `set_global_rustfs_port`, `set_object_store_resolver`, `shutdown_background_services`, `update_erasure_type`). |
| State | Current boundary | Category | Migration stance |
|---|---|---|---|
| `APP_CONTEXT_SINGLETON` | `rustfs/src/app/context/global.rs` | Owner-local compatibility | Keep as the context-first facade while no-context startup and embedded callers still exist. |
| `GLOBAL_OBJECT_API`, `GLOBAL_OBJECT_STORE_RESOLVER` | `crates/ecstore/src/runtime/global.rs`, `rustfs/src/app/context/global.rs`, and storage compatibility APIs | Runtime migration target | Do not migrate first; it is tied to storage startup, IAM-after-storage AppContext publication, and data-plane resolver compatibility. The object-store resolver is now published from the AppContext owner path, no longer re-exported from the RustFS storage root, and RustFS AppContext tests no longer use the old `new_object_layer_fn` fallback chain. RustFS storage root no longer re-exports ECStore runtime/global facade symbols; callers must use storage/app/admin facades. |
| `GLOBAL_ENDPOINTS`, `GLOBAL_IS_ERASURE`, `GLOBAL_IS_DIST_ERASURE`, `GLOBAL_IS_ERASURE_SD`, `GLOBAL_ROOT_DISK_THRESHOLD` | `crates/ecstore/src/runtime/global.rs` and `crates/ecstore/src/runtime/sources.rs` | Runtime migration target | Endpoint and setup-type reads now flow through ECStore `api::runtime` helpers at the RustFS storage facade boundary; root-disk-threshold access stays behind ECStore runtime helpers. Move endpoint ownership only after readiness and quorum behavior have explicit coverage. |
| `GLOBAL_LOCAL_DISK_MAP`, `GLOBAL_LOCAL_DISK_ID_MAP`, `GLOBAL_LOCAL_DISK_SET_DRIVES` | `crates/ecstore/src/runtime/global.rs` and `crates/ecstore/src/runtime/sources.rs` | Runtime migration target | Local disk map, disk-id cache, and set-drive access now stay behind ECStore runtime-source helpers instead of direct global access; preserve disk lookup, remote/local classification, and test reset hooks in later ownership changes. |
| `GLOBAL_EXPIRY_STATE`, `GLOBAL_TRANSITION_STATE`, `GLOBAL_LIFECYCLE_SYS` | `crates/ecstore/src/bucket/lifecycle/*`, `crates/ecstore/src/runtime/global.rs`, and `crates/ecstore/src/runtime/sources.rs` | Runtime migration target | Lifecycle state globals now stay behind ECStore lifecycle owner helpers and ECStore runtime-source helpers; RustFS AppContext has expiry/transition state interfaces and resolver coverage, and daily tier stats derive from the transition-state handle instead of a separate context boundary; scanner expiry-state access still uses the ECStore runtime `expiry_state_handle` boundary until scanner gets an injected provider. |
| `GLOBAL_REPLICATION_POOL`, `GLOBAL_REPLICATION_STATS`, `GLOBAL_BUCKET_MONITOR` | `crates/ecstore/src/bucket/replication/*`, `crates/ecstore/src/runtime/global.rs` | Runtime migration target | Replication pool/stat access now stays behind replication owner and ECStore runtime-source helpers; bucket-monitor reads now flow through ECStore `api::runtime` at the RustFS storage facade boundary while AppContext/runtime-source resolvers remain the caller boundary. |
| `GLOBAL_TIER_CONFIG_MGR`, `GLOBAL_STORAGE_CLASS`, `GLOBAL_CONFIG_SYS`, `GLOBAL_SERVER_CONFIG` | `crates/ecstore/src/config`, `crates/config`, `rustfs/src/app/context/runtime_sources.rs` | Runtime migration target | Tier config manager reads and reloads now use the ECStore runtime-source helper; move remaining config state through config/runtime-source owners only, without combining storage-class behavior or persistence changes. |
| `GLOBAL_EVENT_NOTIFIER`, `GLOBAL_NOTIFICATION_SYS` | `crates/ecstore/src/runtime/global.rs`, `crates/ecstore/src/runtime/sources.rs`, and `crates/ecstore/src/services/*` | Runtime migration target | `GLOBAL_EVENT_NOTIFIER` and `GLOBAL_NOTIFICATION_SYS` access now stay behind ECStore runtime-source and notification owner helpers; move remaining notification ownership only through notify/runtime-source boundaries. |
| `EVENT_DISPATCH_HOOK` | `crates/ecstore/src/services/event_notification.rs`, RustFS server event bridge, and storage compatibility APIs | Runtime migration target / owner helper | Direct hook storage stays inside the ECStore event-notification owner; RustFS registers the bridge through the storage compatibility facade until event dispatch ownership moves behind an injected notification sink. |
| `GLOBAL_BUCKET_METADATA_SYS` | `crates/ecstore/src/bucket/metadata_sys.rs`, `crates/ecstore/src/runtime/sources.rs`, and RustFS storage compatibility APIs | Runtime migration target | Bucket metadata system direct access now stays inside the ECStore metadata owner; callers use metadata owner helpers or storage/runtime-source compatibility functions until metadata ownership moves behind an injected runtime context. |
| `GLOBAL_BOOT_TIME`, `GLOBAL_BACKGROUND_SERVICES_CANCEL_TOKEN`, `GLOBAL_DEPLOYMENT_ID`, `GLOBAL_REGION`, `GLOBAL_RUSTFS_PORT`, `GLOBAL_LOCAL_NODE_NAME_FALLBACK`, `GLOBAL_LOCAL_NODE_NAME_HEX_FALLBACK` | `crates/ecstore/src/runtime/global.rs`, `crates/ecstore/src/runtime/sources.rs` | Runtime migration target | Boot time, background service cancellation token reads, ECStore local-node-name fallback reads, and deployment ID/region/port reads now stay behind the ECStore runtime-source API; scalar writes remain behind bootstrap owner helpers until ownership handles replace them. |
| `WORKLOAD_ADMISSION_SNAPSHOT_PROVIDER` | `crates/ecstore/src/runtime/sources.rs`, RustFS startup background setup, and storage compatibility APIs | Runtime migration target / owner helper | Startup publishes the workload provider through the storage compatibility facade, and ECStore data movement reads it only through the runtime-source helper until workload admission ownership moves into an explicit runtime context. |
| `GLOBAL_LOCAL_LOCK_CLIENT`, `GLOBAL_LOCK_CLIENTS`, `GLOBAL_LOCK_MANAGER` | `crates/ecstore/src/runtime/global.rs`, `crates/lock` | Runtime migration target / process-global split | ECStore lock client reads now flow through ECStore `api::runtime` helpers at the RustFS storage facade boundary; preserve lock quorum and lock client selection while keeping the process-level lock manager separate from endpoint-specific clients. |
| `GLOBAL_CONN_MAP`, `GLOBAL_LOCAL_NODE_NAME`, `GLOBAL_RUSTFS_HOST`, `GLOBAL_RUSTFS_ADDR`, `GLOBAL_ROOT_CERT`, `GLOBAL_MTLS_IDENTITY`, `GLOBAL_OUTBOUND_TLS_GENERATION` | `crates/common`, `crates/tls-runtime`, `crates/ecstore/src/runtime/sources.rs` | Runtime migration target / process-global split | Internode connection cache, common local node name, RustFS host/address reads, and outbound TLS material reads are now owned behind `rustfs_common` helpers; migrate the remaining transport and TLS state only after internode transport and outbound TLS ownership are explicit, without changing cached channel reuse or TLS reload semantics. |
| `GLOBAL_RUSTFS_RPC_SECRET` | `crates/credentials`, `crates/ecstore/src/runtime/sources.rs` | Runtime migration target / process-global split | RPC auth token writes now stay behind the `rustfs_credentials` helper boundary; migrate only if runtime secret ownership changes, preserving lazy environment and credential-derived token semantics. |
| `GLOBAL_HEAL_MANAGER`, `GLOBAL_HEAL_CHANNEL_PROCESSOR`, `GLOBAL_AHM_SERVICES_CANCEL_TOKEN` | `crates/heal/src/lib.rs` | Runtime migration target / process-global split | Direct access now stays inside the heal owner; callers use heal helper functions until heal runtime ownership moves behind explicit owner handles. |
| `AUDIT_SYSTEM` | `crates/audit/src/global.rs` | Runtime migration target / process-global split | Direct global access now stays inside the audit owner; callers use audit helper functions until audit lifecycle ownership moves behind AppContext or a runtime-source boundary. |
| `GLOBAL_PROCESSORS` | `crates/ecstore/src/services/batch_processor.rs`, `crates/ecstore/src/runtime/sources.rs` | Runtime migration target / owner helper | Direct static access now stays inside the ECStore batch processor owner; callers use `get_global_processors` or the ECStore runtime-source helper until processor ownership moves into an injected runtime context. |
| `INTERNODE_DATA_TRANSPORT` | `crates/ecstore/src/cluster/rpc/internode_data_transport.rs` | Runtime migration target / owner helper | Direct static access now stays inside the ECStore internode transport owner; callers use `build_internode_data_transport_from_env` until backend selection moves into an injected runtime context. |
| `GLOBAL_KMS_SERVICE_MANAGER` | `crates/kms/src/service_manager.rs`, RustFS KMS runtime sources | Runtime migration target / owner helper | Direct static access now stays inside the `rustfs_kms` service manager owner; RustFS callers use KMS helpers or AppContext/runtime-source handles until KMS ownership fully moves into runtime context. |
| `GLOBAL_CAPACITY_MANAGER` | `crates/object-capacity/src/capacity_manager.rs`, RustFS capacity service | Runtime migration target / owner helper | Direct static access now stays inside the object-capacity owner; callers use `get_capacity_manager` or isolated manager factories until capacity ownership moves into an injected runtime context. |
| `GLOBAL_BUCKET_TARGET_SYS` | `crates/ecstore/src/bucket/bucket_target_sys.rs`, admin/app/scanner/replication target paths | Runtime migration target / owner helper | Direct static access now stays inside the ECStore bucket target owner; callers still use `BucketTargetSys::get()` until bucket target ownership moves behind a runtime-source or replication target boundary. |
| `USAGE_MEMORY_CACHE`, `USAGE_CACHE_UPDATING` | `crates/ecstore/src/data_usage/mod.rs` | Runtime migration target / owner-local cache | Data-usage memory overlay and singleflight state stay private to the ECStore data-usage owner; callers use data-usage functions until scanner/data-usage ownership moves behind an injected runtime context. |
Owner-helper handles outside the runtime-source list stay inside their owner and are reached through owner functions: `GLOBAL_EVENT_NOTIFIER` (`crates/ecstore/src/runtime/global.rs`); `GLOBAL_NOTIFICATION_SYS`, `EVENT_DISPATCH_HOOK`, `GLOBAL_PROCESSORS`, `INTERNODE_DATA_TRANSPORT`, `GLOBAL_BUCKET_TARGET_SYS`, `GLOBAL_CONFIG_SYS`, `GLOBAL_STORAGE_CLASS`, `WORKLOAD_ADMISSION_SNAPSHOT_PROVIDER` (ECStore owner modules); `GLOBAL_SERVER_CONFIG` (`crates/config/src/server_config.rs`); `GLOBAL_HEAL_RUNTIME`, `GLOBAL_AHM_SERVICES_CANCEL_TOKEN` (`crates/heal/src/lib.rs`); `GLOBAL_KMS_SERVICE_MANAGER` (`crates/kms/src/service_manager.rs`); `GLOBAL_CAPACITY_MANAGER` (`crates/object-capacity/src/capacity_manager.rs`); `APP_CONTEXT_SINGLETON` (`rustfs/src/app/context/global.rs`).
## Owner-Local Cache Inventory
Regenerate:
These owner-local caches and static guards are part of the broad issue #730
`OnceLock` audit, but they are not runtime ownership handles. They stay private
to the defining owner module; callers must use the existing owner APIs instead
of reaching across module boundaries.
```bash
rg -n -A4 'pub use crate::runtime::(sources|global)::' crates/ecstore/src/api/mod.rs
rg -n --glob '*.rs' 'static (ref )?GLOBAL_[A-Z_]+' crates rustfs/src
```
| State | Owner boundary | Category | Migration stance |
|---|---|---|---|
| `READ_REPAIR_HEAL_CACHE` | `crates/ecstore/src/set_disk/read.rs` | Cache or constant / owner-local cache | Read-repair heal suppression stays local to set-disk read handling. |
| `DISK_COMPRESSION_CONFIG` | `crates/ecstore/src/io_support/compress.rs` | Cache or constant / owner-local cache | Disk compression environment parsing stays local to IO support compression helpers. |
| `CACHED_MAX_INFLIGHT_BYTES`, `CACHED_BATCH_BLOCKS`, `CACHED_BYTESMUT_INGEST` | `crates/ecstore/src/erasure/coding/encode.rs` | Cache or constant / owner-local cache | Erasure encode tuning caches stay local to the coding owner. |
| `CACHED_PUT_LARGE_BATCH_MIN_SIZE_BYTES`, `CACHED_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES`, `OBJECT_LOCK_DIAG_ENABLED` | `crates/ecstore/src/set_disk/mod.rs` | Cache or constant / owner-local cache | Set-disk batching and diagnostics caches stay local to the set-disk owner. |
| `DRIVE_TIMEOUT_PROFILE_CACHE`, `DRIVE_TIMEOUT_HEALTH_POLICY_CACHE` | `crates/ecstore/src/disk/disk_store.rs` | Cache or constant / owner-local cache | Drive timeout environment caches stay local to the disk-store owner. |
| `TIER_FREE_VERSION_RECOVERY_STARTED`, `TIER_DELETE_JOURNAL_RECOVERY_STARTED` | `crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs` | Cache or constant / owner-local static guard | Lifecycle recovery single-run guards stay local to lifecycle operations. |
| `REMOTE_DELETE_INFLIGHT`, `REMOTE_DELETE_LIMITER`, `REMOTE_DELETE_BREAKER`, `REMOTE_TIER_DELETE_TEST_HOOK` | `crates/ecstore/src/bucket/lifecycle/tier_sweeper.rs` | Cache or constant / owner-local static guard | Remote tier delete concurrency, breaker, and test hook state stay local to the tier sweeper owner. |
| `ACTIVE_REGISTRY`, `BackendCapacity` | `crates/kms/src/policy.rs` | Process-global owner-local admission capacity registry | KMS policy generations share only active semaphore capacity by backend identity; each generation owns fresh bounded queues and circuit breakers. Callers access this state only through `RetryPolicy`. |
## RustFS Owner-Local Static Inventory
RustFS-side statics that matter architecturally because other modules are tempted to reach them. They stay private to their owner module; callers use the owner's functions.
These RustFS-side lazy, atomic, and `OnceLock` statics are also part of the
issue #730 process-static audit. They are private implementation details for
their owner modules, not shared runtime ownership handles. This section excludes
allocator statics, public contract/error references, route handler constants,
and `APP_CONTEXT_SINGLETON`, which is classified in the runtime migration
inventory. Generic function-local names such as `CACHE`, `LOCK`, `INIT`, and
`ENABLED` are documented by owner row instead of name-regex guarded.
| Static | Owner | Stance |
|---|---|---|
| `KEYSTONE_AUTH`, `KEYSTONE_MAPPER`, `KEYSTONE_CONFIG` | `rustfs/src/auth_keystone.rs` | Keystone provider, mapper, and config stay private to the Keystone owner. |
| `DEADLOCK_DETECTOR` | `rustfs/src/storage/deadlock_detector.rs` | Detector lifecycle stays private to the storage deadlock detector. |
| `CONCURRENCY_MANAGER` | `rustfs/src/storage/concurrency/manager.rs` | Storage concurrency scheduler state stays inside the concurrency owner. |
| `GLOBAL_KMS_DEK_PROVIDER`, `GLOBAL_SSE_DEK_PROVIDER` | `rustfs/src/storage/sse.rs` | DEK provider caches stay private to the SSE owner. |
| `ECSTORE_EVENT_DISPATCH_HOOK` | `rustfs/src/server/event.rs` | Event bridge registration goes through the storage facade. |
| `AUDIT_MODULE_ENABLED`, `NOTIFY_MODULE_ENABLED` | `rustfs/src/module_switches.rs` | Module toggles are read through module-switch helpers; `MODULE_SWITCH_RMW_LOCK` (`rustfs/src/server/module_switch.rs`) serializes persisted updates. |
| `RUNTIME_CONFIG_RELOAD_MUTEX` | `rustfs/src/admin/service/config.rs` | Serializes dynamic config reload fanout. |
| `EMBEDDED_RUNTIME_OWNERS` | `rustfs/src/startup_shutdown.rs` | Embedded runtime owner handles used for shutdown ordering. |
| `SERVICE_FROZEN` | `rustfs/src/admin/handlers/system.rs` | Service freeze flag stays behind the system admin handler. |
| `RECONCILER` | `rustfs/src/site_replication_reconcile.rs` | Site-replication reconciler singleton. |
| `CONSOLE_CONFIG` | `rustfs/src/admin/console.rs` | Console bootstrap config. |
| `LICENSE_STATE`, `LICENSE_VERIFIER` | `rustfs/src/license.rs` | License state and verifier stay behind license helpers. |
| State | Owner boundary | Category | Migration stance |
|---|---|---|---|
| `KEYSTONE_AUTH`, `KEYSTONE_MAPPER`, `KEYSTONE_CONFIG` | `rustfs/src/auth_keystone.rs` | Process-global owner-local state | Keystone authentication provider, identity mapper, and config stay private to the Keystone auth owner. |
| `LICENSE_STATE`, `LICENSE_VERIFIER` | `rustfs/src/license.rs` | Process-global owner-local state | License state and verifier selection stay private to the license owner; callers use license helper functions. |
| `CPU_CONT_GUARD`, `PROFILING_CANCEL_TOKEN` | `rustfs/src/profiling.rs` | Process-global owner-local guard | CPU profiling guard and cancellation state stay private to the profiling owner. |
| `MEMORY_SYSTEM` | `rustfs/src/memory_observability.rs` | Process-global owner-local cache | Memory sampling keeps the `sysinfo::System` cache private to the memory observability owner. |
| `DISPLAY_CONFIG_SNAPSHOT`, `GLOBAL_CONFIG_SNAPSHOT` | `rustfs/src/config/snapshot.rs` | Process-global owner-local state | Config snapshots stay private to the config snapshot owner. |
| `BUFFER_CONFIG_SINGLETON`, `BUFFER_PROFILE_ENABLED` | `rustfs/src/config/workload_profiles.rs` | Process-global owner-local state | Workload buffer profile configuration stays private to workload profile helpers. |
| `LEGACY_CREDENTIAL_WARNED_KEYS` | `rustfs/src/config/config_struct.rs` | Process-global owner-local cache | Legacy credential warning de-duplication stays private to config parsing. |
| `CONSOLE_CONFIG` | `rustfs/src/admin/console.rs` | Process-global owner-local state | Console bootstrap config stays private to the admin console owner. |
| `ACTIVE_HTTP_REQUESTS` | `rustfs/src/server/http.rs` | Process-global owner-local counter | HTTP request inflight accounting stays private to the HTTP server owner. |
| Function-local `CACHE` and `LOCK` statics | `rustfs/src/server/readiness.rs` | Cache or constant / owner-local cache | Readiness and cluster-health caches stay function-local to readiness probes. |
| `USE_STARSHARD_CACHE`, `BUCKET_CACHE_SMALL`, `BUCKET_CACHE_LARGE` | `rustfs/src/storage/ecfs_extend.rs` | Cache or constant / owner-local cache | Bucket validation cache backend selection and cache storage stay private to the ECFS extension owner. |
| `GLOBAL_SSE_DEK_PROVIDER`, `SSE_TEST_LOCK` | `rustfs/src/storage/sse.rs` | Owner-local cache / test state | SSE DEK provider cache and test serialization lock stay private to the SSE owner. |
| `AUTH_FS` | `rustfs/src/storage/access.rs` | Cache or constant / owner-local cache | Authorization tag-condition lookup keeps its filesystem helper private to the access owner. |
| `DEADLOCK_DETECTOR` | `rustfs/src/storage/deadlock_detector.rs` | Process-global owner-local state | Deadlock detector lifecycle state stays private to the storage deadlock detector owner. |
| `CONCURRENCY_MANAGER`, `ACTIVE_GET_REQUESTS`, `ACTIVE_PUT_REQUESTS` | `rustfs/src/storage/concurrency/*` | Process-global owner-local scheduler state | Storage concurrency manager and request counters remain inside the storage concurrency owner boundary. |
| `GET_OBJECT_BUFFER_THRESHOLD_WARNED`, `GET_READER_STREAM_BUFFER_SIZE_OVERRIDE`, function-local `ENABLED`, `OBJECT_SEEK_SUPPORT_THRESHOLD`, `OBJECT_SEEK_SUPPORT_CONCURRENCY_THRESHOLDS` | `rustfs/src/app/object/get.rs` | Cache or constant / owner-local cache | Object GET/seek tuning caches and warning guards stay private to object usecase helpers. |
| `SUPPORTED_HEADERS` | `rustfs/src/storage/options.rs` | Cache or constant / owner-local constant | Supported-header lookup state stays private to storage option parsing. |
| `AUDIT_TARGET_SPECS`, `NOTIFICATION_TARGET_SPECS` | `rustfs/src/admin/handlers/audit.rs`, `rustfs/src/admin/handlers/event.rs`, `rustfs/src/admin/handlers/plugins_instances.rs` | Cache or constant / owner-local constant | Admin target descriptor tables stay private to their handler owners. |
| `SITE_REPLICATION_PEER_CLIENT` | `rustfs/src/site_replication/transport.rs` | Process-global owner-local cache | Site-replication peer client cache stays private to the site-replication transport module. The state RMW transaction holds no process-local mutex — see `rustfs/src/site_replication/state_lock.rs`. |
| `AUDIT_MODULE_ENABLED`, `NOTIFY_MODULE_ENABLED`, `PERSISTED_NOTIFY_MODULE_ENABLED`, `PERSISTED_AUDIT_MODULE_ENABLED`, `PERSISTED_MODULE_SWITCH_CONFIGURED` | `rustfs/src/server/audit.rs`, `rustfs/src/server/event.rs`, `rustfs/src/server/module_switch.rs` | Process-global owner-local toggles | Audit/notify module snapshots stay private to the server module switch owners. |
| `DELETE_TAIL_TOTAL`, `DELETE_CLEANUP_TOTAL`, `DELETE_REPLICATION_TOTAL`, `DELETE_NOTIFY_TOTAL` | `rustfs/src/delete_tail_activity.rs` | Process-global owner-local counters | Delete-tail activity counters stay private behind delete-tail activity helpers. |
| `EMBEDDED_SERVER_STARTED` | `rustfs/src/startup_lifecycle.rs` | Process-global owner-local guard | Embedded startup single-start protection stays private to startup lifecycle. |
| `TEST_OUTBOUND_TLS_GENERATION` | `rustfs/src/site_replication/mod.rs` | Test or fixture state | Outbound TLS generation test hook state stays private to site-replication transport tests. |
| `TEST_REMAINING_FAILURES` | `rustfs/src/startup_iam.rs` | Test or fixture state | IAM startup retry injection state stays private to debug/test startup code. |
| `CAPACITY_DIRTY_SCOPE_ENV`, `CAPACITY_DIRTY_SCOPE_INIT`, `GLOBAL_ENV`, function-local `INIT` | `rustfs/src/app/*_test.rs` | Test or fixture state | App integration test fixture state stays private to the owning test modules. |
Regenerate the full list (long, mostly caches and test hooks):
## First Code-Bearing Candidate
```bash
rg -n '^\s*(pub(\(crate\))? )?static [A-Z_]+' rustfs/src
```
`GLOBAL_EXPIRY_STATE` is the safest first runtime migration candidate:
- AppContext already exposes `ExpiryStateInterface` and resolver coverage in
`rustfs/src/app/context.rs`.
- ECStore access is already concentrated in
`crates/ecstore/src/runtime/sources.rs`.
- The main external readers can be moved through storage/observability facades
before changing lifecycle queue ownership.
Do not migrate `GLOBAL_OBJECT_API` first. It is coupled to storage startup,
object-store resolver publication, IAM-after-storage AppContext initialization,
and broad data-plane compatibility.
## Verification
Inventory and guardrail PRs should run:
- `bash -n scripts/check_architecture_migration_rules.sh`
- `./scripts/check_architecture_migration_rules.sh`
- `cargo fmt --all --check`
- `git diff --check`
Code-bearing migration PRs must add focused tests for the owner being moved
before running broader gates.
@@ -1,77 +0,0 @@
# Heal concurrency model
**Use this when:** changing heal, PUT/multipart commit, delete, lifecycle expiry, or data-movement code that touches the same `(bucket, object)` commit surface; or evaluating whether RustFS needs a persistent per-object healing marker like MinIO's `x-minio-healing`.
**Source of truth:** `crates/ecstore/src/set_disk/ops/heal.rs` (`heal_object_with_explicit_version_regen`, `HealObjectLockKind`, `HEAL_RENAME_INCOMPLETE`), `crates/ecstore/src/set_disk/ops/object.rs` (PUT/DELETE lock sections, `reconcile_old_data_cleanup_receipts`), `crates/ecstore/src/set_disk/core/io_primitives.rs` (`commit_rename_data_dir`, `report_old_data_dir_cleanup`, `reclaim_orphan_data_dirs`), `crates/filemeta/src/fileinfo.rs` (`FileInfo::set_healing`), `crates/heal/src/heal/manager/queue.rs` (dedup keys).
## Model
Heal and every foreground or background write path serialize on the same object-level namespace write lock (a quorum lock RPC in distributed mode, the in-process lock manager on a single node; granularity is the object, the version component is always `None`), and heal holds its guard across the whole rename commit. MinIO's `x-minio-healing` marker is an out-of-lock defence against version-cleanup logic inside `RenameData` interleaving with a heal commit; RustFS's commit model has no such interleaving, so no persistent marker exists (`x-minio-healing` does not occur in `crates/` or `rustfs/`) and none is needed. Three layers replace it:
| Layer | Mechanism | Owner |
| --- | --- | --- |
| In-lock mutual exclusion | Heal and all write-path commit points take the `(bucket, object)` namespace write lock. | `acquire_heal_object_lock` in `crates/ecstore/src/set_disk/ops/heal.rs`; lock sections in `ops/object.rs` and `ops/multipart.rs` |
| Commit-model isolation | `rename_data` contains no version cleanup that could interleave with heal. Physical deletion of a replaced old `data_dir` runs after the object lock is released (the commit tail) and only for unshared directories already superseded by the new commit. | `commit_rename_data_dir` in `crates/ecstore/src/set_disk/core/io_primitives.rs` |
| Transient healing flag | `FileInfo::set_healing` sets the internal `SUFFIX_HEALING` key on the in-memory `FileInfo` of a heal commit; `rename_data` reads it through `is_healing` to clear a stale non-empty target `data_dir` before the rename (in-place repair reuses the `data_dir`, and `rename(2)` cannot replace a non-empty directory). The key is never persisted (`is_skip_meta_key` in `crates/filemeta/src/filemeta.rs`). A non-heal commit that meets a non-empty target fails explicitly; tests lock both directions. | `crates/filemeta/src/fileinfo.rs`, `crates/ecstore/src/disk/local.rs` |
## Heal lock scope
`heal_object` delegates to `heal_object_with_explicit_version_regen`, which takes the namespace write lock at entry unless `opts.no_lock` is set and binds the guard to the function scope. The guard covers the quorum metadata read, EC reconstruction, per-disk rename commit, tmp cleanup, the `HEAL_RENAME_INCOMPLETE` partial-commit return, and orphan `data_dir` reclamation (`reclaim_orphan_data_dirs`).
Read-repair heals (`opts.read_repair`) hold a shared lock (`HealObjectLockKind::Read`) during reconstruction so readers keep flowing, then `acquire_revalidated_read_repair_commit_lock` takes the write lock and re-reads a commit fingerprint; a changed fingerprint aborts the commit (`read_repair_commit_stale`).
## Lock-intersection matrix
| # | Concurrent path | Lock held by that path | Outcome | Where |
| --- | --- | --- | --- | --- |
| 1 | PUT commit | object write lock; `rename_data` inside it | serialized | `ops/object.rs` put commit |
| 2 | PUT old `data_dir` tail cleanup | none (runs after the lock is dropped) | unlocked, semantically safe ([commit tail](#commit-tail-cleanup)) | `commit_rename_data_dir` in `core/io_primitives.rs` |
| 3 | DELETE object or version | object write lock; `delete_version` inside it | serialized | `ops/object.rs` `delete_object` |
| 4 | Batch DELETE | per-object write locks (batch lock RPC in distributed mode) | serialized | `ops/object.rs` `delete_objects` |
| 5 | CompleteMultipartUpload | object write lock plus upload-path lock; rename inside | serialized | `ops/multipart.rs` |
| 6 | CompleteMultipart tail cleanup | none (after lock drop) | unlocked, semantically safe ([commit tail](#commit-tail-cleanup)) | `ops/multipart.rs` |
| 7 | AbortMultipartUpload | upload-path lock in the multipart bucket only | disjoint resources: abort never touches the object `data_dir` or `xl.meta` | `ops/multipart.rs` |
| 8 | ILM expiry including DeleteAllVersions | `delete_prefix_object=true` keeps the object lock; `FreeVersionTask` locks explicitly; noncurrent batches use batch locks | serialized | `crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs` |
| 9 | Pure prefix delete | `delete_prefix` without `delete_prefix_object` takes no child-object lock | unlocked; no production caller ([prefix delete](#pure-prefix-delete)) | `ops/object.rs` lock condition in `delete_object` |
| 10 | Orphan `data_dir` reclamation | none inside the function; its only production caller runs inside the heal lock | serialized within heal | `reclaim_orphan_data_dirs` in `core/io_primitives.rs` |
| 11 | Old-cleanup receipt reconciliation | none inside the function; caller runs inside the heal lock and an epoch fence rejects stale receipts | serialized | `reconcile_old_data_cleanup_receipts` in `ops/object.rs` |
| 12 | Replication | data plane writes to the remote over HTTP; local metadata write-back takes the object lock | serialized or disjoint | `crates/ecstore/src/bucket/replication/replication_resyncer.rs` |
| 13 | Data movement, rebalance, decommission source cleanup | explicit object lock plus version-unchanged recheck; `no_lock` only reuses an already-held guard | serialized | `crates/ecstore/src/data_movement/mod.rs` |
| 14 | CopyObject | destination object lock through the PUT chain | serialized | `ops/object.rs` `copy_object` |
| 15 | Another heal task (different `HealType`, or `force_start`) | dedup keys are per `HealType` and `force_start` skips dedup, so tasks may coexist | serialized on the namespace write lock | `make_dedup_key_for_type` in `crates/heal/src/heal/manager/queue.rs` |
| 16 | Admin heal with `nolock=true` | caller bypasses the lock | unlocked by operator choice ([no_lock](#no_lock-and-force_start)) | `rustfs/src/admin/handlers/heal.rs` |
| 17 | Stale multipart cleanup | upload-path lock in the multipart bucket | disjoint resources | `crates/ecstore/src/bucket/lifecycle/bucket_lifecycle_ops.rs` |
## Residual windows
### Commit tail cleanup
Rows 2 and 6. After a write path commits and releases the object lock, it best-effort deletes the replaced old `data_dir`; the code deliberately does not block the next operation on this. The deletion can race a concurrent heal reading or rebuilding that same old `data_dir`, but the race is semantically safe:
- The target is an unshared `data_dir` already replaced by the new commit. Heal's canonical metadata comes from quorum arbitration (ETag, mod time), and quorum already points at the new version, so heal cannot resurrect the replaced version as canonical.
- The worst outcome is one transient failure or no-op for the heal round on the old version; the next round converges. Cleanup residue is reported and re-queued for heal via `report_old_data_dir_cleanup`.
- Long heals such as drive replacement request explicit versions and read quorum metadata inside the lock, so the tail does not affect them.
### Pure prefix delete
Row 9. `delete_prefix && !delete_prefix_object` takes no child-object locks (an object namespace lock cannot protect a recursive prefix delete), so a heal running during the prefix delete could theoretically rebuild a version from stale quorum metadata. Every production `delete_prefix: true` call site also sets `delete_prefix_object: true` (and therefore takes the object lock); the remaining `delete_prefix`-only call sites are in test modules. A future caller that needs a pure prefix delete must prove isolation from heal and scanner at the call site (for example a bucket-level scan fence).
### `no_lock` and `force_start`
Row 16. Admin heal requests pass the client's `nolock` parameter through (`rustfs/src/admin/handlers/heal.rs`), matching the MinIO madmin option. Setting it is an explicit operator choice that accepts races with concurrent writes; it is documented, not restricted.
Heal-side invariants that hold regardless of the caller:
- Dedup keys are disjoint across `HealType` (object, metadata, MRF, EC decode, prefix), and admin `force_start` skips dedup. Several heal tasks for one object can therefore exist at once, but every production entry calls `heal_object` with `no_lock=false`, so their execution bodies serialize on the namespace write lock.
- Read-repair's local TTL reservation dedups only its own source and does not block heals from other sources; the namespace lock is the backstop.
- The healing flag is never persisted, so there is no reverse risk of a leftover marker making a later commit yield incorrectly.
## Regression tests
Both live in the test module of `crates/ecstore/src/set_disk/ops/heal.rs`:
| Test | Invariant |
| --- | --- |
| `heal_racing_version_delete_never_resurrects_the_deleted_version` | With a doomed version's shards corrupted, a versioned DELETE and a deep heal contend on the same lock; the deleted version is not resurrected and the surviving version is intact. |
| `heal_racing_unversioned_overwrites_preserves_the_last_commit` | Unversioned overwrite commits (exercising the commit-tail old `data_dir` deletion) race a deep-heal loop; the final current version is exactly the last commit (ETag-level equality). |
Related: the atomic-commit and best-effort-rollback invariants for the write path are in [erasure-coding.md](erasure-coding.md).
+123 -82
View File
@@ -1,143 +1,184 @@
# KMS Bulk Rekey Job Contract
**Use this when:** changing the bulk envelope re-wrap sweep (`rustfs/src/kms_rekey.rs`), its admin endpoints (`rustfs/src/admin/handlers/kms_rekey.rs`), the re-wrap primitive, or anything that decides which objects a rekey may touch.
**Source of truth:** `rustfs/src/kms_rekey.rs`, `rustfs/src/admin/handlers/kms_rekey.rs`, `rewrap_object_encryption_metadata` in `rustfs/src/storage/sse.rs`, `KmsManager::rewrap_data_key` / `KmsManager::describe_data_key_wrapping` in `crates/kms/src/manager.rs`, `put_object_metadata` in `crates/ecstore/src/set_disk/ops/object.rs`.
This document defines the contract for the object-side bulk rekey job: a long-running administrative job that re-wraps stored data-key envelopes under the current key-encryption key (KEK) without rewriting object bodies. A first execution engine has shipped: the sweep in `rustfs/src/kms_rekey.rs`, driven by the admin endpoints in `rustfs/src/admin/handlers/kms_rekey.rs`. The contract remains the acceptance bar; where the shipped v1 sweep deliberately narrows it, the [Implementation Status](#implementation-status-v1-sweep) section records the deviation so the document and the tree cannot drift apart silently.
The bulk rekey job re-wraps stored data-key envelopes under the current key-encryption key (KEK) without rewriting object bodies. This document is the acceptance bar; where the shipped v1 sweep deliberately narrows it, [Implementation Status](#implementation-status-v1-sweep) records the deviation.
It tracks [`rustfs/backlog#1642`](https://github.com/rustfs/backlog/issues/1642), which lands the `bulk migrate/rekey` line of [`rustfs/backlog#1562`](https://github.com/rustfs/backlog/issues/1562).
## Scope
- Applies to: job lifecycle, ownership, idempotency, failure semantics, exclusion rules, and completion evidence for bulk envelope re-wrap.
- Out of scope: the cryptographic definition of a single-object re-wrap (owned by the primitive), master key material migration between backends, a pause state, multi-node parallel execution, destruction of superseded key versions.
- Master key material migration is not this job: Vault Transit, AWS KMS, and HSM backends do not export key material, and the one useful case (Local to Local) is already served by `crates/kms/src/backup/local_export.rs` and `crates/kms/src/backup/local_restore.rs`.
- Applies to: the job lifecycle, ownership, idempotency, failure semantics, exclusion rules, and completion evidence for bulk envelope re-wrap.
- Out of scope, and deliberately so: the cryptographic definition of a single-object re-wrap (owned by the re-wrap primitive), master key material migration between KMS backends, a pause state, multi-node parallel execution, and destruction of superseded key versions.
### Why master key material migration is not this job
Vault Transit, AWS KMS, and HSM backends are designed so that key material cannot be exported. There is no path that moves a Local master key into Transit, and the reverse direction would export production key material from an HSM onto local disk, which is a security regression. The one case that is both possible and useful, Local to Local, is already served by the KMS backup and restore bundle in `crates/kms/src/backup/local_export.rs` and `crates/kms/src/backup/local_restore.rs`. Nothing in this contract creates a second, weaker copy of that capability.
## Implementation Status (v1 Sweep)
The shipped sweep (`POST /rustfs/admin/v3/kms/keys/rekey` plus `/status` and `/cancel`, gated on the cluster-scoped `kms:Rekey` action) narrows the contract as follows:
The shipped sweep (`rustfs/src/kms_rekey.rs`, admin surface `POST /rustfs/admin/v3/kms/keys/rekey` plus `/status` and `/cancel`, all gated on the cluster-scoped `kms:Rekey` action) implements the contract with these deliberate narrowings:
| Contract item | v1 behavior |
|---|---|
| Ownership / admission | One in-memory slot per process serializes sweeps; a second start request is refused with the running job id. No persisted CAS job record, lease, or crash-recovered ownership; counters are process-local and reset on restart. |
| Resume cursor | None. Recovery from crash, cancel, or partial failure is re-running the sweep; every already-current envelope costs one describe-shaped KMS call and no write. |
| Backend gate | Start refuses with `501` when the backend does not advertise `BackendCapabilities::rewrap` (`crates/kms/src/backends/mod.rs`). Vault KV2 and Vault Transit pass; Local, Static, and AWS are refused. |
| Exclusion counting | Plaintext, SSE-C, and MinIO-sealed envelopes are counted together as `not_applicable`; delete markers and directory entries are skipped without counting. |
| Dry run | Not implemented; the closest capability is `/status` counters from a completed sweep. |
| Admission posture | Exactly one object at a time (one KMS round-trip, then at most one metadata write). No workload-admission integration: [workload-admission-contracts.md](workload-admission-contracts.md) defines an observation-only snapshot surface with no runtime admission API for a background job to join. |
- **One sweep per process, not scope-scoped admission.** A single in-memory slot serializes sweeps cluster-wide on the node that received the request; a second start request is refused with the running job id. This is narrower than the scope-scoped ownership below — two disjoint-scope jobs cannot run concurrently — which is the safe direction: concurrent sweeps would double every KMS round-trip for zero extra coverage. The persisted CAS job record, lease, and crash-recovered ownership described under [Skeleton, Ownership, And Admission](#skeleton-ownership-and-admission) are not implemented; job state and counters are process-local and reset on restart. Correctness does not depend on them: the envelope itself is the resume state.
- **Cursor-free convergence.** No checkpoint exists at all. The contract already declared the cursor a performance optimization; v1 takes that to its limit — recovery from a crash, cancel, or partial failure is re-running the sweep, and every already-current envelope costs one describe-shaped KMS call and no write.
- **Backend gate at start.** The start endpoint refuses with `501` when the configured backend does not advertise `BackendCapabilities::rewrap`. Vault KV2 and Vault Transit pass; Local, Static, and AWS are refused. This is the "refused at admission" behavior the contract requires for AWS, and it is also what disarms the Local blocker below: a sweep can only run where superseded key versions demonstrably remain decryptable.
- **Collapsed exclusion counting.** Plaintext objects, SSE-C objects, and MinIO-sealed envelopes are counted together as `not_applicable` rather than per-class; delete markers and directory entries are skipped without counting. Per-class exclusion counts remain future work.
- **No dry run.** The dry-run report model below is not implemented; the closest present capability is reading `/status` counters from a completed sweep.
- **Admission posture.** The sweep processes exactly one object at a time — each iteration awaits a KMS round-trip and, on rewrap, one metadata write — so its foreground contention is bounded by strict serialization, the KMS policy layer's shared concurrency cap, and the storage layer's own namespace locks and quorum rules. It does not integrate with a workload-admission mechanism, because [workload-admission-contracts.md](workload-admission-contracts.md) currently defines an observation-only snapshot surface repo-wide, with no runtime admission API for any background job to join. When such a mechanism exists, this job joins it alongside the scanner, heal, and decommission; until then, the requirement is bounded contention, which serialization provides.
Kept exactly as contracted: work units are `(bucket, object, versionId)` with `latest_only: false`; `mod_time` is never set on the rewrap write; object-lock retention is inherited from `put_object_metadata`; every stored envelope copy is replaced by value match across the RustFS-internal and MinIO-compatible slots, and "no replaceable copy found" is an error, not a silent success; failures are counted and logged per object and never abort the sweep; cancellation is cooperative and terminal.
What v1 keeps exactly as contracted: work units are `(bucket, object, versionId)` with `latest_only: false`; `mod_time` is never set on the rewrap write; object-lock retention is inherited from `put_object_metadata`; the rewrap replaces every stored envelope copy by value match across the RustFS-internal and MinIO-compatible slots, and treats "no replaceable copy found" as an error rather than a silent success — the stale-branch hazard rule from [Metadata Write Contract](#metadata-write-contract); failures are counted and logged per object and never abort the sweep; cancellation is cooperative and terminal.
## Terms
| Term | Meaning |
|---|---|
| Envelope | The sealed data key (DEK) stored on an object version's metadata, with the identifiers needed to unseal it. |
| Re-wrap primitive | Single-object operation that unseals one envelope and re-seals it under the target KEK, changing metadata only: `rewrap_object_encryption_metadata` over `KmsManager::rewrap_data_key`. |
| Rekey job | The scan-and-drive layer defined here, applying the primitive across a scope. |
| Envelope | The sealed data key (DEK) stored on an object version's metadata, together with the identifiers needed to unseal it. |
| Re-wrap primitive | A single-object operation that unseals one envelope and re-seals it under the target KEK, changing metadata only. Implemented as `rewrap_object_encryption_metadata` in `rustfs/src/storage/sse.rs`, over `KmsManager::rewrap_data_key`. |
| Rekey job | The scan-and-drive layer defined by this document, which applies the re-wrap primitive across a scope. |
| Work unit | One `(bucket, object, versionId)` triple. Never `(bucket, object)`: each version carries its own envelope. |
| Scope | The bucket and prefix selector that bounds one job; the unit of admission exclusion. |
| Target state | Envelope sealed under the intended key id at the current KEK version. |
| Scope | The bucket and prefix selector that bounds one job, and the unit of admission exclusion. |
| Target state | The envelope state the job is driving toward: sealed under the intended key id at the current KEK version. |
## What The Job Does And Does Not Do
## What the Job Does And Does Not Do
- Re-wraps envelopes only. Erasure-coded shards, part layout, ETag, and storage usage are unchanged; only encryption metadata keys may differ. The metadata-only write is `put_object_metadata` (declared on `ObjectStore` in `crates/ecstore/src/store/mod.rs`, dispatched in `crates/ecstore/src/core/sets.rs`, implemented in `crates/ecstore/src/set_disk/ops/object.rs`).
- **Never destroys a superseded key version.** A half-finished job leaves some envelopes under the new KEK version and some under the old; that state is serviceable only because the old version still decrypts. Destruction stays a separate, human-initiated operation gated on usage evidence.
- Must refuse to start when the target key's retention policy would let the superseded version leave the retention window while the job runs.
The job re-wraps envelopes. It never rewrites object bodies. Erasure-coded shards, part layout, ETag, and storage usage must be unchanged across a rekey; only encryption metadata keys may differ. Metadata-only rewrite is supported by the storage layer: `put_object_metadata` is declared on `ObjectStore` in `crates/ecstore/src/store/mod.rs`, dispatched in `crates/ecstore/src/core/sets.rs`, and implemented in `crates/ecstore/src/set_disk/ops/object.rs`, where it takes a namespace write lock, selects the version named by `opts.version_id`, and merges `opts.eval_metadata` into the existing `FileInfo` metadata under read and write quorum.
**The job never destroys a superseded key version.** This is the hardest constraint in this contract, and every other guarantee rests on it. A job that fails halfway leaves some objects wrapped under the new KEK version and some under the old one. That state is fully serviceable — reads and writes both succeed — precisely and only because the old version can still decrypt. Destroying old versions from inside the job would convert a resumable operational action into irreversible data loss on partial failure. Destruction stays a separate, human-initiated operation gated on usage evidence.
A job must therefore refuse to start when the target key's retention policy would allow the superseded version to leave the retention window while the job runs.
## Idempotency Model
Idempotency comes from object metadata itself: the envelope's state is the target state, so a re-run reads what is already correct and skips it. No idempotency table; the job identity is a `job_id: Uuid` for reporting and ownership, following `ManualTransitionJobRecord` in `crates/ecstore/src/bucket/lifecycle/manual_transition_job.rs`.
Re-running the job must be safe and must converge. The intended source of idempotency is the object metadata itself: the envelope's own state is the target state, so a re-run reads what is already correct and skips it. No separate idempotency table is required, and the job identity is only a `job_id: Uuid` for reporting and ownership, following the ILM manual transition job record in `crates/ecstore/src/bucket/lifecycle/manual_transition_job.rs`.
- **The resume cursor is a performance optimization, not a correctness dependency.** Losing a checkpoint may cause a rescan and a higher skip count, never a wrong result. Checkpoints may therefore be throttled (`PersistThrottle` in `crates/heal/src/heal/resume.rs`).
- **At-least-once with target-state idempotency, never exactly-once.** No design may introduce exactly-once machinery for work units.
Two consequences follow, and both are contract requirements:
- **The resume cursor is a performance optimization, not a correctness dependency.** Losing a checkpoint may cause a rescan and a higher skip count, never a wrong result. This is what makes crash recovery cheap: checkpoints may be throttled rather than written per object, following the `PersistThrottle` policy in `crates/heal/src/heal/resume.rs`, which flushes after a bounded number of buffered mutations or a bounded interval, whichever comes first. That module states the same reasoning for heal: because the operation is idempotent, a crash re-does at most one throttle window.
- **The job is at-least-once with target-state idempotency, never exactly-once.** No design may introduce exactly-once machinery for work units.
### Reading the wrapping KEK version
There is no key-version metadata key. Object metadata carries the key id (`x-rustfs-encryption-key-id`) and the sealed blob under `x-rustfs-encryption-key`; `DecryptResponse` in `crates/kms/src/types.rs` does not report a version either. The version is recoverable because the sealed blob is structured: for every backend that builds one, the ciphertext is the JSON of `DataKeyEnvelope` (`crates/kms/src/encryption/dek.rs`), and the read path already discriminates on it via `is_data_key_envelope` in `rustfs/src/storage/sse.rs`.
The self-evidencing property above holds only when the wrapping KEK version is observable. It is, for every backend that actually rotates, but not from a dedicated metadata field and not by the same mechanism on each backend.
There is no key-version metadata key: object metadata carries the key **id** (`x-rustfs-encryption-key-id` in `rustfs/src/storage/sse.rs`, defaulting to `default`) and the sealed blob under `x-rustfs-encryption-key`, and nothing else names a version. `DecryptResponse` in `crates/kms/src/types.rs` does not report one either, though `EncryptResponse` does.
The version is nonetheless recoverable, because the sealed blob is structured. `x-rustfs-encryption-key` stores the base64 of the backend ciphertext, and for every backend that builds one that ciphertext is the JSON of `DataKeyEnvelope` (`crates/kms/src/encryption/dek.rs`). Reading it needs no new metadata: base64-decode the value, then parse the JSON. The read path in `rustfs/src/storage/sse.rs` already does exactly this discrimination, calling `is_data_key_envelope` on the decoded blob to pick a provider, so this is an established in-tree pattern rather than a new capability.
Where the version sits inside that structure is backend-specific:
| Backend | Rotates | Where the wrapping version lives | Recoverable by a scan |
|---|---|---|---|
| Vault KV2 (`crates/kms/src/backends/vault.rs`) | Yes | `DataKeyEnvelope::master_key_version` | Yes, from the envelope JSON |
| Vault Transit (`crates/kms/src/backends/vault_transit.rs`) | Yes | `vault:vN:` prefix of the ciphertext in `encrypted_key`; the envelope's version field is deliberately `None` | Yes, by parsing that prefix |
| Local (`crates/kms/src/backends/local.rs`) | No, rotation is rejected | Nowhere; hardcoded `None` | Moot while rotation is rejected |
| Static (`crates/kms/src/backends/static_kms.rs`) | No | Nowhere; hardcoded `None` | Moot |
| AWS (`crates/kms/src/backends/aws.rs`) | AWS-managed | Inside the opaque `CiphertextBlob`; no `DataKeyEnvelope` | **No** |
| Vault KV2 (`crates/kms/src/backends/vault.rs`) | Yes | `DataKeyEnvelope::master_key_version`, populated from the key record's version | Yes, from the envelope JSON |
| Vault Transit (`crates/kms/src/backends/vault_transit.rs`) | Yes | The `vault:vN:` prefix of the ciphertext held in the envelope's `encrypted_key`; the envelope's own version field is deliberately `None` because Transit ciphertext self-describes | Yes, by parsing that prefix |
| Local (`crates/kms/src/backends/local.rs`) | No rotation is rejected | Nowhere; the version field is hardcoded `None` because a key has exactly one material | Moot while rotation is rejected |
| Static (`crates/kms/src/backends/static_kms.rs`) | No — single fixed key | Nowhere; hardcoded `None` | Moot |
| AWS (`crates/kms/src/backends/aws.rs`) | AWS-managed | Inside the opaque `CiphertextBlob`; no `DataKeyEnvelope` is built at all | **No** |
Contract rules that follow:
Two traps follow, and both are contract rules.
- **`None` does not mean one thing.** KV2: pre-versioning envelope, resolved by `resolve_envelope_master_key_version` to the key's recorded baseline, never implicitly to "current". Transit: permanent and expected; read the ciphertext prefix. Local/Static: unconditional. Version extraction must be dispatched by backend, never inferred from the field alone.
- **Local's `None` is coupled to the Local blocker.** If Local gains rotation history (`rustfs/backlog#1565`), envelope version recording must land in the same change, or Local becomes a second unreadable backend.
- The primitive exposes the wrapping version through **one backend-dispatched accessor** (`KmsManager::describe_data_key_wrapping`) and reports **"already at target state" as an outcome distinct from "re-wrapped"**.
- **AWS is a scoping exception.** Its ciphertext is opaque, so no scan can skip, report version composition, or self-evidence completion; a re-run would rewrap everything. AWS-backed keys are out of scope and must be refused at admission.
- Skip detection costs a base64 decode plus JSON parse (plus a prefix parse on Transit) per work unit: CPU, not I/O, and part of the rate budget rather than free.
**`None` does not mean one thing.** On Vault KV2 it means a pre-versioning envelope, and `resolve_envelope_master_key_version` resolves it to the key's recorded baseline version, or to the current version for a key that was never rotated — never implicitly to whatever is current now. On Transit it is permanent and expected, and the version must be read from the ciphertext prefix instead. On Local and Static it is unconditional. A scan that reads `None` as a single condition will misclassify three different situations, so version extraction must be dispatched by backend, never inferred from the field alone.
**Local's `None` is coupled to the blocker below.** The Local backend omits the version specifically because rotation is rejected there. When [`rustfs/backlog#1565`](https://github.com/rustfs/backlog/issues/1565) gives Local a rotation history, that construction must begin recording the wrapping version in the same change, or Local silently becomes a second unreadable backend and loses idempotent skip along with it. This coupling is not obvious from either issue and must not be discovered later.
The requirement this places on the re-wrap primitive is therefore narrower than "record a version", most of which the tree already satisfies:
- The primitive must expose the wrapping version through **one backend-dispatched accessor** — satisfied by `KmsManager::describe_data_key_wrapping`, which dispatches per backend so callers never reimplement envelope-field or ciphertext-prefix parsing, which would also put KMS format knowledge on the wrong side of the crate boundary.
- The primitive must report **"already at target state" as an outcome distinct from "re-wrapped"**, so the job counts a skip instead of inferring one.
- For AWS, neither is achievable by inspection, and the contract must say so rather than pretend otherwise (see below).
### The cost of recognizing the target state
Skipping already-current objects is achievable, and it is not free. Every scanned work unit costs a base64 decode plus a JSON parse of its envelope, and on Transit an additional prefix parse. That is CPU and allocation per object version, not extra I/O: the metadata is already being read by the scan, and no KMS round trip is involved. Envelopes are small, so the cost is bounded per object, but at bulk scale it is the dominant cost of a dry run and of the skip check in a re-run, and it belongs in the rate and admission budget rather than being treated as free.
This cost buys three things, all of which the contract requires and none of which are available without it: a re-run that skips completed work and performs zero metadata writes, a dry run that reports which KEK versions are actually in scope, and the per-object half of completion evidence.
**AWS is the exception, and it is a scoping exception rather than a cost.** Its ciphertext is opaque to RustFS, so no inspection can tell a current envelope from a stale one. A rekey scope on an AWS-backed key therefore cannot skip, cannot report version composition in a dry run, and cannot self-evidence completion; a re-run would re-wrap every object again. AWS also rotates backing key material transparently on decrypt, so the operational need that motivates this job is weaker there to begin with. Until there is a reason to do otherwise, AWS-backed keys are out of scope for bulk rekey, and a job must refuse such a scope at admission rather than start one whose re-runs silently rewrite everything.
## Failure Semantics
- A partially complete rekey is a valid, serviceable state: no emergency handling, no fail-closed startup guard, no rollback. This is the sharpest difference from KMS backup restore, whose intermediate state is unserviceable and fails closed on startup.
- Precondition: superseded key versions remain decryptable (see Blockers).
- Cancellation is cooperative and terminal; restarting on the same scope skips already-processed objects.
- Pause is not provided. None of the tree's long-running job frameworks (ILM manual transition, heal resume, tier mutation intent, decommission/rebalance, scanner, KMS restore) has a pause state; rate control plus cancel-and-restart deliver what pause is asked for without lease/slot/abandonment state.
A partially complete rekey is a valid, serviceable state, not a damaged one. It requires no emergency handling, no fail-closed startup guard, and no rollback. This is the sharpest difference from KMS backup restore, whose intermediate state genuinely is unserviceable and which therefore fails closed on startup when its commit marker is present.
The precondition is that superseded key versions remain decryptable. Where that precondition does not hold, the whole model collapses (see Blockers).
Cancellation is cooperative and terminal. A canceled job reaches a terminal state with already-processed objects left in the target state; restarting on the same scope skips them.
## Pause Is Not Provided
The originating requirement asked for pause, resume, and idempotent retry. This contract provides cancel, cursor restart, and rate control instead, and does not provide a pause state.
Seven long-running job frameworks exist in the tree — ILM manual transition, heal resume (`crates/heal/src/heal/resume.rs`), tier mutation intent (`crates/ecstore/src/services/tier/tier_mutation_intent.rs`), decommission and rebalance (`crates/ecstore/src/core/pools.rs`), the scanner (`crates/scanner/src/scanner.rs`), and KMS backup restore. None of them has a pause state; each has cancel or stop only. That consistency is a design position, not an oversight. A paused job has to answer what it still holds: whether its lease is renewed, whether it keeps its scope admission slot, and how long it may stay paused before it is abandoned. Each answer adds state and a failure mode.
The two things pause is actually asked for are that the job must not overwhelm the data path, and that stopping it must not throw away progress. Rate and admission control delivers the first; cancel plus cursor restart delivers the second. Both are existing patterns.
## Objects That Cannot Be Rekeyed
Enumerated during the scan and excluded with a counted reason; never a job failure; the execution phase must not touch them.
These must be enumerated during the scan and excluded with a counted reason. Encountering one is never a job failure, and the execution phase must not touch them.
| Class | Disposition | Reason |
|---|---|---|
| SSE-C objects | Exclude and count | The server never holds the customer key. |
| Objects transitioned to a remote tier | Exclude and count | Body lives remotely; see [tier-ilm-debugging.md](../operations/tier-ilm-debugging.md). |
| In-progress multipart uploads | Exclude and count | Each part carries its own envelope; `crates/kms/src/key_impact.rs` models this as a distinct reference scope. |
| Unencrypted objects | Exclude and count | No envelope. |
| Objects under object-lock retention | Governed by the storage layer (see Metadata Write Contract) | |
| SSE-C objects | Exclude and count | The server never holds the customer key, so it can neither unseal nor re-seal the envelope. |
| Objects transitioned to a remote tier | Exclude and count | The body lives remotely; the relationship between local metadata and the remote object's encryption needs its own analysis first. See [tier-ilm-debugging.md](../operations/tier-ilm-debugging.md). |
| In-progress multipart uploads | Exclude and count | Each part carries its own envelope and an incomplete upload is not a stable work unit. `crates/kms/src/key_impact.rs` already models this as a distinct reference scope. |
| Unencrypted objects | Exclude and count | No envelope to re-wrap. |
| Objects under object-lock retention | Governed by the storage layer, see below | |
Replication destinations are unresolved: propagation depends on `rustfs/backlog#1619`. Until it closes, a rewrap never propagates to a replica and each site runs its own sweep.
Replication destinations are unresolved: whether an envelope metadata rewrite must propagate to a replica depends on [`rustfs/backlog#1619`](https://github.com/rustfs/backlog/issues/1619). Until that closes, this contract does not authorize propagation.
## Metadata Write Contract
Three properties of `put_object_metadata` (`crates/ecstore/src/set_disk/ops/object.rs`) constrain the re-wrap write:
Three properties of `put_object_metadata` constrain the re-wrap write, all confirmed in `crates/ecstore/src/set_disk/ops/object.rs`.
- **The merge is additive; it cannot remove keys.** Overwriting a key that keeps its name is safe; a re-wrap that changes *which* keys describe the envelope leaves the old keys behind. This is a live hazard: `parse_minio_managed_sealed_key` in `rustfs/src/storage/sse.rs` selects the MinIO decrypt branch on the mere presence of the MinIO seal-algorithm header, so a RustFS-native envelope written onto MinIO-compatible headers without neutralizing them steers reads down the stale branch. Any envelope-shape change must neutralize superseded keys in the same write.
- **Object-lock retention is enforced before the merge.** `check_object_lock_retention_update` (`crates/ecstore/src/set_disk/mod.rs`) runs first; rekey inherits its decision and must not acquire a bypass.
- **`mod_time` is preserved unless the caller sets it.** The re-wrap path leaves it unset so age-based lifecycle rules are not perturbed.
**The merge is additive; it cannot remove keys.** `opts.eval_metadata` entries are inserted into the existing metadata map. There is no removal path. Overwriting a key that keeps its name is therefore safe, but a re-wrap that changes *which* metadata keys describe the envelope leaves the old keys behind permanently.
That is a structural hazard, not a theoretical one. `rustfs/src/storage/sse.rs` selects its decrypt branch on the mere presence of the MinIO-compatible seal-algorithm header: `parse_minio_managed_sealed_key` returns a sealed key whenever that header is present with the expected value, and the caller then takes the MinIO branch in preference to the RustFS-native one. A re-wrap that writes a RustFS-native envelope onto an object carrying MinIO-compatible headers, without clearing them, steers subsequent reads down the stale branch. Any re-wrap that changes envelope shape must neutralize the superseded keys in the same write, and cannot rely on deletion to do it.
**Object-lock retention is enforced before the merge.** `check_object_lock_retention_update`, defined in `crates/ecstore/src/set_disk/mod.rs`, runs before `eval_metadata` is applied. Rekey inherits that decision rather than restating it: whatever that check permits for a metadata update, rekey permits; whatever it refuses, rekey counts as an exclusion. Rekey must not acquire a bypass.
**`mod_time` is preserved unless the caller sets it.** The implementation assigns `fi.mod_time` only when `opts.mod_time` is `Some`. The re-wrap path must leave it unset, so that a rekey does not perturb lifecycle rule evaluation — an age-based expiry or transition rule reading a refreshed `mod_time` across a whole bucket would be a cross-feature regression.
## Skeleton, Ownership, And Admission
- Structural template: `ManualTransitionJobRecord` (`job_id`, `scope_key`, `owner_id`, `lease_id` with expiry, state machine with explicit `Unknown`, `cancel_requested`, report, queue snapshot), persisted with S3 conditional writes so ownership transitions are compare-and-swap; capability advertisement via `ManualTransitionJobCapabilities` in `rustfs/src/admin/handlers/system.rs`.
- Ownership is scope-scoped: disjoint scopes may run concurrently; same-scope jobs are refused by admission. The scanner leader lock in `crates/scanner/src/scanner.rs` is the wrong granularity (one worker per cluster) and is a fencing reference only.
- First implementation is single-node; multi-node parallelism is a throughput optimization deferred until correctness evidence exists.
- Taken from KMS backup: the durable file commit protocol in `crates/kms/src/backends/local.rs` (`CommitStep` failpoints); the write-receipt ownership proof and `VaultRestoreSequence` phase guard in `crates/kms/src/backup/vault_restore.rs` (a concurrent writer between read and write-back is a conflict-and-skip, never an overwrite); the three-part zero-write dry-run report model in `crates/kms/src/backup/dry_run.rs`. Not taken: the synchronous, empty-target, all-or-nothing restore lifecycle. The job does not belong in `crates/kms` (which must not depend on `rustfs-ecstore`); KMS supplies the primitive only.
The ILM manual transition job is the structural template. `ManualTransitionJobRecord` in `crates/ecstore/src/bucket/lifecycle/manual_transition_job.rs` already carries `job_id`, `scope_key`, `owner_id`, `lease_id` with an expiry, a state machine including an explicit `Unknown` state for a corrupt journal, `cancel_requested`, a report, and a queue snapshot. Records are persisted under dedicated metadata-bucket prefixes with a schema string and checksum, and mutated with S3 conditional writes (`if_match` for updates, `if_none_match` for creates) so that ownership transitions are compare-and-swap rather than last-write-wins. Crash recovery, cooperative cancel via `request_manual_transition_job_cancel`, and capability advertisement through `ManualTransitionJobCapabilities` in `rustfs/src/admin/handlers/system.rs` all follow from that shape.
Ownership is scope-scoped, not cluster-scoped. Two jobs on disjoint scopes may run concurrently; two jobs on the same scope must be refused by admission. The scanner's leader lock with epoch fencing in `crates/scanner/src/scanner.rs` is the wrong granularity here because it enforces exactly one worker per cluster; it stays a reference for fencing technique only.
The first implementation is single-node: one owner plus a lease plus recovery is sufficient for correctness. Multi-node parallel execution is a throughput optimization and is out of scope until correctness and its acceptance evidence are both in place.
Because the job runs online, it must not contend its way into the foreground data path. The v1 posture — strict serialization plus the KMS policy layer's shared cap and the storage layer's own locks — and the reason no workload-admission mechanism is joined yet are recorded under [Implementation Status](#implementation-status-v1-sweep); when a runtime admission mechanism exists per [workload-admission-contracts.md](workload-admission-contracts.md), this job joins it alongside the scanner, heal, and decommission.
## What Is Taken From KMS Backup, And What Is Not
Four things transfer:
- The durable file commit protocol in `crates/kms/src/backends/local.rs` — write, fsync the file, publish by rename or hard link, fsync the parent directory — together with its injectable `CommitStep` failpoints.
- The write-receipt ownership proof in `crates/kms/src/backup/vault_restore.rs`. Its distinction is the reusable idea: the list of intended targets proves nothing about ownership, and only a receipt recording the version a write actually landed at may authorize touching that record later; everything else is reported as never-written or not-at-written-version. Bulk rekey faces the identical problem when a concurrent writer modifies an object between the job's read and its write-back. Such an object must be counted as a conflict and skipped, never overwritten.
- The sequence guard `VaultRestoreSequence` in the same module: a small, domain-free state machine that makes phase order structural. Rekey's phases are scan, plan, apply, verify.
- The three-part dry-run report model in `crates/kms/src/backup/dry_run.rs` — blockers, conflicts, and external mismatches, with a permission predicate that requires all three to be empty — and its zero-write contract: the report is pure data with no handles and no drop-time side effects.
The lifecycle model does not transfer, and must not be adapted. Backup restore is synchronous, one-shot, single-node, requires an empty target, has no progress surface, and requires the KMS service to be out of `Running` state; its admin layer says as much in `rustfs/src/admin/handlers/kms_backup.rs`. Its commit marker enumerates every file up front, which does not scale to object counts. Its publish primitive is no-clobber, whereas rekey rewrites existing state by definition. Forcing rekey into that four-phase protocol produces an all-or-nothing transaction over the whole scope, which is not operable at this scale.
The job also does not belong in the KMS crate. `crates/kms/Cargo.toml` does not depend on `rustfs-ecstore` and must not: the job body is object scanning and metadata rewriting, which is ecstore and admin territory. The KMS crate supplies the re-wrap primitive only.
## API Surface
- Live surface: the RustFS endpoints above. The MinIO-compatible batch-job surface (`rustfs/src/admin/handlers/batch_job.rs`) still lists `keyrotate` in `KNOWN_JOB_TYPES` and returns a deliberate `NotImplemented` from `start-job`.
- Rule: **one operation must never have two live semantics.** The batch-job `keyrotate` type must keep refusing until it proxies to this engine with full batch-job semantics or is removed; it must never report success while executing nothing.
This section originally required reusing the MinIO-compatible batch-job endpoints in `rustfs/src/admin/handlers/batch_job.rs` and forbade a second REST surface. The shipped v1 superseded that rule: the sweep landed on RustFS-specific endpoints (`/v3/kms/keys/rekey`, `/status`, `/cancel`), reviewed and merged with the engine. The batch-job surface parses MinIO's full job-definition format, whose semantics (per-job flags, retries, notifications) the v1 sweep does not implement — and accepting a job definition whose semantics cannot be executed is exactly what this section forbids.
The rule that survives is about live semantics, not endpoint shape: **one operation must never have two live semantics.** Today there is one live surface (the RustFS endpoints) and one refusing stub — `KNOWN_JOB_TYPES` in `batch_job.rs` still lists `keyrotate`, and `start-job` still returns a deliberate `NotImplemented`, unknown types get `InvalidRequest`, `list-jobs` returns an empty list, and status, describe, and cancel return a no-such-job error. That `NotImplemented` remains an external promise: the batch-job `keyrotate` type must keep refusing until it either proxies to this same engine with full batch-job semantics or is removed. It must never report success while it executes nothing, and it must never grow a second, divergent rekey implementation.
## Completion Evidence
Completion is proven only when no object in scope still references the superseded key version. The evidence surface is the key usage inventory in `crates/kms/src/key_impact.rs`, which deliberately has no `in_use` / `unreferenced` / `safe_to_delete` field and instead reports which sources were consulted and how completely. Rekey inherits that discipline: an empty result means nothing was found in the sources scanned, never that nothing references the key.
A job that reports success has not proven anything until no object in the scope still references the superseded key version. That evidence surface is the key usage inventory, whose typed foundation already exists in `crates/kms/src/key_impact.rs`. That module is deliberately built so a report can never claim a key is unused: it has no `in_use`, no `unreferenced`, and no `safe_to_delete` field, and instead reports which sources were consulted and how completely they could be read. It lists object envelopes and in-progress multipart uploads among its reference scopes and currently marks both as not scanned.
Rekey must inherit that discipline. An empty result means nothing was found in the sources that were scanned, never that nothing references the key. A report that cannot state its own coverage is not completion evidence, and must not be used to authorize destroying anything.
## Blockers
| Item | Status |
|---|---|
| Local rotation history (`rustfs/backlog#1565`) | Resolved by capability gating: Local stays non-production, rotation stays rejected, and the start endpoint refuses any backend without `BackendCapabilities::rewrap`. If Local ever gains rotation, envelope version recording must land in the same change. |
| Execution chain | Resolved: primitive (`rewrap_data_key`, `describe_data_key_wrapping`), object adapter (`rewrap_object_encryption_metadata`), sweep (`rustfs/src/kms_rekey.rs`). |
| Key usage inventory coverage | Open: `key_impact.rs` still reports `ObjectEnvelopes` and `InProgressMultipartUploads` as not scanned, so sweep counters are evidence from that run only. |
| KMS key list pagination | Open; a job enumerating keys would hit it. |
| Replica propagation (`rustfs/backlog#1619`) | Open; no propagation until it closes. |
**Resolved by capability gating — Local rotation history.** [`rustfs/backlog#1565`](https://github.com/rustfs/backlog/issues/1565) (no rotation history in the Local backend) was a hard blocker while a sweep could run against Local: without retained superseded versions, a rekey interrupted halfway would leave every unprocessed object permanently unreadable after rotation, falsifying the partial-completion guarantee this contract is built on. The shipped resolution is not rotation history but scope: the Local backend is positioned as non-production, rotation stays rejected there, and the sweep's start endpoint refuses any backend that does not advertise `BackendCapabilities::rewrap` — so a sweep can only run where the retained-versions invariant holds by construction (Vault KV2 and Vault Transit). If Local ever gains rotation, the coupling recorded under [Reading the wrapping KEK version](#reading-the-wrapping-kek-version) still applies: rotation history and envelope version recording must land in the same change before Local may advertise `rewrap`.
**Resolved — the execution chain is complete.** The envelope-level primitive (`KmsManager::rewrap_data_key`, `KmsManager::describe_data_key_wrapping` in `crates/kms/src/manager.rs`), the object-level adapter (`rewrap_object_encryption_metadata` in `rustfs/src/storage/sse.rs`, which reads a version's envelope, reconstructs its encryption context, re-wraps, and returns the metadata overrides), and the sweep that drives the adapter and persists through `put_object_metadata` (`rustfs/src/kms_rekey.rs`) all exist.
**Affects acceptance, not start — still open.** Key usage inventory coverage over object envelopes: `crates/kms/src/key_impact.rs` still reports `ObjectEnvelopes` and `InProgressMultipartUploads` as not scanned, so a completed sweep's counters are evidence from that run only, not inventory-grade completion proof. KMS key list pagination, which a job enumerating keys would hit. And [`rustfs/backlog#1619`](https://github.com/rustfs/backlog/issues/1619), which decides replica propagation — until it closes, a rewrap never propagates to a replica site and each site runs its own sweep.
## Verification Expectations
Acceptance bar for the full contract (dry-run and checkpoint items await those features; a cursor-free sweep satisfies the checkpoint clause vacuously):
This list is the acceptance bar for the full contract, not a claim about what the v1 sweep has already demonstrated: the dry-run and checkpoint items await the features themselves (a cursor-free sweep satisfies the checkpoint-deletion clause vacuously), and per-class exclusion counting is narrowed as recorded under [Implementation Status](#implementation-status-v1-sweep).
1. Dry run performs zero storage writes.
2. Non-rekeyable objects are excluded and counted rather than failing the job.
3. An immediate second run skips every object and writes no metadata, on both a KV2-backed and a Transit-backed scope.
4. A scope on an AWS-backed key is refused at admission.
5. An envelope with no recorded version is classified by backend, not by the bare `None`.
6. Deleting the checkpoint changes only the skip count, not the outcome.
7. A killed and recovered job reaches a terminal state while every object stays readable throughout.
8. A concurrent writer causes conflict-and-skip, not an overwrite.
9. ETag, part layout, and storage usage are unchanged at the `xl.meta` level.
10. Each version of a multi-version object is processed independently with its `versionId` intact.
11. Superseded key versions still exist and still decrypt afterward.
12. Success, skip, exclusion, conflict, and failure counts sum to the number of work units scanned.
Implementation work under this contract must be able to demonstrate, at minimum: that dry run performs zero storage writes; that non-rekeyable objects are excluded and counted rather than failing the job; that an immediate second run skips every object and writes no metadata, on both a KV2-backed and a Transit-backed scope, since the two recover the wrapping version by different mechanisms; that a scope on an AWS-backed key is refused at admission rather than accepted as a job whose re-runs rewrite everything; that an envelope with no recorded version is classified by backend rather than by the bare `None`; that deleting the checkpoint changes only the skip count, not the outcome; that a killed and recovered job reaches a terminal state while every object remains readable throughout; that a concurrent writer causes a conflict-and-skip rather than an overwrite; that ETag, part layout, and storage usage are unchanged at the `xl.meta` level; that each version of a multi-version object is processed independently with its `versionId` intact; that superseded key versions still exist and still decrypt afterward; and that success, skip, exclusion, conflict, and failure counts sum to the number of work units scanned.
+369 -101
View File
@@ -1,148 +1,416 @@
# MinIO On-Disk Format Interoperability
# MinIO File-Format Interoperability — Gap Analysis & Phased Plan
**Use this when:** deciding whether a MinIO drive set, bucket-metadata blob, or SSE object can be read or imported by a given RustFS build, or before touching any constant or codec listed under Version Anchors.
**Source of truth:** `crates/filemeta/src/filemeta.rs`, `crates/filemeta/src/filemeta/codec.rs`, `crates/ecstore/src/bucket/metadata.rs`, `crates/ecstore/src/bucket/migration.rs`, `rustfs/src/storage/sse.rs`, `rustfs/Cargo.toml` `[features]`, `.github/workflows/ci.yml`, `.github/workflows/minio-interop.yml`.
Assesses how closely the RustFS on-disk format matches MinIO's, so that a
MinIO drive set can be read (and eventually served) by RustFS and vice versa.
This is a **plan and analysis document**. It changes no storage code. Every
claim below cites the code that backs it.
This is an interop contract, not a plan. Migration is one-way (MinIO to RustFS). Erasure-coding internals are owned by [erasure-coding.md](erasure-coding.md); this document owns the interop claim, the fixture evidence, and the out-of-scope list.
Scope: the two on-disk artifacts that matter for interop are the per-object
`xl.meta` (object metadata + inline data) and the per-bucket `.metadata.bin`
(bucket configuration blob). IAM/config layout is noted where it affects
bucket-metadata migration.
## Scope Matrix By Build Variant
Refs rustfs/backlog#580.
Build variants are the `rustfs` crate features in `rustfs/Cargo.toml`: `default`, `full`, and `rio-v2` (which enables `rustfs-ecstore/rio-v2` and pulls in `crates/rio-v2`). `rio-v2` is absent from both `default` and `full`.
## Executive Summary
| MinIO artifact | `default` / `full` build | `rio-v2` build | Notes |
|---|:--:|:--:|---|
| Unencrypted `xl.meta` (meta_ver 1-3, inline, multipart, versioned, delete marker) | Read | Read | Part A. Normalized to meta_ver 3 on rewrite. |
| Transitioned (tiered) `xl.meta` | Not fixture-proven | Not fixture-proven | Out of scope; see erasure-coding.md for the tolerant `transitioned-versionID` read rule. |
| `.metadata.bin` bucket config | Read and imported | Read and imported | Part B. Importer reads a `.minio.sys` layout end to end. |
| IAM config under `config/iam/` | Imported | Imported | `try_migrate_iam_config`; legacy field aliases normalized. |
| SSE-S3 / SSE-KMS objects, MinIO builtin static KMS | Fail closed, diagnosed | Read | Part C. Requires the shared master key. |
| SSE-C objects | Fail closed, diagnosed | Read | Part C. Customer key supplied per request. |
| Any SSE object, MinIO backed by KES / KMS plugin / MinKMS | Fail closed | Fail closed | Not planned; the DEK is sealed by the KES service. |
| RustFS-written drive set read by a live MinIO binary | Unsupported | Unsupported | Set-level divergence: MinIO looks for `.minio.sys`, RustFS writes `.rustfs.sys`. |
| RustFS-written SSE objects read by MinIO | Unsupported | Unsupported | Part C, reverse direction. |
- **`xl.meta`**: RustFS writes `XL_META_VERSION = 3` and reads meta_ver ≤ 3,
including legacy meta_ver 2 objects with legacy checksums. Magic `XL2 `,
erasure algorithm `rs-vandermonde` (Reed-Solomon), and HighwayHash256 bitrot
all match MinIO. `xl.meta` interop is the **strong** part of the story.
- **`.metadata.bin`**: RustFS uses the same filename, the same 4-byte
`format|version` header, the same MessagePack blob layout, and the same
per-config field encodings (XML/JSON) as MinIO's `bucketMetadata`. The
divergence is a small set of RustFS-only fields (table-bucket support,
bucket-targets meta) — not a format mismatch.
- **Migration**: RustFS already ships a one-way importer that reads a legacy
meta bucket and rewrites bucket-metadata + IAM config into the RustFS meta
bucket (`crates/ecstore/src/bucket/migration.rs`).
- **Server-side encryption**: not covered by the above. Objects MinIO wrote with SSE-S3, SSE-KMS, or SSE-C are **not readable by RustFS** in any shipped build. See [Part C](#part-c--server-side-encryption-sse) before planning a migration that includes encrypted objects.
## Version Anchors
For unencrypted objects the remaining work is verification breadth and closing
per-config parsing gaps, not a format rewrite. Encrypted objects are a separate,
unsolved axis (rustfs/backlog#1638).
These constants are compatibility anchors. Bumping any of them requires a read-compat path for the prior value and a migration story, exactly as the meta_ver 2 to 3 read path provides. Values live in code; do not copy them elsewhere.
| Anchor | Symbol | File | Rule |
|---|---|---|---|
| `xl.meta` magic | `XL_FILE_HEADER` | `crates/filemeta/src/filemeta.rs` | Must equal MinIO's XL2 magic. |
| Container major / minor | `XL_FILE_VERSION_MAJOR`, `XL_FILE_VERSION_MINOR` | `crates/filemeta/src/filemeta.rs` | `check_xl2_v1` (`crates/filemeta/src/filemeta/codec.rs`) rejects `major > XL_FILE_VERSION_MAJOR`. |
| Header version | `XL_HEADER_VERSION` | `crates/filemeta/src/filemeta.rs` | `decode_xl_headers` rejects `header_ver > XL_HEADER_VERSION`. |
| Metadata version | `XL_META_VERSION` | `crates/filemeta/src/filemeta.rs` | Written by `FileMeta::new`; `decode_xl_headers` rejects `meta_ver > XL_META_VERSION` (accept-older, reject-newer). |
| Bucket metadata header | `BUCKET_METADATA_FORMAT`, `BUCKET_METADATA_VERSION` | `crates/ecstore/src/bucket/metadata.rs` | Checked by `check_header`; both match MinIO's `bucketMetadataFormat` / `bucketMetadataVersion`. |
| Erasure algorithm string | `ERASURE_ALGORITHM` | `crates/ecstore/src/object_api/mod.rs` | `rs-vandermonde`; enum `ErasureAlgo` in `crates/filemeta/src/fileinfo.rs`. |
| Meta bucket names | `RUSTFS_META_BUCKET`, `MIGRATING_META_BUCKET`, `BUCKET_META_PREFIX` | `crates/ecstore/src/disk/mod.rs` | `.rustfs.sys` is the live meta bucket; `.minio.sys` is the importer source. |
---
## Part A — `xl.meta` Object Format
| Aspect | Contract | Where |
|---|---|---|
| Version probe | `read_format_versions` returns `(major, minor, header_ver, meta_ver)` without a full parse | `crates/filemeta/src/filemeta/codec.rs` |
| Read compatibility | Accepts meta_ver 1-3 including legacy meta_ver 2 with legacy checksums (`uses_legacy_checksum`); `load_or_convert` normalizes on rewrite | `crates/filemeta/src/filemeta.rs`, `crates/ecstore/src/set_disk/read.rs` |
| Container layout | 8-byte header, bin-length-prefixed msgpack header block, CRC trailer, optional inline data (MinIO XL2 v1 shape) | `crates/filemeta/src/filemeta/codec.rs` |
| Erasure coding | Reed-Solomon Vandermonde, `rs-vandermonde` identifier, codec crate `rustfs-erasure-codec` | `Cargo.toml`, [erasure-coding.md](erasure-coding.md) |
| Bitrot | `HighwayHash256S` default; `HighwayHash256SLegacy` (fixed key) for older shards | `crates/ecstore/src/io_support/bitrot.rs`, `crates/ecstore/tests/legacy_bitrot_read_test.rs` |
| Inline data | Inline block after the CRC trailer; `null` / version-id keying via `data_key_for_version`; `physical_data_dir` accounting | `crates/filemeta/src/filemeta.rs`, `crates/filemeta/src/filemeta/inline_data.rs` |
### Version support
MinIO stores an inlined object body as `[HighwayHash256 (32 B)][body]`. Feeding the raw inline shard through RustFS's `BitrotReader` with `HighwayHash256S` verifies the checksum and yields the exact payload; the bitrot prefix is not a format incompatibility.
| Aspect | Value | Evidence |
|---|---|---|
| Write version (`meta_ver`) | 3 | `crates/filemeta/src/filemeta.rs:54` (`XL_META_VERSION = 3`), written in `FileMeta::new` at `crates/filemeta/src/filemeta.rs:121` |
| Read versions accepted | ≤ 3 (1, 2, 3) | Decode rejects only `meta_ver > XL_META_VERSION` — see `crates/filemeta/src/filemeta/codec.rs` (`decode_xl_headers`); `load_or_convert` doc at `crates/filemeta/src/filemeta.rs:864` |
| Legacy meta_ver 2 read | Supported (with legacy checksum) | Regression fixtures `test_issue_2265_legacy_meta_v2_object_compatibility` / `test_issue_2288_legacy_xlmeta_compatibility` at `crates/filemeta/src/filemeta.rs:1130`, `:1152`; `uses_legacy_checksum` asserted at `:1174` |
RustFS is a **read-forward-compatible** consumer of MinIO's `xl.meta`: it can
parse older MinIO objects and normalizes them to meta_ver 3 on rewrite. It does
not write MinIO's older versions.
### Container header
| Field | RustFS value | Evidence |
|---|---|---|
| Magic | `XL2 ` (`[b'X', b'L', b'2', b' ']`) | `crates/filemeta/src/filemeta.rs:46` |
| File version major / minor | 1 / 3 | `crates/filemeta/src/filemeta.rs:51-52` |
| Header version | 3 | `crates/filemeta/src/filemeta.rs:53` |
| Magic + version check (decode entry) | `check_xl2_v1` validates magic and rejects `major > 1` | `crates/filemeta/src/filemeta/codec.rs:45-61` |
| Version-only probe (no full parse) | `read_format_versions` returns `(major, minor, header_ver, meta_ver)` | `crates/filemeta/src/filemeta/codec.rs:30-43` |
The layout after the 8-byte header is `bin-length-prefixed msgpack header block`
followed by a CRC trailer and optional inline data — matching MinIO's XL2 v1
container.
### Erasure coding
| Aspect | Value | Evidence |
|---|---|---|
| Algorithm enum | `ErasureAlgo::ReedSolomon = 1` | `crates/filemeta/src/fileinfo.rs:83-106` |
| Algorithm string | `rs-vandermonde` | `crates/filemeta/src/fileinfo.rs:31` (`ERASURE_ALGORITHM`); also `crates/ecstore/src/object_api/mod.rs:52` |
| Codec crate | `rustfs-erasure-codec` (Reed-Solomon, SIMD) | `Cargo.toml:277` |
Same Reed-Solomon Vandermonde scheme and identifier string as MinIO.
### Bitrot / shard integrity
| Aspect | Value | Evidence |
|---|---|---|
| Default hash | `HashAlgorithm::HighwayHash256S` | Bitrot read/write paths in `crates/ecstore/src/io_support/bitrot.rs` (e.g. `:564`, `:767`) |
| Legacy variant | `HighwayHash256SLegacy` (fixed key) for old objects | referenced from `rustfs_utils::HashAlgorithm` (imported at `crates/ecstore/src/io_support/bitrot.rs:26`) |
| HighwayHash crate | `highway` 1.3.0 | `Cargo.toml:252` |
| Legacy bitrot read coverage | dedicated test | `crates/ecstore/tests/legacy_bitrot_read_test.rs` |
MinIO uses HighwayHash256 for bitrot; RustFS's default `HighwayHash256S` is
compatible, with a legacy-key variant retained for older shards.
### Inline data
Small objects are inlined into the `xl.meta` container after the CRC trailer
rather than written as a separate `part.1`. Handling lives in
`crates/filemeta/src/filemeta/inline_data.rs` (e.g. `physical_data_dir` and the
shared-data-dir accounting), and the inline block is appended/consumed by the
codec in `crates/filemeta/src/filemeta/codec.rs`. This mirrors MinIO's inline
data feature and the `null`/version-id keying used for the inline map
(`data_key_for_version` at `crates/filemeta/src/filemeta.rs:69`, legacy key at
`:77`).
### `xl.meta` interop verdict
| Item | Done | Partial | Todo |
|---|:--:|:--:|:--:|
| Read MinIO meta_ver ≤ 3 | ✅ | | |
| Legacy meta_ver 2 + legacy checksum read | ✅ | | |
| XL2 container magic/version parity | ✅ | | |
| Reed-Solomon `rs-vandermonde` parity | ✅ | | |
| HighwayHash256 bitrot parity | ✅ | | |
| Inline data parity | ✅ | | |
| Broad fixture corpus from real MinIO writers | | ⚠️ | |
| Write-back parity for round-trip (RustFS→MinIO read) | | ⚠️ | |
The two ⚠️ items are verification breadth, not known incompatibilities: the
current fixtures are targeted regressions (issues #2265, #2288), and there is no
CI job proving a MinIO binary can re-read a RustFS-written `xl.meta`.
---
## Part B — Bucket Metadata (`.metadata.bin`)
| Aspect | Contract | Where |
### On-disk layout
| Aspect | RustFS value | Evidence |
|---|---|---|
| Path | `buckets/{bucket}/.metadata.bin` under the meta bucket (`BUCKET_METADATA_FILE`, `save_file_path`) | `crates/ecstore/src/bucket/metadata.rs` |
| Header | 4 bytes: `format: u16 LE` + `version: u16 LE`, stripped before `unmarshal` | `check_header` in `crates/ecstore/src/bucket/metadata.rs` |
| Body | MessagePack-encoded `BucketMetadata`; field names map one-to-one onto MinIO's `bucketMetadata` (PascalCase on the wire) | `BucketMetadata` in `crates/ecstore/src/bucket/metadata.rs` |
| Per-config encoding | XML for S3-XML configs, JSON for policy / quota / targets / ACL; the per-config filename constants (`policy.json`, `lifecycle.xml`, ...) are `update_config` field-selector keys, not separate files | `update_config`, `parse_all_configs` in `crates/ecstore/src/bucket/metadata.rs` |
| RustFS-only fields | `bucket_targets_config_meta_json`, `table_bucket_config_json`; a MinIO reader ignores unknown msgpack fields | `crates/ecstore/src/bucket/metadata.rs` |
| Partial interop | `bucket_targets` meta side-channel is RustFS-specific; `bucket_acl` round-trips as a blob but only canned ACLs are enforced (see [minio-rustfs-router-compatibility.md](minio-rustfs-router-compatibility.md)) | |
| Meta bucket | `.rustfs.sys` | `crates/ecstore/src/disk/mod.rs:29` (`RUSTFS_META_BUCKET`) |
| Bucket-config prefix | `buckets` | `crates/ecstore/src/disk/mod.rs:34` (`BUCKET_META_PREFIX`) |
| Blob file | `.metadata.bin` | `crates/ecstore/src/bucket/metadata.rs:227` (`BUCKET_METADATA_FILE`) |
| Full path | `buckets/{bucket}/.metadata.bin` | `crates/ecstore/src/bucket/metadata.rs:415-416` (`save_file_path`) |
| Header | `format: u16 LE` + `version: u16 LE`, both `= 1` | `crates/ecstore/src/bucket/metadata.rs:228-229`, checked in `check_header` at `:595-614` |
| Body | MessagePack-encoded `BucketMetadata` | `marshal_msg`/`unmarshal` at `crates/ecstore/src/bucket/metadata.rs:582-593`; read strips the 4-byte header (`unmarshal(&data[4..])` at `:1079`) |
### Importer
This is the same design as MinIO's bucket metadata: a single
`.minio.sys/buckets/<bucket>/.metadata.bin` blob with a 4-byte
`bucketMetadataFormat|bucketMetadataVersion` header and a msgpack body. The
filename, header shape, and format/version values (`1`/`1`) all match. The
`BucketMetadata` field names correspond one-to-one to MinIO's `bucketMetadata`
struct (`policyConfigJSON`, `lifecycleConfigXML`, `objectLockConfigXML`, …).
`crates/ecstore/src/bucket/migration.rs` is a one-way, idempotent importer from a `MIGRATING_META_BUCKET` (`.minio.sys`) layout into `.rustfs.sys`, run at startup from `rustfs/src/startup_bucket_metadata.rs`:
> Correction to a common misconception: modern MinIO does **not** store each
> bucket config as a separate loose `versioning.json` / `lifecycle.json` file —
> it embeds them in the same `.metadata.bin` blob, with XML for the S3-XML
> configs and JSON for policy/quota/targets. The per-config filename constants
> in RustFS (`policy.json`, `lifecycle.xml`, …) are the **keys used by
> `update_config`** to select a field, not separate on-disk files.
| Function | Imports |
|---|---|
| `try_migrate_bucket_metadata` | `buckets/{bucket}/.metadata.bin` plus the replication resync blob (`normalize_bucket_meta_blob` via `ReplicationMigrationBridge`) |
| `try_migrate_iam_config` | `config/iam/` records; `normalize_iam_config_blob` rewrites legacy timestamp and policy-mapping aliases |
### Interop matrix (backlog#580 items)
Field/constant references are in `crates/ecstore/src/bucket/metadata.rs`.
"Encoding" is the payload RustFS stores in that field and must match MinIO's for
byte-level interop. Getter functions live in
`crates/ecstore/src/bucket/metadata_sys.rs`.
| Config item | RustFS field / constant | Encoding | MinIO field | Status |
|---|---|---|---|---|
| versioning | `versioning_config_xml` / `BUCKET_VERSIONING_CONFIG` = `versioning.xml` | XML | versioningConfigXML | Done |
| quota | `quota_config_json` / `BUCKET_QUOTA_CONFIG_FILE` = `quota.json` | JSON | quotaConfigJSON | Done |
| object_lock | `object_lock_config_xml` / `OBJECT_LOCK_CONFIG` = `object-lock.xml` | XML | objectLockConfigXML | Done |
| replication | `replication_config_xml` / `BUCKET_REPLICATION_CONFIG` = `replication.xml` | XML | replicationConfigXML | Done |
| policy | `policy_config_json` / `BUCKET_POLICY_CONFIG` = `policy.json` | JSON | policyConfigJSON | Done |
| lifecycle | `lifecycle_config_xml` / `BUCKET_LIFECYCLE_CONFIG` = `lifecycle.xml` | XML | lifecycleConfigXML | Done |
| tagging | `tagging_config_xml` / `BUCKET_TAGGING_CONFIG` = `tagging.xml` | XML | taggingConfigXML | Done |
| bucket_targets | `bucket_targets_config_json` + `bucket_targets_config_meta_json` / `BUCKET_TARGETS_FILE` = `bucket-targets.json` | JSON | bucketTargetsConfigJSON (+ meta variant) | Partial |
| notification | `notification_config_xml` / `BUCKET_NOTIFICATION_CONFIG` = `notification.xml` | XML | notificationConfigXML | Done |
| encryption | `encryption_config_xml` / `BUCKET_SSECONFIG` = `bucket-encryption.xml` | XML | encryptionConfigXML | Done |
| cors | `cors_config_xml` / `BUCKET_CORS_CONFIG` = `cors.xml` | XML | corsConfigXML | Done |
| public_access | `public_access_block_config_xml` / `BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG` = `public-access-block.xml` | XML | publicAccessBlockConfigXML | Done |
| bucket_acl | `bucket_acl_config_json` / `BUCKET_ACL_CONFIG` = `bucket-acl.json` | JSON | bucketACLConfigJSON | Partial |
Field definitions: `crates/ecstore/src/bucket/metadata.rs:274-336`. Constants:
`:227-247`. `update_config` field routing: `:678-761`. `parse_all_configs` is
invoked on load (`load_bucket_metadata_parse` at `:1043`).
Notes on the two "Partial" rows:
- **bucket_targets** — RustFS carries an extra `bucket_targets_config_meta_json`
field (`:288`) beyond MinIO's single targets blob. The primary
`bucket-targets.json` payload is interoperable; the meta side-channel is
RustFS-specific and a MinIO reader would ignore it. ACL enforcement itself is
bounded (S3 `PutBucketAcl`/`PutObjectAcl` accept canned ACLs only — see
[minio-rustfs-router-compatibility.md](minio-rustfs-router-compatibility.md)).
- **bucket_acl** — stored and round-tripped in the blob, but ACL grant
semantics are intentionally limited at the S3 layer.
RustFS also defines fields with no interop requirement from backlog#580 but
worth noting so a migration tool does not choke on them: `logging_config_xml`,
`website_config_xml`, `accelerate_config_xml`, `request_payment_config_xml`
(`:242-245`), and the RustFS-only `table_bucket_config_json`
(`BUCKET_TABLE_CONFIG` = `table-bucket.json`, `:248`). A MinIO reader that does
not know `table_bucket_config_json` will ignore the unknown msgpack field.
### Old-RustFS → new-RustFS migration
RustFS ships a one-way importer that reads a legacy meta bucket
(`MIGRATING_META_BUCKET`) and rewrites both bucket metadata and IAM config into
the current RustFS meta bucket, skipping entries that already exist
(idempotent). See `crates/ecstore/src/bucket/migration.rs`:
- `try_migrate_bucket_metadata` copies `buckets/{bucket}/.metadata.bin` and the
replication resync blob for each bucket (`crates/ecstore/src/bucket/migration.rs:193`).
- `try_migrate_iam_config` walks `config/iam/` and normalizes legacy IAM
records — legacy timestamp fields (`update_at``updatedAt`) and legacy
policy-mapping field aliases (`policies``policy`) are rewritten
(`normalize_iam_config_blob` at `:97`; regression test at `:428`).
- Bucket resync metadata is re-encoded through `ReplicationMigrationBridge`
(`normalize_bucket_meta_blob` at `:178`).
This importer is the practical basis for a MinIO → RustFS bucket-metadata
migration: because the blob layout and field encodings already match, the
missing piece is a source adapter that points the importer at a MinIO
`.minio.sys` layout rather than the RustFS legacy layout.
### Bucket-metadata interop verdict
| Item | Done | Partial | Todo |
|---|:--:|:--:|:--:|
| `.metadata.bin` filename + header + msgpack layout parity | ✅ | | |
| Per-config field encodings (XML/JSON) match MinIO | ✅ | | |
| versioning/quota/object_lock/replication/policy/lifecycle/tagging/notification/encryption/cors/public_access round-trip | ✅ | | |
| bucket_targets primary blob | ✅ | | |
| bucket_targets meta side-channel + ACL grant semantics | | ⚠️ | |
| Old-RustFS → new-RustFS importer | ✅ | | |
| MinIO `.minio.sys` source adapter for the importer | | | ❌ |
| CI proof a MinIO-written `.metadata.bin` loads unchanged | | | ❌ |
---
## Part C — Server-Side Encryption (SSE)
The `xl.meta` around a MinIO SSE object parses in every build, so such objects list, HEAD, and report plausible sizes; only payload readability depends on the build. KMS wire protocols (AWS `awsJson1_1` client in `crates/kms/src/backends/aws.rs`, MinIO KES) are non-targets.
Reading MinIO-written SSE objects is implemented, with a deliberate build boundary. The read path lives behind the `rio-v2` feature and is a **special-purpose migration capability**: it is not compiled into released binaries or container images, and there is no short-term plan to promote it into default builds. A default build fails such reads closed with a diagnosed error (see "How default builds fail" below); a `rio-v2` build reads them, within the scenario matrix below. The read-path work was tracked in rustfs/backlog#1638 (landed across rustfs/rustfs#6191, #6784, #6785).
| Object class | `default` / `full` | `rio-v2` | Requirement |
### Scope boundary: KMS wire protocols and the production gate
This document covers MinIO on-disk metadata and object-encryption seams only. The **AWS KMS wire protocol** and the **MinIO KES wire protocol** are explicit non-targets: RustFS's AWS backend uses the AWS SDK's `awsJson1_1` client path (`crates/kms/src/backends/aws.rs:830`), while KES compatibility is outside this interop work. Those ecosystem evaluations remain separate work in the [#1562 Production Ready exit gate](https://github.com/rustfs/backlog/issues/1562), whose compatibility criterion covers MinIO/RustFS SSE data and rolling upgrades. Closing #1638 does not by itself close that gate.
Note the asymmetry with Parts A and B: the `xl.meta` around a MinIO SSE object parses fine, so such objects list, HEAD, and report plausible sizes. Only payload readability depends on the build and the scenario.
### What can and cannot be migrated
| Object class | Default build | `rio-v2` build | Notes |
|---|:--:|:--:|---|
| SSE-S3 / SSE-KMS, MinIO builtin static KMS, single- and multipart | Fail closed, diagnosed | Read | `RUSTFS_SSE_S3_MASTER_KEY` (base64, 32 bytes) equal to the source MinIO's static secret. |
| SSE-C, MinIO-written | Fail closed, diagnosed | Read | Client supplies the customer key per request; MinIO stores no key MD5, so the AEAD unseal is the key proof. |
| Any SSE, MinIO backed by KES / KMS plugin / MinKMS | Fail closed | Fail closed | Not planned. Re-encrypt or decrypt on the MinIO side first. |
| Bucket default-encryption *configuration* | Round-trips | Round-trips | A config blob; it does not make existing ciphertext readable. |
| Unencrypted objects | ✅ | ✅ | Parts A and B apply. |
| Bucket metadata, IAM config | ✅ | ✅ | Via the importer, once a `.minio.sys` source adapter exists (see Part B). |
| Bucket-level default-encryption *configuration* | ✅ | ✅ | The `encryption` config blob round-trips as a blob; it does not make existing ciphertext readable. |
| SSE-S3 / SSE-KMS, MinIO builtin static KMS (`MINIO_KMS_SECRET_KEY`), single- and multipart | ❌ diagnosed | ✅ | Requires `RUSTFS_SSE_S3_MASTER_KEY` set to the same 32-byte key material as MinIO's static secret. Proven against real MinIO fixtures (rustfs/rustfs#6191). |
| SSE-C, MinIO-written | ❌ diagnosed | ✅ | Detection via MinIO's sealed-key slot; the customer key is proven by the AEAD unseal, since MinIO stores no key MD5 (rustfs/rustfs#6785). |
| Any SSE, MinIO backed by KES / KMS plugin / MinKMS | ❌ | ❌ **not planned** | The wrapped DEK is sealed by the KES service itself; it is not a Vault/Transit ciphertext RustFS could be pointed at. Re-encrypt on the MinIO side before migrating. |
| Objects sealed with legacy `DARE-SHA256` (`InsecureSealAlgorithm`) | ❌ | ❌ out of scope | Pre-DAREv2-HMAC MinIO; `parse_minio_managed_sealed_key` rejects the algorithm and the read fails closed. |
| RustFS-written SSE objects read back by MinIO | ❌ | ❌ | See "Reverse direction". |
### Seams
### The seams, and where they closed
The cryptography (DARE v2 stream format, object-key derivation, sealing) was never the gap. Three metadata seams above it rejected MinIO objects; all are closed in `rio-v2` builds. Symbols are in `rustfs/src/storage/sse.rs` unless noted.
The cryptographic primitives were never the gap — RustFS implements the same DARE V2 stream format, object-key derivation, and sealing. Three seams above the cryptography rejected MinIO-written objects; all three are closed in `rio-v2` builds.
| Seam | Resolution |
|---|---|
| Managed-SSE detection required the persisted public `x-amz-server-side-encryption` key, which MinIO synthesizes at response time | `infer_minio_managed_sse_type` infers the scheme from which MinIO sealed-key slot is present; the slot also selects the sealing-key domain, so a wrong inference cannot derive a wrong key. |
| MinIO's wrapped-DEK ciphertext was accepted by no envelope parser | `decrypt_minio_kms_data_key` implements MinIO's builtin-KMS sealing for both the raw `sealed‖iv‖nonce` layout and the legacy `{"aead": ...}` JSON. Routing is by byte shape: `LocalSseDekEnvelope` (`deny_unknown_fields`) is recognized positively, everything else goes to the MinIO decoder. |
| SSE-C detection keyed on the stored customer-algorithm header, which MinIO also never persists, and demanded a stored key MD5 | `stored_ssec_metadata` accepts MinIO's SSE-C sealed-key slot (`rio-v2` only); `verify_ssec_key_match` tolerates a missing stored MD5 for exactly that shape. |
| Multipart classification used an ETag-length heuristic (MinIO stores encrypted ETags) | Trusts MinIO's own `X-Minio-Internal-Encrypted-Multipart` marker (`crates/utils/src/http/header_compat.rs`). |
| # | Seam | Resolution |
|---|---|---|
| 1 | Managed-SSE detection required the *persisted* public `x-amz-server-side-encryption` key, which MinIO synthesizes at response time and never stores. | Closed by rustfs/rustfs#6191: `infer_minio_managed_sse_type` infers the scheme from which MinIO sealed-key slot is present (the slot also selects the sealing-key domain, so a wrong inference cannot silently derive a wrong key). Inference from the KMS key id would misclassify — MinIO writes `-S3-Kms-Key-Id` on SSE-S3 objects too. |
| 2 | MinIO's wrapped-DEK ciphertext was not accepted by any envelope parser. | Closed by rustfs/rustfs#6191: `decrypt_minio_kms_data_key` implements MinIO's builtin-KMS sealing (`sealingKey = HMAC-SHA256(master, iv)`), accepting both the raw `sealed‖iv‖nonce` layout and the legacy `{"aead": ...}` JSON. Routing is by the data key's own byte shape — RustFS's strict JSON envelopes are recognized positively, everything else goes to the MinIO decoder — because slot names cannot distinguish the writer. `LocalSseDekEnvelope` keeps `deny_unknown_fields`. |
| 3 | SSE-C detection keyed on the stored customer-algorithm header, which MinIO also never persists, and the early key check demanded a stored key MD5 MinIO does not write. | Closed by rustfs/rustfs#6785: `stored_ssec_metadata` also accepts MinIO's SSE-C sealed-key slot (rio-v2 builds only), and `verify_ssec_key_match` tolerates a missing stored MD5 for exactly that shape — the AEAD unseal remains the key proof, and a wrong key still fails there. |
Two further single-part defects were fixed on the way (both rustfs/rustfs#6191 follow-ups): multipart classification now trusts MinIO's own `X-Minio-Internal-Encrypted-Multipart` marker instead of an ETag-length heuristic (MinIO stores *encrypted* ETags, so every single-part SSE object mis-classified as multipart), and single-part plaintext sizes are recovered by DARE reverse-size arithmetic (`dare_v2_decrypted_size`) since MinIO records an explicit size only for multipart uploads.
### How default builds fail
`is_object_encryption_marker` (`crates/utils/src/http/header_compat.rs`) matches the whole `x-minio-internal-server-side-encryption-` prefix, so `ObjectInfo::is_encrypted()` is true and the read plan refuses to construct a reader without decryption material. The refusal is a typed error that names the MinIO-compatible sealed format and the `rio-v2` read path it requires, surfaced as S3 `InvalidObjectState` (non-retryable). Ciphertext is never served as plaintext.
The read fails closed: ciphertext is never served as plaintext. `is_object_encryption_marker` matches the whole `x-minio-internal-server-side-encryption-` prefix, so `ObjectInfo::is_encrypted()` is true for these objects, and the read plan refuses to construct a reader without decryption material. Since rustfs/rustfs#6784 the refusal is diagnosed: the resolver raises a typed error naming the condition — in default builds it points at the MinIO-compatible sealed format and the `rio-v2` read path it would require — and it surfaces as S3 `InvalidObjectState` (non-retryable) instead of the former undiagnosed 500 `InternalError`. List and HEAD still succeed, because `xl.meta` parses normally.
### What a `rio-v2` migration build needs
- A binary built with `--features rio-v2`. The feature is deliberately absent from `default` and `full` in `rustfs/Cargo.toml`; released binaries and images never include it.
- For SSE-S3/SSE-KMS objects: `RUSTFS_SSE_S3_MASTER_KEY` (base64, 32 bytes) set to the same key material as the source MinIO's `MINIO_KMS_SECRET_KEY`. For SSE-C objects: nothing server-side — the client supplies the customer key per request, as on MinIO.
- The interop harness is the evidence chain: `rustfs/src/storage/minio_generated_read_test.rs` (`#[ignore]` reader tests over real MinIO-generated fixtures, run with `--features rio-v2`), the fixture lab under `crates/rio-v2/tests/minio_fixture_lab/`, and the `minio-interop` workflow. The SSE-C lane of that harness (customer-key handout from a fixture capture to the reader test) is not wired yet; SSE-C coverage currently lives in the unit suite, which builds the MinIO shape with the same sealing primitives the fixture suite proved byte-compatible.
Known unverified edge: MinIO seals ETags on SSE objects (`SealETag`); RustFS does not unseal them, so ETag display and `If-Match` semantics on migrated SSE objects are not guaranteed to match MinIO's.
### Reverse direction
Under `rio-v2` RustFS writes its own DEK envelope into MinIO's sealed-key metadata slots labelled with MinIO's seal algorithm, so the metadata is MinIO-shaped while the key bytes are not MinIO-openable. Default builds do not populate those slots. Treat RustFS-written SSE objects as readable only by RustFS. Known unverified edge: MinIO seals ETags on SSE objects; RustFS does not unseal them, so ETag display and `If-Match` on migrated SSE objects are not guaranteed to match MinIO.
Migrating back is also unsupported. Under `rio-v2` RustFS writes its own DEK envelope into MinIO's sealed-key metadata slots and labels it with MinIO's seal algorithm (`rustfs/src/storage/sse.rs:1830-1852`), so the metadata is MinIO-shaped while the key bytes are not MinIO-openable. Default builds do not populate those slots at all (`rustfs/src/storage/sse.rs:1796-1798`). Treat RustFS-written SSE objects as readable only by RustFS.
### Migration options for encrypted objects
### Migration options
1. Static-KMS source: run the migration through a `rio-v2` build with the shared master key, serving in place or copying into a default-build cluster (the copy re-encrypts under RustFS's own KMS).
2. KES / MinKMS source, or no special-purpose build wanted: decrypt on the MinIO side (rewrite as plaintext, or copy out through MinIO's S3 endpoint) and let RustFS encrypt on ingest.
3. Leave encrypted objects on MinIO and migrate only unencrypted data.
- For static-KMS MinIO sources: run the migration through a `rio-v2` build with the shared master key (see above), either serving reads in place or copying objects out into a default-build cluster (the copy re-encrypts under RustFS's own KMS).
- For KES/MinKMS-backed sources, or when a special-purpose build is not wanted: decrypt on the MinIO side first — rewrite the affected objects as plaintext, or copy them out through MinIO's S3 endpoint, which decrypts on read — and let RustFS apply its own encryption on ingest.
- Leave encrypted objects on MinIO and migrate only unencrypted data.
Inventory the source first: bucket default encryption means objects can be encrypted without the uploader asking, so "we never set SSE headers" is not evidence that a bucket has no encrypted objects.
Inventory the source first bucket default-encryption settings mean objects can be encrypted without the uploader having asked for it, so "we never set SSE headers" is not sufficient evidence that a bucket has no encrypted objects.
## rio-v2 variant lifecycle
### SSE interop verdict
`rio-v2` is a dormant, special-purpose migration variant tracked under `rustfs/backlog#1835`.
| Item | Done | Partial | Todo |
|---|:--:|:--:|:--:|
| DARE V2 stream format parity | ✅ | | |
| Object-key derivation / sealing parity | ✅ | | |
| Managed-SSE detection accepts MinIO-written metadata (`rio-v2`) | ✅ | | |
| MinIO builtin-KMS wrapped-DEK parser (raw + legacy JSON) | ✅ | | |
| SSE-C detection accepts MinIO-written metadata (`rio-v2`) | ✅ | | |
| Read MinIO-written SSE-S3 / SSE-KMS end to end, single- and multipart | ✅ | | |
| Read MinIO-written SSE-C end to end | | ⚠️ unit-proven; fixture-lab lane unwired | |
| Migrated-object sealed-ETag semantics | | | ❌ unverified |
| KES / MinKMS / legacy `DARE-SHA256` sources | | | ❌ not planned |
| RustFS-written SSE objects readable by MinIO | | | ❌ |
| CI proof of SSE read parity | | ⚠️ `minio-interop` workflow; nightly once re-enabled | |
| Fact | Value |
|---|---|
| Shipping status | Ships in no default build: absent from `default` and `full` in `rustfs/Cargo.toml`; released binaries and container images never include it. Enable with `--features rio-v2`. |
| Pull-request coverage | `test-and-lint-rio-v2` in `.github/workflows/ci.yml`: clippy plus `cargo nextest` for `rustfs` and `rustfs-ecstore` with `--features rio-v2`. This is the cfg-seam guard; it keeps the feature compiling and its unit suite green on every pull request. |
| Full-suite lane | `build-rustfs-debug-binary-rio-v2` and `e2e-tests-rio-v2` in `ci.yml` run only on the weekly schedule and manual dispatch (`cache-warm.yml` keeps the `ci-feat-rio` cache warm so the scheduled build fits its timeout). |
| Interop evidence | `.github/workflows/minio-interop.yml` (nightly plus manual) regenerates real MinIO backend trees via `crates/rio-v2/tests/minio_fixture_lab/` and runs the `#[ignore]` reader tests in `rustfs/src/storage/minio_generated_read_test.rs` with `--features rio-v2`. Its freshness is tracked in `.github/scheduled-validations.json`. |
| Promote-or-delete condition | The variant stays dormant until one of two things happens. **Promote**: a release commits to shipping MinIO SSE migration as a supported capability; then `rio-v2` joins `default`/`full`, the scheduled lanes run on every pull request, and this section is rewritten. **Delete**: no release commits to it and the scheduled lanes are not kept green; then the feature flag, `crates/rio-v2`, the cfg seams in `rustfs/src/storage/sse.rs`, the three `ci.yml` jobs, the `cache-warm.yml` warm step, `minio-interop.yml`, and its `scheduled-validations.json` entry are removed in one change. Either outcome must update `ARCHITECTURE.md` and the `ci.yml` job comments that cite this section. |
---
## Fixture Evidence
## Phased Plan
Fixtures were captured from a real MinIO single-drive instance and live under `crates/filemeta/tests/fixtures/minio/` and `crates/ecstore/tests/fixtures/minio/`. These tests run in the normal `cargo test` / nextest lanes.
The format is already close; the plan is verification, a source adapter, and
closing the two partial encodings — not a rewrite.
| Test | File | Proves |
|---|---|---|
| `parses_real_minio_object_xlmeta` | `crates/filemeta/src/filemeta.rs` | Inline, two-version plus delete-marker, and multipart `xl.meta` parse to the expected `FileInfo`. |
| `parses_real_minio_bucket_metadata_blob_without_loss` | `crates/ecstore/src/bucket/metadata.rs` | The msgpack blob decodes via MinIO's field names and `parse_all_configs` loads every config in the corpus, including MinIO's lifecycle `<ExpiryUpdatedAt>` and replication `DeleteMarkerReplication` / `ExistingObjectReplication` extensions. |
| `reads_minio_inline_bucket_metadata_via_bitrot` | `crates/ecstore/src/bucket/metadata.rs` | The inline shard's HighwayHash prefix verifies under `HighwayHash256S` and yields the exact `.metadata.bin` blob. |
| `migrates_real_minio_bucket_metadata_end_to_end` | `crates/ecstore/src/bucket/migration.rs` | A real `.metadata.bin` seeded under a `.minio.sys` layout is imported by `try_migrate_bucket_metadata` into `.rustfs.sys` byte-identical, through the object layer on a 4-drive `ECStore`. |
| `test_issue_2265_legacy_meta_v2_object_compatibility`, `test_issue_2288_legacy_xlmeta_compatibility` | `crates/filemeta/src/filemeta.rs` | Legacy meta_ver 2 objects with legacy checksums still read. |
| `minio_generated_read_test.rs` (`#[ignore]`, `rio-v2`) | `rustfs/src/storage/minio_generated_read_test.rs` | Byte-identical plaintext reconstruction of MinIO SSE-S3 / SSE-KMS fixtures; driven by `minio-interop.yml`. |
### Phase 1 — Read parity, proven (verification)
Not fixture-proven: transitioned `xl.meta`; CORS, public-access-block, and bucket-ACL configs (the SNSD corpus did not exercise them); bucket-targets credentials (MinIO stores them KMS-encrypted); the SSE-C fixture-lab lane (customer-key handout is not wired; SSE-C coverage is unit-level).
- Add a MinIO-writer fixture corpus for `xl.meta` (inline + multipart +
versioned + delete-marker + transitioned) and assert RustFS parses each to a
`FileInfo` equivalent to MinIO's, alongside the existing issue #2265 / #2288
fixtures in `crates/filemeta/src/filemeta.rs`.
- Add a fixture `.metadata.bin` written by MinIO and assert
`BucketMetadata::unmarshal` + `parse_all_configs` load every field without
loss (`crates/ecstore/src/bucket/metadata.rs`).
- Exit criterion: a CI job that fails if a real MinIO-written object or bucket
blob cannot be read.
## Out Of Scope
#### Phase 1 status — first fixtures landed (verified 2026-07-07)
- A live MinIO binary serving a RustFS-written drive set (set-level `.minio.sys` vs `.rustfs.sys` divergence). A bidirectional round-trip would require RustFS to optionally write the `.minio.sys` set layout, which is a separate feature.
- RustFS-written SSE objects readable by MinIO.
- KES / MinKMS / KMS-plugin-sealed MinIO objects.
- Objects sealed with pre-DARE-v2-HMAC MinIO seal algorithms; `parse_minio_managed_sealed_key` rejects unknown algorithms and the read fails closed.
- AWS KMS and KES wire-protocol compatibility.
A real MinIO `RELEASE.2025-07-23` single-drive instance wrote a bucket with
versioning, object-lock (GOVERNANCE default), lifecycle, tagging, quota, and a
public-download policy, plus inline / versioned / multipart objects. The on-disk
`xl.meta` blobs are captured as hex fixtures
(`crates/filemeta/tests/fixtures/minio/`, `crates/ecstore/tests/fixtures/minio/`).
Proven by regression tests:
- **Object `xl.meta` read parity**`parses_real_minio_object_xlmeta`
(`crates/filemeta/src/filemeta.rs`): small inline, two-object-version + delete
marker, and multipart objects all parse to the expected `FileInfo`.
- **Bucket-metadata parse parity**`parses_real_minio_bucket_metadata_blob_without_loss`
(`crates/ecstore/src/bucket/metadata.rs`): the msgpack blob decodes via the
PascalCase MinIO field names, and `parse_all_configs` loads **all ten** config
types present in the corpus without loss — policy, lifecycle (**including
MinIO's `<ExpiryUpdatedAt>` extension**), object-lock, versioning, tagging,
quota, notification, encryption (SSE-S3), and replication (**including the
`DeleteMarkerReplication` / `ExistingObjectReplication` MinIO extensions**).
- **Inline bucket-metadata read parity**`reads_minio_inline_bucket_metadata_via_bitrot`
(`crates/ecstore/src/bucket/metadata.rs`): MinIO stores an inlined object body
as `[HighwayHash256 (32B)][body]`. The "`inline_data` 前缀不同" that weisd
raised on 2026-03-06 is exactly that bitrot prefix — **not** a format
incompatibility. Feeding the raw inline shard through RustFS's `BitrotReader`
with the default `HighwayHash256S` verifies the checksum (confirming RustFS's
hash matches MinIO's) and yields the exact `.metadata.bin` blob, which then
parses. So the object-layer inline read is compatible; the earlier "extract
`fi.data` directly" concern was reading the shard before the bitrot layer
strips its prefix.
- **End-to-end migration**`migrates_real_minio_bucket_metadata_end_to_end`
(`crates/ecstore/src/bucket/migration.rs`): on a throwaway 4-drive local
`ECStore`, a real MinIO `.metadata.bin` seeded under a `.minio.sys` layout is
migrated by `try_migrate_bucket_metadata` into `.rustfs.sys`, and the migrated
blob carries every config (policy / lifecycle / object-lock / versioning /
tagging / quota / notification / encryption / replication) byte-identical to
the source. This exercises the Phase 2 source adapter
(`MIGRATING_META_BUCKET = ".minio.sys"`) end-to-end through the object layer —
proven, not just present.
Still to broaden: transitioned `xl.meta`; CORS, public-access-block, and bucket
ACL configs (the SNSD test binary/`mc` did not expose these); and bucket-targets
credentials, which MinIO stores KMS-encrypted (a documented partial). These run
as ordinary crate tests, so they already execute in the normal `cargo
test`/nextest CI jobs.
### Phase 2 — MinIO source adapter for migration
- Generalize the importer in `crates/ecstore/src/bucket/migration.rs` so the
source can be a MinIO `.minio.sys/buckets/<bucket>/.metadata.bin` layout, not
only the RustFS legacy meta bucket. Because the blob format matches, this is
mostly source-path plumbing plus IAM record normalization reuse.
- Exit criterion: importing a MinIO backup reproduces all backlog#580
bucket-config items with byte-identical config payloads.
### Phase 3 — Close the two partial encodings
- bucket_targets: document/normalize the RustFS-only
`bucket_targets_config_meta_json` so a round-trip through MinIO and back does
not silently drop it; or fold its content into a MinIO-compatible
representation.
- bucket_acl: decide whether ACL grant semantics beyond canned ACLs are in
scope; if not, keep the blob round-trippable but document the enforcement
limit (already reflected in the router compatibility matrix).
### Phase 4 — Round-trip / write-back parity (non-goal for migration)
Proving a MinIO binary can re-read a *RustFS-written drive set* (the reverse
direction) is **out of scope for the migration use case**, which is one-way
MinIO → RustFS:
- RustFS's meta bucket is `.rustfs.sys` (`crates/ecstore/src/disk/mod.rs:29`);
MinIO looks for `.minio.sys`. A MinIO binary pointed at a RustFS drive set
does not find `format.json` or bucket configs and refuses the set — this is a
set-level divergence, not an object-format one.
- The object-level `xl.meta` format *does* match (proven above), so the reverse
direction is limited by drive-set discovery, not by per-object encoding.
- The supported flow is one-way: `try_migrate_bucket_metadata` /
`try_migrate_iam_config` / `format.json` migration import a MinIO layout into
RustFS. There is no requirement to keep a live MinIO able to serve
RustFS-written drives.
If a true bidirectional round-trip is ever needed, it would require RustFS to
optionally write the `.minio.sys` set layout — a separate feature, not part of
the interop/migration story tracked here.
---
## Guardrails
- Any change to `crates/filemeta` or `crates/ecstore/src/bucket` metadata encoding is a storage-format change and follows the migration and readiness contracts in [README.md](README.md) and the ecstore layout boundary rules.
- Do not bump a Version Anchor without a read path for the prior value; see [erasure-coding.md](erasure-coding.md) for the accept-older, reject-newer rule.
- `.github/workflows/ci.yml`, `.github/workflows/cache-warm.yml`, and `ARCHITECTURE.md` cite the [rio-v2 variant lifecycle](#rio-v2-variant-lifecycle) heading; keep it when editing this file.
- This document is analysis only. Any change to `crates/filemeta` or
`crates/ecstore/src/bucket` metadata encoding is a storage-format change and
must follow the migration and readiness contracts in
[README.md](README.md) and the ecstore layout boundary rules.
- The version constants (`XL_META_VERSION`,
`BUCKET_METADATA_FORMAT`/`BUCKET_METADATA_VERSION`) are compatibility anchors.
Bumping any of them requires a read-compat path for the prior value and a
migration story, exactly as the current meta_ver 2 → 3 read path provides.
@@ -1,50 +1,213 @@
# MinIO ↔ RustFS Router Compatibility (Exceptions Only)
# MinIO ↔ RustFS Router Compatibility Matrix
**Use this when:** a client or `mc` call that works against MinIO fails against RustFS and you need to know whether the endpoint is missing, stubbed, or deliberately different.
**Source of truth:** S3 plane: the `s3s::S3` trait impl in `rustfs/src/storage/ecfs.rs`. Admin plane: `make_admin_route` in `rustfs/src/admin/mod.rs`, the registration inventory `rustfs/src/admin/route_registration_test.rs`, and the route/action guardrail [admin-route-action-snapshot.md](admin-route-action-snapshot.md).
Tracks how RustFS covers the MinIO HTTP router surface, split into the S3
data-plane router (`cmd/api-router.go` in MinIO: object + bucket APIs) and the
admin control-plane router (`cmd/admin-router.go`: admin `/v3/` and `/v4/`
APIs). Each row records the current RustFS implementation status and the
landing point in the code so the matrix can be re-verified after refactors.
This document lists only exceptions. Anything not listed here is implemented with MinIO-equivalent behavior. For the s3tests-level claim see [s3-compatibility-matrix.md](s3-compatibility-matrix.md).
This complements two neighbouring documents and does not duplicate them:
- [s3-compatibility-matrix.md](s3-compatibility-matrix.md) — the release-facing
S3 compatibility claim and the Ceph s3tests lists that gate it.
- [admin-route-action-snapshot.md](admin-route-action-snapshot.md) — the admin
route/handler/authorization-action migration guardrail (the source of truth
for exact route patterns and auth contracts).
Refs rustfs/backlog#596 rustfs/backlog#603.
## Status Legend
| Status | Meaning |
|---|---|
| 缺失 (missing) | No RustFS route or handler for the MinIO endpoint. |
| 部分兼容 (partial) | Registered and functional, but a documented subset of MinIO behavior is rejected. |
| 已注册未完成 (registered, incomplete) | Route is registered; the handler returns `NotImplemented` as a behavior contract. |
| 行为不一致 (behavior differs) | Implemented, but intentionally diverges from MinIO's response contract. |
| 已实现 (implemented) | Handler is registered and performs the real operation. |
| 部分兼容 (partial) | Registered and functional, but a documented subset of the MinIO behavior is rejected or unsupported. |
| 已注册未完成 (registered, incomplete) | Route is registered but the handler returns `NotImplemented` (a behavior contract, not a real implementation). |
| 缺失 (missing) | No RustFS route/handler for the MinIO endpoint. |
| 行为不一致 (behavior differs) | Implemented but intentionally diverges from MinIO's response contract. |
Admin paths are relative to the canonical `/rustfs/admin` prefix; `/minio/admin` is accepted as an alias via router canonicalization.
Prefixes: RustFS registers admin routes under the canonical `/rustfs/admin`
prefix and accepts `/minio/admin` as a compatibility alias via router
canonicalization (see
[admin-route-action-snapshot.md](admin-route-action-snapshot.md)). Admin paths
below are shown relative to that prefix (e.g. `/v3/info`).
## S3 Data Plane
---
All `s3s::S3` trait methods are implemented in `rustfs/src/storage/ecfs.rs` except the following.
## Part 1 — S3 Data Plane (MinIO `cmd/api-router.go`)
| S3 operation | Status | Detail |
RustFS implements the S3 surface through the `s3s` service trait in
`rustfs/src/storage/ecfs.rs`, delegating to use-case layers under
`rustfs/src/app/`. Line numbers are indicative landing points on the branch
this matrix was written against and may drift; the file paths are stable.
### Bucket-level operations
| MinIO / S3 operation | Status | RustFS landing point |
|---|---|---|
| GetBucketReplicationMetrics | 缺失 | No `get_bucket_replication_metrics`; replication metrics are exposed via admin `/v3/replicationmetrics`. |
| GetBucketOwnershipControls | 缺失 | No handler; s3tests entries remain in `scripts/s3-tests/unimplemented_tests.txt`. |
| PutBucketOwnershipControls, DeleteBucketOwnershipControls | 缺失 | No handler. |
| DeleteBucketNotification, DeleteBucketLogging, DeleteBucketRequestPayment, DeleteBucketAccelerate | 部分兼容 | No distinct DELETE handlers; clear the config by writing an empty configuration through the PUT path. |
| PutBucketAcl, PutObjectAcl | 部分兼容 | Canned-ACL headers only; XML grant bodies return `NotImplemented` (`put_bucket_acl`, `put_object_acl`). |
| GetObjectTorrent | 行为不一致 | `get_object_torrent` returns `404 NoSuchKey` by design, not `501 NotImplemented`, so clients degrade gracefully. |
| CreateBucket | 已实现 | `rustfs/src/storage/ecfs.rs` (`create_bucket`) |
| DeleteBucket | 已实现 | `rustfs/src/storage/ecfs.rs` (`delete_bucket`) |
| HeadBucket | 已实现 | `rustfs/src/storage/ecfs.rs` (`head_bucket`) |
| ListBuckets | 已实现 | `rustfs/src/storage/ecfs.rs` (`list_buckets`) |
| GetBucketLocation | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_bucket_location`) |
| ListObjects (v1) | 已实现 | `rustfs/src/storage/ecfs.rs` (`list_objects`) |
| ListObjectsV2 | 已实现 | `rustfs/src/storage/ecfs.rs` (`list_objects_v2`) |
| ListObjectVersions | 已实现 | `rustfs/src/storage/ecfs.rs` (`list_object_versions`) |
| ListMultipartUploads | 已实现 | `rustfs/src/storage/ecfs.rs` (`list_multipart_uploads`) |
| Get/PutBucketVersioning | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_bucket_versioning`, `put_bucket_versioning`) |
| Get/Put/DeleteBucketPolicy | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_bucket_policy`, `put_bucket_policy`, `delete_bucket_policy`) |
| GetBucketPolicyStatus | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_bucket_policy_status`) |
| Get/Put/DeleteBucketTagging | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_bucket_tagging`, `put_bucket_tagging`, `delete_bucket_tagging`) |
| Get/Put/DeleteBucketLifecycle | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_bucket_lifecycle_configuration`, `put_bucket_lifecycle_configuration`, `delete_bucket_lifecycle`) |
| Get/Put/DeleteBucketReplication | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_bucket_replication`, `put_bucket_replication`, `delete_bucket_replication`) |
| Get/Put/DeleteBucketEncryption | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_bucket_encryption`, `put_bucket_encryption`, `delete_bucket_encryption`) |
| Get/PutObjectLockConfiguration | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_object_lock_configuration`, `put_object_lock_configuration`) |
| Get/Put/DeletePublicAccessBlock | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_public_access_block`, `put_public_access_block`, `delete_public_access_block`) |
| Get/Put/DeleteBucketCors | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_bucket_cors`, `put_bucket_cors`, `delete_bucket_cors`) |
| GetBucketNotificationConfiguration | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_bucket_notification_configuration`) |
| PutBucketNotificationConfiguration | 已实现 | `rustfs/src/storage/ecfs.rs` (`put_bucket_notification_configuration`) |
| Get/PutBucketRequestPayment | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_bucket_request_payment`, `put_bucket_request_payment`) |
| Get/PutBucketLogging | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_bucket_logging`, `put_bucket_logging`) |
| Get/Put/DeleteBucketWebsite | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_bucket_website`, `put_bucket_website`, `delete_bucket_website`) |
| Get/PutBucketAccelerateConfiguration | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_bucket_accelerate_configuration`, `put_bucket_accelerate_configuration`) |
| GetBucketAcl | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_bucket_acl`) |
| PutBucketAcl | 部分兼容 | `rustfs/src/storage/ecfs.rs` (`put_bucket_acl`) — canned-ACL headers only; XML grant policies return `NotImplemented`. |
| GetBucketReplicationMetrics | 缺失 | No S3-path handler; replication metrics are exposed via the admin API `/v3/replicationmetrics` instead. |
| GetBucketOwnershipControls | 缺失 | Not implemented (matches the "bucket ownership controls: planned" note in `s3-compatibility-matrix.md`). |
| Put/DeleteBucketOwnershipControls | 缺失 | Not implemented. |
## Admin Control Plane
Note on delete verbs: several S3 sub-resource DELETE operations
(DeleteBucketNotification, DeleteBucketLogging, DeleteBucketRequestPayment,
DeleteBucketAccelerate) are not exposed as distinct handlers; the corresponding
config is cleared by writing an empty configuration through the PUT path. Treat
these as 部分兼容 at the client level.
Every route asserted in `rustfs/src/admin/route_registration_test.rs` is registered. Exceptions:
### Object-level operations
| MinIO admin family | Status | Detail |
| MinIO / S3 operation | Status | RustFS landing point |
|---|---|---|
| Batch jobs (`/v3/start-job`, `/v3/list-jobs`, `/v3/status-job`, `/v3/describe-job`, `/v3/cancel-job`) | 已注册未完成 | `rustfs/src/admin/handlers/batch_job.rs`: `start-job` returns `NotImplemented` for known job types (`KNOWN_JOB_TYPES`) and `InvalidRequest` for unknown ones; `list-jobs` returns an empty list; status/describe/cancel return no-such-job. See [kms-bulk-rekey-contract.md](kms-bulk-rekey-contract.md) for why `keyrotate` must keep refusing. |
| Service control (`POST /v3/service`) | 行为不一致 | `ServiceHandle` in `rustfs/src/admin/handlers/system.rs`: `restart` and `stop` both initiate graceful shutdown (the process manager must relaunch; no in-process restart); `freeze` / `unfreeze` toggle a global freeze flag under `ServiceFreezeAdminAction`. `rustfs/src/admin/route_policy.rs` still classifies the route as deferred `NotImplemented`. |
| Inspect data (`GET|POST /v3/inspect-data`) | 行为不一致 | `InspectDataHandler` in `system.rs` returns the raw bytes of one exact `volume` + `file`, size-capped, instead of MinIO's encrypted raw-drive-file archive. The bounded archive lives at `POST /v4/inspect/archive` (`rustfs/src/admin/handlers/inspect_archive.rs`). `route_policy.rs` still classifies the v3 route as deferred `NotImplemented`. |
| Pools decommission / cancel / clear | 部分兼容 | `rustfs/src/admin/handlers/pools.rs` returns `NotImplemented` when endpoints are not initialized (single-pool or uninitialized clusters). |
| `/v3/top/drives`, `/v3/top/net` | 缺失 | Only `/v3/top/locks` is registered (`rustfs/src/admin/handlers/diagnostics.rs`). |
| Bucket / site replication per-object diff | 缺失 | `/v3/replicationmetrics` and site-replication status exist; no diff endpoint. |
| MRF (most-recent-failures) replication metrics breakdown | 缺失 | Only the generic `/v3/metrics` stream and replication metrics wire (`rustfs/src/admin/replication_metrics_wire.rs`). |
| GetObject | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_object`) |
| PutObject | 已实现 | `rustfs/src/storage/ecfs.rs` (`put_object`) |
| DeleteObject | 已实现 | `rustfs/src/storage/ecfs.rs` (`delete_object`) |
| DeleteObjects (multi-delete) | 已实现 | `rustfs/src/storage/ecfs.rs` (`delete_objects`) |
| HeadObject | 已实现 | `rustfs/src/storage/ecfs.rs` (`head_object`) |
| CopyObject | 已实现 | `rustfs/src/storage/ecfs.rs` (`copy_object`) |
| GetObjectAcl | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_object_acl`) |
| PutObjectAcl | 部分兼容 | `rustfs/src/storage/ecfs.rs` (`put_object_acl`) — canned-ACL headers only; XML grants return `NotImplemented`. |
| Get/Put/DeleteObjectTagging | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_object_tagging`, `put_object_tagging`, `delete_object_tagging`) |
| GetObjectAttributes | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_object_attributes`) |
| Get/PutObjectLegalHold | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_object_legal_hold`, `put_object_legal_hold`) |
| Get/PutObjectRetention | 已实现 | `rustfs/src/storage/ecfs.rs` (`get_object_retention`, `put_object_retention`) |
| RestoreObject (POST restore) | 已实现 | `rustfs/src/storage/ecfs.rs` (`restore_object`) |
| SelectObjectContent | 已实现 | `rustfs/src/storage/ecfs.rs` (`select_object_content`) → `rustfs/src/app/select_object.rs` |
| CreateMultipartUpload | 已实现 | `rustfs/src/storage/ecfs.rs` (`create_multipart_upload`) |
| UploadPart / UploadPartCopy | 已实现 | `rustfs/src/storage/ecfs.rs` (`upload_part`, `upload_part_copy`) |
| CompleteMultipartUpload | 已实现 | `rustfs/src/storage/ecfs.rs` (`complete_multipart_upload`) |
| AbortMultipartUpload | 已实现 | `rustfs/src/storage/ecfs.rs` (`abort_multipart_upload`) |
| ListParts | 已实现 | `rustfs/src/storage/ecfs.rs` (`list_parts`) |
| PostObject (POST form upload) | 已实现 | Routed via the POST-object marker into the put-object path (`rustfs/src/app/object/put.rs`). See the "POST Object form upload checksum handling: planned" note in `s3-compatibility-matrix.md`. |
| GetObjectTorrent | 行为不一致 | `rustfs/src/storage/ecfs.rs` (`get_object_torrent`) — returns `404 NoSuchKey` by design (not `501 NotImplemented`) so clients degrade gracefully. |
Formerly-missing families that are now registered and therefore not exceptions: `/v3/healthinfo`, `/v3/obdinfo`, `/v3/force-unlock`, `/v3/top/locks`, `/v3/speedtest*`, `/v3/log`, `/v3/trace`, `/v3/profile`, `/v3/profiling/*`, `/v3/idp/{ldap|openid}/*`, `/v3/idp-config/*`.
For the gate-level view of which of these are covered by executable s3tests,
defer to [s3-compatibility-matrix.md](s3-compatibility-matrix.md); this table is
the router/handler view, not the test-list view.
## Update Rule
---
When an exception above changes state, edit its row here in the same PR that changes the handler, and extend `rustfs/src/admin/route_registration_test.rs` and [admin-route-action-snapshot.md](admin-route-action-snapshot.md) for admin routes. Do not add "implemented" rows to this document; absence from this list is the implemented claim.
## Part 2 — Admin Control Plane (MinIO `cmd/admin-router.go`)
Router assembly is `rustfs/src/admin/mod.rs::register_admin_routes`; the exact
route patterns, handler ownership, and authorization actions are the guardrail
in [admin-route-action-snapshot.md](admin-route-action-snapshot.md). This table
maps MinIO admin route families to RustFS status.
### Implemented / registered families
| MinIO admin family | Status | RustFS landing point |
|---|---|---|
| STS / is-admin probe | 已实现 | `rustfs/src/admin/handlers/sts.rs`, `is_admin.rs` |
| User lifecycle (list/add/info/remove/status) | 已实现 | `rustfs/src/admin/handlers/user_lifecycle.rs`, `user.rs` |
| Groups | 已实现 | `rustfs/src/admin/handlers/group.rs` |
| Service accounts / access keys | 已实现 | `rustfs/src/admin/handlers/service_account.rs` |
| Canned policies + builtin policy attach/detach + policy-entities | 已实现 | `rustfs/src/admin/handlers/policies.rs` |
| IAM import/export | 已实现 | `rustfs/src/admin/handlers/user_iam.rs`, `user.rs` |
| Account info | 已实现 | `rustfs/src/admin/handlers/account_info.rs` |
| Config KV (get/set/del/help/history/restore + `/v3/config`) | 已实现 | `rustfs/src/admin/handlers/config_admin.rs` |
| Server info / storageinfo / datausageinfo | 已实现 | `rustfs/src/admin/handlers/system.rs` |
| Metrics stream (`/v3/metrics`) | 已实现 | `rustfs/src/admin/handlers/metrics.rs` via `system.rs` |
| Runtime capabilities (`/v4/runtime/capabilities`) | 已实现 | `rustfs/src/admin/handlers/system.rs` |
| Pools list/status | 已实现 | `rustfs/src/admin/handlers/pools.rs` |
| Pools decommission/cancel/clear | 部分兼容 | `rustfs/src/admin/handlers/pools.rs` — returns `NotImplemented` when endpoints are not initialized (single-pool / uninitialized clusters). |
| Rebalance start/status/stop | 已实现 | `rustfs/src/admin/handlers/rebalance.rs` |
| Heal + background-heal status | 已实现 | `rustfs/src/admin/handlers/heal.rs` |
| Tier (list/stats/verify/add/edit/remove/clear) | 已实现 | `rustfs/src/admin/handlers/tier.rs` |
| Quota (legacy + bucket-scoped + stats/check) | 已实现 | `rustfs/src/admin/handlers/quota.rs` |
| Bucket metadata export/import | 已实现 | `rustfs/src/admin/handlers/bucket_meta.rs` |
| Scanner status | 已实现 | `rustfs/src/admin/handlers/scanner.rs` |
| Notification targets (list/arns/put/reset) | 已实现 | `rustfs/src/admin/handlers/event.rs` |
| Audit targets (list/put/reset) | 已实现 | `rustfs/src/admin/handlers/audit.rs` |
| Module switches | 已实现 | `rustfs/src/admin/handlers/module_switch.rs` |
| Plugin catalog + instances (`/v4/plugins/*`) | 已实现 | `rustfs/src/admin/handlers/plugins_catalog.rs`, `plugins_instances.rs` |
| Extension catalog + instances (`/v4/extensions/*`) | 已实现 | `rustfs/src/admin/handlers/extensions.rs` |
| Object ZIP download (`/v3/zip-downloads`) | 已实现 | `rustfs/src/admin/handlers/object_zip_download.rs` |
| Cluster snapshot (`/v4/cluster/snapshot`) | 已实现 | `rustfs/src/admin/handlers/cluster_snapshot.rs` |
| Bucket-level remote targets (list/metrics/set/remove) | 已实现 | `rustfs/src/admin/handlers/replication.rs` |
| Site replication (add/remove/info/status/peer/resync + devnull/netperf) | 已实现 | `rustfs/src/admin/handlers/site_replication.rs` |
| Admin profiling (`/debug/pprof/profile`, `/debug/pprof/status`) | 已实现 | `rustfs/src/admin/handlers/profile_admin.rs`, `profile.rs` |
| TLS debug (`/debug/tls/status`) | 已实现 | `rustfs/src/admin/handlers/tls_debug.rs`, `profile.rs` |
| KMS management / dynamic / keys | 已实现 | `rustfs/src/admin/handlers/kms_management.rs`, `kms_dynamic.rs`, `kms_keys.rs` |
| OIDC public + config | 已实现 | `rustfs/src/admin/handlers/oidc.rs` |
| Table catalog (Iceberg) | 已实现 | `rustfs/src/admin/handlers/table_catalog/mod.rs` |
### Registered-but-incomplete
| MinIO admin family | Status | RustFS landing point |
|---|---|---|
| Service restart/stop (`POST /v3/service`) | 已注册未完成 | `rustfs/src/admin/handlers/system.rs` — handler returns `NotImplemented`. |
| Inspect data (`GET|POST /v3/inspect-data`) | 已注册未完成 | `rustfs/src/admin/handlers/system.rs` — handler returns `NotImplemented`. |
These registered-but-`NotImplemented` routes are behavior contracts; per the
migration rules in [admin-route-action-snapshot.md](admin-route-action-snapshot.md),
implementing or removing them is a behavior-change PR.
---
## Gaps Only — Missing Admin Endpoints (follow-up checklist)
The following MinIO admin `/v3/` route families have **no** RustFS registration
today. This is the actionable checklist for closing admin-API parity. Verified
against `rustfs/src/admin/mod.rs` and `rustfs/src/admin/handlers/` on the branch
this doc was written on.
- [ ] **Server profiling start/stop** — MinIO `/v3/profile` (bulk profiling
session). RustFS only exposes `/debug/pprof/profile` and
`/debug/pprof/status`, which are a different, single-shot pprof surface.
- [ ] **Health info** — MinIO `/v3/healthinfo` (cluster health report / subnet
diagnostics). No RustFS route.
- [ ] **LDAP / generic IDP config CRUD** — MinIO `/v3/idp/{ldap|openid}/...`
config management. RustFS exposes OIDC config under `/v3/oidc/*` only; there
is no LDAP IDP config route.
- [ ] **Bucket / site replication diff** — MinIO replication-diff endpoints.
RustFS exposes `/v3/replicationmetrics` (metrics) and site-replication
status, but no per-object diff.
- [ ] **MRF metrics** — MinIO's most-recent-failures replication metrics
breakdown. RustFS has only the generic `/v3/metrics` stream.
- [ ] **Batch jobs** — MinIO `/v3/batch`, `/v3/list-batch-jobs`, job
describe/cancel. No RustFS batch API.
- [ ] **Distributed locks introspection** — MinIO `/v3/force-unlock` and
`/v3/top/locks`. No RustFS locks-management API.
- [ ] **Speedtest / perf** — MinIO `/v3/speedtest` (object/drive/net perf).
RustFS has `netperf`/`devnull` **only** inside the site-replication family,
not as standalone admin speedtest endpoints.
- [ ] **Console log stream** — MinIO `/v3/log` (kstream / log search). No RustFS
route.
- [ ] **Top introspection** — MinIO `/v3/top/locks`, `/v3/top/drives`,
`/v3/top/net`. No RustFS unified `top` family.
- [ ] **Trace stream** — MinIO `/v3/trace`. A `trace.rs` handler skeleton
exists under `rustfs/src/admin/handlers/` but its registration function is
**not** called from `register_admin_routes`, so no route is live.
When one of these lands, register it in `rustfs/src/admin/mod.rs`, extend
`rustfs/src/admin/route_registration_test.rs`, update
[admin-route-action-snapshot.md](admin-route-action-snapshot.md) with the
route/handler/action rows, and move the item out of this checklist.
@@ -1,35 +1,65 @@
# Observability ECStore Dependency Inventory
**Use this when:** adding, removing, or moving any `rustfs_ecstore` or `rustfs_storage_api` reference inside `crates/obs`.
**Source of truth:** the `use` block at the top of `crates/obs/src/metrics/storage_api.rs`; the guard in `scripts/check_architecture_migration_rules.sh`.
This inventory closes the first `rustfs/backlog#735` step: make every
observability dependency on ECStore visible before introducing traits or moving
dependency direction.
`rustfs-obs` still depends on `rustfs-ecstore` (`crates/obs/Cargo.toml`). Every direct reference is confined to one boundary file so the dependency can later be replaced by provider traits without touching collectors.
No behavior or crate movement is planned in inventory PRs. The current boundary
is `crates/obs/src/metrics/storage_api.rs`; all direct `rustfs_ecstore` and
`rustfs_storage_api` source references in `rustfs-obs` must stay in that file
until the contracts below are extracted.
## Dependency Inventory
The authoritative list is the `pub(crate) use rustfs_ecstore::api::...` block in `crates/obs/src/metrics/storage_api.rs`; it is not copied here. Each import belongs to one of three coupling categories:
| Current symbol in `crates/obs/src/metrics/storage_api.rs` | Consumed by | Classification | Purpose |
|---|---|---|---|
| `rustfs_ecstore::api::storage::ECStore` as `ObsStore` | `stats_collector.rs` | Type dependency | Concrete object-store handle used to call storage admin methods and data-usage loaders. |
| `rustfs_storage_api::{BucketOperations, BucketOptions, StorageAdminApi}` | `stats_collector.rs` | Type and trait dependency | Method-resolution and associated type contracts for bucket listing, backend info, and storage info. |
| `rustfs_ecstore::api::runtime::object_store_handle` | `stats_collector.rs` | Runtime dependency | Resolves the currently published object-store handle for metric collection. |
| `rustfs_ecstore::api::data_usage::load_data_usage_from_backend` | `stats_collector.rs` | Behavior dependency | Loads bucket/object usage and is projected into obs-local DTOs before collectors consume it. |
| `rustfs_ecstore::api::capacity::{get_total_usable_capacity, get_total_usable_capacity_free}` | `stats_collector.rs` | Behavior dependency | Computes usable and free capacity from ECStore storage info. |
| `rustfs_ecstore::api::bucket::metadata_sys::get_quota_config` | `stats_collector.rs` | Behavior dependency | Reads per-bucket quota limits used in bucket usage metrics. |
| `rustfs_ecstore::api::bucket::bandwidth::monitor::Monitor` | `runtime_sources.rs`, `stats_collector.rs` | Type and runtime dependency | Reads replication bandwidth reports from the global bucket monitor handle. |
| `rustfs_ecstore::api::runtime::bucket_monitor` | `runtime_sources.rs` | Runtime dependency | Resolves the global bucket bandwidth monitor for metric collection. |
| `rustfs_ecstore::api::bucket::replication::get_global_replication_stats` | `storage_api.rs` snapshot helpers | Runtime dependency | Reads replication status, transfer, failure, and site-replication stats, then projects them into obs-local snapshot DTOs. |
| `rustfs_ecstore::api::bucket::lifecycle::bucket_lifecycle_ops::{GLOBAL_ExpiryState, GLOBAL_TransitionState}` | `runtime_sources.rs`, `stats_collector.rs` | Runtime dependency | Reads lifecycle expiry and transition queue counters. |
| `rustfs_ecstore::api::error::Result` as `ObsEcstoreResult` | `stats_collector.rs` | Type dependency | Preserves ECStore error propagation while data-usage behavior remains ECStore-owned. |
| Category | Covers | Examples (aliases defined in the boundary file) |
|---|---|---|
| Type coupling | Concrete ECStore types and storage-api traits used for method resolution | `ObsStore`, `ObsEcstoreResult`, `ObsBucketBandwidthMonitor`, the `rustfs_storage_api` trait imports |
| Runtime handle coupling | Resolving process-wide handles for metric collection | object-store handle, bucket monitor, expiry and transition state handles (`rustfs_ecstore::api::runtime::*`), replication stats read inside the snapshot helpers |
| Behavior coupling | ECStore-owned computations whose output is projected into obs-local DTOs | data-usage loading, compression totals, quota lookup, usable-capacity math |
## Classification
Collectors consume only the aliases and the obs-local DTOs. Removing `rustfs-ecstore` from `crates/obs/Cargo.toml` is unsafe until all three categories have replacement contracts and compile coverage.
The remaining coupling is not just a dependency declaration problem:
- type coupling: `ObsStore`, `ObsEcstoreResult`, `ObsBucketBandwidthMonitor`,
`StorageAdminApi`, `BucketOperations`, and `BucketOptions`;
- runtime handle coupling: object-store handle, bucket monitor, replication
stats inside snapshot helpers, expiry state, and transition state;
- behavior coupling: data-usage loading, quota lookup, and capacity math.
Removing `rustfs-ecstore` from `crates/obs/Cargo.toml` is unsafe until those
three categories have replacement contracts and compile coverage.
## Extraction Plan
1. Keep all direct ECStore and storage-api imports centralized in `crates/obs/src/metrics/storage_api.rs`.
2. Keep projecting ECStore data-usage and replication stats into obs-local DTOs before collectors consume them.
3. Introduce obs-owned provider traits for storage info, bucket info, quota, data usage, replication, bandwidth, and lifecycle queue snapshots.
4. Implement those traits in ECStore or an ECStore-owned adapter crate once the trait shapes are covered by focused tests.
5. Remove the `rustfs-ecstore` dependency from `rustfs-obs` only after metrics behavior is unchanged through the provider traits.
1. Keep all direct ECStore and storage-api imports centralized in
`crates/obs/src/metrics/storage_api.rs`.
2. Keep projecting ECStore data-usage and replication stats output into
obs-local DTOs before collectors consume it.
3. Introduce obs-owned provider traits for storage info, bucket info, quota,
data usage, replication, bandwidth, and lifecycle queue snapshots.
4. Implement those traits in ECStore or an ECStore-owned adapter crate after the
trait shapes are covered by focused tests.
5. Remove the `rustfs-ecstore` dependency from `rustfs-obs` only after metrics
behavior is unchanged through the provider traits.
## Guardrails
Enforced by `scripts/check_architecture_migration_rules.sh`:
The architecture guard enforces this inventory boundary:
- `crates/obs/src/metrics/storage_api.rs` is the only `rustfs-obs` source file allowed to reference `rustfs_ecstore` or `rustfs_storage_api`.
- Raw replication stats handles and ECStore replication stat methods stay behind the snapshot helpers in that file.
- `rustfs-obs` must not add passthrough bridge modules (a second `storage_api.rs`, an `ecstore_compat.rs`, or similar) that re-export ECStore items to other crates.
- An extraction PR that removes a dependency category updates this inventory and the guard in the same change.
- `crates/obs/src/metrics/storage_api.rs` is the only `rustfs-obs` source file
allowed to reference `rustfs_ecstore` or `rustfs_storage_api`;
- raw replication stats handles and ECStore replication stat methods must stay
behind the snapshot helpers in `crates/obs/src/metrics/storage_api.rs`;
- `rustfs-obs` must not add `storage_compat.rs` or `ecstore_compat.rs`
passthrough bridges;
- future extraction PRs must update this inventory and the guard in the same
reviewed change when a dependency category is removed.
+53 -7
View File
@@ -1,20 +1,60 @@
# RustFS Architecture Evolution
**Use this when:** you need the historical framing of the architecture-migration program or the phase order that the migration contracts assume.
**Source of truth:** [README.md](README.md) is the index of architecture documents; the per-topic contracts it lists are authoritative.
This document set tracks the architecture migration from
[`rustfs/backlog#660`](https://github.com/rustfs/backlog/issues/660).
## Baseline
The architecture-migration program (`rustfs/backlog#660`) closed in 2026-07. Its original baseline commit predates the current `main` lineage and is no longer reachable from `main`; treat it as historical. The guardrails the program introduced remain enforced by `scripts/check_architecture_migration_rules.sh`.
- Baseline branch: `upstream/main`
- Baseline commit: `61f0dfbc40f748be313be84d834d8259cf3e19c9`
- Baseline title: `fix(ecstore): invalidate wiped disk id cache (#3251)`
- First migration PR type: `docs-only`
## Core Principle
Cut wrong dependency directions with directories and contracts first, migrate global state in small steps next, and split crates only after boundaries are stable. Storage hot-path behavior must not drift during this migration.
Cut wrong dependency directions with directories and contracts first, migrate global
state in small steps next, and split crates only after boundaries are stable. Storage
hot-path behavior must not drift during this migration.
## Architecture Documents
- [`runtime-lifecycle.md`](runtime-lifecycle.md): runtime, AppContext,
startup/readiness, and shutdown contracts.
- [`readiness-matrix.md`](readiness-matrix.md): request-surface behavior,
runtime dependency readiness, probe semantics, and preservation rules.
- [`s3-tables-support-matrix.md`](s3-tables-support-matrix.md): supported,
preview, reference-only, and not-claimed S3 Tables and Iceberg REST Catalog
surfaces.
- [`storage-control-data-plane.md`](storage-control-data-plane.md): boundaries
between StorageCore, ECStore, ClusterControlPlane, and BackgroundControllers.
- [`background-services-inventory.md`](background-services-inventory.md): current
scanner, heal, lifecycle, replication, config reload, metrics, and shutdown
surface before BackgroundController work.
- [`background-controller-contract.md`](background-controller-contract.md):
desired/current/status/reconcile vocabulary and lifecycle boundaries for
future read-only BackgroundController work.
- [`crate-boundaries.md`](crate-boundaries.md): PR types, crate direction,
compatibility rules, and migration guardrails.
- [`global-state-crate-split-plan.md`](global-state-crate-split-plan.md): late
global-state cleanup, runtime-source boundaries, fallback removal rules, and
crate-split evaluation criteria.
- [`obs-ecstore-dependency-inventory.md`](obs-ecstore-dependency-inventory.md):
observability-to-ECStore dependency inventory, classification, and extraction
guardrails.
- [`ecstore-config-consumer-inventory.md`](ecstore-config-consumer-inventory.md):
current `ecstore::config::{Config, KV, KVS}` definitions, consumers,
migration risks, and do-not-change contract.
- [`ecstore-api-facade-inventory.md`](ecstore-api-facade-inventory.md): current
`rustfs_ecstore::api` facade groups, external consumer boundaries, shrink
rules, and split dependency inventory.
- [`config-model-boundary-adr.md`](config-model-boundary-adr.md): target crate,
module path, dependency rules, and verification gates for moving the pure
server-config model.
- [`compat-cleanup-register.md`](compat-cleanup-register.md): temporary
compatibility code that must be removed later.
## Phase Order
Historical sequencing of the migration phases. All phases are closed; the diagram is kept because later documents refer to phase names.
```mermaid
flowchart LR
G["Phase 0: Baseline and guardrails"]
@@ -40,4 +80,10 @@ flowchart LR
GS --> CR
```
The document index is [README.md](README.md). The ECStore facade boundary that the storage phases converged on is described in [ecstore-api-facade-inventory.md](ecstore-api-facade-inventory.md).
The first implementation sequence is conservative:
1. Record baseline and migration context.
2. Establish PR and compatibility rules.
3. Add dependency and loss-prevention checks in a separate `ci-gate` PR.
4. Inventory `ecstore::config::{Config, KV, KVS}` before moving any code.
5. Decide the config model boundary before extracting or migrating consumers.

Some files were not shown because too many files have changed in this diff Show More