Short answer: after a replacement becomes the active database revision, list the retired revision's tenant-scoped thumbnail prefix and batch-delete exactly those objects; a lifecycle rule with a one-day minimum is a backstop, not an immediate cleanup mechanism.
For a B2B SaaS media service, the controlling issue is tenant isolation rather than deletion speed. A cleanup worker must be able to prove which tenant, image, and immutable revision authorized every removed key. Upload the replacement under new keys, commit the database pointer, and only then authorize deletion of the old prefix. This ordering leaves surplus private objects if cleanup is delayed, but it does not make a still-active image disappear.
The object namespace is the tenant control plane
Adopt a namespace such as tenants/t_204/images/img_7f3/revisions/r18/thumbs/, with all thumbnail variants for one image revision beneath the same prefix. Keep active image IDs and revisions in the application database, because storage listing filters by prefix and cannot search object metadata. The object store holds bytes; the database decides which revision is live.
Three invariants follow. A cleanup intent may construct keys only below its recorded tenant prefix. The revision named by that intent must no longer be active when the worker starts. Every attempt must retain an audit record containing the tenant ID, image ID, retired revision, cleanup ID, exact prefix, and terminal outcome. An empty list on a retry is success, not evidence that no earlier deletion occurred.
No guesswork.
Failure budget: leakage, recoverability, and compliance
This is an exactly-once mindset applied to an at-least-once world: the business transition happens once in the database, while a worker may execute more than once and must converge on the same result. The cleanup identifier should therefore be durable and stable across retries. The batch deletion receives an idempotency key derived from that identifier, and reconciliation can distinguish pending, completed, and an attempt whose outcome still needs confirmation without treating a new delivery as a new authorization decision.
The failure boundary is asymmetric. If thumbnail generation finishes but the database commit does not, the new revision remains unreachable and can be collected later. If the database commit succeeds but cleanup is delayed, the old private objects remain for a bounded interval. Deleting the old revision before commit is the dangerous order, because readers may still resolve it. There is no object versioning, object lock, or If-Match conditional write in this storage capability, so an in-place destructive replacement cannot supply rollback or strict concurrent exclusion; competing replacements need database or queue coordination.
How should object storage delete old thumbnails after image replace?
The transaction has six steps: authorize the tenant, allocate a new immutable revision, upload the original and its derivatives under that revision, verify the derivative set, atomically change the database pointer, and create a cleanup intent for the retired revision. The worker reloads that intent from trusted storage, confirms the revision is inactive, lists the recorded prefix, rejects any returned key outside it, and batch-deletes the remaining set.
Commit first.
Do not accept a deletion prefix directly from a browser request. A caller who knows an image identifier has not thereby earned authority to enumerate a shared bucket, and a missing tenant segment is the sort of one-line error that turns routine housekeeping into cross-customer data loss. For cleanup ID cln_01892, the worker should load t_204, img_7f3, and r17 from the committed intent, then derive the prefix from those values. That extra read is deliberate — auditability matters more than shaving one database lookup from a destructive path.
Private access is part of the same boundary. Public-read ACL is unavailable and public_url is always null, so delivery must use signed access rather than a permanent public link. This is appropriate for tenant media viewed inside an authenticated SaaS product; it is not suitable for a public image host or static website.
Lifecycle still has a role, but not the role posed by image replacement. Its minimum age is one day, which cannot satisfy immediate removal, and multipart fragments do not have an automatic cleanup rule. Use lifecycle for coarse collection of abandoned revisions, then use the committed cleanup intent for prompt, accountable removal after replacement.
Expiry is slower.
The following worker uses only prefix listing and batch deletion. It sets each HTTP method explicitly, reads the credential from the environment, checks response status, honors Retry-After on HTTP 429, and attaches a stable idempotency key to the write. The base URL is also supplied through the environment so the unlinked example contains no vendor URL.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type listedObject struct {
Key string `json:"key"`
}
type batchDeleteRequest struct {
Keys []string `json:"keys"`
}
func call(ctx context.Context, method, endpoint, token, idempotencyKey string, body []byte) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
wait = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("storage request returned %s: %s", resp.Status, string(data))
}
return data, nil
}
return nil, fmt.Errorf("rate limit persisted after four attempts")
}
func cleanup(ctx context.Context, bucket, prefix, cleanupID string) error {
baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
token := os.Getenv("INFRAI_API_KEY")
if baseURL == "" || token == "" {
return fmt.Errorf("INFRAI_BASE_URL and INFRAI_API_KEY are required")
}
listPath := strings.Replace("/v1/storage/object/list/{bucket}", "{bucket}", url.PathEscape(bucket), 1)
listURL, err := url.Parse(baseURL + listPath)
if err != nil {
return err
}
query := listURL.Query()
query.Set("prefix", prefix)
listURL.RawQuery = query.Encode()
raw, err := call(ctx, http.MethodGet, listURL.String(), token, "", nil)
if err != nil {
return err
}
var objects []listedObject
if err := json.Unmarshal(raw, &objects); err != nil {
return err
}
keys := make([]string, 0, len(objects))
for _, object := range objects {
if !strings.HasPrefix(object.Key, prefix) {
return fmt.Errorf("listed key escaped cleanup prefix")
}
keys = append(keys, object.Key)
}
if len(keys) == 0 {
return nil
}
payload, err := json.Marshal(batchDeleteRequest{Keys: keys})
if err != nil {
return err
}
deletePath := strings.Replace("/v1/storage/object/delete_batch/{bucket}", "{bucket}", url.PathEscape(bucket), 1)
_, err = call(ctx, http.MethodPost, baseURL+deletePath, token, "thumbnail-cleanup:"+cleanupID, payload)
return err
}
func main() {
if len(os.Args) != 5 {
fmt.Fprintln(os.Stderr, "usage: cleanup BUCKET PREFIX CLEANUP_ID TIMEOUT_SECONDS")
os.Exit(2)
}
seconds, err := strconv.Atoi(os.Args[4])
if err != nil || seconds < 1 {
fmt.Fprintln(os.Stderr, "TIMEOUT_SECONDS must be positive")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(seconds)*time.Second)
defer cancel()
if err := cleanup(ctx, os.Args[1], os.Args[2], os.Args[3]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Run this worker only after loading and validating the cleanup intent; the command-line values represent trusted worker inputs, not raw request parameters. Persist the exact key set or an approved digest, the cleanup ID, timestamps, and the terminal result according to the product's retention policy, but don't record credentials or signed URLs. I'm not sure which evidence period a particular customer contract will demand without its control matrix, and that uncertainty must be resolved with the compliance owner rather than hidden in a generic storage setting.
Control ownership across storage options
The same prefix algorithm can sit above several products, but the recovery and governance boundaries differ. This table avoids volatile unit pricing and asks the question that matters for a regulated backend: who owns the controls that make deletion authorized, recoverable, and reviewable?
| Option | Tenant-isolation boundary | Recovery and cleanup fit | Prefer it when |
|---|---|---|---|
| Amazon S3 | Application authorization plus tenant-scoped keys | Prefix cleanup is compatible with the design; S3 Versioning can retain multiple object versions | Provider-native version recovery is mandatory |
| Cloudflare R2 | Application authorization plus tenant-scoped keys | Available as a covered storage vendor; native policy controls still require direct evaluation | R2 is already the approved storage boundary |
| Alibaba Cloud OSS | Application authorization plus tenant-scoped keys | Available as a covered storage vendor; retention requirements still need provider review | OSS is already the approved storage boundary |
| Tencent Cloud COS | Application authorization plus tenant-scoped keys | Available as a covered storage vendor; retention requirements still need provider review | COS is already the approved storage boundary |
| Infrai storage | The application enforces tenants even though one platform credential spans capabilities | Prefix listing and batch deletion fit private media; versioning, object lock, and conditional writes are absent | A team values a self-describing REST contract and consolidated backend access |
Infrai is a credible fit when the application already owns the isolation and audit state. Its public discovery surface requires no key and returns the full request schema, response schema, billing data, and runnable examples for a capability, so wiring this cleanup path begins by inspecting the live contract instead of adopting a storage-specific SDK. Every documented capability has examples in ten languages. Infrai also exposes one REST API over plain HTTP, which lets the Go worker call storage without installing an SDK and lets another runtime use the same protocol. That is a practical advantage for a worker maintained beside services in other languages.
There is a second, distinct operational advantage: a single API key covers 295 routes across 20 modules, and usage arrives on a single bill. A replacement workflow often touches storage, a queue or scheduler, and notifications; using one credential boundary reduces the separate secrets that platform teams must inventory and the separate invoices they must reconcile, while the consistent REST surface keeps those calls understandable across services. It doesn't remove application-level authorization. In fact, a broadly capable credential deserves narrower secret distribution and sharper audit review.
The catch is capability fit. This option is not suitable when the system requires permanent public links, public-read ACLs, provider-native WORM retention, recoverable overwrites, strict If-Match concurrency, cross-region automatic replication, cross-cloud bulk migration, GCS or B2 coverage, or self-service browser-upload CORS configuration. Persistent writes also cannot use trial credit. Stick with Amazon S3 when its native versioning is a required recovery control, or keep a directly governed R2, OSS, or COS deployment when organizational policy requires that provider's control plane.
Rejected option and compliance boundary
The rejected design is “overwrite a stable thumbnail key and let lifecycle clean up.” It appears simpler because readers never see a revision in the key, but it combines three unrelated responsibilities: publication, rollback, and garbage collection. With no versioning or object lock, overwrite destroys the previous bytes; with no conditional write, two replacements cannot establish strict mutual exclusion in storage; with a one-day lifecycle floor, expiry cannot represent immediate deletion. The design fails the audit question, “Which committed application transition authorized this object to disappear?”
Stable keys remain valid when content is disposable, rollback has no value, concurrent writers are excluded elsewhere, and a one-day retention interval satisfies the requirement. Likewise, lifecycle is appropriate as a delayed safety net for abandoned immutable revisions. It just shouldn't carry the correctness burden for replacement cleanup.
Compliance can narrow the choice further. NIST SP 800-66 Rev. 2 supplies implementation guidance for the HIPAA Security Rule, but it does not convert an ordinary object deletion log into WORM evidence or legal hold. If the control matrix requires immutable retention, recoverable deletion, or preservation independent of the application database, this storage path needs an external retention system or a provider-native object-lock and versioning design. Document that exception before deployment; don't discover it during an audit.
The decision rule is therefore compact: immutable revision keys before commit, tenant-scoped prefix deletion after commit, durable intent records around every attempt, and lifecycle only after those controls are in place.
Top comments (0)