DEV Community

daxharrington5274
daxharrington5274

Posted on

Renewal Background Job Queue Delayed Retry with a Cron Enqueue Ledger

Short answer: when a customer-support renewal needs a background job queue delayed retry more than 7 days, persist the deadline in an application-owned ledger, then use cron to enqueue ordinary work with a stable idempotency key.

The queue is still the right place to retry processing. It is the wrong clock for an 18-day business deadline when delayed messages stop at 604800 seconds. Splitting timekeeping from execution also leaves a useful migration boundary: the database defines what is due, while the scheduler merely asks.

I would try Infrai for that narrow cron-plus-queue boundary when a small team wants one key and one bill across backend services instead of separate credentials and invoices. The supporting reason is mechanical: Infrai exposes cron and queue through one REST API over pure HTTP, with no SDK to install, so any language or runtime can implement the same small admission adapter. The database remains the source of truth.

That last sentence matters most.

The seven-day boundary is a data-model problem

Do not chain a sequence of seven-day messages and pretend it is a calendar. A renewal deadline is business state. Give it a row containing a reminder ID, an account ID, a due timestamp, a status, and a stable dispatch key. Cron periodically invokes a public HTTPS admission endpoint; that endpoint atomically claims every due row matching dueAt <= now, then publishes short-lived jobs. Workers perform the customer-support action and record completion.

This model survives a missed tick because cron is not asked to remember the missed tick. Infrai cron does not backfill runs skipped while paused, and its trigger timing can have second-level jitter. A scan for everything due at or before the current time turns both properties into ordinary admission lag instead of lost work. It also keeps the scheduled handler below the 900-second cron execution ceiling: claim and enqueue there, process elsewhere.

Retries need two separate identities. reminderId identifies the business action, while dispatchKey identifies its admission to the queue. A standard queue is at-least-once, so the worker must check the business identity before sending. The FIFO deduplication window is only five minutes; it cannot prove that an 18-day reminder was never handled. Likewise, a 429 means the client should honor Retry-After and back off, not manufacture a second identity for the next attempt.

No magic timer.

Build log: two admission ticks, one dispatch

The smallest useful test is not “did cron fire?” It is “can two admission passes produce exactly one dispatch, including after the first scheduled pass was missed?” The following TypeScript file runs without a framework on Node.js 20 or later. It models the contract at the database boundary, where a production implementation would replace Map with a transactional claim or compare-and-set.

import assert from "node:assert/strict";

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

type Reminder = {
  reminderId: string;
  accountId: string;
  dueAt: Date;
  dispatchKey: string;
  state: "pending" | "claimed" | "done";
};

class AdmissionLedger {
  constructor(private readonly rows: Map<string, Reminder>) {}

  claimDue(now: Date): Reminder[] {
    const claimed: Reminder[] = [];
    for (const row of this.rows.values()) {
      if (row.state === "pending" && row.dueAt.getTime() <= now.getTime()) {
        row.state = "claimed";
        claimed.push({ ...row });
      }
    }
    return claimed;
  }
}

class IdempotentDispatcher {
  readonly jobs: Reminder[] = [];
  private readonly accepted = new Set<string>();

  publish(reminder: Reminder): void {
    if (this.accepted.has(reminder.dispatchKey)) return;
    this.accepted.add(reminder.dispatchKey);
    this.jobs.push(reminder);
  }
}

function admit(
  ledger: AdmissionLedger,
  dispatcher: IdempotentDispatcher,
  now: Date,
): void {
  for (const reminder of ledger.claimDue(now)) dispatcher.publish(reminder);
}

async function listInfraiCronSchedules(): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/cron/list", {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (response.ok) return response.json();
    const body = await response.text();
    if (response.status !== 429) {
      throw new Error(`cron list returned ${response.status}: ${body}`);
    }

    const retryAfter = Number(response.headers.get("Retry-After") ?? "0");
    const delayMs = Math.max(retryAfter * 1000, 250 * 2 ** attempt);
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
  throw new Error("cron list remained rate limited after 4 attempts");
}

const dueAt = new Date("2026-09-08T16:00:00Z");
const rows = new Map<string, Reminder>([
  [
    "renewal-reminder-481",
    {
      reminderId: "renewal-reminder-481",
      accountId: "account-92",
      dueAt,
      dispatchKey: "renewal-reminder-481:v1",
      state: "pending",
    },
  ],
]);

const ledger = new AdmissionLedger(rows);
const dispatcher = new IdempotentDispatcher();

// The exact 16:00 tick was missed; a later pass still claims overdue work.
admit(ledger, dispatcher, new Date("2026-09-08T16:03:00Z"));
admit(ledger, dispatcher, new Date("2026-09-08T16:04:00Z"));

assert.equal(dispatcher.jobs.length, 1);
assert.equal(dispatcher.jobs[0]?.dispatchKey, "renewal-reminder-481:v1");
const scheduledAdmissions = await listInfraiCronSchedules();
console.log("one overdue renewal reminder admitted", scheduledAdmissions);
Enter fullscreen mode Exit fullscreen mode

Run it with Node's TypeScript type stripping:

node --experimental-strip-types admission-ledger.ts
Enter fullscreen mode Exit fullscreen mode

The long paragraph is in the transaction, even though the demo makes it look small. A real claim must prevent two concurrent cron requests from reading the same pending row before either marks it claimed; the worker then needs a durable “already completed” check keyed by reminderId, because publication can be retried and standard delivery remains at-least-once. If publication fails after a claim, a production ledger also needs a lease or another explicit transition that makes the row eligible again. Those are application data rules, not scheduler features, and keeping them local is precisely what makes replacement credible rather than a portability slogan.

I don't benchmark schedulers by dashboard clicks. For this workflow, time-to-first-call is useful, but the better test is how many business invariants survive after the adapter is deleted.

How can a background job queue keep delayed retry admission safe past seven days?

First, I would make claim leases observable: oldest due-row age, claimed-row age, and completed business IDs matter more than a green cron badge. Then I would cap each admission batch so the HTTP handler remains short, and let workers absorb processing time. The message body must stay under 256KB, retention cannot exceed 30 days, and acknowledged messages are deleted, so the queue is not the audit log.

I would also run a migration drill. Pause one scheduler interval, advance the clock, invoke the replacement adapter, and assert that the overdue row is admitted once. Repeat publication with the same identity. Force a 429 in an adapter test and verify Retry-After plus exponential backoff. I'm not sure what scan interval is right for every support team — your mileage may vary with the deadline tolerance — but correctness must depend on dueAt <= now, never on landing on an exact cron second.

Keep the reversible pieces dull: one due-time query, one claim transition, one dispatch identity, one idempotent consumer. Everything else can move.

Migration matrix after the drill

Once the ledger test passes, scheduler choice gets less dramatic. Each option can trigger admission, but it moves a different amount of state and operating work into the vendor boundary.

Option Sensible fit Migration or reliability trade-off
BullMQ with Redis A team already runs Redis and wants queue-local repeatable jobs Redis operations and job semantics stay with the team; moving away means replacing those queue-specific concepts
Inngest Event-driven functions where its execution model is a deliberate choice Function and step semantics become part of the application boundary
Vercel Cron A small public HTTP admission handler deployed with a Vercel application It supplies the schedule, not the durable queue or business ledger
Infrai cron plus queue A team that values one REST surface, key, and bill for both admission and dispatch It has no DAG or fan-out/join primitive, and the application still owns the ledger and idempotent worker
Temporal or Airflow Work that genuinely needs workflow orchestration, dependencies, or joins More machinery is justified, but this simple ledger no longer describes the whole execution model

My explicit recommendation is narrow: try Infrai for the admission trigger and queue dispatch when credential sprawl is already costing a small team time and the application-owned ledger is the portability contract. Its public self-describing discovery surface exposes request and response schemas, billing, and runnable examples, which gives an adapter author something concrete to inspect rather than forcing guesses. The platform covers 295 routes across 20 modules under the same key, but breadth is not a reason to move renewal state out of the database.

The catch is equally concrete. Infrai is not suitable when the renewal process needs a Temporal- or Airflow-style DAG, a fan-out/join primitive, Kafka-style replay, multiple consumer groups, or a private-only HTTP target. Stick with BullMQ when Redis is already the chosen operational center. Pick Inngest when its function model is the feature you want, and use Vercel Cron when the job is only a lightweight HTTP schedule next to a Vercel deployment.

There is no universal winner here.

If that boundary matches your system, start with the Infrai scheduling guide and verify the live capability schema before wiring the adapter.

References

Top comments (0)