Last active
September 4, 2026 13:24
-
-
Save clemlesne/35e681efb01fa43aaa69e424b4cdac8e to your computer and use it in GitHub Desktop.
Empty and delete S3-compatible buckets fast. Sharded parallel listing streams straight into parallel deletes, so wall clock is max(list, delete) instead of list + delete. Streaming bulk passes run to exhaustion BEFORE any per-key repair, because repair costs a HEAD plus a PUT and a DELETE each. Clears what `aws s3 rm --recursive` leaves behind: …
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S uv run --script | |
| # /// script | |
| # requires-python = ">=3.11" | |
| # dependencies = ["boto3>=1.34"] | |
| # /// | |
| """Empty (and optionally delete) S3-compatible buckets, fast. | |
| Runs against AWS S3 and S3-compatible stores such as Ceph RGW, which backs | |
| Hetzner Object Storage, Scaleway and OVH. | |
| Why this exists, when ``aws s3 rm --recursive`` already does most of it: | |
| 1. Listing is the bottleneck, not deleting. ListObjects returns at most 1000 | |
| keys per round trip and paginates serially, so a 200k-object bucket costs | |
| ~200 sequential requests before the first delete is even issued. This script | |
| splits the keyspace on common prefixes and runs two concurrent stages joined | |
| by a bounded queue, so deleting starts while listing is still going. Wall | |
| clock becomes max(list, delete) instead of list + delete. | |
| 2. Object versions and delete markers keep a versioned bucket non-empty. | |
| 3. Incomplete multipart uploads keep billing storage and block DeleteBucket, | |
| and no object-level delete removes them. | |
| 4. Ceph RGW can leave the bucket index disagreeing with the data behind it. It | |
| shows up in two shapes, and only the first one is fixable from the outside. | |
| a. The listing returns a key whose object is gone. HeadObject answers 404 | |
| while the web console still shows the file, and DeleteObject on it is a | |
| no-op. Re-PUT a zero-byte object to rebuild the entry, then DELETE it — | |
| that is what the repair stage does. | |
| b. Every listing call returns nothing, yet DeleteBucket still answers | |
| BucketNotEmpty. No object-level request reaches whatever the index is | |
| counting, so this script cannot clear it. Wait and retry first: the | |
| index count lags a large deletion, and Hetzner puts that lag at 15-20 | |
| minutes. If it persists, only a cluster-side `radosgw-admin bucket check | |
| --fix` clears it, which a managed-hosting customer cannot run — open a | |
| support ticket with the bucket id. | |
| Order of work, and why it is that way: streaming bulk passes repeat until one | |
| removes nothing, and only then does per-key repair run on the remainder. A bulk | |
| pass moves thousands of keys per second; repair costs a HEAD plus a PUT and a | |
| DELETE each. Repairing before the bulk passes have drained the bucket pays the | |
| per-key price for keys the next bulk pass would have taken for free. | |
| Bulk passes also stop when two in a row remove the SAME count, which is what a | |
| residue of stale index entries looks like: each pass deletes them successfully | |
| and the next lists them again, so the figure never reaches zero and the loop | |
| would otherwise run to its cap. That residue is repair's job, not streaming's. | |
| Repair needs HeadObject to tell a live object from a stale entry. On an SSE-C | |
| bucket that call requires the customer key and answers 400 without it, so repair | |
| is detected as inoperative up front and skipped with one message, rather than | |
| failing once per key. Pass --sse-customer-key to enable it there. | |
| Credentials come from the environment. This script never reads or writes a | |
| credentials file, and it prints no secret: the only values it echoes are the | |
| endpoint, the concurrency and the bucket names. | |
| AWS_ACCESS_KEY_ID required | |
| AWS_SECRET_ACCESS_KEY required | |
| AWS_SESSION_TOKEN optional | |
| S3_ENDPOINT_URL optional, same as --endpoint | |
| AWS_DEFAULT_REGION optional, defaults to us-east-1 | |
| S3_SSE_CUSTOMER_KEY optional, same as --sse-customer-key | |
| Prefer S3_SSE_CUSTOMER_KEY to the flag. An argument is visible in the process | |
| table to every user on the host and lands in shell history; an environment | |
| variable does neither. | |
| Most S3-compatible stores ignore the region for reads and deletes, but some | |
| validate it: Hetzner rejects a mismatch with the endpoint's location, so pair | |
| https://fsn1.your-objectstorage.com with AWS_DEFAULT_REGION=fsn1. | |
| Usage: | |
| export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... | |
| export S3_ENDPOINT_URL=https://fsn1.your-objectstorage.com | |
| uv run s3-purge.py --dry-run my-bucket | |
| uv run s3-purge.py --delete-bucket --yes old-a old-b | |
| uv run s3-purge.py -p 64 big-bucket | |
| # SSE-C bucket: without the key the repair stage is skipped, not silently | |
| # broken. Supply it to let repair clear stale index entries. | |
| S3_SSE_CUSTOMER_KEY=$(cat key.b64) uv run s3-purge.py --delete-bucket --yes enc-bucket | |
| # Millions of keys: bulk passes do the work, so cap what repair holds. | |
| uv run s3-purge.py -p 64 --repair-limit 50000 huge-bucket | |
| License: MIT | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import os | |
| import queue | |
| import sys | |
| import threading | |
| import time | |
| from concurrent.futures import ThreadPoolExecutor | |
| from dataclasses import dataclass, field | |
| from typing import Any, Iterator | |
| import boto3 | |
| from botocore.config import Config | |
| from botocore.exceptions import BotoCoreError, ClientError | |
| VERSION = "1.1.1" | |
| # DeleteObjects accepts at most 1000 keys per call. | |
| BATCH_SIZE = 1000 | |
| # How long a delete worker holds a partial batch before sending it anyway. It | |
| # only matters at the tail of a pass; during the pass batches fill on their own. | |
| IDLE_FLUSH_SECONDS = 2.0 | |
| # Caps on each stage. The module docstring explains why bulk runs before repair. | |
| # Both are ceilings, not targets: each stage also stops as soon as a pass makes | |
| # no progress, so a healthy bucket never reaches them. | |
| MAX_BULK_PASSES = 20 | |
| MAX_REPAIR_PASSES = 5 | |
| # Entries held for one repair round. Repair needs a worklist, not the whole | |
| # bucket, and an unbounded collect on a multi-million-key bucket is gigabytes of | |
| # dicts. Successive rounds pick up whatever this leaves behind. | |
| REPAIR_LIMIT = 100_000 | |
| # Prefix levels to descend while splitting the keyspace for parallel listing. | |
| # Six covers the layouts these stores produce in practice — ClickHouse writes | |
| # data/<3>/<29>, pgBackRest archive/<stanza>/<major>/<segment> — while keeping | |
| # discovery itself from turning into the serial listing it exists to avoid. | |
| MAX_SHARD_DEPTH = 6 | |
| # Sentinel pushed onto the key queue to retire one delete worker. | |
| _DONE = object() | |
| # Memoised keyspace split, per (bucket, versioned). See `discover_shards`. | |
| _SHARD_CACHE: "dict[tuple[str, bool], list[Shard]]" = {} | |
| # --------------------------------------------------------------------------- | |
| # model | |
| # --------------------------------------------------------------------------- | |
| @dataclass(frozen=True) | |
| class Shard: | |
| """A slice of the keyspace one list worker owns. | |
| ``deep`` lists everything under ``prefix``. A shallow shard lists only the | |
| keys sitting directly at ``prefix``, which is what makes an expanded prefix | |
| safe to split: the union of one shallow shard and the deep shards for each | |
| child prefix is exactly the deep listing it replaced. | |
| """ | |
| prefix: str | |
| deep: bool = True | |
| @dataclass | |
| class Stats: | |
| listed: int = 0 | |
| deleted: int = 0 | |
| repaired: int = 0 | |
| errors: list[str] = field(default_factory=list) | |
| _lock: threading.Lock = field(default_factory=threading.Lock, repr=False) | |
| def add_listed(self, n: int) -> None: | |
| with self._lock: | |
| self.listed += n | |
| def add_deleted(self, n: int) -> None: | |
| with self._lock: | |
| self.deleted += n | |
| def add_repaired(self, n: int) -> None: | |
| with self._lock: | |
| self.repaired += n | |
| def error(self, message: str) -> None: | |
| with self._lock: | |
| self.errors.append(message) | |
| def log(message: str = "") -> None: | |
| print(message, file=sys.stderr, flush=True) | |
| # --------------------------------------------------------------------------- | |
| # client | |
| # --------------------------------------------------------------------------- | |
| def make_client(args: argparse.Namespace, pool_size: int) -> Any: | |
| """Build one S3 client shared by every worker. | |
| botocore's low-level clients are thread safe, so a single client with a | |
| connection pool at least as large as the worker count beats one client per | |
| thread: connections are reused instead of renegotiated per worker. | |
| """ | |
| cfg: dict[str, Any] = { | |
| "max_pool_connections": max(pool_size, 10), | |
| "retries": {"max_attempts": args.max_attempts, "mode": "adaptive"}, | |
| "connect_timeout": 15, | |
| "read_timeout": 60, | |
| } | |
| if args.addressing_style != "auto": | |
| cfg["s3"] = {"addressing_style": args.addressing_style} | |
| # botocore >= 1.36 sends CRC32 checksums on every upload by default, which | |
| # several S3-compatible stores reject. Ask for them only when required. | |
| # Older botocore does not accept these keys at all. | |
| try: | |
| config = Config( | |
| request_checksum_calculation="when_required", | |
| response_checksum_validation="when_required", | |
| **cfg, | |
| ) | |
| except TypeError: | |
| config = Config(**cfg) | |
| return boto3.client( | |
| "s3", | |
| endpoint_url=args.endpoint or None, | |
| region_name=os.environ.get("AWS_DEFAULT_REGION") or "us-east-1", | |
| config=config, | |
| ) | |
| def is_missing(exc: ClientError) -> bool: | |
| """True when the store said the object is not there, and only then. | |
| Repair keys its whole decision off this: a false positive makes it PUT a | |
| zero-byte object over live data. So it matches 404 alone and treats every | |
| other failure — 400 from a missing SSE-C key, 403, a throttle — as unknown | |
| rather than as absent. | |
| """ | |
| code = str(exc.response.get("Error", {}).get("Code", "")) | |
| status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode") | |
| return code in {"404", "NoSuchKey", "NotFound"} or status == 404 | |
| # --------------------------------------------------------------------------- | |
| # discovery | |
| # --------------------------------------------------------------------------- | |
| def is_versioned(client: Any, bucket: str) -> bool: | |
| try: | |
| status = client.get_bucket_versioning(Bucket=bucket).get("Status") | |
| except (ClientError, BotoCoreError): | |
| return False | |
| return status in {"Enabled", "Suspended"} | |
| def sse_headers(args: argparse.Namespace) -> dict: | |
| """SSE-C headers for HeadObject, or an empty dict when none was supplied.""" | |
| if not args.sse_customer_key: | |
| return {} | |
| return { | |
| "SSECustomerAlgorithm": args.sse_customer_algorithm, | |
| "SSECustomerKey": args.sse_customer_key, | |
| } | |
| def preflight_sse( | |
| client: Any, bucket: str, sample: dict | None, sse: dict | |
| ) -> tuple[bool, str]: | |
| """Decide whether repair can classify keys on this bucket, before it starts. | |
| Repair rests entirely on HeadObject telling a live object from a stale index | |
| entry. On an SSE-C bucket that call needs the customer key and returns 400 | |
| without it, so every key reads as an error: repair does nothing, but only | |
| after logging one failure per key. One probe up front turns thousands of | |
| useless errors into a single accurate line. | |
| """ | |
| if sample is None: | |
| return True, "" | |
| key = sample["Key"] | |
| try: | |
| client.head_object(Bucket=bucket, Key=key, **sse) | |
| return True, "" | |
| except ClientError as exc: | |
| if is_missing(exc): | |
| # A stale entry: HeadObject answered correctly, which is the signal | |
| # repair needs. Nothing to warn about. | |
| return True, "" | |
| status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode") | |
| if status == 400 and not sse: | |
| return False, ( | |
| "HeadObject answers 400, which means the objects are SSE-C encrypted. " | |
| "Repair cannot tell a live object from a stale index entry without the " | |
| "customer key; pass --sse-customer-key to enable it." | |
| ) | |
| if status in {400, 403} and sse: | |
| return False, f"HeadObject rejected the supplied SSE-C key ({status})" | |
| return True, "" | |
| except BotoCoreError: | |
| return True, "" | |
| def probe( | |
| client: Any, bucket: str, shard: Shard, versioned: bool, stats: "Stats | None" = None | |
| ) -> tuple[list[str], bool]: | |
| """Return one level of child prefixes under a shard, and whether it has | |
| keys of its own sitting directly at that level. | |
| A versioned bucket must be probed with ListObjectVersions. ListObjectsV2 | |
| reports only current objects, so a prefix holding nothing but non-current | |
| versions or delete markers never appears as a common prefix, no shard | |
| covers it, and those entries are silently never listed. | |
| """ | |
| params: dict[str, Any] = { | |
| "Bucket": bucket, | |
| "Prefix": shard.prefix, | |
| "Delimiter": "/", | |
| "MaxKeys": 1000, | |
| } | |
| try: | |
| if versioned: | |
| resp = client.list_object_versions(**params) | |
| own = bool(resp.get("Versions") or resp.get("DeleteMarkers")) | |
| else: | |
| resp = client.list_objects_v2(**params) | |
| own = bool(resp.get("Contents")) | |
| except (ClientError, BotoCoreError) as exc: | |
| # Report it. Silently returning "no children" collapses the keyspace to a | |
| # single flat shard, so the run loses all its parallelism and nothing says | |
| # why — the failure looks like a small bucket rather than a denied probe. | |
| if stats is not None: | |
| stats.error(f"probe {shard.prefix!r}: {exc}") | |
| return [], False | |
| return [c["Prefix"] for c in resp.get("CommonPrefixes") or []], own | |
| def discover_shards( | |
| client: Any, | |
| bucket: str, | |
| target: int, | |
| versioned: bool, | |
| stats: "Stats | None" = None, | |
| cache: bool = True, | |
| ) -> list[Shard]: | |
| """Split the keyspace until there are enough shards to fill the pool. | |
| Two bounds keep discovery from becoming the bottleneck it is meant to | |
| remove. Shards are capped, because a level holding thousands of prefixes | |
| would otherwise turn one listing into thousands of them. And each level's | |
| probes run concurrently, since they are independent I/O. | |
| The result is memoised per bucket. A bucket is walked many times over one | |
| run — every bulk pass, then every repair round — and re-probing the prefix | |
| tree each time costs a request per node for a split that barely moves. The | |
| prefixes stay valid as the bucket drains: a shard whose keys are gone simply | |
| lists nothing, so a stale split is a no-op rather than a miss. | |
| """ | |
| cache_key = (bucket, versioned) | |
| if cache and cache_key in _SHARD_CACHE: | |
| return _SHARD_CACHE[cache_key] | |
| max_shards = max(target * 4, 8) | |
| shards = [Shard("", deep=True)] | |
| for _ in range(MAX_SHARD_DEPTH): | |
| if len(shards) >= target: | |
| break | |
| deep = [s for s in shards if s.deep] | |
| if not deep: | |
| break | |
| with ThreadPoolExecutor(max_workers=min(len(deep), max(target, 1))) as pool: | |
| probes = list( | |
| pool.map(lambda s: (s, probe(client, bucket, s, versioned, stats)), deep) | |
| ) | |
| nxt: list[Shard] = [s for s in shards if not s.deep] | |
| expanded = False | |
| for shard, (children, own) in probes: | |
| # A flat leaf has nothing to split on, and past the cap the extra | |
| # requests cost more than the parallelism buys. | |
| if not children or len(nxt) >= max_shards: | |
| nxt.append(shard) | |
| continue | |
| # A single child yields no parallelism by itself, but the level | |
| # below it often does: archive/ -> platform-postgres/ -> 18-1/ -> | |
| # hundreds of segment directories. Keep descending through the | |
| # narrow part instead of giving up at the first one. | |
| expanded = True | |
| if own: | |
| nxt.append(Shard(shard.prefix, deep=False)) | |
| nxt.extend(Shard(p, deep=True) for p in children) | |
| if not expanded: | |
| break | |
| shards = nxt | |
| if cache: | |
| _SHARD_CACHE[cache_key] = shards | |
| return shards | |
| # --------------------------------------------------------------------------- | |
| # stage 1: listing | |
| # --------------------------------------------------------------------------- | |
| def iter_shard(client: Any, bucket: str, shard: Shard, versioned: bool) -> Iterator[dict]: | |
| """Yield every delete-ready record in one shard. | |
| A versioned bucket needs ListObjectVersions so that non-current versions | |
| and delete markers are removed too; ListObjectsV2 only sees current | |
| objects and would leave the bucket non-empty. | |
| """ | |
| params: dict[str, Any] = {"Bucket": bucket} | |
| if shard.prefix: | |
| params["Prefix"] = shard.prefix | |
| if not shard.deep: | |
| params["Delimiter"] = "/" | |
| op = "list_object_versions" if versioned else "list_objects_v2" | |
| groups = ("Versions", "DeleteMarkers") if versioned else ("Contents",) | |
| for page in client.get_paginator(op).paginate(**params): | |
| for group in groups: | |
| for obj in page.get(group) or []: | |
| record = {"Key": obj["Key"]} | |
| version = obj.get("VersionId") | |
| # "null" is a real version id, not a missing one: it is what every | |
| # object carries in a bucket whose versioning was enabled and then | |
| # suspended. Dropping it makes the delete omit VersionId, which on | |
| # a versioned bucket writes a DELETE MARKER instead of removing the | |
| # version — so the bucket never empties and DeleteBucket keeps | |
| # failing. Send whatever the listing returned. | |
| if version: | |
| record["VersionId"] = version | |
| yield record | |
| def list_worker( | |
| client: Any, | |
| bucket: str, | |
| versioned: bool, | |
| shards: queue.Queue, | |
| keys: queue.Queue, | |
| stats: Stats, | |
| stop: threading.Event, | |
| ) -> None: | |
| while not stop.is_set(): | |
| try: | |
| shard = shards.get_nowait() | |
| except queue.Empty: | |
| return | |
| try: | |
| count = 0 | |
| for record in iter_shard(client, bucket, shard, versioned): | |
| if stop.is_set(): | |
| return | |
| # Blocks when the delete stage falls behind, which bounds | |
| # memory on a bucket with millions of keys. | |
| keys.put(record) | |
| count += 1 | |
| stats.add_listed(count) | |
| except (ClientError, BotoCoreError) as exc: | |
| stats.error(f"list {shard.prefix!r}: {exc}") | |
| # --------------------------------------------------------------------------- | |
| # stage 2: deleting | |
| # --------------------------------------------------------------------------- | |
| def flush_batch(client: Any, bucket: str, batch: list[dict], stats: Stats) -> None: | |
| """Delete one batch and count what the store accepted. | |
| Quiet mode returns only failures, so the count is the batch minus its | |
| errors. That is a count of ACCEPTED DELETE REQUESTS, not of objects that | |
| existed: S3 reports success for a key that was never there, so a stale index | |
| entry scores here exactly like a real object. Never report it as space | |
| reclaimed. | |
| """ | |
| if not batch: | |
| return | |
| try: | |
| resp = client.delete_objects( | |
| Bucket=bucket, Delete={"Objects": batch, "Quiet": True} | |
| ) | |
| except (ClientError, BotoCoreError) as exc: | |
| stats.error(f"delete batch of {len(batch)}: {exc}") | |
| return | |
| failures = resp.get("Errors") or [] | |
| for err in failures[:10]: | |
| stats.error(f"delete {err.get('Key')!r}: {err.get('Code')} {err.get('Message')}") | |
| stats.add_deleted(len(batch) - len(failures)) | |
| def delete_worker( | |
| client: Any, | |
| bucket: str, | |
| keys: queue.Queue, | |
| stats: Stats, | |
| stop: threading.Event, | |
| ) -> None: | |
| batch: list[dict] = [] | |
| last_add = time.monotonic() | |
| while True: | |
| try: | |
| item = keys.get(timeout=0.25) | |
| except queue.Empty: | |
| # Flush a partial batch only once it has gone cold, NOT on every poll. | |
| # Listing is the slow stage, so a worker routinely holds a fraction of | |
| # a batch: flushing each time the poll expires splits one 1000-key | |
| # request into dozens of tiny ones, and the extra request RATE is what | |
| # tips a throttling store into 503s. The wait costs latency on the | |
| # tail of a pass only, where there is nothing left to overlap with. | |
| if batch and time.monotonic() - last_add >= IDLE_FLUSH_SECONDS: | |
| flush_batch(client, bucket, batch, stats) | |
| batch = [] | |
| if stop.is_set(): | |
| flush_batch(client, bucket, batch, stats) | |
| return | |
| continue | |
| if item is _DONE: | |
| # Safe to leave immediately: the sentinels are queued only after every | |
| # lister has finished, and the queue is FIFO, so all remaining keys sit | |
| # ahead of them. Draining past the sentinel here would swallow another | |
| # worker's copy and hang it — `stop` is not set until after the joins. | |
| flush_batch(client, bucket, batch, stats) | |
| return | |
| batch.append(item) | |
| last_add = time.monotonic() | |
| if len(batch) >= BATCH_SIZE: | |
| flush_batch(client, bucket, batch, stats) | |
| batch = [] | |
| def purge_pass( | |
| client: Any, | |
| bucket: str, | |
| versioned: bool, | |
| args: argparse.Namespace, | |
| stats: Stats, | |
| ) -> int: | |
| """Run one full list-and-delete pass. Returns the keys listed BY THIS PASS. | |
| ``stats`` accumulates across the passes of one bucket, so the per-pass figure | |
| has to be a difference, not the running total. | |
| """ | |
| listed_before = stats.listed | |
| shards = discover_shards(client, bucket, args.parallel, versioned, stats) | |
| shard_q: queue.Queue = queue.Queue() | |
| for shard in shards: | |
| shard_q.put(shard) | |
| # Backpressure: cap in-flight keys so a huge bucket cannot exhaust memory. | |
| key_q: queue.Queue = queue.Queue(maxsize=BATCH_SIZE * args.parallel * 2) | |
| stop = threading.Event() | |
| n_list = min(args.parallel, max(len(shards), 1)) | |
| # Size the delete stage to what the list stage can actually feed it. One | |
| # request moves 1000 keys either way, so deleters much beyond the lister | |
| # count cannot go faster — they only split the same stream into smaller | |
| # batches, costing a round trip per handful. A single flat shard is the case | |
| # that matters: it yields one lister, and 32 deleters behind it would send | |
| # tiny requests all pass long. Double for headroom against uneven shards. | |
| n_delete = max(2, min(args.parallel, n_list * 2)) | |
| log(f" {len(shards)} shard(s), {n_list} list + {n_delete} delete worker(s)") | |
| listers = [ | |
| threading.Thread( | |
| target=list_worker, | |
| args=(client, bucket, versioned, shard_q, key_q, stats, stop), | |
| daemon=True, | |
| ) | |
| for _ in range(n_list) | |
| ] | |
| deleters = [ | |
| threading.Thread( | |
| target=delete_worker, | |
| args=(client, bucket, key_q, stats, stop), | |
| daemon=True, | |
| ) | |
| for _ in range(n_delete) | |
| ] | |
| before = stats.deleted | |
| for t in listers + deleters: | |
| t.start() | |
| reporter = threading.Thread( | |
| target=progress, args=(stats, stop, before), daemon=True | |
| ) | |
| reporter.start() | |
| try: | |
| for t in listers: | |
| t.join() | |
| # Listing is done, so retire the delete stage once the queue drains. | |
| for _ in deleters: | |
| key_q.put(_DONE) | |
| for t in deleters: | |
| t.join() | |
| except KeyboardInterrupt: | |
| stop.set() | |
| raise | |
| finally: | |
| stop.set() | |
| reporter.join(timeout=1) | |
| return stats.listed - listed_before | |
| def progress(stats: Stats, stop: threading.Event, before: int) -> None: | |
| start = time.monotonic() | |
| last = -1 | |
| while not stop.wait(2.0): | |
| done = stats.deleted - before | |
| if done == last: | |
| continue | |
| last = done | |
| rate = done / max(time.monotonic() - start, 0.001) | |
| log(f" {done:,} deleted ({rate:,.0f}/s)") | |
| # --------------------------------------------------------------------------- | |
| # repair | |
| # --------------------------------------------------------------------------- | |
| def repair_key( | |
| client: Any, bucket: str, record: dict, stats: Stats, sse: dict | None = None | |
| ) -> bool: | |
| """Clear one entry the bulk pass left behind. | |
| HeadObject separates a live object that resisted deletion from a stale index | |
| entry, so the zero-byte rebuild can never overwrite real data. That check is | |
| load bearing: without it the rebuild is a blind PUT over whatever is there. | |
| On an SSE-C bucket HeadObject REQUIRES the customer key and answers 400 | |
| without it — not 404 — so every key looks like an error and repair cannot run | |
| at all. Pass ``sse`` to make it work; ``preflight_sse`` detects the case. | |
| """ | |
| key = record["Key"] | |
| try: | |
| client.head_object(Bucket=bucket, Key=key, **(sse or {})) | |
| live = True | |
| except ClientError as exc: | |
| if not is_missing(exc): | |
| stats.error(f"head {key!r}: {exc}") | |
| return False | |
| live = False | |
| except BotoCoreError as exc: | |
| stats.error(f"head {key!r}: {exc}") | |
| return False | |
| try: | |
| if not live: | |
| # Rebuild the orphaned index entry so the delete below can bind | |
| # to a real object and remove both. | |
| client.put_object(Bucket=bucket, Key=key, Body=b"") | |
| client.delete_object(Bucket=bucket, Key=key) | |
| except (ClientError, BotoCoreError) as exc: | |
| stats.error(f"repair {key!r}: {exc}") | |
| return False | |
| stats.add_repaired(1) | |
| return True | |
| def collect_all( | |
| client: Any, | |
| bucket: str, | |
| versioned: bool, | |
| parallel: int, | |
| stats: "Stats | None" = None, | |
| limit: int = 0, | |
| ) -> list[dict]: | |
| """Collect the entries a bulk pass left behind, bounded and fault tolerant. | |
| ``limit`` caps how many records are held at once. The repair stage only ever | |
| needs a worklist, never the whole bucket, and an unbounded collect on a | |
| multi-million-key bucket is gigabytes of dicts in memory — the streaming path | |
| is bounded by the key queue, so this one has to be bounded too. | |
| A listing error here is recorded and skipped rather than raised. Letting it | |
| propagate aborts the process mid-run and takes every remaining bucket with | |
| it, which is exactly what a transient 403 from the store used to do. | |
| """ | |
| shards = discover_shards(client, bucket, parallel, versioned, stats) | |
| records: list[dict] = [] | |
| lock = threading.Lock() | |
| full = threading.Event() | |
| def run(shard: Shard) -> None: | |
| if full.is_set(): | |
| return | |
| try: | |
| found: list[dict] = [] | |
| for record in iter_shard(client, bucket, shard, versioned): | |
| found.append(record) | |
| if limit and len(found) >= limit: | |
| break | |
| with lock: | |
| records.extend(found) | |
| if limit and len(records) >= limit: | |
| del records[limit:] | |
| full.set() | |
| except (ClientError, BotoCoreError) as exc: | |
| if stats is not None: | |
| stats.error(f"collect {shard.prefix!r}: {exc}") | |
| with ThreadPoolExecutor(max_workers=max(parallel, 1)) as pool: | |
| list(pool.map(run, shards)) | |
| return records | |
| # --------------------------------------------------------------------------- | |
| # multipart | |
| # --------------------------------------------------------------------------- | |
| def abort_multipart_uploads(client: Any, bucket: str, parallel: int, stats: Stats) -> int: | |
| uploads: list[tuple[str, str]] = [] | |
| try: | |
| for page in client.get_paginator("list_multipart_uploads").paginate(Bucket=bucket): | |
| for upload in page.get("Uploads") or []: | |
| uploads.append((upload["Key"], upload["UploadId"])) | |
| except (ClientError, BotoCoreError) as exc: | |
| stats.error(f"list multipart uploads: {exc}") | |
| return 0 | |
| if not uploads: | |
| return 0 | |
| def abort(item: tuple[str, str]) -> bool: | |
| key, upload_id = item | |
| try: | |
| client.abort_multipart_upload(Bucket=bucket, Key=key, UploadId=upload_id) | |
| return True | |
| except (ClientError, BotoCoreError) as exc: | |
| stats.error(f"abort mpu {key!r}: {exc}") | |
| return False | |
| with ThreadPoolExecutor(max_workers=max(parallel, 1)) as pool: | |
| return sum(pool.map(abort, uploads)) | |
| # --------------------------------------------------------------------------- | |
| # per-bucket driver | |
| # --------------------------------------------------------------------------- | |
| def purge_bucket(client: Any, bucket: str, args: argparse.Namespace) -> bool: | |
| log(f"\n== {bucket}") | |
| started = time.monotonic() | |
| stats = Stats() | |
| try: | |
| versioned = is_versioned(client, bucket) | |
| except (ClientError, BotoCoreError) as exc: | |
| log(f" cannot inspect: {exc}") | |
| return False | |
| if versioned: | |
| log(" versioning is on; removing versions and delete markers") | |
| ok = True | |
| # Drain with bulk passes until one removes nothing. A single bulk pass does | |
| # not empty a large bucket: deletes race the listing that feeds them, and a | |
| # store that hands back stale index entries re-serves keys mid-scan. Each | |
| # extra pass is cheap because it streams, so repeat before paying for repair. | |
| last_removed = -1 | |
| for bulk in range(1, args.max_bulk_passes + 1): | |
| log(f" bulk pass {bulk}: streaming list -> delete") | |
| before = stats.deleted | |
| purge_pass(client, bucket, versioned, args, stats) | |
| removed = stats.deleted - before | |
| # Delete requests accepted, NOT bytes reclaimed: S3 reports success for a | |
| # key that does not exist, so a stale index entry counts here exactly like | |
| # a real object. Read it as progress, never as space recovered. | |
| log(f" {removed:,} delete(s) accepted") | |
| if removed == 0: | |
| break | |
| # A count that repeats exactly is not progress. A stale index entry is | |
| # deleted successfully on every pass and listed again on the next, so a | |
| # residue of them holds `removed` at a small constant and would otherwise | |
| # burn every remaining pass. Two identical rounds is the signal to stop | |
| # streaming and hand what is left to repair, which can actually clear it. | |
| if removed == last_removed: | |
| log(" same count twice; bulk passes are not making progress") | |
| break | |
| last_removed = removed | |
| left = collect_all( | |
| client, bucket, versioned, args.parallel, stats, limit=args.repair_limit | |
| ) | |
| sse = sse_headers(args) | |
| can_repair, why_not = preflight_sse(client, bucket, left[0] if left else None, sse) | |
| if left and not can_repair: | |
| log(f" skipping repair: {why_not}") | |
| repair_passes = 0 | |
| else: | |
| repair_passes = args.max_repair_passes | |
| # Whatever survives the bulk pass is either a live object that resisted | |
| # deletion or a stale index entry. Repair those one by one, and stop as | |
| # soon as a pass stops making progress. | |
| for attempt in range(1, repair_passes + 1): | |
| if not left: | |
| break | |
| log(f" repair pass {attempt}: {len(left):,} entries survived bulk deletion") | |
| before = stats.repaired | |
| with ThreadPoolExecutor(max_workers=max(args.parallel, 1)) as pool: | |
| list(pool.map(lambda r: repair_key(client, bucket, r, stats, sse), left)) | |
| progressed = stats.repaired - before | |
| log(f" repaired {progressed:,} of {len(left):,}") | |
| if progressed == 0: | |
| break | |
| left = collect_all( | |
| client, bucket, versioned, args.parallel, stats, limit=args.repair_limit | |
| ) | |
| if left: | |
| log(f" STILL NOT EMPTY: {len(left):,} entries remain") | |
| for record in left[:5]: | |
| log(f" {record['Key']}") | |
| ok = False | |
| else: | |
| log(f" empty ({stats.deleted:,} deleted, {stats.repaired:,} repaired)") | |
| aborted = abort_multipart_uploads(client, bucket, args.parallel, stats) | |
| if aborted: | |
| log(f" aborted {aborted} incomplete multipart upload(s)") | |
| if args.delete_bucket and ok: | |
| try: | |
| client.delete_bucket(Bucket=bucket) | |
| log(" bucket deleted") | |
| except ClientError as exc: | |
| if str(exc.response.get("Error", {}).get("Code")) == "NoSuchBucket": | |
| log(" bucket already gone") | |
| else: | |
| log(f" bucket NOT deleted: {exc}") | |
| ok = False | |
| except BotoCoreError as exc: | |
| log(f" bucket NOT deleted: {exc}") | |
| ok = False | |
| if stats.errors: | |
| log(f" {len(stats.errors)} error(s); first few:") | |
| for message in stats.errors[:10]: | |
| log(f" {message}") | |
| ok = False | |
| log(f" took {time.monotonic() - started:.1f}s") | |
| return ok | |
| def report_bucket(client: Any, bucket: str, args: argparse.Namespace) -> None: | |
| log(f"\n== {bucket} (dry run)") | |
| stats = Stats() | |
| versioned = is_versioned(client, bucket) | |
| shards = discover_shards(client, bucket, args.parallel, versioned, stats) | |
| records = collect_all( | |
| client, bucket, versioned, args.parallel, stats, limit=args.repair_limit | |
| ) | |
| uploads = 0 | |
| try: | |
| for page in client.get_paginator("list_multipart_uploads").paginate(Bucket=bucket): | |
| uploads += len(page.get("Uploads") or []) | |
| except (ClientError, BotoCoreError) as exc: | |
| stats.error(str(exc)) | |
| capped = args.repair_limit and len(records) >= args.repair_limit | |
| log(f" versioned: {versioned}") | |
| log(f" shards for parallel listing: {len(shards)}") | |
| log( | |
| f" objects/versions listed: {len(records):,}" | |
| + (f" (capped at --repair-limit; the bucket holds more)" if capped else "") | |
| ) | |
| log(f" incomplete multipart uploads: {uploads}") | |
| if stats.errors: | |
| log(f" listing errors: {len(stats.errors)} (first: {stats.errors[0][:80]})") | |
| if args.delete_bucket: | |
| log(" would delete the bucket itself") | |
| # --------------------------------------------------------------------------- | |
| # cli | |
| # --------------------------------------------------------------------------- | |
| def parse_args(argv: list[str]) -> argparse.Namespace: | |
| parser = argparse.ArgumentParser( | |
| prog="s3-purge.py", | |
| description="Empty (and optionally delete) S3-compatible buckets.", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=( | |
| "Credentials come from AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY\n" | |
| "(and optionally AWS_SESSION_TOKEN). The endpoint may come from\n" | |
| "S3_ENDPOINT_URL instead of --endpoint. Some stores validate the\n" | |
| "region: pair a Hetzner fsn1 endpoint with AWS_DEFAULT_REGION=fsn1.\n\n" | |
| "Pass an SSE-C key through S3_SSE_CUSTOMER_KEY rather than the flag:\n" | |
| "an argument is readable in the process table and in shell history.\n\n" | |
| "Work runs as streaming bulk passes until one deletes nothing, then\n" | |
| "per-key repair on the remainder. Repair needs HeadObject, which an\n" | |
| "SSE-C bucket refuses without the key, so it is skipped there.\n\n" | |
| "Examples:\n" | |
| " uv run s3-purge.py --dry-run my-bucket\n" | |
| " uv run s3-purge.py --delete-bucket --yes old-a old-b\n" | |
| " uv run s3-purge.py -e https://fsn1.your-objectstorage.com -p 64 big\n" | |
| " S3_SSE_CUSTOMER_KEY=$(cat key.b64) uv run s3-purge.py --yes enc-bucket\n" | |
| ), | |
| ) | |
| parser.add_argument("buckets", nargs="+", metavar="BUCKET") | |
| parser.add_argument( | |
| "-e", "--endpoint", | |
| default=os.environ.get("S3_ENDPOINT_URL", ""), | |
| help="S3 endpoint URL (default: $S3_ENDPOINT_URL; omit for AWS S3)", | |
| ) | |
| parser.add_argument( | |
| "-p", "--parallel", type=int, default=32, | |
| help="workers per stage (default: 32)", | |
| ) | |
| parser.add_argument( | |
| "-D", "--delete-bucket", action="store_true", | |
| help="delete each bucket once it is empty", | |
| ) | |
| parser.add_argument( | |
| "-n", "--dry-run", action="store_true", | |
| help="report what would be deleted; change nothing", | |
| ) | |
| parser.add_argument( | |
| "-y", "--yes", action="store_true", | |
| help="do not prompt for confirmation", | |
| ) | |
| parser.add_argument( | |
| "--addressing-style", choices=("auto", "path", "virtual"), default="auto", | |
| help="S3 addressing style; try 'path' if the endpoint rejects requests", | |
| ) | |
| parser.add_argument( | |
| "--max-attempts", type=int, default=10, | |
| help="retry attempts per request (default: 10)", | |
| ) | |
| parser.add_argument( | |
| "--max-bulk-passes", type=int, default=MAX_BULK_PASSES, | |
| help=f"streaming list+delete passes before repair (default: {MAX_BULK_PASSES})", | |
| ) | |
| parser.add_argument( | |
| "--max-repair-passes", type=int, default=MAX_REPAIR_PASSES, | |
| help=f"per-key repair passes after the bulk passes (default: {MAX_REPAIR_PASSES})", | |
| ) | |
| parser.add_argument( | |
| "--repair-limit", type=int, default=REPAIR_LIMIT, | |
| help=( | |
| "cap on entries collected for repair at once; 0 means no cap. Bounds " | |
| f"memory on a multi-million-key bucket (default: {REPAIR_LIMIT})" | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--sse-customer-key", | |
| default=os.environ.get("S3_SSE_CUSTOMER_KEY", ""), | |
| help=( | |
| "SSE-C key (base64) for HeadObject during repair; without it repair " | |
| "cannot classify keys on an SSE-C bucket. Default: $S3_SSE_CUSTOMER_KEY" | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--sse-customer-algorithm", default="AES256", | |
| help="SSE-C algorithm (default: AES256)", | |
| ) | |
| parser.add_argument("-V", "--version", action="version", version=VERSION) | |
| args = parser.parse_args(argv) | |
| if args.parallel < 1: | |
| parser.error("--parallel must be at least 1") | |
| return args | |
| def confirm(buckets: list[str], delete_bucket: bool) -> bool: | |
| what = "permanently delete every object in" | |
| if delete_bucket: | |
| what = "permanently delete every object in, then remove," | |
| log(f"\nAbout to {what} {len(buckets)} bucket(s):") | |
| for bucket in buckets: | |
| log(f" - {bucket}") | |
| log("This cannot be undone.") | |
| try: | |
| reply = input(f"Type the number of buckets ({len(buckets)}) to continue: ") | |
| except (EOFError, KeyboardInterrupt): | |
| return False | |
| return reply.strip() == str(len(buckets)) | |
| def main(argv: list[str]) -> int: | |
| args = parse_args(argv) | |
| for name in ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"): | |
| if not os.environ.get(name): | |
| log(f"s3-purge: {name} is not set") | |
| return 1 | |
| client = make_client(args, pool_size=args.parallel * 2 + 8) | |
| log(f"s3-purge {VERSION}") | |
| log(f"endpoint: {args.endpoint or '<AWS S3 default>'}") | |
| log(f"concurrency: {args.parallel} per stage") | |
| log(f"buckets: {' '.join(args.buckets)}") | |
| if args.dry_run: | |
| for bucket in args.buckets: | |
| report_bucket(client, bucket, args) | |
| log("\ndry run: nothing was changed") | |
| return 0 | |
| if not args.yes and not confirm(args.buckets, args.delete_bucket): | |
| log("aborted") | |
| return 1 | |
| failed = False | |
| for bucket in args.buckets: | |
| if not purge_bucket(client, bucket, args): | |
| failed = True | |
| log("\nfinished with errors" if failed else "\ndone") | |
| return 2 if failed else 0 | |
| if __name__ == "__main__": | |
| try: | |
| sys.exit(main(sys.argv[1:])) | |
| except KeyboardInterrupt: | |
| log("\ninterrupted") | |
| sys.exit(130) | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment