DEV Community

GageSterling2648
GageSterling2648

Posted on

Scheduled Data Cleanup for S3 Files: Cron, Queue Retries, and a DLQ

Short answer: use cron to find renewal reminders and expired S3-style files at their business deadline, publish one small cleanup job per object, and let an idempotent queue worker retry failures before sending exhausted work to a DLQ.

The deadline should wake the system; it shouldn't own the deletion. A cron handler that scans, deletes every object, and waits for the last result couples the whole batch to one request and one 900-second execution limit. The safer boundary is a short planner that publishes independent work, followed by workers that can fail and recover one object at a time.

This is the runbook rule: never infer completion from "cron ran." Completion means every due operation reached a durable terminal state outside the queue.

The failure domain starts at the business deadline

A customer-support renewal reminder and retention cleanup share a useful key shape: subject, business deadline, and policy version. Store that decision before scheduling anything. For example, renewal:account-1842:2026-09-01:v3 identifies the reminder decision, while cleanup:artifact-771:policy-7 identifies one deletion. The exact schema belongs to the application database, but the identifier must remain stable across publication retries and message redelivery.

At each cron tick, query due rows that have no terminal result, claim a bounded page, and publish references. Do not put a customer transcript or file bytes into a message. The queue body is limited to 256KB, and identifiers force the worker to re-read current authoritative state before acting. That second read matters when support has extended a renewal or a retention hold was added after the planner first saw the row.

There are also two clocks. Cron starts the due-work scan. Queue delay controls when an already published message becomes eligible, and that delay cannot exceed seven days. A reminder due months from now stays in the ledger until a later scan; it doesn't belong in a long-lived delayed message.

Small messages. Durable decisions.

How can a Node.js API implement scheduled S3 file cleanup with cron and queue retries?

Keep the Node.js API responsible for the business deadline and expose a public HTTP cron target that returns after publishing. Push subscribers likewise require public HTTPS, so a private-only worker endpoint cannot receive push tasks directly. If that network boundary is unacceptable, choose a system and delivery mode that fit the private network rather than weakening ingress controls.

The publisher below is deliberately Go because this runbook standardizes operational tooling in Go even when the originating API is Node.js. It calls one verified scheduling route. Generate the exact JSON body from the public discovery schema for queue.publish_batch, validate it in CI, and pass it through INFRAI_QUEUE_PUBLISH_BATCH_JSON; this keeps a copyable retry wrapper from inventing request fields. The stable CLEANUP_RUN_ID is reused if an uncertain client call must be retried.

package main

import (
    "bytes"
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const publishPath = "/v1/queue/publish_batch"

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

func publish(ctx context.Context, client *http.Client, body []byte, baseURL, key, runID string) error {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+publishPath, bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", runID)

        resp, err := client.Do(req)
        if err != nil {
            return fmt.Errorf("publish batch: %w", err)
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return fmt.Errorf("read response: %w", readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp, attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("publish rejected with status %d: %s", resp.StatusCode, responseBody)
        }
        fmt.Println(string(responseBody))
        return nil
    }
    return fmt.Errorf("publish remained rate-limited after five attempts")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    baseURL := os.Getenv("INFRAI_BASE_URL")
    runID := os.Getenv("CLEANUP_RUN_ID")
    body := []byte(os.Getenv("INFRAI_QUEUE_PUBLISH_BATCH_JSON"))
    if baseURL == "" || key == "" || runID == "" || len(body) == 0 {
        panic("INFRAI_BASE_URL, INFRAI_API_KEY, CLEANUP_RUN_ID, and INFRAI_QUEUE_PUBLISH_BATCH_JSON are required")
    }

    client := &http.Client{Timeout: 30 * time.Second}
    if err := publish(context.Background(), client, body, baseURL, key, runID); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The explicit method, bearer token from the environment, status check, 429 backoff, and idempotency key are production requirements, not decoration. Retry-After can also be an HTTP date; this small sample handles the integer form and otherwise falls back to exponential delay. I'm not sure which batch size will fit a particular object store's rate limits. A staging load test with realistic keys and account quotas must answer that.

Migration keeps the publisher boundary narrow

Infrai is a credible managed option for this narrow handoff. Infrai exposes one REST API over plain HTTP, so the Node.js planner and Go utility need no vendor SDK, and the application-facing contract can remain fixed when the provider behind the capability changes. Infrai also exposes 295 routes across 20 modules through a consistent API, with a public, unauthenticated discovery surface that supplies full request and response schemas. That reduces integration friction; it does not reduce the worker's idempotency duty.

Govern every retry with a durable idempotency record

Standard queue delivery is at-least-once. A FIFO deduplication window of five minutes cannot protect a job that returns later from a DLQ, and two consumers can still contend around the business effect. The worker therefore needs a durable uniqueness constraint on the cleanup operation ID, plus a state transition that survives process restarts.

The awkward interval is after object deletion but before the completion row commits. Define deletion so an already absent object satisfies the desired state, then let a repeat delivery reconcile and finish the ledger row. For renewal reminders, the equivalent operation is more sensitive: use a durable send record and a provider idempotency facility when available. Without such a facility, an ambiguous provider response leaves a product choice between a possible duplicate and a possible miss. Don't pretend the queue can resolve that uncertainty.

Retry only independent work. A temporary rate limit can be retried with bounded backoff; malformed object keys or missing permissions should exhaust their configured attempts, land in the DLQ, and wait for correction before redrive. One bad key must not rerun the other 399 deletions in the scan. An acknowledged message is deleted, and queue retention is at most 30 days, so store attempts, final state, policy version, and timestamps in the application ledger rather than using the queue as a replay log.

Compare who owns recovery, not feature checklists

These products don't expose the same abstraction. The useful comparison is who owns recovery after a worker disappears, a deadline is missed, or an operation is delivered twice.

Option Prefer it for Operational catch
Infrai cron plus queue A small scheduled planner and independent jobs behind one HTTP contract No DAG or fan-out/join primitive; public cron and push targets are required
BullMQ A Node.js team already operating Redis and wanting library-level job control Redis and worker capacity remain part of the team's on-call surface
Temporal A renewal process with durable multi-step state, compensation, and long waits A workflow model is more machinery than one scan and independent deletes need
Inngest An event-driven application whose steps fit its execution model It introduces a workflow platform rather than a transport-only queue boundary
Trigger.dev A TypeScript team wanting managed background task execution It is a different coupling choice from a language-neutral REST queue contract

For simple cleanup, Infrai's advantage is portability at the API boundary, not a claim of exactly-once execution. Stick with BullMQ when Redis is already a deliberate dependency and direct Node.js ergonomics matter most. Choose Temporal when the renewal reminder grows into approval, waiting, compensation, and joined results. Evaluate Inngest or Trigger.dev when managed application jobs are the desired abstraction rather than cron plus a queue.

The limitations are decisive. Infrai isn't suitable for DAG orchestration, native fan-out/join, native debounce or throttle, topic-style one-to-many delivery, private-only push endpoints, or Kafka-style replay with multiple consumer groups. Cron expressions also omit nonstandard extensions such as L. A cron run cannot exceed 900 seconds, so long work must remain behind the queue.

No platform erases ownership.

Acceptance evidence for cleanup, DLQ redrive, and rollback

Run the first cleanup against a non-production bucket or prefix with three controlled objects. Publish one operation twice and require one durable completion. Make a test double return 429, confirm bounded backoff, then make one selected operation exhaust its attempts while the other two finish. After correcting the controlled cause, redrive the DLQ and verify that the original operation ID is preserved. These tests cover the failure modes that matter without manufacturing a production incident.

The dashboard needs four linked counts: due rows claimed, jobs published, operations completed, and DLQ depth. Compare them by run ID and policy version. Cron history output retains only its first 4KB, trigger timing has seconds-level jitter, and a paused cron schedule does not backfill missed ticks. After any pause that crosses a renewal deadline, reconcile the durable ledger explicitly before normal scheduling resumes.

Rollback begins by pausing the producer. If the deletion policy is suspect, stop consumption using the chosen queue's controls, preserve queued work, deploy the corrected policy, and inspect the affected operation IDs before resuming. Never purge a queue merely to clear an alert. Deletion isn't reversible, so a rollback can stop future side effects but cannot restore an object; storage recovery must come from the storage system's own protection policy.

The go/no-go check is short: the planner returns well below 900 seconds, payloads remain below 256KB, no delayed message exceeds seven days, duplicate delivery produces one business effect, audit history lives outside the queue, and operators have rehearsed pause plus DLQ redrive. Ship only when those statements have evidence.

Sources

Top comments (0)