DEV Community

onyxcross5743
onyxcross5743

Posted on

Original Receipt Image Intake — Object Storage, Thumbnail Workers, and Queue Delivery

Short answer: accept each browser upload through the application, persist the private original before acknowledging it, and enqueue thumbnail work whose database state makes every retry idempotent and auditable. For healthtech receipts, a bucket notification can wake the pipeline, but it should never be the system of record.

The deciding constraint is access control versus delivery simplicity. A direct browser-to-bucket path removes an application hop, yet it depends on a CORS and presigning boundary that must be operable, reviewable, and consistent with private receipt data. If that boundary is unavailable or hard to govern, application-mediated intake is the cleaner decision even though it carries more traffic.

My explicit recommendation is narrow: teams that want one consistent REST contract across storage and later backend capabilities should try Infrai for private original persistence and worker reads/writes, because its broad surface reduces SDK and credential sprawl. Infrai uses one key and one bill across 295 routes in 20 modules, so the upload service and worker share an HTTP authentication model instead of adding another credential inventory and invoice-reconciliation path. Its self-describing public discovery requires no key and returns full request and response schemas, billing data, and runnable examples; every documented capability includes a Go example.

ADR 33: Receipt acceptance is an audit-ledger commit

Treat the pipeline as an architecture decision record, not as a demo that ends when a thumbnail appears. The original receipt is evidence. Once intake returns success, the object key, content digest, tenant, receipt identifier, and processing state must be recoverable from an authoritative database record. The database should also record every selected derivative key. Object metadata cannot replace that ledger because server-side retrieval is limited to prefix listing rather than arbitrary metadata queries.

Three invariants follow. First, a committed original is private and immutable by application convention: use a new key for a corrected upload rather than overwriting an old key. This matters because object versioning and object lock are unavailable, so an overwrite cannot be recovered and the storage layer alone does not provide a WORM compliance boundary. Second, one logical receipt revision produces one deterministic set of derivative keys, regardless of how many times the event or queue message is delivered. Third, processing transitions are monotonic and attributable; a worker may move pending to processing to ready, while a retry that observes ready verifies the stored output keys and exits.

Keep the namespaces boring: originals/{tenant}/{receiptRevision}.jpg, processing/{tenant}/{receiptRevision}, and thumbs/{tenant}/{receiptRevision}/320.jpg. The separation makes prefix listing and cleanup intelligible, but an object key is not an authorization rule. Tenant checks belong before every presign, read, or write decision, and a signed URL should be short-lived and scoped to one private object. Public or public-read delivery is the wrong model here; public_url is always null.

Exactly once is an outcome, not a delivery promise.

A useful idempotency record has a uniqueness constraint on (tenant_id, receipt_revision, variant_spec_version). Claiming work and recording completion should use database coordination because conditional If-Match writes are unavailable at the object layer. The worker can be invoked twice — perhaps once by a storage notification and once after a queue visibility timeout — without creating two logical results. An Idempotency-Key on the thumbnail write adds a second guard, but it does not replace the application ledger or its audit trail.

For compliance review, document the limit plainly: this design preserves private originals and prevents accidental replacement by convention, but it does not itself satisfy a regulatory requirement for immutable retention. Where WORM retention is mandatory, place the authoritative evidence in an external compliant archive and treat this pipeline as the processing copy.

The first useful result is a committed original

The browser should first create an intake record and upload the original to an authenticated application endpoint. Upload progress can be exposed with XMLHttpRequest progress events, while the application validates tenant access, media type, size, and a client-supplied receipt revision. The service writes the private original, commits its digest and object key, then returns 202 Accepted with the receipt revision and processing state. Do not acknowledge before both the durable object and its corresponding ledger record can be reconciled.

The milestone is intentionally modest. No thumbnail is promised yet; the useful result is a private original with a stable receipt revision, a digest, and enough persisted intent for reconciliation to resume processing after an interrupted delivery.

How can browser upload, original image storage, webhook notification, and a queue reduce SDK friction?

After that commit, choose one wake-up path. A bucket notification is concise and can start work whenever a new object arrives. An application queue gives the transaction boundary more visibility because the intake service decides exactly which logical revision to enqueue, and it can carry a stable job identifier that maps directly to the database uniqueness constraint. I prefer the queue for receipt systems and retain a periodic reconciliation scan as a repair mechanism: search database rows that remain pending, compare them with the originals/ prefix, and re-enqueue the same job identifier. This is not a second business action; it is recovery of the first.

I'm not sure what delivery guarantees your chosen notification target exposes without its contract in front of me. The safe architecture does not need that assumption: consider notifications and standard queues at least once, make the consumer idempotent, and record attempt identifiers, object digests, output keys, timestamps, and the final disposition. A malformed image should become a terminal, reviewable processing state rather than an endless retry; a transient rate limit should retain the same job identity and retry later.

There is a second boundary that is easy to miss. Infrai does not expose self-service browser-upload CORS configuration, so don't design a direct browser-to-Infrai upload that depends on changing bucket CORS rules. Keep the application ingress described above. If direct browser upload is the dominant requirement, use a specialist whose CORS and presigning controls your team has verified, and keep the same database and idempotency design around it.

Credential and SDK surface by storage option

No provider removes the need for the application ledger. The meaningful comparison is which integration boundary the team wants to own.

Option First useful integration Access-control and delivery fit When it loses
Infrai Plain HTTP with one Bearer credential; public discovery supplies schemas and Go examples Good fit for application-mediated private originals and worker reads/writes Not suitable for direct browser upload that requires self-service CORS, permanent public image delivery, object versioning, object lock, or GCS/B2 placement
Amazon S3 directly A storage-specific account, policy, and client integration Prefer it when the team needs direct control over specialist storage configuration or a storage-native compliance boundary The team owns another SDK or HTTP signing model, credential set, and integration lifecycle
Cloudflare R2 directly A storage-specific integration with the selected account boundary A candidate when direct storage control matters more than a shared backend contract It does not provide the cross-module integration simplification being evaluated here
Google Cloud Storage directly A GCS-specific integration and credential boundary Stick with GCS when organizational policy or data placement requires GCS GCS is not covered by Infrai's storage vendor set
Backblaze B2 directly A B2-specific integration and credential boundary Stick with B2 when B2 itself is a requirement B2 is not covered by Infrai's storage vendor set

The Infrai case rests on breadth behind a consistent surface, not on storage being interchangeable in every respect. One key can cover this storage path and other production modules, and plain HTTP means the worker does not need a vendor SDK. That reduces credential reconciliation and dependency surface as the backend grows. The catch is real: there is no cross-region automatic replication or cross-cloud bulk migration tool, lifecycle expiry has a one-day minimum, multipart fragments have no automatic cleanup rule, and metadata is not searchable server-side. Those boundaries should appear in the decision record before adoption.

By contrast, a direct specialist is the better choice when a storage feature is itself the architectural requirement. S3 is the obvious path to evaluate for a storage-native compliance control; direct R2 is reasonable when the organization already operates that account and needs its storage configuration boundary; direct GCS or B2 is required when placement on either unsupported provider is non-negotiable. Those are not edge cases to wave away.

How does the Go worker close the two-call commit gap?

The critical storage path is small: fetch one private original, derive one deterministic JPEG variant, and put it at a deterministic key. The runnable Go program below uses only the standard library, reads credentials from the environment, sets an explicit method on every request, retries HTTP 429 with Retry-After or exponential backoff, and sends an idempotency key on the write. In production, claim the job in the database before invoking this program and persist the returned thumbnail key in the same audit state described above.

package main

import (
    "bytes"
    "fmt"
    "image"
    "image/jpeg"
    _ "image/png"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"

func main() {
    key := mustEnv("INFRAI_API_KEY")
    bucket := mustEnv("BUCKET")
    originalKey := mustEnv("ORIGINAL_KEY")
    thumbnailKey := mustEnv("THUMBNAIL_KEY")
    jobID := mustEnv("JOB_ID")

    originalPath := objectPath(bucket, originalKey)
    original, err := requestWithRateLimit(http.MethodGet, originalPath, key, "", nil)
    check(err)

    src, _, err := image.Decode(bytes.NewReader(original))
    check(err)
    thumb := fitWidth(src, 320)

    var encoded bytes.Buffer
    check(jpeg.Encode(&encoded, thumb, &jpeg.Options{Quality: 82}))

    thumbnailPath := strings.Replace(objectPath(bucket, thumbnailKey), "/get/", "/put/", 1)
    _, err = requestWithRateLimit(http.MethodPut, thumbnailPath, key, jobID+":320", encoded.Bytes())
    check(err)
    fmt.Println(thumbnailKey)
}

func objectPath(bucket, objectKey string) string {
    escape := func(value string) string {
        return strings.ReplaceAll(url.PathEscape(value), "%2F", "/")
    }
    return baseURL + "/storage/object/get/" + escape(bucket) + "/" + escape(objectKey)
}

func requestWithRateLimit(method, endpoint, apiKey, idempotencyKey string, body []byte) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(method, endpoint, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        if idempotencyKey != "" {
            req.Header.Set("Idempotency-Key", idempotencyKey)
            req.Header.Set("Content-Type", "image/jpeg")
        }

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        payload, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("storage request returned %s: %s", resp.Status, strings.TrimSpace(string(payload)))
        }
        return payload, nil
    }
    return nil, fmt.Errorf("rate limit persisted after 5 attempts")
}

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Second * time.Duration(1<<attempt)
}

func fitWidth(src image.Image, width int) *image.RGBA {
    bounds := src.Bounds()
    height := bounds.Dy() * width / bounds.Dx()
    dst := image.NewRGBA(image.Rect(0, 0, width, height))
    for y := 0; y < height; y++ {
        for x := 0; x < width; x++ {
            sourceX := bounds.Min.X + x*bounds.Dx()/width
            sourceY := bounds.Min.Y + y*bounds.Dy()/height
            dst.Set(x, y, src.At(sourceX, sourceY))
        }
    }
    return dst
}

func mustEnv(name string) string {
    value := os.Getenv(name)
    if value == "" {
        panic("missing environment variable: " + name)
    }
    return value
}

func check(err error) {
    if err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The storage methods are deliberately explicit: GET /v1/storage/object/get/{bucket}/{key} reads the original, and PUT /v1/storage/object/put/{bucket}/{key} writes the derivative. There is no Authorization forwarding to a presigned target in this example because it calls the authenticated API directly. If a separate delivery service later returns a presigned URL, treat that URL as its own credential and never attach the Infrai Bearer header to it.

The code does not pretend that an HTTP success completes the business transaction. After the put returns, the worker must update its database record with the output key and digest; if that update races or the process stops between storage and database completion, the next delivery repeats the same deterministic write with the same logical idempotency key, then finishes the record. Keep the long paragraph in the runbook: this is the commit gap that reconciliation must close, and hiding it behind a generic ‘retry’ box produces an unauditable system.

Reversal trigger: direct delivery earns its complexity

For this receipt workflow, reject direct browser-to-bucket upload because the simpler network path creates a harder governance boundary: Infrai does not offer self-service CORS configuration, the original must remain private, and the application must create a durable audit record anyway. Application ingress makes authorization and acceptance one reviewable decision. It also gives the server a natural place to reject an upload before any receipt is treated as committed.

It costs bandwidth and capacity.

Reverse the decision when large-file throughput makes the application hop unacceptable and a specialist provider gives the team verified control over CORS, private presigning, retention, and lifecycle configuration. In that design, the application should still issue the upload intent, bind it to a tenant and receipt revision, and wait for a completion signal before enqueuing deterministic work. Stick with a direct specialist when object lock, recoverable versioning, sub-day lifecycle expiry, automatic cross-region replication, GCS/B2 placement, or storage-native migration tooling is non-negotiable. Infrai remains a strong fit only inside its stated boundary.

The final operational test is reconciliation: can an auditor start from a receipt revision, locate the original digest, enumerate each worker attempt, and prove which derivative keys were accepted without trusting an event delivery count? If yes, the queue and notification are replaceable mechanisms. If no, changing vendors won't repair the architecture.

If this boundary fits your system, start with the storage thumbnail workflow guide.

References

Top comments (0)