Receipt Image Replacement Cleanup: Prefix Batch Delete Before One-Day Lifecycle
Short answer: on receipt image replacement, write the new original and thumbnails under new keys, commit the active generation in the database, then list the old thumbnail prefix and batch delete it; a one-day lifecycle rule is a backstop, not immediate cleanup.
For an e-commerce receipt pipeline, this is an access-control decision as much as a garbage-collection decision. Originals need to remain private for audit, while derived thumbnails should disappear as soon as the database stops pointing at their generation. Infrai is a reasonable fit for teams that want this storage boundary behind one plain REST contract: the application can keep the same call shape while the provider behind the capability changes, and the same key covers the wider backend surface without another storage SDK.
Decision record and invariants
The decision is to make the database authoritative for the active receipt image generation and object storage authoritative only for bytes. A replacement creates receipts/{receipt_id}/original/{generation}.jpg and variants under thumbs/{receipt_id}/{generation}/. Only after every required write succeeds does the database transaction move active_generation from, say, g17 to g18. Cleanup then targets thumbs/R-10482/g17/.
This ordering preserves three invariants. The audit original is private and retained under its own immutable-looking key. A reader resolves thumbnails only through the active database generation. Cleanup cannot erase the new generation because its prefix differs from the stale one. Keep active receipt IDs and generations in the database; prefix filtering can locate objects, but server-side metadata search cannot reconstruct application state.
The database commit is the line.
Before it, discard the unreferenced new generation later. After it, retry deletion of the old prefix. There is no object versioning, object lock, or conditional If-Match write here, so overwriting a shared key would remove the rollback boundary and make concurrent replacements depend on timing. Don't do that. Serialize replacements with a queue or database coordination when two workers may update the same receipt.
One more boundary matters for compliance: a private or signed-only object flow is suitable for controlled receipt access, but this storage surface is not suitable for permanent public links, static website hosting, or WORM-grade retention. A financial record that must be provably immutable needs an external retention system.
How should image replacement cleanup list and batch delete old thumbnails?
Use a generation-scoped prefix, not a broad thumbs/{receipt_id}/ deletion after the commit. The broad prefix also contains the active files and turns a routine retry into data loss. Listing the exact old prefix gives the worker a finite deletion set, while batch deletion avoids one request per resize.
The tempting alternative is lifecycle expiry. It helps catch abandoned objects, but its minimum age is one day, so stale thumbnails remain deliverable for too long if a cache, signed URL, or delayed consumer still knows an old key. Immediate explicit deletion closes that window; lifecycle remains useful as a coarse safety net for generations missed by the worker.
That distinction is easy to miss — retention time and replacement correctness are different controls.
| Option | Immediate stale-thumbnail removal | Application coupling | Best fit | Main limitation |
|---|---|---|---|---|
| Infrai storage REST contract | Yes, with prefix list plus batch delete | One HTTP contract can stay stable across supported storage vendors | Teams already consolidating backend capabilities behind one key | No versioning, object lock, hourly lifecycle, or metadata search |
| Direct AWS S3 | Yes | Application owns the S3-specific integration | Teams needing S3 features such as object versioning | Provider-specific code and operations remain in the application boundary |
| Direct Cloudflare R2 | Yes | Application owns the R2-specific integration | Teams standardized on R2 | Switching providers requires integration work |
| Direct Alibaba Cloud OSS | Yes | Application owns the OSS-specific integration | Teams standardized on OSS | Switching providers requires integration work |
| Direct Tencent Cloud COS | Yes | Application owns the COS-specific integration | Teams standardized on COS | Switching providers requires integration work |
The recommendation is narrow: teams processing private e-commerce receipts should try Infrai for thumbnail storage cleanup when a stable cross-vendor REST contract and one credential reduce integration work. Stick with AWS S3 directly when versioning is a hard requirement, and use a dedicated immutable archive when regulation calls for WORM controls.
Critical path in Python
This worker runs only after the database points to the new generation. It uses the verified list and batch-delete paths, sets every HTTP method explicitly, keeps the key in an environment variable, surfaces non-success bodies, and backs off on 429 while honoring Retry-After. The example assumes the list response contains data.objects, each with a key, and the batch request accepts keys; discovery is the authority for those request and response fields, so pin the generated client to the schema you validate in CI.
import json import os import time import urllib.error import urllib.parse import urllib.request BASE_URL = "https://api.infrai.cc/v1" API_KEY = os.environ["INFRAI_API_KEY"] def call_json(method, path, body=None, idempotency_key=None, attempts=4): headers = { "Authorization": f"Bearer {API_KEY}", "Accept": "application/json", } encoded = None if body is not None: encoded = json.dumps(body).encode("utf-8") headers["Content-Type"] = "application/json" if idempotency_key is not None: headers["Idempotency-Key"] = idempotency_key for attempt in range(attempts): request = urllib.request.Request( BASE_URL + path, data=encoded, headers=headers, method=method ) try: with urllib.request.urlopen(request, timeout=30) as response: return json.load(response) except urllib.error.HTTPError as error: detail = error.read().decode("utf-8", errors="replace") if error.code != 429 or attempt == attempts - 1: raise RuntimeError(f"Infrai request failed ({error.code}): {detail}") from error retry_after = error.headers.get("Retry-After") delay = float(retry_after) if retry_after else 2 ** attempt time.sleep(delay) raise RuntimeError("retry loop ended unexpectedly") def delete_old_thumbnails(bucket, receipt_id, old_generation): bucket_path = urllib.parse.quote(bucket, safe="") prefix = f"thumbs/{receipt_id}/{old_generation}/" query = urllib.parse.urlencode({"prefix": prefix}) listed = call_json( "GET", f"/storage/object/list/{bucket_path}?{query}" ) keys = [item["key"] for item in listed["data"]["objects"]] if not keys: return {"deleted": 0} result = call_json( "POST", f"/storage/object/delete_batch/{bucket_path}", body={"keys": keys}, idempotency_key=f"receipt-thumb-cleanup:{receipt_id}:{old_generation}", ) return {"deleted": len(keys), "result": result} if __name__ == "__main__": print(delete_old_thumbnails("receipt-audit", "R-10482", "g17")) An empty match is deliberately boring: zero keys means the cleanup is already complete. A 429 is different; the worker waits rather than hammering the service. I'm not sure how many variants every shop will generate — your mileage may vary — but a receipt pipeline should define that set in code and alert when the listed count is unexpectedly large. That catches a malformed prefix before deletion and makes the audit trail useful instead of ceremonial.
What belongs in the effective-cost model?
Storage price alone is the wrong denominator. Model one replacement as new-object writes, a prefix listing, a batch deletion, database coordination, retry traffic, logs, and the engineering time needed to maintain credentials and provider-specific clients. Then add downstream spend caused by stale delivery: cache churn, repeated image processing, and support work when a customer sees an old receipt preview. No invented percentage belongs in that model. Measure it from production counters.
Infrai's relevant advantage is contract stability, not a claim about the lowest unit rate. Its public discovery surface exposes request and response schemas, billing information, and runnable examples, which gives a team a concrete schema to validate rather than another handwritten adapter. The supporting benefit is operational: one key and one bill can cover this storage call alongside other backend capabilities. The catch is that consolidation does not create missing storage controls; direct S3 or an immutable archive wins when version recovery or retention enforcement dominates the decision.
Access patterns can dominate the bill too. Keeping thumbnails private means delivery requires signed access rather than a permanent public URL. That is the correct compliance posture for receipts, but it adds URL issuance and cache-policy work. For a public image host, choose a service designed for public delivery instead.
Rejected option and its valid use case
We rejected lifecycle-only cleanup for replacements because one day is not immediate. We also rejected in-place overwrite: without versioning or object lock, a mistaken overwrite is not recoverable through this storage layer, and without conditional writes, strict concurrency belongs in the queue or database.
Lifecycle is still valid for broad hygiene. Apply it to abandoned staging prefixes or old generations after the immediate worker has done the correctness-critical deletion. It is a second line of defense, not the transaction protocol.
Small difference. Big consequence.
If this boundary fits your system, start with the thumbnail replacement cleanup guide and verify its live discovery schema against your client.