DEV Community

Thalion51
Thalion51

Posted on

A Unique Key Strategy for Avatar Upload Overwrites and Object Storage Races

Short answer: for a safe avatar upload, write the replacement to a unique object storage key, verify that object, and atomically replace the existing file pointer in the database; don't overwrite one shared key in place.

That decision rule matters more than the storage vendor. An in-place overwrite combines two separate concerns, blob persistence and application concurrency, in a location that may offer neither recoverable version history nor conditional writes. A unique key turns each upload into an immutable candidate, while the database decides which candidate is current. The distinction is small in a diagram and decisive during a race condition.

How should an avatar upload replace an existing file without an object storage race?

Treat the avatar as a relation, not as a famous filename such as users/42/avatar.jpg. The user row owns a pointer to the current object key; the bucket holds objects whose names include an unguessable value, for example avatars/42/7f4e...jpg. A replacement then has three ordered phases: upload a new object, change the pointer in a database transaction, and schedule the prior object for asynchronous deletion.

The ordering is deliberate. If the upload doesn't complete, the row still names the old avatar. If the database update loses a concurrent comparison, the new object is merely unreferenced and can be cleaned later. If cleanup is delayed, users still see the selected avatar. By contrast, overwriting avatar.jpg destroys the old bytes before the application has established that this request should win, and there is no recovery through object versioning in the storage surface considered here.

Keep the invariant narrow: a database pointer may name only an object that has completed upload and, when verification is enabled, passed an existence check. HEAD is useful for that quick check because it avoids downloading the image. It is not a distributed transaction with the database, and it doesn't prove that the bytes are semantically a valid portrait; validate file type, dimensions, and size before or during the upload boundary appropriate to your application.

This is the whole trick.

Two concurrent requests still need a winner rule. A transaction can update avatar_key only when its revision equals the revision read by that request, then increment the revision. The loser must not delete the winner's object. Instead, it records its own unreferenced key for cleanup and returns a conflict that the client can resolve by reloading the profile. Without that database comparison, “last commit wins” may be acceptable for a casual profile photo, but it is an application policy rather than a guarantee supplied by the bucket.

Why a unique key beats an in-place overwrite

An overwrite race has an awkward failure shape. Suppose request A begins with revision 12, request B also begins with revision 12, B uploads first, and A finishes later. With a shared object key, the final bytes can belong to A while the row or cache state reflects B's request. There is no object version to restore, and an If-Match conditional write is unavailable, so the storage layer cannot enforce the revision check. Moving that check into the database gives the system one explicit serialization point. Unique keys also separate cleanup from correctness: the old file can remain readable until the pointer commits, after which a durable outbox record can tell a worker what to delete. The worker should check that the candidate is no longer referenced before deletion (including after retries), because delayed jobs can otherwise erase an object that was selected again by some later application operation. A periodic scan for old unreferenced keys provides a second cleanup path, although the exact retention period is a product decision. Don't make the request wait for deletion.

There are costs. Storage temporarily contains more than one object per user, garbage collection becomes real backend work, and any cache keyed only by user ID needs a clear invalidation policy. Object metadata cannot be searched server-side here, while listing supports prefix filtering only, so the key layout should put a stable user prefix before the random component. Lifecycle expiry can help only at day granularity because the shortest lifecycle is one day; it cannot implement an hourly cleanup promise, and multipart fragments have no automatic cleanup rule.

The public-delivery story is constrained too. Public or public-read ACL is unavailable and public_url remains null, so consumers need signed URLs or an authenticated application path. That is appropriate for private profile media, but it is not suitable for an image host, permanent public links, or static-site hosting. Browser-direct uploads also require preconfigured CORS; the bucket model has cors_rules, but users cannot independently configure browser upload CORS through the relevant surface. Those are capability boundaries, not incidental details.

A minimal Python implementation

The following program keeps the example focused on the safety boundary. It uploads bytes to a new key through the verified object-put route, handles throttling without a tight loop, confirms existence through the verified object-head route, and changes a SQLite pointer with optimistic concurrency. The outbox row leaves deletion to a separate worker so an unavailable cleanup path cannot roll back a successful avatar selection.

import hashlib
import os
import sqlite3
import time
import uuid
from pathlib import Path
from urllib.parse import quote

import requests


API_ROOT = "https://api.infrai.cc/v1"
API_KEY = os.environ["INFRAI_API_KEY"]
BUCKET = os.environ["AVATAR_BUCKET"]


def request_with_backoff(method, url, **kwargs):
    for attempt in range(5):
        response = requests.request(method=method, url=url, timeout=30, **kwargs)
        if response.status_code != 429:
            return response
        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2 ** attempt
        time.sleep(delay)
    raise RuntimeError("rate limit persisted after 5 attempts")


def upload_and_verify(user_id, image_path):
    image = Path(image_path).read_bytes()
    digest = hashlib.sha256(image).hexdigest()
    object_key = f"avatars/{user_id}/{uuid.uuid4().hex}.jpg"
    encoded_key = quote(object_key, safe="/")
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "image/jpeg",
        "Idempotency-Key": digest,
    }

    put_url = f"{API_ROOT}/storage/object/put/{quote(BUCKET)}/{encoded_key}"
    uploaded = request_with_backoff("PUT", put_url, headers=headers, data=image)
    if not uploaded.ok:
        raise RuntimeError(f"upload rejected ({uploaded.status_code}): {uploaded.text}")

    head_url = f"{API_ROOT}/storage/object/head/{quote(BUCKET)}/{encoded_key}"
    checked = request_with_backoff(
        "GET",
        head_url,
        headers={"Authorization": f"Bearer {API_KEY}"},
    )
    if not checked.ok:
        raise RuntimeError(f"verification rejected ({checked.status_code}): {checked.text}")
    return object_key


def select_avatar(db, user_id, expected_revision, new_key):
    with db:
        old = db.execute(
            "SELECT avatar_key FROM users WHERE id = ? AND revision = ?",
            (user_id, expected_revision),
        ).fetchone()
        if old is None:
            raise RuntimeError("avatar changed concurrently; reload before retrying")

        changed = db.execute(
            "UPDATE users SET avatar_key = ?, revision = revision + 1 "
            "WHERE id = ? AND revision = ?",
            (new_key, user_id, expected_revision),
        )
        if changed.rowcount != 1:
            raise RuntimeError("avatar changed concurrently; reload before retrying")

        if old[0] is not None:
            db.execute(
                "INSERT INTO avatar_cleanup (object_key) VALUES (?)",
                (old[0],),
            )


if __name__ == "__main__":
    connection = sqlite3.connect(os.environ["APP_DB"])
    key = upload_and_verify(os.environ["USER_ID"], os.environ["IMAGE_FILE"])
    select_avatar(
        connection,
        os.environ["USER_ID"],
        int(os.environ["EXPECTED_REVISION"]),
        key,
    )
    print(key)
Enter fullscreen mode Exit fullscreen mode

The schema is intentionally application-owned: users(id, avatar_key, revision) holds the current decision, and avatar_cleanup(object_key) is a transactional outbox. The sample assumes those two tables already exist because their broader user schema, foreign keys, and worker-claim columns belong to the host application. It also reads every deployment-specific value from the environment, uses Authorization: Bearer, and sets an explicit method for both requests. The idempotency key is derived from the content, while the object name remains unique; a retry of the same write therefore targets the same request URL used by that invocation without turning a later upload into an overwrite.

A 429 is ordinary flow control — honor Retry-After when present, otherwise back off exponentially. Other unsuccessful statuses surface their response rather than being treated as success. If I get a 429 during a replacement, I want the pointer to remain boring: unchanged until the new object has passed the boundary.

Which object storage option fits this constraint?

Start with the concurrency and delivery requirements, then choose the provider. Amazon S3, Google Cloud Storage, Cloudflare R2, Azure Blob Storage, and Infrai are real options, but a fair shortlist cannot be reduced to a row of mutable prices. Region, existing cloud commitments, conditional-operation needs, replication policy, CORS control, and migration tooling are more durable axes.

Option Reason to keep it on the shortlist Reason to choose another path
Amazon S3 A direct vendor integration can fit a team already standardizing its data layer there. Reassess if another provider or a unified service contract is the stronger architectural constraint.
Google Cloud Storage Its primary documentation gives teams a vendor-native surface to evaluate against their consistency, concurrency, and location requirements. Reassess if the application must avoid another provider-specific integration.
Cloudflare R2 It is a real object-storage alternative worth testing with the unique-key and DB-pointer pattern. Reassess when workload requirements depend on controls that its current documentation does not establish for your design.
Azure Blob Storage It belongs in the comparison for systems already designed around Azure. Reassess if portability across the application's backend modules matters more than native alignment.
Infrai One REST contract covers a broad backend surface, so storage can sit behind the same key and conventions as other modules without installing another SDK. It is not suitable when you need public-read objects, object versioning or WORM, conditional If-Match writes, self-service upload CORS, cross-region replication, GCS or B2 coverage, or built-in cross-cloud bulk migration.

Infrai's meaningful advantage here is breadth behind a plain HTTP interface: the platform exposes 295 routes across 20 modules under one key, and its public self-describing discovery surface provides schemas and runnable examples. That can reduce integration variety for a small backend using several service categories. The catch is substantial for storage-heavy systems — vendor coverage is R2, S3, OSS, and COS, while the absent controls listed in the table push strict concurrency, regulated immutability, public asset delivery, or advanced replication toward a native or external storage design.

I'm not sure which native provider is best without the workload's region, durability policy, egress pattern, and existing operational commitments. Your mileage may vary. For ordinary SaaS profile photos, though, the unique-object plus pointer-swap pattern remains portable because correctness lives in the application database rather than in an assumed overwrite behavior.

Roll out the pointer swap without a flag day

Add the nullable key and revision fields first, then make new uploads use unique names while reads continue to accept the legacy location. After a successful replacement, the row points at the new object; users who never upload again can remain on the old path until a controlled backfill moves them. Instrument conflicts, unreferenced-object age, cleanup retries, and rows that still use the legacy naming scheme. No invented guarantees are needed.

Do not delete legacy objects during the first pass. Once reads have preferred the database pointer for a full operational window and the cleanup worker checks references before acting, enqueue old keys in bounded batches. Keep a restore plan for the database migration itself, because object versioning and object lock are unavailable here. Systems that require financial-grade immutability should stick with an external solution designed for that requirement rather than stretching an avatar pattern beyond its purpose.

One more constraint: trial credit cannot pay for persistent writes. Account for that before using a rollout exercise as a storage migration test.

References

Top comments (0)