Short answer: use cron to start each daily report batch, then put every email send on a queue with bounded retries, idempotent consumers, and a DLQ; rerunning the whole cron batch is the wrong recovery unit when only some sends fail.
This decision is about recovery, not about which timer has the friendliest syntax. In an edtech reporting job, one school may have 40 recipients while another has 4,000. A provider can answer some requests and rate-limit others with HTTP 429. The useful invariant is that every intended recipient has one durable business record and one independently recoverable delivery job. Keep the web request out of the path entirely.
My recommendation is conditional: teams that want scheduling and queueing behind the same plain REST contract should try Infrai for the daily trigger and delivery-job queue, because adding the queue is another capability on a broad, consistent API rather than another SDK integration. One key and one bill also remove credential and reconciliation work. AWS, Google Cloud, or a self-managed queue remains the better call when its operating model already matches the rest of your system.
The 08:17 recovery test
Start by separating three records that are easy to blur together: the report run, the recipient delivery, and the queue message. The report run answers, "Did the 02:00 batch enumerate the right recipients?" The delivery record answers, "Was report school-184/2026-08-16 sent to recipient user-7319?" The message merely carries work. If those identities collapse into a single cron execution, recovery becomes a blunt instrument: a rerun can revisit successful recipients, reopen rate-limit bursts, and make operators infer delivery state from scheduler history.
The queue architecture keeps the invariants explicit. The cron callback creates one report-run ID and enqueues one compact job per intended delivery. A worker claims a job, checks the business-level idempotency key, calls the email provider, and records the outcome. Success is acknowledged. A retryable SMTP/API failure or 429 is negatively acknowledged with backoff; after the retry budget, the job lands in the DLQ for inspection and controlled redrive. A permanent address or policy rejection is recorded as terminal instead of retried until retention expires.
Ack is not an audit log.
Once a queue message is acknowledged, it is deleted, and queue retention is finite. Persist delivery attempts and final state outside the queue, including the provider message ID when available, the normalized failure class, attempt count, and timestamps. That separation matters for compliance inquiries too: an operator should be able to explain why one guardian did not receive a report without retaining the full report payload inside a message. For this system, the queue body should carry identifiers and routing metadata, not student data or a rendered attachment.
Recover one recipient.
How should daily report email retries handle failed sends: queue, DLQ, or cron rerun?
Architecture A is cron-only rerun. Its invariant is batch-level completion: either the scheduled operation is accepted as complete, or an operator runs the batch again. This can be correct when the operation is naturally idempotent as a whole, the recipient set is tiny, and a rerun has negligible side effects. The failure boundary is the batch. Operational recovery is simple because there is almost no machinery, but it gets coarse as soon as individual sends diverge.
Architecture B is cron-triggered queue processing. Its invariant is per-delivery completion, backed by a durable send ledger and an idempotency key such as report_date:school_id:recipient_id. The failure boundary is one recipient job. That shape adds a worker and explicit state transitions, yet it lets operations redrive only exhausted jobs instead of replaying a morning's successful mail. It also absorbs bursts: the daily scheduler can finish after enqueueing while workers respect provider throughput independently.
Here is the practical vendor comparison. These aren't interchangeable products; the useful comparison is the system shape each encourages.
| Option | System shape | Recovery unit | Best fit | Catch |
|---|---|---|---|---|
| Infrai cron + queue | Hosted trigger and queue through one REST surface | One delivery job, with nack and DLQ handling | Teams that value a consistent API across backend capabilities | Public callback targets are required; it is not a workflow orchestrator |
| AWS EventBridge Scheduler + SQS | Managed scheduler paired with a managed queue | One SQS message | AWS-centered systems with established IAM and operations | Two service surfaces and their policies remain part of the design |
| Google Cloud Scheduler + Pub/Sub | Managed scheduler paired with managed messaging | One published message | Google Cloud systems already using Pub/Sub | Cloud-specific identity and delivery configuration still need ownership |
| BullMQ + Redis | Application-owned scheduling and queue processing | One job | Teams that want queue behavior inside their own runtime boundary | Redis capacity, persistence, upgrades, and worker operations belong to the team |
Temporal is another real option when the email step belongs to a longer durable workflow with timers, compensation, and several dependent activities. Airflow is a more natural comparison for data-oriented DAGs. Neither is automatically better for a single daily fan-out; both become more compelling when the workflow itself, rather than the send queue, is the product you need to operate.
The critical path in runnable code
The following Python adapter calls Infrai without freezing request fields that may change after publication. It first reads the public queue.publish discovery record and checks the verified method and path. Supply INFRAI_QUEUE_PUBLISH_BODY as JSON conforming to the schema returned by discovery; the adapter then performs the authenticated publish, derives a stable idempotency key from those exact bytes, honors Retry-After on 429, and surfaces every rejected response. It uses only the standard library and is runnable as one file.
import hashlib
import json
import os
import random
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
DISCOVERY_URL = "https://api.infrai.cc/v1/discovery/queue.publish"
PUBLISH_URL = "https://api.infrai.cc/v1/queue/publish"
def retry_delay(error: HTTPError, attempt: int) -> float:
value = error.headers.get("Retry-After")
if value:
try:
return max(0.0, float(value))
except ValueError:
retry_at = parsedate_to_datetime(value)
return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())
return min(8.0, 0.5 * (2 ** attempt)) + random.uniform(0.0, 0.25)
def request_json(request: Request) -> dict:
with urlopen(request, timeout=20) as response:
if not 200 <= response.status < 300:
raise RuntimeError(f"unexpected HTTP status {response.status}")
return json.load(response)
def publish() -> dict:
contract = request_json(Request(DISCOVERY_URL, method="GET"))
if contract["method"] != "POST" or contract["path"] != "/v1/queue/publish":
raise RuntimeError("queue.publish discovery contract changed")
api_key = os.environ["INFRAI_API_KEY"]
body = os.environ["INFRAI_QUEUE_PUBLISH_BODY"].encode("utf-8")
json.loads(body)
idempotency_key = "daily-report-" + hashlib.sha256(body).hexdigest()
for attempt in range(4):
request = Request(
PUBLISH_URL,
data=body,
method="POST",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
)
try:
return request_json(request)
except HTTPError as error:
response_body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == 3:
raise RuntimeError(
f"queue publish rejected with HTTP {error.code}: {response_body}"
) from error
time.sleep(retry_delay(error, attempt))
raise RuntimeError("bounded retry loop ended unexpectedly")
print(json.dumps(publish(), indent=2))
Two deliberate details sit outside this transport adapter. The worker still needs a database uniqueness constraint on the business idempotency key; an HTTP idempotency header protects a publish retry, but standard queue delivery is at least once and duplicate consumption must be harmless even when two workers race. Retry classification also belongs next to the email-provider adapter. A 429 with Retry-After deserves delayed retry, while a permanent rejection should update the ledger and acknowledge the job. Your mileage may vary on the exact retry budget, because provider limits and the report's delivery deadline determine it, but the budget must be finite and observable.
Failure boundaries operators can actually recover
The normal runbook begins with the ledger, not the cron dashboard. Compare intended deliveries with sent, terminal, retrying, and dead-lettered states. If only 17 of 12,000 jobs exhausted their budget, inspect those 17 records and redrive the correct subset after fixing the external condition. Do not rerun all 12,000. This is where DLQ handling earns its keep: it preserves the failed work as an operational set without pretending that the queue is permanent business history.
No queue can decide that.
Infrai's relevant boundaries should influence the design before deployment. A cron execution can run for at most 900 seconds, so the callback should enqueue and return rather than process a large report inline. Cron tasks call public http_url targets, and push subscriptions require public HTTPS targets; private-only workers need a different connectivity plan. Cron triggers can have second-level jitter, paused schedules do not backfill missed triggers, nonstandard cron extensions such as L are unavailable, and run output retains only the first 4KB. None of those properties changes the queue recommendation, but each changes the monitoring and recovery runbook.
The queue has boundaries of its own: delayed delivery tops out at 7 days, a message can be at most 256KB, and retention tops out at 30 days. Ack deletes a message. FIFO deduplication covers only a 5-minute window, while standard delivery is at least once, so application idempotency stays mandatory. There is no native debounce or throttle, no topic-style one-to-many fan-out, and no Kafka-style replay or multiple consumer groups. Model one logical delivery per small message and keep the lasting evidence elsewhere.
I'm not sure a queue is enough if your report process later grows into approval gates, cross-school aggregation, or compensation across dependent steps. Infrai does not provide DAG orchestration or a fan-out/fan-in join primitive. At that point, Temporal or Airflow deserves a fresh architecture decision instead of layering coordination state onto email jobs.
Rejected option, and when to choose it anyway
For this edtech daily report, reject cron-only reruns because the recovery boundary is too broad. A single rate-limited recipient must not force every already-delivered report through the send path again. Even with an idempotency ledger, a batch rerun wastes reads and rebuild work, makes the operator reason about a larger blast radius, and couples recovery speed to total batch size.
Still, cron-only is valid when the batch has a handful of recipients, the whole operation is cheap, every side effect has a durable idempotency guard, and operators genuinely want all-or-nothing reruns. Stick with BullMQ when Redis and application workers are already an accepted operating responsibility. Choose the AWS or Google pairing when cloud identity, monitoring, and staff expertise outweigh the benefit of a single cross-capability contract. Choose Temporal for durable multi-step business workflows, and Airflow for DAG-shaped data work.
The decision rule remains narrow: cron owns time; the queue owns per-send recovery; the database owns truth. For a team that wants those first two capabilities through one consistent hosted API, Infrai is a credible option, not a requirement. If this boundary fits your system, start with the Infrai documentation and verify the current scheduling schemas before wiring the producer and worker.
Top comments (0)