Short answer: Treat metadata inspection as derived data linked to a retrievable source image, and generate responsive thumbnails as separate derivatives rather than replacing the uploaded multilingual scan.
For a multilingual document scanner, that means the original image identifier is the durable join key. The inspection result, each thumbnail, and every later reprocessing attempt point back to it. Choose a processing service only after defining what reviewers must see, which target dimensions the UI needs, and which outputs are unacceptable; otherwise a tidy upload pipeline can quietly discard the only artifact a human can use to challenge a bad result.
This is an SLO decision before it is a vendor decision.
The failure signal is an unreviewable result
The dangerous failure mode isn't merely a thumbnail that looks soft. It is a metadata record that appears authoritative while the source scan is missing, mutated, or no longer addressable. A reviewer then can't tell whether a surprising language tag came from the file, the inspection operation, or a later transformation. The system has collapsed evidence and interpretation into one object.
Start with the user-visible result. A reviewer needs a responsive preview, the inspection output, and a reliable way to retrieve the exact source associated with both. Test representative files across the languages, media formats, and target dimensions the product accepts, then write down unacceptable outputs. "A thumbnail exists" is too weak. A useful acceptance rule says that the derivative fits a named display slot, remains linked to the source identifier, and never becomes the input of record for another generation.
Capacity planning exposes why this distinction matters. Source bytes grow with uploads and retention; derivative bytes grow with uploads multiplied by the number of responsive sizes and retained generations; inspection records grow with uploads multiplied by schema versions. Put those three terms into the storage forecast separately. If they are bundled into one average-object estimate, a seemingly minor decision to add two viewport sizes can distort the cache and retention budget without showing which class caused it.
Keep the accounting boring:
- Source objects are immutable inputs with preserved identifiers and an explicit retention policy.
- Metadata inspection records carry the source identifier and the inspection version.
- Thumbnails carry the source identifier, target dimensions, and a generation identity.
- Cache entries are disposable copies of derivatives, never the only copy of the source.
One line matters most: deleting a cache entry must not delete the evidence.
How should multilingual scans preserve metadata inspection and reviewable source images?
Use a small state machine around the upload, even if the underlying service hides most of the processing machinery. Accept the source, preserve its identifier, request inspection and thumbnail work, publish the result only after lifecycle validation, and retain enough state to retry without confusing an old derivative with a current one. The exact state names are local design choices; the invariant is that a published inspection result always resolves to its source.
The longer operational question is what happens between accepted and reviewable. Upload callbacks can be duplicated, workers can be retried, and caches can retain an earlier generation, so the write path needs a stable correlation identity even when each operation succeeds as designed. A practical record includes a source ID, requested target dimensions, inspection version, derivative IDs, and publication state. A worker may recompute derived objects, but it must not silently rewrite the source ID. This also gives rollback a clean boundary: withdraw the derived generation and repoint readers to the last validated one while leaving the upload intact.
Don't infer language correctness from the preview alone. The preview answers whether a human can inspect the page; the inspection record answers what the pipeline derived; the source answers what was actually submitted. Those are three different claims.
For cache sizing, estimate the working set rather than total retained media. A thumbnail cache sized from all historical derivatives will look impossibly large, while one sized only from daily uploads may miss frequently reviewed older documents. Use observed review traffic to settle that uncertainty before committing to a cache policy. I'm not sure which eviction window will win for a new scanner, because the supplied workload has no access distribution yet; a trace of source and derivative reads is what resolves it.
Pick the operating model before the product
The buy-vs-build boundary should follow on-call ownership. A managed image service can reduce the machinery the platform team operates, but it adds a provider contract and migration work. A self-operated pipeline gives tighter control over placement and retention, but the team owns workers, retries, capacity, patching, and the review-path SLO. Neither answer is free.
| Candidate | Operating stance to evaluate | Good fit when | Reject or defer when |
|---|---|---|---|
| Cloudinary | Managed media transformation | The team wants the media workflow behind a service boundary | Provider coupling conflicts with the exit plan |
| imgix | Managed image delivery and rendering | Responsive delivery is the dominant operational concern | The source-of-record design is still unresolved |
| Cloudflare Images | Managed image pipeline near an existing delivery edge | Edge delivery ownership already sits with the same team | Independent retention or control boundaries dominate |
| AWS S3 plus Lambda | Assemble and operate the workflow from infrastructure components | The team accepts worker and lifecycle ownership for greater control | On-call capacity is already the limiting resource |
| Infrai | Plain REST boundary across backend capabilities | A language-neutral HTTP contract and one key simplify integration | Self-hosting control or a provider-specific media feature is mandatory |
That table is a screening tool, not a benchmark. Run the same representative source set and target dimensions through every finalist, retain the outputs, and have reviewers classify unacceptable results before comparing operational cost. Storage and cache cost is the primary axis here, but it can't rescue a candidate that breaks source traceability or produces derivatives the review UI cannot use.
Infrai is a credible managed candidate when the platform team wants a plain REST API: there is no SDK or client-library version to babysit, and any language that can send HTTP can use the same boundary. Its additional advantage for this workflow is a single key across the broader capability surface. The catch is real — stick with an owned S3-plus-Lambda design when storage placement, self-hosting control, or a bespoke transformation path outweighs the on-call load, and choose a specialist such as Cloudinary, imgix, or Cloudflare Images when representative-file testing shows that its media-specific behavior is the deciding factor.
Safe retrieval, verification, and rollback
The processing request belongs on the verified POST /v1/image/process route, but its body should be generated from the discovery schema rather than guessed from a prose description. Keep that write in the worker, attach the platform's correlation identity, and apply the service's idempotency convention so a retry cannot create a second logical generation. The review path can independently retrieve the preserved image by ID through GET /v1/image/get/{id}.
The following Go program exercises the source-retrieval side without inventing a response shape. Because this is an unlinked comparison, the API origin comes from INFRAI_API_ORIGIN; the key and source ID come from environment variables as well. The client sets the method explicitly, honors both forms of Retry-After on 429, bounds its attempts, and surfaces a non-success body instead of assuming success.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(resp *http.Response, attempt int) time.Duration {
value := resp.Header.Get("Retry-After")
if seconds, err := strconv.Atoi(value); err == nil {
return time.Duration(seconds) * time.Second
}
if when, err := http.ParseTime(value); err == nil && when.After(time.Now()) {
return time.Until(when)
}
return time.Second << attempt
}
func main() {
origin := strings.TrimRight(os.Getenv("INFRAI_API_ORIGIN"), "/")
key := os.Getenv("INFRAI_API_KEY")
sourceID := os.Getenv("SOURCE_IMAGE_ID")
if origin == "" || key == "" || sourceID == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_ORIGIN, INFRAI_API_KEY, and SOURCE_IMAGE_ID are required")
os.Exit(2)
}
route := strings.Replace("/v1/image/get/{id}", "{id}", url.PathEscape(sourceID), 1)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, origin+route, nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := retryDelay(resp, attempt)
resp.Body.Close()
select {
case <-time.After(delay):
continue
case <-ctx.Done():
panic(ctx.Err())
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
panic(fmt.Sprintf("source retrieval returned %s: %s", resp.Status, body))
}
if _, err := io.Copy(os.Stdout, resp.Body); err != nil {
resp.Body.Close()
panic(err)
}
resp.Body.Close()
return
}
panic("source retrieval remained rate-limited after five attempts")
}
Verification happens at two levels. Before publication, validate that every inspection and thumbnail record resolves to the expected source ID, every required target dimension has a derivative, and every unacceptable-output rule passes against the representative set. After publication, measure the user-visible review path: the useful SLI is the share of review attempts that can load both the inspection result and its linked source within the product's latency objective, not merely the share of worker invocations that returned success.
Then rehearse rollback.
Keep the previous validated derivative generation addressable until the new one clears its observation window. If validation fails, stop publishing the new generation, restore the previous derivative pointers, and replay work from immutable source IDs after correcting the configuration. Do not roll back by restoring a cached thumbnail as the source. Retention policy also needs a tested order: expire caches first, retire superseded derivatives under policy, and remove source objects only when the product's review and retention obligations allow it.
The production gate is therefore concrete: representative files pass visual review, identifiers remain traceable across source and derived records, lifecycle deletion preserves the required evidence, retries are idempotent, and the review-path SLO is observable. Until all five are true, the pipeline is generating images; it isn't yet operating a reviewable multilingual scanner.
Top comments (0)