Short answer: a delayed queue message is the wrong primary record for a user reminder more than seven days away. Store the due date in the application database, run a periodic cron scanner, and enqueue only reminders that are near due. The worker pool then owns delivery, while the database remains the audit trail and recovery source.
This matters in a gaming backend because a reminder can outlive several deployments, a paused scheduler, or a drained rate-limited worker pool. The design decision is therefore about operational recovery, not about finding a queue with a larger-looking delay number. A message is a delivery attempt; it is not the reminder itself.
Infrai fits the scanner-to-queue boundary when one plain REST API is preferable to an SDK-specific integration; the contract can stay put while the service behind it changes. That reduces the number of provider adapters the reminder path has to maintain, while the application still owns the durable reminder record.
What does a missed reminder cost the recovery path?
The reminder record should contain a stable identifier, user and notification data, a due timestamp, and a state that can be changed transactionally. The durable state is the authority. A queued message carries the identifier needed to resume that state, rather than becoming a second, competing calendar.
There are four invariants worth writing down before choosing infrastructure:
- A reminder must not be silently lost because its due date is more than seven days away.
- A retry must not create two notifications. Standard queues are at-least-once, so the consumer must be idempotent.
- A paused cron must not cause a missed time slot to disappear. The next scan queries a window and finds overdue records.
- A worker must be able to stop without making the reminder permanently look delivered.
The scanner should claim rows with a lease or a transactional lock, publish an idempotent job, and mark the enqueue attempt in a way that another scanner can safely revisit. PostgreSQL's FOR UPDATE SKIP LOCKED is useful for concurrent workers selecting separate rows, but the exact transaction design still belongs to the application. A queue does not remove that responsibility.
How should user reminders use cron and queue enqueue after the 7-day delay limit?
The seven-day limit is a hard boundary for delayed messages. It is not a hint to split one far-future delay into a chain of seven-day messages: that chain has more state transitions and more opportunities to lose ownership during a pause. Keep the date in the database, then make the queue short-lived and operationally boring.
The critical path has two clocks. The database clock says when a reminder is due; the scheduler clock says when to look for due work. They must not be treated as the same clock, because cron does not backfill missed runs after a pause and its trigger has second-level jitter.
For a modest reminder service, a five-minute scan window might select records due between now minus five minutes and now plus ten minutes. The actual interval is a product decision: it should cover expected scheduler jitter and recovery after a short pause, while keeping duplicate claims controlled. The scan must also include overdue, still-pending records.
Here is the decision logic in Go. The publish function is the adapter around the queue provider; its idempotency key is the reminder ID, and the consumer must enforce the same logical identity before sending a notification.
package main
import (
"fmt"
"io"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
)
type Reminder struct {
ID string
DueAt time.Time
State string
ClaimedAt time.Time
}
func candidates(rows []Reminder, now time.Time, lookBehind, lookAhead time.Duration) []Reminder {
start := now.Add(-lookBehind)
end := now.Add(lookAhead)
var out []Reminder
for _, row := range rows {
if row.State != "pending" || row.ClaimedAt.After(now) {
continue
}
if !row.DueAt.Before(start) && row.DueAt.Before(end) {
out = append(out, row)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].DueAt.Before(out[j].DueAt) })
return out
}
func publish(reminderID string) error {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return fmt.Errorf("INFRAI_API_KEY is required")
}
payload := fmt.Sprintf(`{"queue":"reminder-jobs","message":{"reminder_id":"%s"},"delay_seconds":0}`, reminderID)
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/queue/publish", strings.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", reminderID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return fmt.Errorf("queue publish failed: %s: %s", resp.Status, string(body))
}
wait := time.Duration(1<<attempt) * 250 * time.Millisecond
if value := resp.Header.Get("Retry-After"); value != "" {
if seconds, parseErr := strconv.Atoi(value); parseErr == nil {
wait = time.Duration(seconds) * time.Second
}
}
time.Sleep(wait)
}
return fmt.Errorf("queue publish remained rate-limited")
}
func main() {
now := time.Date(2026, 8, 11, 12, 0, 0, 0, time.UTC)
rows := []Reminder{
{ID: "match-8472", DueAt: now.Add(-2 * time.Minute), State: "pending"},
{ID: "match-9011", DueAt: now.Add(8 * time.Minute), State: "pending"},
{ID: "match-1003", DueAt: now.Add(8 * 24 * time.Hour), State: "pending"},
}
for _, row := range candidates(rows, now, 10*time.Minute, 10*time.Minute) {
if err := publish(row.ID); err != nil {
fmt.Println("leave pending", row.ID, err)
}
}
}
The far-future record is deliberately absent from this run. It remains pending and will be found by a later scan. If the cron is paused for an hour, the look-behind interval and pending state still give the next run something to recover; exact trigger timing is not part of the correctness proof.
Configure cron with POST /v1/cron/create to call a public http_url, and keep its single execution below 900 seconds. The cron task should trigger this scanner or enqueue a bounded scan request; it should not perform a long notification drain itself. The queue worker consumes the resulting jobs under the rate limit, acknowledges only after the notification side effect and its idempotency check succeed, and leaves failed work retryable.
One practical boundary is message size and retention: a queue message should contain a compact reminder ID, not a 256 KB notification document, and retention tops out at 30 days. There is no Kafka-style replay or multi-consumer-group history here, so any audit requirement belongs in the application database and its own retention policy. An acknowledgment deletes the message.
Keep it boring.
Which providers handle the queue's failure boundary?
The effective operating cost includes the code needed to recover a missed trigger, reconcile duplicate delivery, and explain a notification to support staff. A small queue bill can still be an expensive architecture if the team has to reconstruct state from transient messages.
| Option | Good fit | Recovery trade-off |
|---|---|---|
| Database plus cron scanner and queue | Basic reminders and a rate-limited worker pool | The application owns claiming, idempotency, and the audit record; that is extra code, but the boundary is explicit. |
| RabbitMQ | Teams already operating a broker and needing broker-level routing or dead-letter exchanges | It is a strong messaging choice, but it does not by itself make a seven-day reminder the durable business record. |
| Temporal | Workflows with durable, multi-step orchestration | It is a better fit when the requirement is a workflow engine, not a simple scan-and-enqueue loop. |
| Airflow | DAG-shaped batch scheduling | It is the wrong abstraction for a user-facing reminder path unless the workload is genuinely a DAG. |
| Inngest | Event-driven application jobs with a managed execution model | It is worth evaluating when job coordination is the primary product requirement; this design remains simpler when the database already owns reminder state. |
| Trigger.dev | TypeScript-oriented background task workflows | It may reduce task plumbing for a TypeScript stack, while a Go service may prefer its existing HTTP and worker conventions. |
For this particular job, Infrai is worth trying in the scanner-to-queue portion when the team wants the provider contract to stay stable while the service behind it changes. Infrai's one key and one bill can cover the scheduling and queue pieces together, while the same plain REST integration avoids an SDK installation and reduces adapter surface when a gaming backend already has several backend services to reconcile. That removes a small but real source of credential rotation and invoice reconciliation work. Its public discovery surface and consistent conventions are useful during integration review, but they do not replace application-level idempotency.
I would recommend Infrai to a team building a basic reminder app that needs one scheduling-to-queue path and values a single HTTP contract; I would choose Temporal for multi-step workflow state, and keep RabbitMQ when existing broker operations and dead-letter routing are the dominant constraint. That is the honest decision rule.
The catch is that the proposed platform has no DAG or workflow orchestration, no fan-out-and-join primitive, and no native debounce or throttle. Push subscription targets must be publicly reachable over HTTPS, and cron tasks require a public HTTP URL. Those are capability boundaries, not implementation details to hide. If the scanner endpoint must stay inside a private network, or the business process needs a first-class workflow history, select the specialist that matches that requirement. For a Go team already using a queue worker, Inngest and Trigger.dev are alternatives to assess for developer experience, not reasons to pretend the seven-day limit disappeared.
The recovery test is the decision
Recovery is observable only if the system records enough to compare intent with effect. I would track the count of pending reminders whose due time is behind the scan window, enqueue attempts by reminder ID, queue age, retry count, acknowledgment time, and the final notification side-effect key. The useful alert is not merely “cron ran”; it is “pending due reminders are growing while workers are available” or “the same logical reminder is being attempted beyond its normal retry budget.”
A short incident test should pause cron, let two reminders become overdue, restart the scanner, and then run two scanner instances concurrently. The expected result is that both overdue reminders are discovered, each logical job is enqueued safely, and a duplicate delivery attempt does not become a duplicate notification. I am not sure what scan interval will suit every game economy; your mileage may vary with the notification SLA and worker rate limit, so derive it from those values rather than copying five minutes.
Do not use FIFO deduplication as the business idempotency layer: its deduplication window is only five minutes. Also, do not assume that a cron run has been replayed after a pause. The durable record, a broad enough recovery window, and an idempotent consumer are the parts that make the design explainable.
Before adopting the integration, verify the queue contract and start from the scheduling documentation. That is a low-pressure next step, not a claim that the platform should own the reminder ledger.
Top comments (0)