DEV Community

DorianReed2186
DorianReed2186

Posted on

Marketplace Recovery: Node.js Cron Queues for Duplicate-Safe Webhook Reminder Messages

Short answer: give every marketplace reminder delivery a stable event ID, let a durable queue retry that same event, and require the public webhook endpoint to record the ID before applying the event; cron should recover due work, not invent a fresh delivery on every run.

This design starts with an uncomfortable boundary. A sender can know that it made a request, and a receiver can know that it committed a change, but a lost response can leave the sender unsure which of those facts holds. No choice of cron expression removes that ambiguity. Duplicate-safe recovery comes from preserving identity across attempts and making repeated receipt harmless.

That is the decision rule. For a marketplace, an order reminder such as order_841:pickup-window:v3 is one logical event even if the queue exposes it five times. Email, SMS, and webhook delivery each get a separate channel key because their outcomes can diverge. The scheduler decides when an event becomes eligible; it does not decide whether that event has already taken effect at a destination.

How should Node.js cron queue delayed reminder messages reach a public webhook endpoint?

Model the webhook receiver's deduplication rule before tuning the scheduler. The sender keeps one immutable eventId, one intended local time, one IANA time-zone identifier, and one resolved UTC instant. A cron-driven sweep claims due intents and publishes their IDs. A worker loads the current payload, sends it with the original event ID, and acknowledges the queue item only after the attempt reaches a defined outcome.

The public endpoint then inserts that event ID into the same database transaction that applies the marketplace change. A uniqueness constraint settles races between two workers or two queue deliveries. If the insert conflicts because the event was already handled, the endpoint returns its normal accepted result without applying the business mutation again. Repetition is expected here — changing identity during a retry is the actual defect.

Retries preserve identity.

The runnable core is small enough to inspect. These interfaces deliberately leave the database, broker, and HTTP server unspecified; the useful contract is the order of operations.

type ReminderChannel = "email" | "sms" | "webhook";

type Delivery = {
  eventId: string;
  intentId: string;
  channel: ReminderChannel;
  destination: string;
  attempt: number;
};

type WebhookEvent = {
  eventId: string;
  type: "marketplace.reminder.due";
  intentId: string;
};

interface DeliveryLedger {
  find(eventId: string): Promise<"pending" | "delivered" | "ambiguous">;
  markDelivered(eventId: string, recordedAt: Date): Promise<void>;
  markAmbiguous(eventId: string, recordedAt: Date): Promise<void>;
}

interface DeliveryQueue {
  acknowledge(delivery: Delivery): Promise<void>;
  retry(delivery: Delivery, delayMs: number): Promise<void>;
}

interface WebhookClient {
  post(destination: string, event: WebhookEvent): Promise<{ accepted: boolean }>;
}

export async function deliverWebhook(
  delivery: Delivery,
  ledger: DeliveryLedger,
  queue: DeliveryQueue,
  client: WebhookClient,
): Promise<void> {
  const state = await ledger.find(delivery.eventId);
  if (state === "delivered") {
    await queue.acknowledge(delivery);
    return;
  }

  try {
    const result = await client.post(delivery.destination, {
      eventId: delivery.eventId,
      type: "marketplace.reminder.due",
      intentId: delivery.intentId,
    });

    if (!result.accepted) {
      await queue.retry({ ...delivery, attempt: delivery.attempt + 1 }, 60_000);
      return;
    }

    await ledger.markDelivered(delivery.eventId, new Date());
    await queue.acknowledge(delivery);
  } catch (error) {
    await ledger.markAmbiguous(delivery.eventId, new Date());
    await queue.retry({ ...delivery, attempt: delivery.attempt + 1 }, 60_000);
  }
}
Enter fullscreen mode Exit fullscreen mode

The catch is the interval after the destination commits but before the sender records delivered. The code marks that result ambiguous and retries with the same eventId; it is safe only when the receiver enforces the transaction rule above. Without receiver-side deduplication or an equivalent idempotency agreement, the sender cannot promise duplicate-free effects. Don't hide that limitation behind a sent Boolean.

RabbitMQ's documentation draws another boundary that matters: publisher confirms cover a publisher and broker, while consumer acknowledgements cover delivery from broker to consumer. Neither proves that a remote webhook committed its database transaction. The queue acknowledgment therefore belongs after the sender has recorded its outcome, and redelivery still has to be harmless.

For reminder email and SMS, use distinct keys such as order_841:pickup-window:v3:email and order_841:pickup-window:v3:sms. The same state machine applies, but duplicate suppression depends on the channel provider's actual contract. When that contract offers neither idempotent submission nor status reconciliation, high-impact ambiguous attempts are not suitable for blind automatic replay; hold them for review. A low-impact reminder may accept a different policy. I'm not sure there is one sensible answer across every marketplace because a pickup deadline and a promotional nudge carry different consequences.

Put the recovery record ahead of the timer

Cron is a wake-up mechanism. The recovery record is the system.

Persist the business intent before scheduling anything: the marketplace object, reminder type, payload version, requested wall-clock time, time zone, resolved UTC due time, and channel-specific event IDs. A frequent cron job can query due records in bounded batches, acquire expiring leases, and publish references to the queue. If a process restarts between batches, the next sweep sees unclaimed or expired work. If two schedulers overlap, an atomic claim prevents both from owning the same row; receiver deduplication remains the final defense if a duplicate still reaches the network.

This makes rescheduling explicit. Changing a pickup window creates a new payload version and new logical event IDs, while the previous version becomes canceled before it is due. Retrying version 3 keeps the version 3 key. Turning it into version 4 during an attempt would disguise a retry as new business intent and defeat the ledger.

The records need more nuance than success and failure. pending means eligible but not claimed, leased means a worker owns it temporarily, delivered means the accepted outcome was recorded, and ambiguous means the request outcome needs safe replay or reconciliation. A terminal state is appropriate for a destination that should no longer receive reminders. Keep attempt timestamps and sanitized reason categories as evidence; don't put credentials or full message bodies into operational logs.

One long-delay in-process timer is a poor fit for this recovery model. It gives an operator little durable evidence, disappears with its process, and makes changed due times hard to audit. A broker's delayed-message feature can be useful, but it still needs a durable intent and a repair scan if operational recovery matters. Stick with a simple database sweep when minute-level precision is acceptable and due-row volume is manageable. Choose a broker-native scheduler when tighter timing or very large scheduled volume justifies the extra operational surface, but retain stable event identity either way.

Cost follows polling frequency, retained state, and attempts. Empty sweeps can waste database work; huge delayed queues can make cancellation and inspection awkward. Measure oldest-due age, claimed batch size, retry volume, and ambiguous outcomes before changing cadence. Fast is good. Recoverable is better.

How should user time zones be resolved before cron queues a reminder?

Store the user's wall-clock choice and an IANA time-zone identifier, then resolve them to a UTC instant when the reminder is created or edited. Query by that UTC value. The original pair stays beside it so support can explain why a seller's 09:00 reminder became a particular instant and can recompute it when the user changes the schedule.

Daylight-saving transitions create local times that repeat or do not exist. The product needs a written policy for each case: reject the choice, move it to the next valid instant, or select a defined occurrence of a repeated time. Test that policy against the exact time-zone library and data used in production. Server-local time should not silently choose on the user's behalf.

The crontab manual documents that jobs are evaluated in the cron daemon's notion of time and describes special behavior for clock changes. That is a strong reason to run a regular UTC recovery sweep rather than generate a host cron entry for every user's local reminder. Cron asks, “which resolved instants are due?” The application owns the marketplace meaning of local time.

Keep these tests concrete: create a reminder on each side of a daylight-saving change, edit it after queue publication, run two sweepers over the same due batch, and deliver one webhook event twice to a receiver with a unique event constraint. Then interrupt the worker after the receiver accepts the event but before the sender records delivery. The final test should produce one business mutation and an ambiguous attempt that can be replayed with its original ID.

Recover one uncertain marketplace delivery without guessing

Suppose order_841:pickup-window:v3:webhook is due, the worker posts it, and the response never arrives. An operator should first see the immutable intent, resolved due instant, event ID, attempt time, and ambiguous state. The operator does not create a replacement event. They either reconcile using a destination receipt when the integration contract provides one, or replay the original event so the receiver's uniqueness constraint can return the already-accepted result.

Access to that replay is part of correctness. Record who initiated it and why, preserve the earlier attempt, and limit replay to states whose policy permits it. An audit trail that can be silently edited is weak evidence. A retry button that generates a new ID is worse. Before deployment, verify the path in plain prose: an intent is durable before it is eligible; one event ID survives every transport attempt; cron can reclaim expired leases; the consumer acknowledges only after recording an outcome; the public endpoint deduplicates inside its business transaction; time-zone ambiguity follows a written rule; and an operator can distinguish pending, delivered, and ambiguous without reading raw logs. Next, rehearse the uncertain case from end to end: pause the worker after the destination commits, allow the lease to expire, observe the queue redelivery, and confirm that the replay carries order_841:pickup-window:v3:webhook rather than a newly generated identifier. The destination should recognize the existing event inside its transaction, the sender should move the ambiguous attempt to its accepted outcome, and the audit record should retain both transport attempts. This is the one drill that exposes false confidence in a green queue metric, because the queue can be empty while the marketplace mutation happened twice. If any sentence in that chain is false, the recovery path is unfinished.

Never mint a replacement.

This approach is not suitable when you do not control the public endpoint and its contract gives no idempotency or reconciliation mechanism. In that case, duplicate-free outbound effects cannot be guaranteed. Reduce automatic retries, make the residual risk explicit, and choose manual resolution for consequences the marketplace cannot safely repeat.

References

Top comments (0)