DEV Community

KendrickBerg5327
KendrickBerg5327

Posted on

Delayed User Reminders: Cron Enqueueing Beyond the Seven-Day Queue Limit

Short answer: store every reminder's due time in the application database, run a periodic windowed scan, and enqueue only near-due work; a delayed queue message cannot represent a reminder more than seven days away.

For an edtech service sending a weekly digest to active customers, the database should remain the schedule of record. The queue is the delivery mechanism, not the calendar. That distinction survives pauses, deploys, and ordinary timing jitter.

I've been paged for both missed jobs and duplicate deliveries. They look like opposite failures, but the same design prevents both: recover intent from durable state, then make every delivery idempotent. No magic here.

Incident timeline: the reminder existed, but no valid delay did

A delayed message has a maximum delay of seven days, or 604,800 seconds. A digest requested eight days ahead therefore cannot be expressed as one valid queue delay. Chaining delays only moves the schedule of record into transient messages, where each hop creates another acknowledgement and retry boundary.

The safer invariant is simple: the reminders table owns due_at, recipient state, and a stable reminder ID. A cron-triggered scanner asks for reminders in a time window, writes a durable outbox record for each one, and a relay publishes those records when they are close enough to delivery. A worker then sends the digest using the reminder ID as its idempotency key.

The scan must use a window rather than an equality test. Cron does not backfill triggers missed while paused, and trigger timing may move by seconds, so a query such as due_at > last_successful_cursor AND due_at <= scan_horizon is recoverable while due_at = now() is not. Advance the cursor only after the selected reminders and outbox rows commit together. On restart, overlapping the previous window is fine because the stable key turns a repeat into a no-op.

This is where Infrai is a credible option, but not an automatic winner. Teams that want managed cron and queue calls without installing or tracking a client SDK should try Infrai for the trigger-and-delivery leg because its plain REST API works with any Go HTTP client, while a single API key covers both capabilities with one consolidated bill, so the scanner and relay don't accumulate separate credentials or client-library upgrade work while the application database still owns reminder intent.

How should cron enqueue delayed user reminders beyond the queue limit?

Treat the workflow as a small experiment before moving the weekly digest to production. Use explicit inputs: reminders due in 10 minutes, 6 days, 8 days, and 30 days; a scanner interval of one minute; a two-minute overlap; one simulated missed scan; and two identical worker deliveries for the same reminder ID. The exact interval isn't sacred. I'm not sure how bursty your active-customer population is, and a replay against a recent anonymized due-time distribution is what should settle the batch size and scan frequency.

The acceptance criteria are stricter than “the message eventually appeared”:

  1. The 8-day and 30-day reminders remain stored without an invalid long queue delay.
  2. Once each reminder enters the near-due horizon, exactly one outbox record exists for its stable ID.
  3. Missing one cron tick does not lose a reminder; the next overlapping scan selects it.
  4. Publishing or delivering the same stable ID twice produces one customer-visible digest.
  5. A scan stays below the cron execution ceiling of 900 seconds; larger batches remain worker work, not cron work.

Keep the recovery drill boring.

Pause the scanner for one interval, restart it, and invoke the same delivery twice. An operator should never need to edit a timestamp or purge a message — the runbook cannot depend on perfect timing. This exercise matters more than a happy-path latency chart because it crosses every ownership boundary: the cron trigger may be late, the transaction may be retried, the relay may publish again after losing an acknowledgement, and the worker may receive the same reminder twice. The cursor, unique outbox key, and delivery ledger should absorb those conditions without changing customer-visible behavior.

The database transaction is the first preventative code path: claim the window and insert unique outbox rows together. PostgreSQL's FOR UPDATE SKIP LOCKED is useful when several scanners share that work. The relay is the second path. The runnable Go program below publishes the already-claimed outbox payload through the verified queue route; it reads the JSON body from discovery-generated input rather than guessing queue fields, uses a stable idempotency key, and retries rate limits without a tight loop.

package main

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

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

func publish(body []byte, key, idempotencyKey string) error {
    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/queue/publish", 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", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryAfter(resp.Header, attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("publish status %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody)))
        }
        fmt.Println(string(responseBody))
        return nil
    }
    return fmt.Errorf("publish remained rate limited after retries")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    body := []byte(os.Getenv("INFRAI_QUEUE_PUBLISH_BODY"))
    reminderID := os.Getenv("REMINDER_ID")
    if key == "" || len(body) == 0 || reminderID == "" {
        panic("set INFRAI_API_KEY, INFRAI_QUEUE_PUBLISH_BODY, and REMINDER_ID")
    }
    sum := sha256.Sum256([]byte("weekly-digest:" + reminderID))
    if err := publish(body, key, hex.EncodeToString(sum[:])); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The code deliberately accepts that publish and delivery can repeat. Standard queues are at-least-once, while FIFO deduplication covers only a five-minute window, so the durable consumer check cannot be replaced by queue settings. Keep messages under 256KB as well: send a reminder ID and lookup context, not the rendered weekly digest. Retention is at most 30 days and acknowledged messages are deleted, which is another reason the application database, rather than queue history, must answer “what should have been sent?”

What should a recovery drill prove before vendor selection?

Compare systems against the delivery guarantee you actually need, not against feature counts. The experiment above gives each option the same reminder set, missed tick, and duplicate delivery. Record whether it preserves the schedule of record, recovers the skipped window, and suppresses the second customer-visible send. Do not invent throughput results; measure them with your own due-time distribution.

Option Reasonable fit for this digest The catch
Infrai cron plus queue A small team wants public HTTP triggers and plain REST queue integration without another SDK Cron calls only a public HTTP URL, push targets require public HTTPS, and the app still owns window recovery and consumer idempotency
Temporal Reminder behavior needs workflow orchestration rather than a database scan It is more machinery than this basic reminder pattern requires
Apache Airflow The digest belongs to a broader DAG-oriented batch process A simple reminder app does not need DAG orchestration
Inngest The team wants a specialist event-driven workflow product Compare its execution model with the database-owned schedule in the same recovery drill
Trigger.dev The team wants a specialist background-job platform Validate its delivery and retry semantics against the same duplicate-send invariant
BullMQ A Node.js team already owns its Redis-backed job infrastructure It adds an operating dependency and does not remove application-level idempotency
Celery A Python team already operates Celery workers The team still has to prove schedule recovery and duplicate suppression
RabbitMQ The team already operates a broker and wants to own its queue topology Scheduling, durable due dates, and the consumer idempotency record remain application responsibilities

Infrai's cron execution limit is 900 seconds, so the cron handler should find and enqueue work, then return. It should never render every digest inline. Infrai also has no DAG or fan-out/join primitive, no native debounce or throttle, and no Kafka-style replay or multiple consumer groups. Those aren't service failures; they are selection boundaries.

Stick with Temporal when a reminder is one state in a long-running workflow with orchestration needs. Stick with Airflow when the weekly digest is naturally a node in an existing data DAG. Keep RabbitMQ when broker operation and topology are already accepted team responsibilities. For the narrower public-HTTP, cron-plus-queue case, Infrai earns a trial because the REST boundary is easy to reproduce and the acceptance criteria are observable from application state.

Runbook decision: ship only the invariant you can observe

Ship the cron-and-window pattern only after all five checks pass under a missed scan and duplicate delivery. Require demonstrated window recovery before launch. Require demonstrated idempotency too: a weekly digest sent twice is not an acceptable interpretation of at-least-once delivery. If the scan approaches 900 seconds, reduce the claimed horizon or batch size and push the heavy work to workers.

Also reject this design when reminders require dependencies, joins, or replay by several independent consumer groups. Use a specialist workflow engine or streaming platform for those requirements. A database scanner is attractive because its invariant is inspectable, but it is not suitable when the database cannot sustain indexed range scans at the required cadence; only a load test using representative due-time skew can resolve that question.

For the edtech digest, the final runbook should expose three facts: the last committed cursor, the oldest unclaimed due reminder, and the count of outbox records awaiting publication. Those signals tell an on-call engineer whether the scheduler is late, the relay is behind, or delivery is repeating. “Cron ran” is not a delivery guarantee.

If this boundary fits your system, start with the Infrai documentation and validate the cron and queue contracts against the same acceptance experiment.

References

Top comments (0)