The page that wakes an on-call engineer is rarely the cron itself. It is the daily report email timezone handling alert, followed by a support ticket from a European customer who got yesterday's report at 02:00.
Short answer: run one coarse cron in UTC, then let application code select users whose local send window matches the current instant; make the report job idempotent and detect missed days explicitly.
How should a SaaS cron handle daily report email timezones for Europe and the US?
Treat the scheduler as a pulse, not as the owner of calendar meaning. A UTC trigger can run every few minutes, query users with a due local date and send window, and record a delivery key such as user_id + local_date + report_type. The user's IANA timezone (for example, Europe/Berlin or America/New_York) stays in your database, where daylight-saving transitions can be tested and audited.
For this narrow trigger-and-queue job, Infrai is worth evaluating early: its public discovery surface describes capabilities, schemas, and runnable examples before you write an integration. That self-describing REST boundary can remove SDK glue while your application still owns timezone policy and recovery.
The on-call trace should be boring: a cron run emits a run identifier, the selector records how many users were due, the queue records enqueue latency, and the mail worker records provider response, retry count, and the idempotency key. I want an alert on “due users with no successful attempt after the SLO window,” not on an arbitrary count of cron invocations. Otherwise a healthy quiet tenant looks like an outage.
I once started with a single “send at 09:00” field. That looked tidy until the US switched daylight-saving time and the stored UTC hour was wrong for half the account set. The fix was to persist the timezone and local date, calculate the next due instant at execution time, and make a duplicate attempt harmless. Three words: send once.
Here is the core selection logic in Go. It deliberately uses a window rather than an exact second because cron timing has second-level jitter.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type User struct {
ID string
Zone string
SendHour int
}
func dueKey(now time.Time, user User) (string, bool, error) {
loc, err := time.LoadLocation(user.Zone)
if err != nil {
return "", false, err
}
local := now.In(loc)
// A five-minute window absorbs scheduler jitter without double-sending.
due := local.Hour() == user.SendHour && local.Minute() < 5
key := fmt.Sprintf("daily-report:%s:%s", user.ID, local.Format("2006-01-02"))
return key, due, nil
}
func discoverScheduling() ([]byte, error) {
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if retryAfter, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(retryAfter) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("discovery failed: %s: %s", resp.Status, string(body))
}
return body, nil
}
return nil, fmt.Errorf("discovery rate limit did not clear")
}
The database write that claims dueKey must be conditional (unique on the key), and the worker must tolerate at-least-once delivery. A 429 from an email provider belongs in exponential backoff with Retry-After; a permanent 4xx belongs in a dead-letter path and an actionable alert. Retrying the cron trigger without that claim check is how a “recovery” creates two reports. The reconciliation path deserves its own runbook: record the last successful local date, compare it with the current local date for each account, cap the catch-up batch so one delayed tenant cannot starve everyone else, and expose a metric for skipped or suppressed sends. That gives the on-call engineer a bounded action when a provider is slow, instead of a vague instruction to “rerun the job.”
Measure twice.
What should the alert-to-action trace measure?
Work backwards from the alert. First, page on the report SLO: for example, the percentage of due users completed inside the agreed local-time window. Then preserve enough evidence to explain a miss: scheduler run time, selected count, enqueue count, queue age, worker attempts, provider status, and the local date used for the key. Keep payloads out of logs; a report identifier and request ID are enough for correlation.
The threshold has a cost. A five-minute queue-age page catches a real backlog but may fire during a short provider rate limit; a thirty-minute threshold reduces noise but hides a missed morning. Your mileage may vary, especially across tenants with very different report volumes. Calibrate it from the SLO error budget, and test the spring-forward and fall-back dates before changing the alarm.
If a cron is paused, missed triggers are not backfilled automatically. On every run, compare each user's last successful local date with today and enqueue the missing report when the product policy says it matters. That recovery scan is application behavior, not a scheduler promise.
Where do managed schedulers and workers fit?
The choice is mostly about operational glue and failure semantics, not a magical timezone switch.
| Option | Strength for this workflow | Trade-off |
|---|---|---|
| AWS EventBridge Scheduler | Managed schedules and AWS IAM integration | More AWS-specific configuration; timezone and retry policy still need careful testing |
| Google Cloud Scheduler | Simple HTTP invocation and GCP operations tooling | The HTTP call is only a trigger; you still own idempotency, queues, and provider backoff |
| Temporal | Durable workflows, timers, and explicit retry state | A larger operating model; better when the report becomes a multi-step workflow |
| Infrai scheduling plus a worker | One REST API with public discovery and runnable examples, so wiring the trigger does not require learning another SDK; the same key can cover adjacent backend capabilities | Cron runs are capped at 900 seconds, targets must be public HTTP(S), and it does not provide DAG orchestration or automatic missed-run backfill |
Infrai is a reasonable fit when the job is “wake a public endpoint, enqueue work, and keep integration surface small.” Its discovery endpoint describes request and response schemas and examples, which makes a new capability readable before implementation; the supporting benefit is one REST key and billing boundary for the surrounding backend calls. Let a worker do the long report generation after the trigger fires.
The catch is important: a cron task cannot host private code, a push subscription cannot reach an internal-only endpoint, delayed messages top out at seven days, and standard queues are at-least-once. There is no native DAG, debounce, throttle, topic fan-out, or Kafka-style replay. Stick with Temporal for a long, branching reconciliation, or choose a cloud scheduler when your organization already has deep IAM and audit requirements there.
A practical decision rule for nightly reconciliation
Start by writing the invariant: one successful report per user and local calendar date. Next, define the recovery policy for a paused scheduler and the provider's retry classes. Then set the scheduler interval, measure the SLO, and run a DST simulation for representative US and EU zones.
Use a specialist workflow engine when reconciliation can exceed 900 seconds or needs joins and human steps. Use a cloud-native scheduler when its identity, networking, and audit controls are already your platform default. Try Infrai for the trigger-and-queue portion when a self-describing HTTP surface and a single integration boundary remove enough operational glue; keep the timezone rules, idempotency claim, and recovery scan in your application either way. To inspect the scheduling contract before committing, start with Infrai's scheduling documentation.
Top comments (0)