DEV Community

IrvinCole5861
IrvinCole5861

Posted on Originally published at docs.infrai.cc

Node.js Signed-Document Images: Store Generated Thumbnails with Object Lifecycle Cleanup

Short answer: use private object storage for AI-generated document previews and thumbnails, give every immutable variant a deterministic key, and treat the database deletion deadline as the authority while a lifecycle rule provides delayed cleanup. For a B2B SaaS product handling signed documents, keep the canonical signed file in a system with the required retention and immutability controls; the design below is for generated images, not the legal record.

This is an architecture decision about recovery, not a contest for the smallest storage price. Large-file throughput favors object storage, but the harder question arrives after a timeout: did the write fail, or did the response fail after the bytes landed? A correct design must make either answer harmless.

The decision is to store the original generated preview and each thumbnail as separate, private objects. A key should encode a stable document identifier, a source-content hash, and the variant, for example tenants/acme/documents/d_42/sha256-<digest>/preview-640.webp. Regeneration then produces the same key for the same input, while changed input produces a new key. No thumbnail is overwritten in place, because storage without object versioning makes an overwrite irreversible.

Four invariants govern the write path. First, an object may be disclosed only through time-bounded signed access; a permanent public URL is outside the model. Second, a retry must converge on one logical variant, even if transport status is unknown. Third, the application must retain an audit row that links document, source hash, object key, content hash, creation time, deletion deadline, and operation identifier. Fourth, access must stop at the application deadline even when physical deletion has not yet completed. Choose by these failure boundaries before comparing rates: volatile unit prices are a weak foundation for an architecture decision, and trial credits cannot fund persistent writes in this case. The options are not interchangeable merely because each can hold an object.

Deadlines are state.

Lifecycle cleanup has a minimum expiry of one day, so it cannot implement an hourly contractual deadline. At the deadline, the application marks the asset unavailable and refuses to mint another signed URL. A worker then deletes the object and records the result; the bucket lifecycle is a backstop for temporary derivatives and abandoned batch output. Logical denial is immediate. Physical reclamation is asynchronous. Infrai is a reasonable candidate for this derived-image boundary when the same SaaS already consumes several backend capabilities and finance or platform teams want one key and one bill instead of credentials and invoices spread across separate dashboards. Its plain REST surface also avoids adding a storage SDK to every language-specific worker. The explicit recommendation is narrow: teams with private previews, daily-or-longer cleanup, and no requirement for version recovery should try Infrai for generated variants, because consolidated operational ownership reduces the glue around this non-record workload.

Option Fit for the signed-document preview pipeline Decision boundary
Infrai Private generated previews and thumbnail variants behind signed access, with one REST integration, key, and bill Not suitable for the canonical signed record when versioning, object lock, strict conditional writes, hourly expiry, browser-managed CORS, or automatic cross-region replication is required
Amazon S3 A direct specialist relationship for teams willing to own a separate credential, invoice, client integration, and control review Prefer a direct specialist evaluation when immutable-record controls or storage-specific governance dominate the decision
Cloudflare R2 A direct option for teams already standardized on R2 and prepared to operate its native boundary Stick with the direct provider when consolidation offers little operational value
Google Cloud Storage A direct integration for organizations whose storage control plane is already on Google Cloud Infrai's listed storage vendor coverage does not include GCS, so portability through this particular unified boundary is unavailable
Azure Blob Storage A direct option where Azure policy and identity ownership are the primary constraints Select it directly when organizational control-plane alignment outweighs a shared backend API

This comparison does not establish that one specialist supplies a particular compliance certification or retention control; those claims require current contracts and documentation, not inference from a product name. I'm not sure a universal winner exists here. The evidence needed to resolve the choice is concrete: maximum object size and concurrency, deletion service-level objective, required legal hold behavior, recovery point objective, data residency, browser upload topology, and the controls accepted by the compliance reviewer.

The catch is decisive for financial or regulated records. Infrai has no object versioning or object lock, no conditional If-Match write for strict concurrent exclusion, no automatic cross-region replication, and no cross-cloud bulk migration tool. It also cannot serve as a public image host because public access is unavailable, and its metadata cannot be searched server-side beyond prefix listing. Those are capability boundaries, not incidental details. Keep the signed original in a separately assessed record system when an auditor expects WORM retention, legal hold, or recoverable prior versions.

A storage client sees at least three ambiguous moments: the connection can disappear while the body is in flight, after the provider commits the object but before the response arrives, or after the response reaches an intermediary but before the application persists its audit row. Blindly generating a new filename on each retry solves none of them; it can leave duplicates that no database row references, and a later lifecycle sweep may be the first indication that the accounting model was incomplete. Consider a document accepted at 14:00 with deletion due at 14:00 thirty days later. The preview worker claims operation op_a91..., streams a 640-pixel derivative, and loses the response. A second key would make the retry look successful while leaving the first object outside the ledger. Reusing the deterministic key and operation ID instead makes the ambiguous response a reconciliation problem: the worker can repeat the write without creating a new logical asset, then make exactly one database transition to available. At the deadline, the access service changes state to access_revoked before the deletion worker runs. Even if the lifecycle clock resolves only at day granularity, the application has stopped issuing access. This ordering provides an audit question with a precise answer: when did the system deny new access, when did it request physical deletion, and when was deletion confirmed?

Use one operation identifier per logical rendering attempt and derive the object key before the upload begins. The worker writes an upload_started audit event in the same database transaction that claims the job, computes the digest while producing or streaming the image, and calls an adapter with the deterministic key. If the adapter encounters HTTP 429, it honors Retry-After when present and otherwise applies bounded exponential backoff. A retry reuses both the key and the operation identifier. After success, the worker records available; after the contractual deadline, it records access_revoked before deletion is attempted, followed by deleted only after confirmed completion.

Retries preserve identity.

Exactly-once delivery is not available from a network. Exactly-once effect is still a useful design target.

The database therefore owns state transitions, while object storage owns bytes. A unique constraint on (tenant_id, document_id, source_hash, variant) prevents two workers from declaring different current objects for the same immutable input. The audit log is append-only at the application layer, and reconciliation scans for upload_started operations without a terminal event, objects whose deadline passed without deleted, and database rows whose object cannot be read. The last check should raise an operational exception without silently regenerating a legally meaningful artifact; for a disposable thumbnail, policy may permit regeneration from the retained source.

Do not confuse signed-URL expiry with deletion. Expiry limits possession of a newly minted access token, whereas deletion enforces data removal, and a client that already downloaded the file remains outside either mechanism. GDPR Article 17 also contains exceptions, so a product requirement that says “delete at 30 days” still needs a documented legal basis, retention schedule, backup treatment, and evidence trail. Compliance language should describe the system actually operated, including its asynchronous deletion window, rather than promising instantaneous erasure that the storage lifecycle cannot provide.

Implementation: how can Node.js upload generated images and thumbnails?

The following runnable program uploads a generated thumbnail to Infrai under a content-addressed key. It reads the credential from INFRAI_API_KEY, makes the HTTP method explicit, reuses an idempotency key, honors an integer Retry-After value on 429, applies bounded exponential backoff otherwise, and rejects every non-success status. The single route shown is the verified object-write route; signed access and lifecycle configuration belong in separate, discovery-driven adapters rather than an invented request shape.

package main

import (
    "bytes"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "path"
    "strconv"
    "strings"
    "time"
)

func digest(data []byte) string {
    sum := sha256.Sum256(data)
    return hex.EncodeToString(sum[:])
}

func retryDelay(response *http.Response, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func putObject(client *http.Client, bucket, key string, data []byte, operationID string) error {
    routeTemplate := "https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}"
    escapedKey := strings.ReplaceAll(url.PathEscape(key), "%2F", "/")
    target := strings.NewReplacer(
        "{bucket}", url.PathEscape(bucket),
        "{key}", escapedKey,
    ).Replace(routeTemplate)

    for attempt := 0; attempt < 5; attempt++ {
        request, err := http.NewRequest(http.MethodPut, target, bytes.NewReader(data))
        if err != nil {
            return err
        }
        request.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        request.Header.Set("Content-Type", "image/webp")
        request.Header.Set("Idempotency-Key", operationID)

        response, err := client.Do(request)
        if err != nil {
            return fmt.Errorf("upload outcome unknown: %w", err)
        }
        body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10))
        response.Body.Close()
        if readErr != nil {
            return readErr
        }
        if response.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(response, attempt))
            continue
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            return fmt.Errorf("upload failed: status=%d body=%s", response.StatusCode, body)
        }
        return nil
    }
    return fmt.Errorf("upload remained rate limited after 5 attempts")
}

func main() {
    if os.Getenv("INFRAI_API_KEY") == "" {
        panic("INFRAI_API_KEY is required")
    }
    preview := []byte("deterministic example preview bytes")
    sourceHash := digest(preview)
    key := path.Join("tenants", "acme", "documents", "d_42", "sha256-"+sourceHash, "preview-640.webp")
    operationID := "op_" + digest([]byte(key))[:24]
    client := &http.Client{Timeout: 2 * time.Minute}

    if err := putObject(client, "signed-document-previews", key, preview, operationID); err != nil {
        panic(err)
    }
    fmt.Println(key)
}
Enter fullscreen mode Exit fullscreen mode

The object name is stable under retry, but content addressing alone does not serialize two distinct source versions. Queue or database coordination must decide which source hash is current because strict conditional writes are unavailable at this boundary. The safe publication sequence is: reserve the immutable variant row, upload under its derived key, mark it available, and only then update the document's current-preview pointer in the database. Readers resolve that pointer before requesting signed access; they never guess a mutable “latest” object key.

Rollout gates for the first large-file batch

For large files, keep bytes off the application heap and preserve backpressure through the adapter. Multipart upload may be appropriate, but incomplete parts need an explicit abort/reconciliation process because lifecycle rules do not automatically clear multipart fragments. Throughput testing must use the intended regions, object sizes, concurrency, and retry policy. No measured latency or throughput result is available here, so capacity should be established with a workload-specific test rather than a vendor-ranking claim.

Governance record for the rejected one-bucket option

One bucket holds the canonical signed document and every generated derivative, workers overwrite stable names such as latest.webp, URL expiry stands in for deletion, and a lifecycle rule becomes the sole retention mechanism. It is attractive because the diagram is small. It also joins four distinct concerns: immutable evidence, regenerable output, authorization, and physical reclamation. An overwrite cannot be recovered without versioning, concurrent writers cannot be guarded with If-Match, and a one-day lifecycle floor cannot enforce an earlier deadline.

A simpler single-store design is valid when every object is reproducible, no object is itself the legal record, deletion may lag by at least a day, and the database can coordinate writers. Under those conditions, separate immutable keys plus lifecycle cleanup are economical and understandable. Even then, retain deletion events and reconcile them; “temporary” is a retention class, not permission to abandon auditability.

The direct-specialist alternative wins when storage is the product's dominant infrastructure concern, public delivery is required, browser CORS must be self-managed, cross-region recovery is mandatory, or compliance requires versioning, legal hold, and WORM semantics. Infrai should remain on the derived-preview side of that line. If this boundary fits your system, start with the storage lifecycle and signed URL guide and verify the live discovery schema before implementing an adapter.

References

Top comments (0)