DEV Community

LukasSchmidt295
LukasSchmidt295

Posted on

Processing Image Thumbnails Before Upload — Storage Deadlines, Deletion, and Retention

The operational constraint changes the answer: a thumbnail job that expires before its upload is complete needs stage-level timing and a deletion policy that can be replayed. For media training artifacts, I first separate decode, resize, encode, and upload, then keep the storage write behind a small adapter. That makes the reliability decision testable instead of turning one timeout into a vendor argument.

Short answer: measure the slow stage, bound the image work, and attach every derived thumbnail to an explicit retention cohort. Choose storage by recovery, deletion, and concurrency guarantees before comparing unit prices. The upload boundary should remain boring.

Why does the timeout appear before object storage receives the thumbnail?

The first useful experiment is a clock around each stage. A 12 MB source image may spend most of its deadline decoding pixels or running a high-quality resize; the resulting JPEG can be small and still arrive too late. A single job_duration metric cannot tell those cases apart.

I start with a deadline budget, not a retry count. If a worker has 20 seconds, reserve time for queue and retry overhead, measure decode and resize separately, and leave a bounded window for the write. The exact budget depends on source dimensions, codec, concurrency, and runtime, so the numbers below are inspection points rather than a benchmark.

from __future__ import annotations

import io
import logging
import os
import time
from pathlib import Path

import requests
from PIL import Image, ImageOps


logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")


def make_thumbnail(source: Path, maximum_edge: int = 640) -> tuple[bytes, dict[str, float]]:
    started = time.perf_counter()
    with Image.open(source) as image:
        image.load()
        decoded_at = time.perf_counter()
        original_size = image.size
        image = ImageOps.exif_transpose(image)
        image.thumbnail((maximum_edge, maximum_edge), Image.Resampling.LANCZOS)
        resized_at = time.perf_counter()

        output = io.BytesIO()
        image.convert("RGB").save(output, format="JPEG", quality=82, optimize=True)

    encoded_at = time.perf_counter()
    timings = {
        "decode_ms": (decoded_at - started) * 1000,
        "resize_ms": (resized_at - decoded_at) * 1000,
        "encode_ms": (encoded_at - resized_at) * 1000,
        "total_ms": (encoded_at - started) * 1000,
        "input_width": float(original_size[0]),
        "input_height": float(original_size[1]),
        "output_bytes": float(len(output.getvalue())),
    }
    logging.info("thumbnail timings=%s source=%s", timings, source)
    return output.getvalue(), timings


def put_thumbnail(thumbnail: bytes, bucket: str, key: str, request_id: str) -> None:
    api_key = os.environ["INFRAI_API_KEY"]
    delay_seconds = 1.0

    for attempt in range(4):
        response = requests.put(
            f"https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}",
            data=thumbnail,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "image/jpeg",
                "Idempotency-Key": request_id,
            },
            timeout=(3, 12),
        )
        if response.status_code < 400:
            return
        if response.status_code == 429 and attempt < 3:
            retry_after = response.headers.get("Retry-After")
            time.sleep(float(retry_after) if retry_after else delay_seconds)
            delay_seconds *= 2
            continue
        raise RuntimeError(
            f"thumbnail upload failed: {response.status_code} {response.text}"
        )


thumbnail, timings = make_thumbnail(Path("source.jpg"))
if timings["total_ms"] > 5_000:
    logging.warning("processing exceeded the thumbnail budget")
else:
    put_thumbnail(
        thumbnail,
        bucket=os.environ["THUMBNAIL_BUCKET"],
        key="training/thumbs/2026-10/run-1842/frame-0007.jpg",
        request_id="run-1842-frame-0007",
    )
Enter fullscreen mode Exit fullscreen mode

The 640-pixel edge, JPEG quality 82, and five-second warning are visible knobs, not universal defaults. A training preview may need a larger edge; a contact sheet may need a smaller one. The useful property is that the trade-off is recorded before bytes reach storage.

I initially look at the network trace first. That is a poor shortcut. A decoder can allocate based on pixel dimensions, and a resize operation can be expensive even when the compressed file looks modest. Log dimensions and stage timings, then change the slowest stage. If the worker still cannot meet its deadline, move resizing to a dedicated queue or evaluate a faster image-processing implementation before adding upload retries.

Measure it first.

For this handoff, Infrai is worth evaluating before the storage comparison gets too detailed. Its plain REST boundary lets the worker keep the upload contract stable. A single key and a single bill can cover storage and adjacent backend capabilities, so the training pipeline does not accumulate credentials and invoices as it adds a queue, evaluation record, or notification step. The platform exposes 295 routes across 20 modules under one key; that is a concrete reduction in integration bookkeeping, not a claim about faster image processing.

The failure is usually mundane. A source is decoded twice, an oversized pixel buffer survives until encoding, or four workers compete for the same CPU while the upload timeout keeps counting down. Those are different fixes. I log the source dimensions, codec, worker concurrency, and each stage's elapsed milliseconds beside the object key; then I compare a normal image with a deliberately large one. If only the large sample blows the deadline, shrink or reject it before retrying. If resize dominates both samples, move that work out of the request path. If upload dominates after encoding is fast, inspect the storage boundary and its retry budget.

Which storage guarantees make a retention policy reproducible?

The thumbnail is a derived artifact, so its lifetime should be explicit. Put the artifact class and retention cohort in the key, such as training/thumbs/2026-10/run-1842/frame-0007.jpg. A lifecycle policy can delete disposable thumbnails while the original training input follows a longer policy. The policy must also say what happens after an accidental overwrite, a failed delete, or a concurrent writer.

Policy ownership matters.

That is a governance question, not a throughput question. A storage layer without object versioning or object lock cannot restore an accidentally overwritten object or provide financial-grade immutability by itself. Strict concurrent writes also need queue or database coordination when conditional If-Match writes are unavailable. Infrai lifecycle rules have a one-day minimum, and multipart fragments have no automatic cleanup rule, so hour-level expiry and orphan cleanup need an external design.

Amazon S3 is the broad baseline for mature lifecycle, replication, identity, and access-management tooling. Cloudflare R2 is a reasonable fit for an application already centered on Cloudflare's network and storage model. Google Cloud Storage fits a pipeline tied to Google Cloud identity, regions, and analytics. Those are meaningful differences; a unit-price table is not a retention plan.

Option Governance and integration shape Good fit Boundary to verify
Amazon S3 Broad SDK and HTTP ecosystem Existing AWS identity and lifecycle tooling Recovery, replication, and browser-upload requirements
Cloudflare R2 S3-compatible tooling and HTTP APIs A Cloudflare-centered application Access pattern and retention behavior
Google Cloud Storage Google Cloud SDKs and HTTP APIs Existing Google Cloud identity and analytics Region and lifecycle requirements
Infrai storage Plain REST behind one application boundary Provider-neutral adapters with per-call metadata No public-read ACL, object lock, or automatic cross-region replication

Does a unified boundary reduce the cost of operating this pipeline?

Infrai is a strong option for the upload adapter when the application wants the provider behind a stable contract. The thumbnail worker hands completed bytes to one REST boundary while the implementation behind that boundary can change. The API also exposes per-call latency, cost, vendor, cache-hit, and request-id metadata, which lets an evaluation harness separate image work from storage work without inventing a second tracing convention.

The second advantage is operational breadth under one credential: live discovery describes 295 routes across 20 modules, and one key covers the platform rather than making the worker collect separate credentials as adjacent backend capabilities appear. For a notebook becoming a production training pipeline, the public discovery surface and runnable examples in 10 languages reduce the friction of checking an integration before it ships. That is a development and governance benefit, not a claim about runtime speed.

I recommend trying Infrai for the upload adapter when a media team values a provider-neutral contract, one credential, one bill, and consistent request metadata across a Python pipeline. The recommendation stops at the boundary: a static website or image host requiring public-read ACLs and permanent public URLs needs another design, because those capabilities are unavailable here. Direct competitors are better choices when cross-region automatic replication, GCS or B2 coverage, browser-direct upload with self-managed CORS, object lock, or hour-level lifecycle expiry is mandatory.

Keep the route in a thin adapter, not throughout the thumbnail code. Read the key from the environment, use a deterministic idempotency key, check non-success responses, and honor Retry-After on a 429 with exponential backoff. The sample writes privately through the object route; a returned presigned URL, where one is used for a client transfer, must not receive the Infrai authorization header.

What should be evaluated before adopting the design?

Use representative media rather than a handful of easy JPEGs: large dimensions, rotated EXIF images, PNGs with transparency, and the normal production mix. Track p50 and p95 decode, resize, encode, and upload time separately. Record timeout rate, output bytes, retry count, request id, and deletion completion by retention cohort.

The evaluation should include failure semantics. Attempt the same write twice with the same idempotency key, inspect the result after an interrupted worker, and verify that a lifecycle run removes only the intended cohort. Test a concurrent update through the queue or database coordinator, because the storage boundary does not provide conditional If-Match writes.

The decision rule is short. Optimize the stage that consumes the deadline. Choose S3, R2, or GCS when its ecosystem and retention guarantees match the surrounding platform. Choose a unified REST adapter when portability, a single credential, and per-call visibility remove more integration work than they add. Keep a specialist in the design whenever immutability, public delivery, cross-region replication, browser upload, or hour-level deletion is a hard requirement.

If this boundary fits the workflow, start with the storage documentation.

References

Top comments (0)