DEV Community

LiamFoster1844
LiamFoster1844

Posted on

Delete Old Thumbnails on Image Replace: Node.js Object Storage Audit Workflow

Short answer: keep the original receipt immutable, publish resized copies under a new generation prefix, switch the database pointer, and queue prefix-based batch deletion only after that commit; treat a one-day lifecycle rule as delayed housekeeping, not as the replacement transaction.

The deciding constraint for a media platform is large-file throughput. A receipt upload may be a large original, while its thumbnails are small and numerous. They should share an audit identity, but they should not share the same overwrite path. That separation gives the ingest pipeline room to stream the original, gives readers a stable current-generation pointer, and gives cleanup a finite set of keys to retire.

This is a runbook for the boring part that eventually becomes expensive: image replacement succeeds, the new thumbnails look right, and the old ones quietly remain addressable.

How should I delete old thumbnails after an image replace in object storage?

Watch the gap between the database's active generation and the objects still present beneath retired generations. Availability dashboards will often stay green while this gap grows. The failure is retention drift, not necessarily a failed request.

For each replacement, record the image identifier, the committed generation, the retired prefix, and the enqueue time. The useful SLO is cleanup age: how long a retired prefix may remain before it is fully removed. Track the count of keys under retired prefixes too; that is the capacity-planning signal when a batch of large-file ingests creates many derivative images at once.

A concrete key layout keeps the check mechanical:

package thumbnails

import "fmt"

func OriginalKey(receiptID, uploadID string) string {
    return fmt.Sprintf("receipts/%s/original/%s", receiptID, uploadID)
}

func ThumbnailPrefix(receiptID, generation string) string {
    return fmt.Sprintf("receipts/%s/thumbs/%s/", receiptID, generation)
}
Enter fullscreen mode Exit fullscreen mode

The original key is never part of the thumbnail deletion set. A reconciliation job can compare the active generation in application data with a prefix listing, but the bucket cannot decide which receipt is current by itself. That is why the database pointer is the authority for liveness.

How should large-file throughput shape the replacement budget?

Large-file throughput changes the queue design before it changes the delete code. Give the original receipt its own streaming path and bounded upload concurrency; put thumbnail work on a separate queue with its own worker budget. Otherwise a burst of large originals can starve derivative creation, leave the old generation visible longer than the cleanup SLO, and make a storage setting look like the bottleneck.

Measure three budgets separately: bytes in flight for originals, derivative jobs waiting, and keys awaiting retirement. Capacity-plan for a replacement burst, including retries and a second listing pass. Average upload rate is the wrong number when a single media ingest can fan out into several thumbnail keys.

Use a four-step transaction boundary: stream the new original, write all derivatives under a fresh generation, commit the new pointer, then enqueue a cleanup job naming the exact generation it displaced. Do not derive the deletion target later from “whatever is old”; a delayed job needs a stable target.

Order matters.

The worker should first verify that the generation in its cleanup job is still retired. It should then list the exact prefix, continue through every page, divide the returned keys into the storage service's documented batch size, and retry a batch with backoff when the service asks for slower traffic. A successful delete response is not proof that the prefix is empty, so the worker must list again before marking the job complete.

The following Go interface is deliberately generic. The production adapter can be called from a Node.js worker, but the destructive ordering is easier to unit-test when storage is behind a small contract.

package thumbnails

import (
    "context"
    "errors"
    "fmt"
)

type ObjectStore interface {
    ListPrefix(ctx context.Context, bucket, prefix, cursor string) (keys []string, next string, err error)
    DeleteBatch(ctx context.Context, bucket string, keys []string, operationID string) error
}

type Catalog interface {
    IsRetired(ctx context.Context, receiptID, generation string) (bool, error)
}

type CleanupJob struct {
    Bucket     string
    ReceiptID  string
    Generation string
}

func (j CleanupJob) Prefix() string {
    return fmt.Sprintf("receipts/%s/thumbs/%s/", j.ReceiptID, j.Generation)
}

func Sweep(ctx context.Context, store ObjectStore, catalog Catalog, job CleanupJob) (int, error) {
    retired, err := catalog.IsRetired(ctx, job.ReceiptID, job.Generation)
    if err != nil {
        return 0, err
    }
    if !retired {
        return 0, errors.New("cleanup target is not retired")
    }

    cursor := ""
    removed := 0
    for {
        keys, next, err := store.ListPrefix(ctx, job.Bucket, job.Prefix(), cursor)
        if err != nil {
            return removed, err
        }
        if err := deleteDocumentedBatches(ctx, store, job.Bucket, keys,
            fmt.Sprintf("receipt-thumbnail-retire:%s:%s", job.ReceiptID, job.Generation)); err != nil {
            return removed, err
        }
        removed += len(keys)
        if next == "" {
            return removed, nil
        }
        cursor = next
    }
}

func deleteDocumentedBatches(ctx context.Context, store ObjectStore, bucket string, keys []string, operationID string) error {
    const batchSize = 100 // Replace with the selected storage API's documented limit.
    for start := 0; start < len(keys); start += batchSize {
        end := start + batchSize
        if end > len(keys) {
            end = len(keys)
        }
        if err := store.DeleteBatch(ctx, bucket, keys[start:end], operationID); err != nil {
            return err
        }
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

That 100 is an example boundary, not a universal object-storage fact; replace it with the selected API's documented limit and test the adapter against that contract. The important properties are the prefix, pagination, a stable operation ID, and a liveness check immediately before deletion. In Node.js, the same state machine belongs in a queue consumer with an AbortSignal, bounded concurrency, and structured logs; language choice does not remove the need for those guards.

Which storage boundary preserves throughput without losing audit control?

The storage decision should follow the largest operational risk, not the shortest upload demo. For large originals, measure sustained ingest throughput, retry traffic, and time to restore the original. For thumbnails, measure listing latency, batch capacity, and cleanup age. A single bucket setting cannot answer all four questions.

Choice Fits when Trade-off to accept
Native object-storage API Provider-specific versioning, retention, or replication is central to the audit design The application owns another adapter, credential model, and on-call contract
A storage abstraction Several backends must share one application contract and the team can live with the common denominator Provider-specific recovery and lifecycle controls may need separate escape hatches
Self-hosted object storage Data placement and operational control outweigh managed-service convenience The team owns capacity, disks, upgrades, replication, and failure testing
Database metadata plus object storage The catalog must decide which receipt generation is active Every replacement needs a reliable commit and reconciliation path

The catch is that the abstraction is not suitable when the audit requirement depends on a storage feature absent from its common interface. Stick with a native backend when you need its documented versioning or retention semantics, and keep the original receipt outside the thumbnail garbage-collection path. A one-day minimum lifecycle policy is suitable for delayed leftovers only when the product's retention SLO permits that delay; it is not a substitute for an immediate post-commit cleanup job.

What conditions should release deletion, rollback, and recovery?

Test the replacement as a state machine, not as one happy-path upload. A failed derivative must leave the old pointer active. A database commit must enqueue the retired generation exactly once, or make duplicate delivery harmless. A cleanup job must refuse an active generation. A partial batch failure must resume without deleting a different generation.

For verification, list the retired prefix after the final batch and require an empty result across all pages. Emit the receipt ID, generation, operation ID, submitted key count, remaining key count, and cleanup age. Alert on the cleanup SLO and on the number of orphaned generations. During load tests, mix large original uploads with thumbnail bursts; otherwise a test can prove that thumbnail deletion works while hiding queue starvation behind the large-file stream.

Rollback has a hard boundary. Before deletion, switching the database pointer back can restore the previous generation without copying objects. After deletion, recovery depends on whatever retention or versioning contract the chosen storage system actually provides. NIST's HIPAA Security Rule implementation guidance is a useful reminder to make access, integrity, and audit controls explicit rather than assuming that object presence equals a defensible record.

Your mileage may vary on the cleanup threshold. I’m not sure a universal number exists: a newsroom's public thumbnail and a regulated receipt archive do not carry the same stale-data risk. Set the SLO from the audit and product requirements, then capacity-plan for the worst replacement burst, not the average day.

References

Top comments (0)