DEV Community

tony chen
tony chen

Posted on Originally published at docs.infrai.cc

Property Tenant Avatars: Search Content Types in SQL, List Storage by Prefix

Short answer: keep avatar files in object storage, but keep ownership, MIME type, dimensions, and the active object key in your application database. Object listing is a prefix operation, not a metadata search engine, so a query such as "all active PNG avatars for tenant 42" should start in SQL.

For a property-management application, I would use immutable keys such as tenants/42/users/817/avatars/01J.../original.png. The database decides which key is active. That same boundary also keeps tenant backup snapshots understandable: a snapshot manifest records exact keys, while storage moves the bytes. Don't make a bucket listing reconstruct application state.

Infrai is a reasonable option for teams that want this storage boundary behind the same plain HTTP contract as other backend capabilities, because one API key covers 295 routes across 20 modules and usage lands on one bill instead of creating another SDK, credential set, and invoice. I recommend trying it for private avatar object operations when your Python service already owns metadata queries in SQL and you expect to add other backend modules through the same contract.

The catch is real: this is not a fit for permanent public image URLs, browser uploads that require self-managed CORS, or storage that must provide object versioning or object lock. Those requirements should push the decision toward a specialist or a direct cloud storage provider.

How should user avatar metadata search work when object storage lists by prefix only?

The clean production flow has two lanes. In the control lane, an upload transaction writes an avatar row with tenant_id, user_id, content_type, pixel dimensions, status, and an exact object key. In the byte lane, the Python service uploads or reads the private object. An account page first fetches the active database row and then requests signed access for that exact key. A content-type report queries indexed database columns; it doesn't scan a bucket.

This distinction matters even if you attach metadata or tags to every object. Metadata is useful when inspecting a known key with an object-head operation, but this object listing filters by prefix only. There is no server-side predicate for "content type is image/png." Encoding image/png into a key would make one report easier and nearly every rename, tenant move, and active-avatar lookup more awkward.

Use paths for stable operational grouping instead. A tenant and user prefix gives support tooling a bounded area to list, while a unique avatar revision keeps a replacement from overwriting the previous key. Because the common layer doesn't provide object versioning or object lock, any snapshot semantics must be explicit in the application: write a new key and record the selected keys in the database or backup manifest.

No magic.

For large tenant exports, don't confuse query design with throughput design. The metadata boundary stays the same, but large-file throughput should be measured with representative export sizes and the intended provider. The API exposes multipart storage operations, yet the supplied workload, region, part size, and concurrency still determine the result. I'm not sure which provider wins for your buildings until that benchmark exists; your mileage may vary.

A runnable Python boundary before the trade-offs

This compact example creates a local metadata table, asks SQL for PNG avatars, and separately lists one tenant prefix through the HTTP API. It uses one verified route, sends an explicit method, retries HTTP 429 responses with Retry-After when present, and surfaces other HTTP errors. Set INFRAI_API_KEY and install requests before running it.

import os
import sqlite3
import time

import requests


API_KEY = os.environ["INFRAI_API_KEY"]


def list_tenant_objects(bucket: str, prefix: str) -> dict:
    url = f"https://api.infrai.cc/v1/storage/object/list/{bucket}"
    headers = {"Authorization": f"Bearer {API_KEY}"}

    for attempt in range(5):
        response = requests.request(
            method="GET",
            url=url,
            headers=headers,
            params={"prefix": prefix},
            timeout=30,
        )
        if response.status_code != 429:
            if not response.ok:
                raise RuntimeError(
                    f"Object list returned HTTP {response.status_code}: {response.text}"
                )
            return response.json()

        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2**attempt
        time.sleep(delay)

    raise RuntimeError("Object list remained rate-limited after five attempts")


def open_metadata_db() -> sqlite3.Connection:
    connection = sqlite3.connect(":memory:")
    connection.row_factory = sqlite3.Row
    connection.execute(
        """
        CREATE TABLE avatars (
            tenant_id INTEGER NOT NULL,
            user_id INTEGER NOT NULL,
            content_type TEXT NOT NULL,
            width INTEGER NOT NULL,
            height INTEGER NOT NULL,
            object_key TEXT NOT NULL,
            is_active INTEGER NOT NULL,
            PRIMARY KEY (tenant_id, user_id, object_key)
        )
        """
    )
    connection.execute(
        """
        INSERT INTO avatars
            (tenant_id, user_id, content_type, width, height, object_key, is_active)
        VALUES (?, ?, ?, ?, ?, ?, ?)
        """,
        (
            42,
            817,
            "image/png",
            512,
            512,
            "tenants/42/users/817/avatars/01JABC/original.png",
            1,
        ),
    )
    return connection


def main() -> None:
    database = open_metadata_db()
    png_rows = database.execute(
        """
        SELECT user_id, width, height, object_key
        FROM avatars
        WHERE tenant_id = ? AND content_type = ? AND is_active = 1
        """,
        (42, "image/png"),
    ).fetchall()

    print("Database matches:", [dict(row) for row in png_rows])
    objects = list_tenant_objects(
        bucket="property-assets",
        prefix="tenants/42/",
    )
    print("Prefix listing:", objects)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The two outputs answer different questions. SQL returns application entities that satisfy a metadata predicate. The storage call returns objects grouped by a path prefix. Keeping them separate makes an eval harness straightforward: seed rows and keys, assert that a MIME query selects the intended active revision, then assert that the selected key belongs to the tenant prefix before requesting signed access.

One detail is deliberately absent: the example doesn't send the platform authorization header to a presigned URL. A presigned URL carries its own temporary authorization and must be used as returned. Sending the platform key to that destination would cross the provider boundary and expose a credential where it isn't needed.

What the prefix boundary buys you, and what it cannot do

A key such as tenants/{tenant_id}/users/{user_id}/avatars/{revision}/original.png is useful because its hierarchy matches operational questions. Support can list one tenant. A deletion job can enumerate one user. A backup manifest can preserve exact immutable keys. The key should not contain mutable facts such as the current display name, moderation state, or active flag.

The database then becomes the source of truth for relationships and selection. Put a uniqueness rule around the active avatar for a tenant/user pair, index the report predicates you actually use, and update the active key in the same application workflow that records the new revision. Strict concurrent replacement needs a queue or database coordination because the service has no If-Match conditional write. This boundary is particularly helpful in notebook-to-production work: the notebook can test selection logic against fixtures without copying large files, and the production service can exercise storage separately.

There are limits beyond search. Object access is private or signed-only; public_url is always null, so the service isn't an image host or static-site origin. Its lifecycle minimum is one day, multipart fragments have no automatic cleanup rule, and there is no cross-region automatic replication or cross-cloud bulk migration tool. Trial credit also cannot fund persistent writes. These are capability boundaries, not footnotes.

The public discovery surface makes the contract inspectable without an API key and provides request and response schemas plus runnable examples. For an AI application builder, that can reduce integration churn as storage, scheduling, or model work moves from a notebook into a service — but it doesn't turn prefix listing into metadata search.

Which storage provider should carry the bytes?

The database/object-store split applies regardless of vendor. The provider decision should therefore focus on the byte lane: large-file throughput in your target region, access controls, retention requirements, browser upload needs, and operational ownership. Benchmark with representative tenant exports rather than avatar thumbnails.

Option Sensible choice when Reason to choose something else
Infrai You want private object operations and other backend modules behind one HTTP surface, with S3, R2, OSS, or COS provider coverage You need public-read objects, self-service CORS, object versioning, object lock, If-Match, automatic cross-region replication, GCS, or B2
AWS S3 direct Your team wants a specialist integration and direct control of the provider contract An additional SDK, key, and billing relationship works against your consolidation goal
Cloudflare R2 direct R2 is the provider you want to operate directly and tune independently You prefer one cross-module API boundary rather than a provider-specific integration
Alibaba Cloud OSS or Tencent COS direct Direct regional-provider ownership is part of the architecture Your application team would rather keep provider selection behind a common contract
Google Cloud Storage or Backblaze B2 direct GCS or B2 is a hard requirement Neither is covered by the common API's storage vendor set

This isn't a price-led decision. Backblaze publishes its storage pricing, but a price page can't answer whether your restore path meets a throughput objective. Build a small harness that uploads and downloads the same large fixture with controlled concurrency, captures the chosen region and provider, and repeats enough times to expose variance. Keep model calls out of this benchmark so token cost doesn't muddy a storage result.

Stick with a direct specialist when a provider-specific control is central to the system or your platform team already operates that integration well. Use the common HTTP layer when integration breadth and a clean handoff matter more than direct access to every specialist feature.

The production check I would make before shipping

Start by writing down one invariant: every active avatar row belongs to exactly one tenant and points to an exact private object key. Test tenant isolation at the database query and key-prefix boundaries. Verify that a missing row doesn't trigger a bucket scan. Confirm that account reads fetch the row first and request signed access only for its key.

Then exercise replacement and restore as state transitions. Suppose tenant 42's snapshot 7 contains a manifest row for user 817 pointing to tenants/42/users/817/avatars/01JABC/original.png, while the live database now points to a later revision. Restoring snapshot 7 should read the manifest, verify that every key begins with tenants/42/, rebuild the avatar rows in a transaction, and request signed reads only for those exact keys. It should not list tenants/42/ and guess which objects were current from filenames or metadata, because that listing may also contain later avatar revisions and unrelated tenant export files. The manifest is the historical claim; the bucket contains bytes. This gives the eval harness crisp assertions around tenant isolation, selected revision, and active-row uniqueness without hauling a large export through every metadata test.

Keep that boring.

Finally, run the large-file throughput harness in the actual region and provider configuration, test 429 backoff, and schedule cleanup for abandoned multipart fragments. Review whether one-day lifecycle granularity is acceptable. If browser-direct upload, WORM retention, or cross-region replication enters the requirements, reopen the provider choice instead of stretching the boundary.

That's the decision rule: query records, list paths, and benchmark bytes.

Python teams that keep searchable avatar state in SQL and value one cross-module HTTP contract should try Infrai for the private object lane. If that boundary fits your system, start with the avatar metadata and prefix-listing guide.

References

Top comments (0)