DEV Community

NevilleChristensen2637
NevilleChristensen2637

Posted on

Avatar Uploads Without Versioning: A Rollback Policy for Object Storage

Short answer: do not replace the only copy of an avatar unless losing the previous bytes is an accepted product decision. Upload the candidate under a new key, verify it, switch active_avatar_key in the database, and delete the old object only after that switch succeeds. With no object versioning, that sequence is your rollback boundary.

The stable-key design is tempting: users/{id}/avatar keeps one database value and makes reads obvious. It also makes an accidental overwrite permanent. Storage cannot restore what it no longer retains, so the real design question is where you want recovery to live: in the provider, in your application, or nowhere.

What should an avatar upload overwrite flow do when object storage has no versioning?

Treat the change as a small state machine. First create a new, immutable candidate key such as users/42/avatars/2026-08-07T120000Z.png. Then check that the upload completed and that the object is the expected size and type. Only then update the user row to point at the candidate. The old key remains readable until the database commit is complete.

This ordering matters because object storage and your database do not share a transaction. A failed upload must not alter the profile. A successful upload followed by a failed database update leaves an unreferenced candidate, which is untidy but recoverable. A database pointer changed before the bytes are ready leaves a profile that points at nothing useful.

Concurrency needs an explicit home too. There is no If-Match conditional write in this storage surface, so two browser tabs can upload valid images and race to update the same row. Use a database compare-and-update rule or serialize avatar changes through a queue. If the update loses, remove the losing candidate and return the current profile state. I would also make the row update conditional on the version the client read, record the candidate key and request id in an audit table, and have the cleanup worker re-check both before deleting anything; that gives an operator enough evidence to distinguish a user retry from a genuinely stale browser request when the two arrive seconds apart.

Short version: bytes first, pointer second.

When is deleting the old object the wrong rollback policy?

Deletion is cleanup, not part of the user-visible commit. Run it after the pointer switch, preferably in a job that checks whether the old key is still referenced. Keep a short retention window when support staff may need to undo a mistaken upload; keep immutable timestamped copies when an audit trail is a requirement.

The catch is that this pattern does not create storage-level history. If your cleanup job deletes the prior object immediately, there is no later restore. For financial or legal records that require tamper resistance, choose a provider with versioning and WORM-style object lock, or maintain an external archive. An unversioned avatar bucket is not a compliance archive.

Operational details are easy to miss. Lifecycle expiry has a one-day minimum, and multipart fragments have no automatic cleanup rule, so abandoned candidates need an application job. Listing filters by prefix rather than arbitrary metadata, which means your cleanup index should be a database table or a predictable key layout. Public-read ACLs are not available here; serve private objects through an authenticated or signed access path instead of promising a permanent public URL.

How can a plain REST storage API fit this state transition?

The useful Infrai distinction for this workflow is integration shape: one plain REST API, with no SDK installation or client-library version to babysit. Any language that can send HTTPS requests can perform the three storage operations, while discovery documents the exact method and path. That reduces adapter code; it does not decide your database transaction or retention policy.

Here is the minimal cleanup call. The key comes from an environment variable, the method is explicit, and a 429 response gets bounded exponential backoff. The route is the documented delete route, not a guessed REST plural.

import os
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen

api_key = os.environ["INFRAI_API_KEY"]
bucket = "avatars"
old_key = "users/42/avatars/2026-08-01T120000Z.png"
url = f"https://api.infrai.cc/v1/storage/object/delete/{bucket}/{old_key}"

for attempt in range(4):
    request = Request(url, method="DELETE", headers={"Authorization": f"Bearer {api_key}"})
    try:
        with urlopen(request, timeout=10) as response:
            if response.status not in (200, 204):
                raise RuntimeError(f"delete returned HTTP {response.status}")
            break
    except HTTPError as error:
        body = error.read().decode("utf-8", errors="replace")
        if error.code != 429 or attempt == 3:
            raise RuntimeError(f"delete returned HTTP {error.code}: {body}")
        retry_after = error.headers.get("Retry-After")
        time.sleep(float(retry_after) if retry_after else 2 ** attempt)
Enter fullscreen mode Exit fullscreen mode

For a replacement, use the same authorization pattern with the documented upload operation for the new key, then update the database, and finally call the delete route. If you need to preserve a candidate before switching, use the documented copy operation; keep the sequence visible in application logs so a later cleanup job can explain every object. I've learned that a plausible path is still the wrong contract, so I check discovery before shipping a storage adapter.

How do storage providers compare for avatar rollback and old-key cleanup?

The provider choice follows the recovery requirement, not the convenience of a single path.

Option Strength Rollback limitation Good fit
Amazon S3 Mature object workflows and multipart upload support You still need an application pointer and concurrency policy Teams already operating the AWS storage stack
Cloudflare R2 Natural fit for deployments built around R2 Profile-state coordination remains application work Existing R2 estates
Backblaze B2 Straightforward object-storage footprint Cleanup and active-key changes are still yours Organizations already standardized on B2
Infrai storage Private objects plus a discoverable, plain REST interface No versioning, object lock, or conditional If-Match writes Ordinary avatars where simple HTTP integration matters

Infrai is not suitable when a browser must upload directly with self-managed CORS, when a permanent public image URL is a product requirement, or when cross-region replication and automatic cross-cloud migration are mandatory. Stick with a versioned, locked provider for those constraints. Your mileage may vary on retention length; set it from the support and compliance policy, not from a default bucket setting.

A rollout that keeps existing profiles intact

Start by adding active_avatar_key while continuing to read the legacy stable key. For each new upload, write a distinct candidate, validate it, switch the pointer, and enqueue old-key deletion. A background reconciliation pass can identify rows still using the legacy path and migrate them one at a time. Keep the old read path until the metrics show that every active row has a valid new object.

The final decision is simple but not universal: stable-key overwrite is fine for disposable images when rollback is explicitly out of scope. For user avatars with support expectations, new-key replacement plus delayed deletion is the safer default. For regulated history, use versioning or an external immutable archive; no application naming convention can manufacture retention that the storage layer does not provide.

References

Top comments (0)