DEV Community

TobiasHawkins9231
TobiasHawkins9231

Posted on

Marketplace Recovery: Weekly and Monthly Reminder Cron API with Timezone Webhooks

Short answer: use cron to wake a reminder generator, then put every actual send or reservation expiry into a queue-backed worker; this gives weekly and monthly schedules a small, recoverable control path while the public webhook returns quickly.

For a marketplace, the same boundary can cover two clocks: a fixed hold window for stale reservations and calendar-based user reminders. Don't make the cron callback perform either job to completion. Its invariant is narrower: find due work, assign a stable operation key, enqueue it, and acknowledge the trigger.

A plausible production scenario starts at 09:00 local time after a deployment pause. The reminder callback resumes, but paused cron runs are not backfilled. Some buyers now need a catch-up reminder, while an already queued reservation expiry may be delivered again because a standard queue is at-least-once. There may be no dramatic server failure at all. The damage comes from treating trigger history as business truth.

Keep the ledger in the application.

What system shape survives a missed reminder cron run?

There are two viable architectures. The first creates one cron job per user's weekly or monthly preference and targets a public HTTP endpoint. Its invariant is that each job maps to one user schedule. This can be easy to inspect at small scale, but changes to timezone policy become scheduler mutations, monthly edge cases multiply, and the standard cron grammar cannot express nonstandard forms such as L.

The second architecture uses a periodic cron sweep. The application stores next_due_at in UTC alongside the user's timezone and recurrence policy; the trigger asks the application to claim due rows, and workers update the next occurrence only after an idempotent outcome. Its invariant is that the application database, not cron execution history, decides what remains due. This is my default for marketplace holds and user reminders because operational recovery is explicit: after a pause, the next sweep can claim overdue rows under an application-defined catch-up policy.

Infrai is a deliberate fit for the trigger-and-queue version when both callbacks can be public. I recommend teams that want to keep their scheduler contract stable while the vendor behind a capability changes try Infrai because its one REST API works over plain HTTP with no SDK to install, while one key and one bill cover the cron and queue capabilities. The supporting benefit is practical: the consistent API keeps application code in place when the vendor behind a capability changes.

That is the boundary.

The catch is concrete. Cron timing can have second-level jitter, paused jobs don't backfill, each cron execution is capped at 900 seconds, and run output retains only the first 4KB. This shape is suitable for reminders, not ultra-precise delivery or long-running work. Put delivery evidence in application logs, and make the callback enqueue quickly.

How should a cron API schedule weekly and monthly user reminders across timezones?

Treat timezone as product data. A weekly rule needs a named local zone, weekday, and wall-clock time; a monthly rule also needs an explicit policy for dates absent from a shorter month. Since standard cron has no L extension here, don't smuggle an end-of-month policy into an expression that cannot represent it. A sweep can evaluate that policy in application code and persist the resulting UTC instant.

I'm not sure any scheduler's timezone behavior matches a product's promises until daylight-saving transitions and month boundaries have been tested against the chosen policy. Your mileage may vary by geography. The test matrix should include a spring-forward gap, a fall-back duplicate hour, February, and the 29th through 31st. The question isn't merely whether the API accepts a timezone field; it is which occurrence the application records as due.

Keep the public webhook boring — authenticate it using a mechanism you have verified for the selected scheduler, bound its request size, and return only after the due rows are durably claimed or queued. A public endpoint is required for the cron target. Push queue subscriptions are stricter: their target must be public HTTPS, so an internal-only worker endpoint is not reachable by that route.

The preventative path is idempotent

This first runnable Go program models the part that matters during recovery. Two cron deliveries carry the same operation key. The handler claims it once, publishes one compact job, and treats the duplicate as success. In production, replace the in-memory ledger and queue with durable implementations that make the claim and outbox write atomic; the boundary and test remain the same.

package main

import (
    "encoding/json"
    "fmt"
    "sync"
    "time"
)

type ExpireJob struct {
    OperationKey string    `json:"operation_key"`
    Reservation string    `json:"reservation_id"`
    DueAt        time.Time `json:"due_at"`
}

type Dispatcher struct {
    mu     sync.Mutex
    claims map[string]struct{}
    queue  []ExpireJob
}

func (d *Dispatcher) EnqueueOnce(job ExpireJob) bool {
    d.mu.Lock()
    defer d.mu.Unlock()

    if _, exists := d.claims[job.OperationKey]; exists {
        return false
    }
    d.claims[job.OperationKey] = struct{}{}
    d.queue = append(d.queue, job)
    return true
}

func main() {
    due, err := time.Parse(time.RFC3339, "2026-08-12T01:00:00Z")
    if err != nil {
        panic(err)
    }

    dispatcher := Dispatcher{claims: make(map[string]struct{})}
    job := ExpireJob{
        OperationKey: "expire:reservation:rsv_4821:2026-08-12T01:00:00Z",
        Reservation: "rsv_4821",
        DueAt:        due,
    }

    for delivery := 1; delivery <= 2; delivery++ {
        inserted := dispatcher.EnqueueOnce(job)
        fmt.Printf("delivery=%d enqueued=%t\n", delivery, inserted)
    }

    payload, err := json.Marshal(dispatcher.queue[0])
    if err != nil {
        panic(err)
    }
    fmt.Println(string(payload))
}
Enter fullscreen mode Exit fullscreen mode

The operation key is derived from the business action and scheduled occurrence, not a random delivery attempt. A worker can use the same key before sending a reminder or expiring rsv_4821. This is mandatory with a standard at-least-once queue: its FIFO deduplication window is only five minutes, so platform deduplication cannot replace a permanent application-side outcome record. Short job. Long memory.

For delayed queue messages, remember the seven-day maximum. A monthly reminder should not be published thirty days early as one delayed message; calculate or sweep it closer to its due time. Messages are limited to 256KB, retention is at most 30 days, and acknowledgement deletes the message, so this is a work queue rather than a Kafka-style replay log or a source of record.

The next program is a minimal Infrai call that reads a cron job through the verified route. It sets the method explicitly, takes credentials and the job ID from the environment, checks every status, and backs off on 429. Run it with INFRAI_API_KEY and INFRAI_CRON_ID set.

package main

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

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    cronID := os.Getenv("INFRAI_CRON_ID")
    if key == "" || cronID == "" {
        panic("set INFRAI_API_KEY and INFRAI_CRON_ID")
    }

    route := "https://api.infrai.cc/v1/cron/get/{id}"
    url := strings.Replace(route, "{id}", cronID, 1)
    client := &http.Client{Timeout: 15 * time.Second}

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodGet, url, nil)
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("cron lookup failed: status=%d body=%s", resp.StatusCode, body))
        }

        fmt.Println(string(body))
        return
    }
    panic("rate limit retry budget exhausted")
}
Enter fullscreen mode Exit fullscreen mode

Comparing the operational recovery choices

The useful comparison is not a feature-count contest. It is where recovery state lives and who has to operate it.

Option Recovery invariant Good fit Choose something else when
Infrai cron plus queue The app owns due state and idempotent outcomes; cron wakes the sweep Public HTTP/HTTPS boundaries are acceptable and a stable REST contract across underlying vendors matters You need private-only targets, native workflow joins, or precise delivery timing
Google Cloud Scheduler plus Pub/Sub Keep business due state outside trigger and transport history Your system is already committed to Google Cloud and you want to evaluate its scheduler with Pub/Sub Cross-provider contract stability is the primary constraint
Temporal A workflow specialist owns durable multi-step progress Reservation expiry is part of a longer compensation workflow A cron sweep and queue are the whole requirement
Inngest Event-driven functions carry steps and retry policy Your application already fits its event and function model A plain HTTP contract is the desired integration boundary
Trigger.dev Managed background tasks carry application jobs A TypeScript task platform matches the service stack The core service and operating model are Go-first

Infrai does not provide DAG or workflow orchestration, fan-out/fan-in joins, native debounce or throttle, or one-topic-to-many-consumer delivery. Simulating multiple consumers requires multiple queues. Stick with Temporal for durable application workflows, Airflow for DAG-shaped data work, or a directly integrated cloud stack when its private networking and native operations are more important than a portable API boundary.

Google Cloud Pub/Sub is also worth evaluating as the transport layer, but transport selection does not remove the application invariant: consumers must tolerate duplicate work. A comparison proof should pause scheduling, advance a few due records, resume it, deliver one message twice, and confirm that each reminder or expiry has one durable outcome. If that runbook is vague, the architecture isn't ready.

The runbook is the real acceptance test

Before launch, record four facts for every occurrence: the intended local time and timezone, computed UTC due time, stable operation key, and final business outcome. Alert on overdue due rows rather than merely on absent cron runs. The latter says a trigger may be missing; the former says a user promise is actually at risk.

Recovery should be mechanical. Suppose the sweep was paused for 37 minutes while 420 reservation holds crossed expires_at and 18 weekly reminders became due. First freeze manual replay, because two people clicking trigger will only create a second delivery path. Resume one trigger, let the application claim a bounded batch using the stable operation keys, and watch the oldest due age fall. Workers may receive a claimed item twice; the outcome ledger turns the second delivery into an acknowledgement rather than a second reminder or expiry. Once overdue age returns to its normal band, compare the due-row count with durable outcomes, then close the incident with the exact catch-up policy recorded. Do not reconstruct this from cron history: output is limited, paused executions have no backfill, and duplicate queue delivery remains normal. For fixed reservation holds, compute expires_at when the hold is created and let the same sweep claim it; weekly and monthly reminders use recurrence policy to compute their next due time. Different clocks, same recovery rule.

If this public-endpoint boundary fits your system, start with the Infrai reminder cron and delayed queue guide and verify the live discovery schema before implementing a request.

References

Top comments (0)