An incomplete S3 multipart upload can consume storage without creating an object. Ordinary object listings do not show its uploaded parts, so a bucket may contain one visible file and megabytes or terabytes of additional part data. The safe cleanup unit is not “everything incomplete.” It is one reviewed bucket, object key, and upload ID.
Storage and backup operators who can run Bash and use an S3-compatible API are the target readers. A disposable MinIO lab creates two open uploads plus one completed object, rejects a deliberately wrong upload ID, aborts only the selected stale session, completes the unrelated session, and proves that both completed objects remain readable. The same identity and verification model applies to Amazon S3 and compatible providers, while lifecycle automation remains provider-specific.
Multipart upload is a protocol with separate create, part-upload, and completion requests. After CreateMultipartUpload, each UploadPart stores bytes under an upload session. Only CompleteMultipartUpload assembles those parts into an object. If a client exits before completing or aborting the session, the bytes remain associated with the upload ID but no object key appears in ListObjectsV2.
Amazon’s current multipart abort guide states that uploaded parts continue to incur storage until the upload completes or is aborted. The low-level abort operation needs all three identifiers: bucket, key, and upload ID. That tuple matters because two sessions may target the same key at the same time.
Do not infer staleness from invisibility. A backup agent, media uploader, or data pipeline may still be sending parts. Initiation time, client ownership, recent part activity, transfer window, and change record decide whether a session is abandoned. The current AbortMultipartUpload API reference also warns that an in-progress part can race with an abort, so production cleanup should stop or coordinate the producer first and re-list until the upload disappears.
Before sending an abort request, preserve a secret-free inventory with these fields:
An ordinary aws s3 ls result is insufficient because it enumerates objects, not upload sessions. list-multipart-uploads finds sessions, and list-parts measures one session’s stored bytes. Scaleway’s MinIO-client cleanup tutorial and Wasabi’s failed multipart upload procedure expose the same separate inventory surface through mc, but a recursive incomplete-upload deletion is broader than the exact-ID method tested here.
| Evidence surface | Shows completed object? | Shows incomplete parts? | Safe decision it supports |
|---|---|---|---|
list-objects-v2 |
Yes | No | Protect known completed data |
list-multipart-uploads |
No | Upload sessions | Select bucket, key, and upload ID |
list-parts |
No | One session’s parts | Measure bytes and recent activity |
head-object or a read/hash |
One exact object | No | Prove preserved or newly completed data |
Object retention is a different layer. MinIO Object Lock boundaries explain why retained versions and retention-admin authority must not be confused with incomplete part cleanup. Aborting a multipart session does not delete a completed locked object, and object retention does not automatically clean abandoned upload parts.
aws s3 ls?aws s3 ls lists completed objects. Uploaded parts belong to a multipart session until CompleteMultipartUpload creates the object, so use list-multipart-uploads and list-parts to inventory them.
AbortMultipartUpload targets one bucket, key, and upload ID and removes that session’s parts. It does not issue an object delete. The lab below proves that boundary with a completed uploads/stale.bin object under the same key as the aborted session, then downloads and hashes it after the abort.
Yes. Each initiation creates a distinct upload ID, even when bucket and key match. That is why a key-only cleanup decision is incomplete and why a recursive abort can interrupt another client’s legitimate session.
Call list-parts for its exact bucket, key, and upload ID, then sum each returned part’s Size. Handle pagination before accepting the total on sessions with many parts.
Set the threshold above the longest legitimate transfer or pause window, including scheduled batches and retry behavior. Review oldest-session evidence first; do not copy seven days when valid uploads can remain open longer.
Noncurrent versions, retained objects, replication, snapshots, metadata, and provider reporting delay can still own bytes. On a Linux-backed endpoint, deleted-open-file recovery checks whether a live process still references removed names. Reconcile each storage layer instead of repeating a successful multipart abort.
No. Cleanup controls unused part storage. Backup safety still needs retention authority separation, complete capture, off-host copies where required, and a tested restore whose files and application data match.
On Debian 13, the reproduced run used MinIO RELEASE.2025-09-07T16-13-09Z, MinIO Client RELEASE.2025-08-13T08-35-41Z, and AWS CLI 1.46.0 on August 12, 2026 UTC. Release-specific archive URLs and the matching published SHA-256 files pin both MinIO binaries. The server binds only to loopback, credentials are synthetic, and all state stays under one marker-owned path.
All seven tested blocks share one Bash session. Required local tools are curl, ss from iproute2, python3 with the venv module, sha256sum, awk, find, and at least 350 MiB of free temporary space. The first block refuses an existing directory or either listener, records the exact process start time, and leaves failed files available for inspection while its error trap stops only the owned process.
set -euo pipefail
LAB=/tmp/voxfor-s3-mpu-145-lab
PORT=14500
CONSOLE_PORT=14501
BUCKET=voxfor-mpu-evidence
ACCESS_KEY=voxforlab
SECRET_KEY='VoxforLabOnly-2026!'
MARKER_VALUE=voxfor-s3-mpu-lab-v1
MINIO_RELEASE=RELEASE.2025-09-07T16-13-09Z
MC_RELEASE=RELEASE.2025-08-13T08-35-41Z
[[ ! -e "$LAB" ]]
for required_command in curl ss python3 sha256sum awk find; do
command -v "$required_command" >/dev/null
done
for required_port in "$PORT" "$CONSOLE_PORT"; do
if ss -H -ltn "sport = :$required_port" | grep -q .; then
printf 'Port %s is already in use.\n' "$required_port" >&2
exit 1
fi
done
mkdir -m 700 "$LAB"
printf '%s\n' "$MARKER_VALUE" > "$LAB/.voxfor-owner"
mkdir -m 700 "$LAB/bin" "$LAB/data" "$LAB/receipts" "$LAB/mc-config"
[[ "$(df -Pk "$LAB" | awk 'NR==2 {print $4}')" -ge 358400 ]]
cleanup_on_error() {
local status=$?
set +e
if (( status != 0 )) && [[ -f "$LAB/minio.pid" && -f "$LAB/minio.start" ]]; then
local owned_pid
owned_pid="$(cat "$LAB/minio.pid")"
if [[ "$owned_pid" =~ ^[0-9]+$ ]] \
&& [[ "$(readlink -f "/proc/$owned_pid/exe" 2>/dev/null)" == "$LAB/bin/minio" ]] \
&& [[ "$(awk '{print $22}' "/proc/$owned_pid/stat" 2>/dev/null)" == "$(cat "$LAB/minio.start")" ]]; then
kill "$owned_pid" 2>/dev/null
wait "$owned_pid" 2>/dev/null
fi
fi
exit "$status"
}
trap cleanup_on_error EXIT
cd "$LAB/bin"
curl -fsSL "https://dl.min.io/server/minio/release/linux-amd64/archive/minio.$MINIO_RELEASE" -o minio
curl -fsSL "https://dl.min.io/server/minio/release/linux-amd64/archive/minio.$MINIO_RELEASE.sha256sum" -o minio.sha256sum
curl -fsSL "https://dl.min.io/client/mc/release/linux-amd64/archive/mc.$MC_RELEASE" -o mc
curl -fsSL "https://dl.min.io/client/mc/release/linux-amd64/archive/mc.$MC_RELEASE.sha256sum" -o mc.sha256sum
printf '%s minio\n' "$(awk 'NR==1{print $1}' minio.sha256sum)" | sha256sum -c -
printf '%s mc\n' "$(awk 'NR==1{print $1}' mc.sha256sum)" | sha256sum -c -
chmod 700 minio mc
python3 -m venv "$LAB/venv"
"$LAB/venv/bin/pip" -q install --disable-pip-version-check awscli==1.46.0
MINIO_ROOT_USER="$ACCESS_KEY" MINIO_ROOT_PASSWORD="$SECRET_KEY" \
"$LAB/bin/minio" server --address "127.0.0.1:$PORT" \
--console-address "127.0.0.1:$CONSOLE_PORT" "$LAB/data" \
> "$LAB/minio.log" 2>&1 &
MINIO_PID=$!
printf '%s\n' "$MINIO_PID" > "$LAB/minio.pid"
awk '{print $22}' "/proc/$MINIO_PID/stat" > "$LAB/minio.start"
for _ in $(seq 1 60); do
curl -fsS "http://127.0.0.1:$PORT/minio/health/ready" >/dev/null 2>&1 && break
sleep 0.25
done
curl -fsS "http://127.0.0.1:$PORT/minio/health/ready" >/dev/null
printf 'LAB_READY minio=%s mc=%s aws=%s pid=%s\n' \
"$("$LAB/bin/minio" --version | awk 'NR==1{print $3}')" \
"$("$LAB/bin/mc" --version | awk 'NR==1{print $3}')" \
"$("$LAB/venv/bin/aws" --version 2>&1 | cut -d' ' -f1)" "$MINIO_PID"
A small completed object under uploads/stale.bin establishes the same-key preservation control before either multipart session exists. Cleanup fails review if that object’s content changes or disappears. The block also creates two deterministic 5 MiB part files without uploading them yet.
[[ "$(cat "$LAB/.voxfor-owner")" == "$MARKER_VALUE" ]]
[[ "$(readlink -f "/proc/$MINIO_PID/exe")" == "$LAB/bin/minio" ]]
[[ "$(awk '{print $22}' "/proc/$MINIO_PID/stat")" == "$(cat "$LAB/minio.start")" ]]
MC=("$LAB/bin/mc" --config-dir "$LAB/mc-config")
"${MC[@]}" alias set lab "http://127.0.0.1:$PORT" "$ACCESS_KEY" "$SECRET_KEY" >/dev/null
"${MC[@]}" mb "lab/$BUCKET" >/dev/null
printf 'completed-object-v1\n' > "$LAB/completed.txt"
"${MC[@]}" cp "$LAB/completed.txt" "lab/$BUCKET/uploads/stale.bin" >/dev/null
"${MC[@]}" stat "lab/$BUCKET/uploads/stale.bin" --json > "$LAB/receipts/completed-before.json"
export AWS_ACCESS_KEY_ID="$ACCESS_KEY"
export AWS_SECRET_ACCESS_KEY="$SECRET_KEY"
export AWS_DEFAULT_REGION=us-east-1
export AWS_EC2_METADATA_DISABLED=true
AWS=("$LAB/venv/bin/aws" --endpoint-url "http://127.0.0.1:$PORT" s3api)
python3 - "$LAB" <<'PY'
from pathlib import Path
import sys
root = Path(sys.argv[1])
(root / 'part-a.bin').write_bytes(b'A' * (5 * 1024 * 1024))
(root / 'part-b.bin').write_bytes(b'B' * (5 * 1024 * 1024))
PY
printf 'BASELINE object_sha256=%s part_bytes=%s\n' \
"$(sha256sum "$LAB/completed.txt" | awk '{print $1}')" \
"$(stat -c %s "$LAB/part-a.bin")"
Two independent creation calls produce separate upload IDs, and one part is placed in each session. The key names describe the planned decision—stale.bin will be aborted and active.bin will be completed—but the API does not know those words imply policy. The existing completed stale.bin remains a separate object underneath the new same-key session.
[[ "$(cat "$LAB/.voxfor-owner")" == "$MARKER_VALUE" ]]
curl -fsS "http://127.0.0.1:$PORT/minio/health/ready" >/dev/null
UPLOAD_A="$("${AWS[@]}" create-multipart-upload \
--bucket "$BUCKET" --key uploads/stale.bin --query UploadId --output text)"
UPLOAD_B="$("${AWS[@]}" create-multipart-upload \
--bucket "$BUCKET" --key uploads/active.bin --query UploadId --output text)"
printf '%s\n' "$UPLOAD_A" > "$LAB/upload-a.id"
printf '%s\n' "$UPLOAD_B" > "$LAB/upload-b.id"
"${AWS[@]}" upload-part --bucket "$BUCKET" --key uploads/stale.bin \
--part-number 1 --upload-id "$UPLOAD_A" --body "$LAB/part-a.bin" \
> "$LAB/receipts/part-a.json"
"${AWS[@]}" upload-part --bucket "$BUCKET" --key uploads/active.bin \
--part-number 1 --upload-id "$UPLOAD_B" --body "$LAB/part-b.bin" \
> "$LAB/receipts/part-b.json"
[[ "$UPLOAD_A" != "$UPLOAD_B" ]]
printf 'SESSIONS_OPEN keys=uploads/stale.bin,uploads/active.bin ids_distinct=yes\n'
A cross-surface inventory now compares upload state with object state. Its assertions require two sessions, 10 MiB of combined part data, and exactly one visible completed object. This is the central explanation for a storage total that exceeds the sum of ordinary object listings.
"${AWS[@]}" list-multipart-uploads --bucket "$BUCKET" > "$LAB/receipts/uploads-before.json"
"${AWS[@]}" list-parts --bucket "$BUCKET" --key uploads/stale.bin \
--upload-id "$UPLOAD_A" > "$LAB/receipts/stale-parts-before.json"
"${AWS[@]}" list-parts --bucket "$BUCKET" --key uploads/active.bin \
--upload-id "$UPLOAD_B" > "$LAB/receipts/active-parts-before.json"
"${AWS[@]}" list-objects-v2 --bucket "$BUCKET" > "$LAB/receipts/objects-before.json"
python3 - "$LAB/receipts" <<'PY'
import json, sys
from pathlib import Path
r = Path(sys.argv[1])
uploads = json.loads((r / 'uploads-before.json').read_text())['Uploads']
stale = sum(p['Size'] for p in json.loads((r / 'stale-parts-before.json').read_text())['Parts'])
active = sum(p['Size'] for p in json.loads((r / 'active-parts-before.json').read_text())['Parts'])
objects = json.loads((r / 'objects-before.json').read_text())['Contents']
assert {u['Key'] for u in uploads} == {'uploads/stale.bin', 'uploads/active.bin'}
assert stale == active == 5 * 1024 * 1024
assert [o['Key'] for o in objects] == ['uploads/stale.bin']
print(f'INVENTORY open_uploads={len(uploads)} hidden_part_bytes={stale + active} visible_objects={len(objects)}')
PY
AWS’s current Storage Lens cleanup article shows how incomplete multipart bytes can be discovered across buckets. Fleet metrics identify where to investigate; they do not identify a safe abort tuple by themselves. Preserve the API inventory before moving from account-level evidence to a destructive session-level action.
A negative control proves the request is bound to an exact upload identity. Appending wrong to the real ID must fail, and both sessions must still exist afterward. Only then does the lab abort the reviewed stale tuple and require the active tuple to remain.
if "${AWS[@]}" abort-multipart-upload --bucket "$BUCKET" \
--key uploads/stale.bin --upload-id "${UPLOAD_A}wrong" \
2> "$LAB/receipts/wrong-id.err"; then
printf 'Wrong upload ID unexpectedly succeeded.\n' >&2
exit 1
fi
[[ "$("${AWS[@]}" list-multipart-uploads --bucket "$BUCKET" \
--query 'length(Uploads || `[]`)' --output text)" == 2 ]]
"${AWS[@]}" abort-multipart-upload --bucket "$BUCKET" \
--key uploads/stale.bin --upload-id "$UPLOAD_A"
[[ "$("${AWS[@]}" list-multipart-uploads --bucket "$BUCKET" \
--query 'length(Uploads || `[]`)' --output text)" == 1 ]]
[[ "$("${AWS[@]}" list-multipart-uploads --bucket "$BUCKET" \
--query 'Uploads[0].Key' --output text)" == uploads/active.bin ]]
printf 'ABORT exact_stale_tuple=removed wrong_id=rejected active_upload=preserved\n'
Bulk commands hide this decision. They may be appropriate only after every candidate session is independently classified by age and owner. A narrow exact-ID loop with a reviewed manifest is easier to audit than a recursive --force invocation: freeze the scope, reject abnormal selection, retain evidence, then change exactly what was approved.
Completion of the remaining multipart upload uses its recorded ETag. Afterwards, zero open uploads and byte-for-byte comparisons for both uploads/stale.bin and uploads/active.bin prove three different outcomes: one session was aborted, one session became an object, and the pre-existing same-key object survived.
ETAG_B="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["ETag"])' \
"$LAB/receipts/part-b.json")"
python3 - "$ETAG_B" "$LAB/complete-active.json" <<'PY'
import json, sys
json.dump({'Parts': [{'ETag': sys.argv[1], 'PartNumber': 1}]}, open(sys.argv[2], 'w'))
PY
"${AWS[@]}" complete-multipart-upload --bucket "$BUCKET" \
--key uploads/active.bin --upload-id "$UPLOAD_B" \
--multipart-upload "file://$LAB/complete-active.json" \
> "$LAB/receipts/complete-active.json"
[[ "$("${AWS[@]}" list-multipart-uploads --bucket "$BUCKET" \
--query 'length(Uploads || `[]`)' --output text)" == 0 ]]
"${AWS[@]}" get-object --bucket "$BUCKET" --key uploads/stale.bin \
"$LAB/retained-after.txt" >/dev/null
cmp "$LAB/completed.txt" "$LAB/retained-after.txt"
"${AWS[@]}" get-object --bucket "$BUCKET" --key uploads/active.bin \
"$LAB/active-after.bin" >/dev/null
cmp "$LAB/part-b.bin" "$LAB/active-after.bin"
printf 'ACCEPTANCE open_uploads=0 retained_sha256=%s active_sha256=%s active_bytes=%s\n' \
"$(sha256sum "$LAB/retained-after.txt" | awk '{print $1}')" \
"$(sha256sum "$LAB/active-after.bin" | awk '{print $1}')" \
"$(stat -c %s "$LAB/active-after.bin")"
Representative output from the completed run follows. Process IDs vary by host; object hashes and byte counts are deterministic for this fixture.
minio: OK
mc: OK
LAB_READY minio=RELEASE.2025-09-07T16-13-09Z mc=RELEASE.2025-08-13T08-35-41Z aws=aws-cli/1.46.0 pid=1642231
BASELINE object_sha256=4825d5a7613741d3b05cf9f21aff67e43b77ab577b148573e603ee888de3146c part_bytes=5242880
SESSIONS_OPEN keys=uploads/stale.bin,uploads/active.bin ids_distinct=yes
INVENTORY open_uploads=2 hidden_part_bytes=10485760 visible_objects=1
ABORT exact_stale_tuple=removed wrong_id=rejected active_upload=preserved
ACCEPTANCE open_uploads=0 retained_sha256=4825d5a7613741d3b05cf9f21aff67e43b77ab577b148573e603ee888de3146c active_sha256=9ab1f039f8d32f96707e3ef8174e4739018b7546fb139b337426f18144aae8d3 active_bytes=5242880
CLEANUP path_absent=yes listener_absent=yes
Cleanup is accepted when the initial inventory contains two distinct upload IDs and 10 MiB of parts but only one completed object, a wrong ID changes nothing, the reviewed stale tuple disappears, the unrelated upload can still complete, both final objects match their expected content, zero open sessions remain, and the owned listener and path are removed. On production storage, add producer inactivity evidence, an approved age threshold, provider audit logs, billing or capacity follow-up, and a restore check for backup data.
A successfully completed object is still not proof that a backup is usable. Voxfor’s restore data verification workflow shows the next layer: select immutable recovery input, restore outside the live path, and compare application-level data rather than treating object presence as recovery evidence.
Amazon S3 supports AbortIncompleteMultipartUpload in bucket lifecycle configuration. Its current lifecycle guide says the action affects incomplete sessions after the configured number of days and does not delete completed objects. The provider executes it asynchronously, so a saved rule is not same-minute deletion evidence.
This configuration is an unexecuted provider example, not part of the MinIO lab. Review the current lifecycle document first because put-bucket-lifecycle-configuration replaces the bucket’s complete configuration. Merge this rule with existing transition, expiration, noncurrent-version, and delete-marker rules instead of overwriting them.
{
"Rules": [
{
"ID": "abort-incomplete-multipart-after-7-days",
"Status": "Enabled",
"Filter": {"Prefix": "uploads/"},
"AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 7}
}
]
}
Choose the age from the longest legitimate upload window, including paused clients, checkpointed transfers, network outages, and business batch schedules. Seven days is an example, not a universal answer. S3-compatible providers may differ in lifecycle support, timing, filters, metrics, and audit behavior; validate the rule through the provider’s current documentation and a disposable bucket before relying on it.
Automation should also expose recurring producer failure. Track incomplete-session count, summed part bytes, oldest session age, bucket/key prefix, producer identity, abort count, and recurrence after cleanup. Avoid placing raw upload IDs or unbounded object keys in metric labels; retain them in restricted logs or evidence records instead.
An abort removes that session’s parts, but a self-hosted disk can remain full for unrelated reasons. Noncurrent object versions, Object Lock retention, filesystem snapshots, replication queues, deleted files held open by a process, and storage-layer reserve all have separate owners.
When the S3 endpoint is backed by a Ceph cluster, Ceph OSD capacity diagnosis separates a full or imbalanced OSD from multipart-session accounting and preserves backfill headroom.
When a self-hosted endpoint still reports unexpected allocation, ZFS snapshot space diagnosis covers blocks retained by snapshots, holds, clones, and deferred destroy. That storage layer is not fixed by aborting more S3 sessions.
Do not treat a falling filesystem number as the only success signal. Re-list upload sessions, re-read protected objects, verify the application or backup job, wait for provider metrics at their documented cadence, and compare the same scoped capacity surfaces captured before cleanup.
An aborted upload ID cannot be resumed. Rollback therefore means stopping further cleanup, restoring the previous reviewed lifecycle configuration, and restarting a legitimate transfer from a controlled client—not recreating the old session or deleting completed objects to make totals match.
If production review finds that an active upload was misclassified, disable the cleanup worker or restore the complete previous lifecycle document, preserve the abort request and audit trail, notify the producer owner, and restart only the affected transfer with a new upload ID. Reconcile the intended object key and expected hash before completion; do not remove other sessions, versions, retention rules, or completed objects as compensation.
Retain endpoint, account, bucket, key, upload ID, initiation and activity timestamps, producer owner, paginated part inventory, byte total, same-key completed-object baseline, approval reference, exact abort result, both final object hashes, capacity follow-up, lifecycle document hash, and restart or restore outcome.
Only after that packet is saved should the disposable endpoint stop. The final block verifies executable path and process start time, checks the ownership marker, confirms the listener is gone, and removes descendants of the exact lab path.
PID_FROM_FILE="$(cat "$LAB/minio.pid")"
[[ "$PID_FROM_FILE" =~ ^[0-9]+$ ]]
[[ "$(readlink -f "/proc/$PID_FROM_FILE/exe")" == "$LAB/bin/minio" ]]
[[ "$(awk '{print $22}' "/proc/$PID_FROM_FILE/stat")" == "$(cat "$LAB/minio.start")" ]]
[[ "$(cat "$LAB/.voxfor-owner")" == "$MARKER_VALUE" ]]
kill "$PID_FROM_FILE"
wait "$PID_FROM_FILE"
if ss -H -ltn "sport = :$PORT" | grep -q .; then
printf 'Owned listener is still present.\n' >&2
exit 1
fi
cd /tmp
find "$LAB" -depth -mindepth 1 -delete
rmdir "$LAB"
[[ ! -e "$LAB" ]]
trap - EXIT
printf 'CLEANUP path_absent=yes listener_absent=yes\n'
No listener and no lab path remain at the final state. Its retained receipt proves that hidden storage was recovered through one session identity, while the completed same-key object and unrelated upload were both read back from the endpoint rather than inferred from a lower disk total.