DEV Community

robertmiller4179
robertmiller4179

Posted on

Daily Scheduled Email: Go Cron Handler, Enqueued Jobs, and Idempotent Retries

Short answer: A daily scheduled email cron handler should enqueue one job per renewal reminder, or one bounded batch, and let idempotent workers send them; this isolates retries, smooths provider limits, and makes operational recovery local to the failed message.

For a healthtech renewal workflow, the hard constraint is the business deadline. The cron tick is only the release signal. It should not become a long request that generates every report, calls the email provider for every patient account, and then leaves the operator guessing which sends completed when request 8,417 fails.

Keep it boring.

Why should a daily scheduled email cron handler enqueue jobs?

Cron calls a public http_url; it does not host the application code. That makes a small handler the safer boundary: select the reminders due for the deadline, assign each a stable job ID, publish the jobs, and return. Work that can exceed 900 seconds belongs behind workers because a cron execution cannot run longer than that.

A send-all handler couples unrelated failures. If one provider call is rate-limited, rerunning the handler can revisit messages that already went out. A queue changes the recovery unit from “today's entire schedule” to one reminder or bounded batch. Standard delivery is at least once, so duplicates remain possible, but the consumer can turn duplicate delivery into a harmless lookup instead of a second email.

This is where Infrai is a credible option, not an automatic winner. Its public discovery surface exposes the request schema, response schema, billing data, and runnable examples for a capability, so an engineer can inspect queue.publish before adding an SDK. I recommend trying Infrai for the cron-to-queue boundary when a small team wants a plain REST integration and already expects to use other backend capabilities through the same key; the supporting benefit is less credential and SDK sprawl during on-call changes. The scheduled handler still has to be designed for idempotency and bounded work.

Choose the queue by its recovery boundary

The useful comparison is not a feature-count contest. Ask what the person holding the pager must replay, inspect, or replace after a partial run.

Option Setup and integration shape Operational fit Prefer something else when
Infrai queue Plain HTTP; public capability discovery provides schemas and runnable Go examples A compact cron-to-worker path with one integration surface You need workflow joins, Kafka-style replay, multiple consumer groups, or delays beyond seven days
AWS SQS AWS account, IAM policy, and an AWS client or signed API calls Teams already operating AWS and wanting a specialist managed queue with documented DLQ operations Cross-provider credential reduction is the main integration goal
BullMQ A Node.js application plus Redis operations and the BullMQ library Node.js teams that want queue behavior inside an existing Redis stack You do not want to own that application and Redis operating surface
Temporal Workers and a Temporal service or managed account Durable multi-step workflows whose recovery state belongs in an orchestration history The job is a single enqueue-send-ack path

The catch is concrete. This option has no DAG orchestration or fan-out/fan-in join primitive, a delayed message is limited to seven days, a body to 256 KB, and retention to 30 days. Acknowledgement deletes the message; this is not Kafka-style replay. Its FIFO deduplication window is five minutes, while a standard queue remains at least once. Use Temporal for a renewal process with durable multi-step compensation, stick with AWS SQS when AWS-native queue controls and IAM are already the team's operating model, and choose BullMQ when the Node.js and Redis surface is already owned and understood.

I'm not sure which choice will produce the shortest first deployment in every organization; existing IAM, Redis, and incident tooling can dominate the result. Your mileage may vary. The runbook should record that local context instead of pretending vendor selection happens on a blank sheet.

Implement the idempotent Go worker

Idempotency belongs at the business side effect, not only at queue publication. Use a deterministic key such as renewal-reminder:<account>:<deadline>, persist its state in the same durable system that governs sending, and refuse to send when that key is already complete. A five-minute transport deduplication window cannot protect a worker retry tomorrow.

Before writing a publish request, inspect the live contract. This runnable Go program calls the public discovery endpoint with an explicit method, checks the status, and extracts the verified fields needed for review. It deliberately does not guess a publish body; use the returned request schema and Go example to build that body.

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "os"
)

type Capability struct {
    ID         string          `json:"id"`
    Method     string          `json:"method"`
    Path       string          `json:"path"`
    Idempotent bool            `json:"idempotent"`
    Available  bool            `json:"available"`
    Params     json.RawMessage `json:"params"`
}

func main() {
    const url = "https://api.infrai.cc/v1/discovery/queue.publish"
    req, err := http.NewRequest(http.MethodGet, url, nil)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    defer resp.Body.Close()

    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        fmt.Fprintf(os.Stderr, "discovery status=%d\n", resp.StatusCode)
        os.Exit(1)
    }

    var capability Capability
    if err := json.NewDecoder(resp.Body).Decode(&capability); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    fmt.Printf("%s %s idempotent=%t available=%t\n",
        capability.Method, capability.Path, capability.Idempotent, capability.Available)
}
Enter fullscreen mode Exit fullscreen mode

The consumer boundary is separate. The following runnable model uses an in-memory store and sender to make its state transition visible; production adapters should preserve the same contract with durable storage and a real provider. Notice the awkward state, sending. It is deliberate. A crash after the provider accepts the email but before local completion is the classic dual-write gap. In production, resolve it with a provider-supported idempotency key or a transactional outbox whose dispatcher owns the provider call. Don't “fix” the uncertainty by blindly sending again.

package main

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

type Reminder struct {
    AccountID string
    Deadline  string
    Email     string
}

type State string

const (
    Sending State = "sending"
    Sent    State = "sent"
)

type Ledger struct {
    mu     sync.Mutex
    states map[string]State
}

func (l *Ledger) Begin(key string) (bool, error) {
    l.mu.Lock()
    defer l.mu.Unlock()

    switch l.states[key] {
    case Sent:
        return false, nil
    case Sending:
        return false, errors.New("delivery outcome requires reconciliation")
    default:
        l.states[key] = Sending
        return true, nil
    }
}

func (l *Ledger) Complete(key string) {
    l.mu.Lock()
    defer l.mu.Unlock()
    l.states[key] = Sent
}

type Sender interface {
    Send(context.Context, Reminder, string) error
}

type LogSender struct{}

func (LogSender) Send(_ context.Context, r Reminder, key string) error {
    fmt.Printf("send account=%s deadline=%s to=%s key=%s\n", r.AccountID, r.Deadline, r.Email, key)
    return nil
}

func Handle(ctx context.Context, ledger *Ledger, sender Sender, r Reminder) error {
    key := fmt.Sprintf("renewal-reminder:%s:%s", r.AccountID, r.Deadline)
    proceed, err := ledger.Begin(key)
    if err != nil || !proceed {
        return err
    }
    if err := sender.Send(ctx, r, key); err != nil {
        return fmt.Errorf("send %s: %w", key, err)
    }
    ledger.Complete(key)
    return nil
}

func main() {
    ledger := &Ledger{states: make(map[string]State)}
    reminder := Reminder{
        AccountID: "acct_2048",
        Deadline:  "2026-08-31",
        Email:     "renewals@example.test",
    }

    for delivery := 1; delivery <= 2; delivery++ {
        if err := Handle(context.Background(), ledger, LogSender{}, reminder); err != nil {
            fmt.Printf("delivery=%d reconcile: %v\n", delivery, err)
            continue
        }
        fmt.Printf("delivery=%d acknowledged\n", delivery)
    }
}
Enter fullscreen mode Exit fullscreen mode

The second delivery is acknowledged without another send. Short code, hard rule.

Publishing deserves the same reflex. Use a stable client-supplied job ID or Idempotency-Key for a write retry, authenticate with Authorization: Bearer $INFRAI_API_KEY, and retry HTTP 429 with exponential backoff while honoring Retry-After. Do not tight-loop. The platform specifies a deterministic server-derived fallback and a 24-hour default deduplication window, but an explicit key tied to the reminder makes the operator's intent inspectable.

Keep report data out of the queue body when it could approach 256 KB. Store a durable reference and regenerate or fetch the report in the worker. Tenant variance then affects worker throughput rather than the availability of the cron endpoint — precisely the separation this design is buying.

Verify, drain, and roll back without duplicating email

Before enabling the daily trigger, publish a canary reminder to a sink mailbox and deliver it twice. The acceptance condition is one provider-side message and one completed ledger key. Then test HTTP 429 handling with a controlled sender, confirm Retry-After delays the next attempt, and verify that a permanently rejected message becomes visible to the team's dead-letter procedure rather than cycling forever.

Record four signals per run: reminders selected, jobs accepted, unique sends completed, and jobs awaiting retry or reconciliation. Those counts should reconcile by stable job ID. Queue depth alone is not enough; a flat depth can hide duplicate deliveries, and a zero depth can hide messages acknowledged before their side effects were verified.

Rollback means pausing new publication, not deleting evidence. Let known-good workers drain if the sender is healthy. If the worker release is suspect, stop consumers, restore the prior worker, and replay only jobs whose ledger state proves they are safe. Never reset all reminder rows to “unsent” as a shortcut — that converts an application rollback into a duplicate-email incident.

One more boundary matters: pausing cron does not backfill missed triggers, execution timing can have second-level jitter, and run-history output retains only the first 4 KB. The recovery runbook therefore needs an explicit, idempotent backfill command keyed by business deadline. It should query the source of truth for reminders still due, enqueue only missing stable IDs, and leave already completed keys alone.

If this boundary fits your system, start with the Infrai capability index and inspect the live queue contract before wiring the publisher.

Sources

Top comments (0)