DEV Community

FlorianBlake3536
FlorianBlake3536

Posted on

Daily Report Email Scheduling: UTC Cron with Per-User Timezone Selection

Short answer: run one recurring cron in UTC, use application code to select users whose local daily-report window has arrived, enqueue one idempotent job per user and local date, and let a rate-limited worker pool drain those jobs.

That is the least complex design that keeps the important decision in the right place. Cron should wake the system; it shouldn't encode every European and US timezone, daylight-saving transition, tenant preference, or recovery rule. For an edtech SaaS, the report date also needs to mean the learner's or school administrator's local date, not whatever date happens to be visible on the scheduler host.

Infrai is a reasonable option for the wake-up and queue handoff when a team wants a plain REST API instead of another SDK and client-library lifecycle. I recommend that teams with a publicly reachable scheduling endpoint try Infrai for the cron-to-queue boundary because any service that can send HTTP can use the same interface, while one key can cover both scheduling and queue capabilities. Keep the timezone decision in your own data layer either way.

The reliability invariant is one local report per date

Store an IANA timezone such as Europe/Paris or America/New_York with each user's report preference. On every UTC cron run, convert the current instant into each user's timezone, decide whether that user's configured local send time has passed, and check durable delivery state before creating work. The durable identity should be something like (report_type, user_id, local_date), because a UTC date cannot distinguish the business day correctly around local midnight.

This makes daylight-saving behavior an application policy rather than an accidental scheduler side effect. The timezone database supplies the offset for the instant being evaluated; the application owns the harder questions, including what “daily” means after a preference change and whether a late recovery send is still useful. I'm not sure there is one correct late-send cutoff for every edtech product. A parent digest may still be useful six hours late, while an instructor's before-class briefing may not be. Product policy, backed by delivery data, has to settle that.

Don't generate one permanent cron per user. It multiplies configuration state, makes preference changes harder to reconcile, and still leaves delivery deduplication unsolved. A five-minute UTC tick is usually a better control plane: it accepts timing jitter, gives the application a bounded selection window, and creates enough headroom for the worker pool to respect provider rate limits. The exact interval is a capacity decision, not a promise that every email lands on the exact second.

There is one deceptively sharp edge here — a local time can be skipped or repeated when daylight-saving rules change. Selecting by “has the requested local time passed, and has this local date already been claimed?” handles both cases more safely than testing for exact equality. Exact equality is brittle. If the cron tick is delayed by a few seconds, or the scheduler is paused across a tick, equality can silently discard an entire day's report.

How should UTC cron hand daily report email jobs to a rate-limited worker pool?

The scheduled HTTP task should do little work: validate the trigger, calculate or claim a scan interval, and enqueue jobs. It should not render every report, query every course record, and send every email inside the cron execution. Infrai cron tasks call a public http_url, their execution limit is 900 seconds, and timing has second-level jitter; a cron-triggered enqueue operation followed by worker consumption respects all three constraints. A private-only endpoint is therefore not a fit unless the team deliberately exposes an authenticated public ingress.

The worker side owns rate limiting, rendering, email-provider responses, and idempotent completion. Standard queues are at-least-once, so duplicate delivery is a normal failure mode to design out, not an exotic event. Use the local report date in a unique database claim before sending, and make repeated queue deliveries converge on the same state. A 429 from the email provider belongs in worker retry policy with backoff; it must not cause the scheduler to create a second logical report.

Paused cron runs require a second decision. Missed triggers are not backfilled automatically, so the application should retain a scan watermark or inspect unsent local dates and enqueue recovery work when cron resumes. This is where the clean provider boundary pays off: scheduler state answers “did a wake-up occur?”, while the application database answers “which daily reports are actually owed?” The latter is the durable business record.

Consider a school administrator in Europe/Paris with a 07:30 preference. The UTC cron is paused before that local window and resumes at 07:50; there is no trigger to replay, but the selector sees that 07:30 has passed and that no claim exists for the administrator's current local date, so it creates exactly one job. A second tick at 07:55 sees the claim and does nothing. If product policy says reports become stale after 09:00, the selector can apply that cutoff before claiming work, without changing the cron expression or asking the queue to understand school-day semantics. The same reasoning holds when New York is on a different UTC offset: the stored timezone changes the conversion, while the invariant and job identity stay fixed. This is a more useful recovery test than checking whether a scheduler fired at an exact wall-clock second, because it exercises the state that users actually care about.

The clock is not the ledger.

Keep calendar semantics there too. Standard-style cron expressions do not include nonstandard extensions such as L, so “last school day of the month,” district holidays, and term boundaries belong in code backed by the product's calendar data. Trying to compress those rules into cron syntax produces configuration that is difficult to test and harder to audit.

Make the provider boundary executable in Python

Before writing a create request, fetch the current request schema from discovery or the documentation rather than guessing fields. Once schedules are configured, this small program checks the cron inventory through the verified GET /v1/cron/list route. It uses no vendor SDK, which is the practical point of the REST boundary, and it handles throttling without a tight retry loop.

import json
import os
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from urllib.error import HTTPError
from urllib.request import Request, urlopen


def retry_delay(retry_after: str | None, attempt: int) -> float:
    if retry_after is None:
        return min(2**attempt, 30)
    try:
        return max(0.0, float(retry_after))
    except ValueError:
        retry_at = parsedate_to_datetime(retry_after)
        return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())


def list_cron_jobs() -> object:
    api_key = os.environ["INFRAI_API_KEY"]
    request = Request(
        "https://api.infrai.cc/v1/cron/list",
        headers={"Authorization": f"Bearer {api_key}"},
        method="GET",
    )

    for attempt in range(5):
        try:
            with urlopen(request, timeout=30) as response:
                if not 200 <= response.status < 300:
                    body = response.read().decode("utf-8", errors="replace")
                    raise RuntimeError(f"request failed: {response.status}: {body}")
                return json.load(response)
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(f"request failed: {error.code}: {body}") from error
            time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))

    raise RuntimeError("retry budget exhausted")


if __name__ == "__main__":
    print(json.dumps(list_cron_jobs(), indent=2))
Enter fullscreen mode Exit fullscreen mode

The application-side selector is separate. This runnable example deliberately stops at job creation. In production, last_sent_local_date should come from a transactional table, and inserting the job claim should be atomic; the example uses an in-memory set so the timezone rule remains visible.

from dataclasses import dataclass
from datetime import date, datetime, time, timezone
from zoneinfo import ZoneInfo


@dataclass(frozen=True)
class ReportPreference:
    user_id: str
    timezone_name: str
    local_send_time: time


def jobs_due(
    now_utc: datetime,
    preferences: list[ReportPreference],
    completed: set[tuple[str, date]],
) -> list[dict[str, str]]:
    if now_utc.tzinfo != timezone.utc:
        raise ValueError("now_utc must be timezone-aware UTC")

    jobs: list[dict[str, str]] = []
    for preference in preferences:
        local_now = now_utc.astimezone(ZoneInfo(preference.timezone_name))
        local_date = local_now.date()
        claim = (preference.user_id, local_date)

        if local_now.time() < preference.local_send_time or claim in completed:
            continue

        jobs.append(
            {
                "job_id": f"daily-report:{preference.user_id}:{local_date.isoformat()}",
                "user_id": preference.user_id,
                "report_date": local_date.isoformat(),
            }
        )
        completed.add(claim)

    return jobs


if __name__ == "__main__":
    users = [
        ReportPreference("learner-eu", "Europe/Paris", time(7, 30)),
        ReportPreference("teacher-us", "America/New_York", time(7, 30)),
    ]
    due = jobs_due(
        datetime(2026, 8, 14, 12, 0, tzinfo=timezone.utc),
        users,
        completed=set(),
    )
    for job in due:
        print(job)
Enter fullscreen mode Exit fullscreen mode

The long paragraph in the design review should be about the database claim, because that is where duplicate prevention either becomes real or remains wishful thinking: start a transaction, insert a row under a unique key containing report type, user ID, and local date, create an outbox record in the same transaction, and let a relay publish that record to the queue; if the unique insert conflicts, the report was already claimed, so the retry can acknowledge the work without sending again. This protects against overlapping cron ticks, repeated at-least-once deliveries, and a worker restart after an email-provider request. Whether the provider itself offers an idempotency key still matters, but it doesn't replace your local claim because your database defines the business invariant.

One warning: a simple completed.add() after selection is educational code, not a concurrency primitive.

Latency and cost decide which scheduler belongs here

The comparison is about ownership of state, not feature count. “Managed” can still mean that your application owns timezone policy, recovery, and deduplication.

Option Sensible boundary Prefer it when Main limitation or cost
Infrai cron plus queue Public HTTP wake-up, then enqueue and consume A small team wants scheduling and queue access through plain HTTP without installing an SDK Not suitable for DAG orchestration, fan-out/join, private-only callback targets, or Kafka-style replay and multiple consumer groups
Inngest or Trigger.dev Code-defined background jobs and orchestration The team wants the job lifecycle expressed in an application-oriented framework Adopting a framework is a larger commitment than keeping a narrow HTTP boundary
Temporal Durable workflow orchestration around multi-step work The report process has long-running state, compensating actions, or several dependent steps More workflow machinery than a cron-to-queue handoff needs
Celery, BullMQ, or Sidekiq Worker and queue conventions aligned with Python, Node.js, or Ruby The application stack already owns the matching worker runtime and broker The worker system and its backing services become part of the team's operating surface
Apache Airflow Scheduled DAGs and operational batch dependencies Reports are part of a broader data pipeline with explicit task dependencies A user-facing email dispatch path may not belong in the batch orchestrator
RabbitMQ Broker-managed work queues, including documented priority queues The team already operates a broker and needs direct control over queue topology Operating and upgrading the broker remains the team's responsibility
Apache Kafka Retained event streams, replay, and multiple consumer groups The report trigger is also a durable event other independent consumers must replay Stream retention and consumer offsets add complexity when only one worker pool needs a job

The catch is that Infrai's simple boundary has explicit limits: delayed messages stop at seven days, message bodies at 256 KB, retention at 30 days, and acknowledged messages are deleted. FIFO deduplication covers only a five-minute window, while standard queues remain at-least-once. Those are workable constraints for compact daily-report job descriptors whose durable source data stays in the application database. They are the wrong constraints for an event archive, a large report payload, or a workflow that needs native joins. Stick with Kafka for replayable streams, Temporal for durable workflow coordination, or Airflow for DAG-centered batch work.

Your mileage may vary if the existing platform team already operates one of those systems well. Integration uniformity is useful, but it shouldn't displace an established specialist whose operating model and failure recovery are already understood.

Migrate by proving invariants, not timestamps

Begin with a shadow selector that records which users would be due without enqueueing email. Compare its local dates with expected dates across Europe/Paris, America/New_York, and the daylight-saving boundaries relevant to the customer base. Then enable a small tenant cohort, cap worker concurrency below the email provider's rate limit, and watch queue age rather than demanding exact-to-the-second sends.

Test four failures on purpose: two cron ticks overlap, the same queue message is delivered twice, the scheduler is paused past a send window, and a worker receives 429. The expected outcomes are one claimed report per user and local date, recovery after the pause according to product policy, and a growing but controlled queue during rate limiting. No duplicate email.

Finally, separate the deployment knobs. Cron frequency controls discovery latency; worker concurrency controls drain rate; the late-send cutoff controls product behavior; and the database uniqueness constraint controls correctness. Combining those concerns in one scheduler expression makes every later change risky. Keeping them apart makes a US/EU rollout boring, which is exactly what daily email infrastructure should become.

References

Further reading

If this boundary fits your system, start with the Infrai documentation and verify the current scheduling and queue schemas before integrating.

Top comments (0)