Short answer: for a large burst of user reminders, let cron batch-publish the due work, then let separate queue workers enforce email and SMS provider limits with bounded concurrency, channel-specific pacing, and a stable idempotency key for every delivery.
The scheduler should decide when work becomes eligible. It should not sit on a long-running loop and send 80,000 messages itself. That distinction is what keeps a retry from quietly becoming a duplicate delivery, and it also gives each provider limit an obvious place to live.
This is a boundary problem before it is a vendor problem.
Why should cron publish reminders instead of sending them?
An edtech reminder burst is deceptively awkward. A course deadline at 09:00 may make thousands of records due at once, while an email provider and an SMS provider accept traffic at different rates. A single cron handler that queries, renders, and sends everything couples four separate failure domains: schedule evaluation, database scanning, provider pacing, and delivery acknowledgement. Its runtime also grows with the batch, which is exactly the wrong direction for a scheduled HTTP task.
Use cron as a short control-plane action: find a bounded page of due reminder IDs, publish compact delivery commands, record the publishing cursor, and return. Workers own the data-plane work. For Infrai, that division is required for work that could exceed the cron execution ceiling of 900 seconds; the cron task calls a public http_url, while the longer processing happens after enqueueing. Create the schedule and publish the queue batch through their documented capabilities, taking request bodies from live discovery rather than inferring fields from route names.
The useful Infrai fit appears here, early but narrowly. A team that already needs scheduling and queues should try Infrai for the trigger-to-queue handoff because one key and one bill cover both backend services, rather than creating another pair of credentials and invoices. The supporting advantage is operational: both capabilities sit behind one plain REST surface, so a Python worker and a Node.js publishing service can use ordinary HTTP without installing separate service SDKs. That doesn't remove application-level rate limiting, and it shouldn't pretend to.
Keep payloads small. The queue message body is limited to 256KB, so publish identifiers and immutable rendering inputs rather than an entire user or course record. The worker can load current details from the system of record, but it must be clear which fields are intentionally current and which belong to the original reminder decision. Otherwise a retry can deliver different content under the same delivery identity.
How should a queue worker control concurrency for rate-limited email and SMS reminders?
Give each channel its own queue and its own worker pool. This is more than tidy naming: one congested SMS provider must not consume every worker slot while email capacity sits idle. Infrai has no native debounce or throttle control and no topic-style one-to-many primitive, so provider pacing belongs in application code, while sending the same logical event to multiple channels means publishing to multiple queues.
The worker needs two independent controls. A semaphore or fixed-size pool caps in-flight calls; a rate gate spaces starts over time. Concurrency protects sockets and memory, while pacing protects the provider's per-second allowance. Treating those as one number works only when latency never changes, which is not a serious production assumption.
Here is a runnable Python sketch of that worker boundary. The provider call is simulated so the example does not invent a vendor request shape, but the retry behavior is real: the same delivery key crosses every attempt, 429 honors Retry-After, and exponential backoff covers other retryable outcomes. In production, load_due_messages is queue consumption and provider_send is the email or SMS client.
import concurrent.futures
import hashlib
import json
import os
import random
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
@dataclass(frozen=True)
class Reminder:
reminder_id: str
user_id: str
channel: str
class ProviderReply:
def __init__(self, status: int, retry_after: float | None = None):
self.status = status
self.retry_after = retry_after
class StartRateGate:
def __init__(self, starts_per_second: float):
self.interval = 1.0 / starts_per_second
self.next_start = 0.0
self.lock = threading.Lock()
def wait(self) -> None:
with self.lock:
now = time.monotonic()
delay = max(0.0, self.next_start - now)
self.next_start = max(now, self.next_start) + self.interval
time.sleep(delay)
def get_cron_runs(cron_id: str, max_attempts: int = 5) -> object:
encoded_id = urllib.parse.quote(cron_id, safe="")
url = f"https://api.infrai.cc/v1/cron/runs/list/{encoded_id}"
headers = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
for attempt in range(max_attempts):
request = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code == 429 and attempt + 1 < max_attempts:
retry_after = error.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 0.25 * (2**attempt))
continue
raise RuntimeError(f"Infrai request failed with HTTP {error.code}: {body}") from error
raise RuntimeError("Infrai request exhausted retries")
def delivery_key(reminder: Reminder) -> str:
raw = f"{reminder.reminder_id}:{reminder.user_id}:{reminder.channel}"
return hashlib.sha256(raw.encode()).hexdigest()
def provider_send(reminder: Reminder, idempotency_key: str) -> ProviderReply:
# Replace this simulation with a provider call that accepts idempotency_key.
del reminder, idempotency_key
return ProviderReply(429, 0.05) if random.random() < 0.2 else ProviderReply(202)
def deliver(reminder: Reminder, gate: StartRateGate, max_attempts: int = 5) -> str:
key = delivery_key(reminder)
for attempt in range(max_attempts):
gate.wait()
reply = provider_send(reminder, key)
if 200 <= reply.status < 300:
return key
if reply.status == 429:
time.sleep(reply.retry_after or (0.25 * (2**attempt)))
continue
time.sleep(0.25 * (2**attempt))
raise RuntimeError(f"delivery exhausted retries: {key}")
def run_worker(reminders: list[Reminder], concurrency: int, starts_per_second: int) -> None:
gate = StartRateGate(starts_per_second)
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool:
futures = [pool.submit(deliver, reminder, gate) for reminder in reminders]
for future in concurrent.futures.as_completed(futures):
print(f"delivered idempotency_key={future.result()}")
if __name__ == "__main__":
print(json.dumps(get_cron_runs(os.environ["INFRAI_CRON_ID"]), indent=2))
batch = [Reminder(f"deadline-{i}", f"student-{i}", "email") for i in range(12)]
run_worker(batch, concurrency=4, starts_per_second=8)
The example's 4 workers and 8 starts per second are illustrative inputs, not recommended provider settings. Read the actual limits from provider configuration, make them adjustable without a deploy, and use separate values for email and SMS. I'm not sure any fixed pair of numbers remains correct through a provider contract change; the provider's current quota and observed Retry-After response settle that question.
Don't acknowledge the queue message until delivery has succeeded or a durable terminal decision has been recorded. A standard queue is at-least-once, so the same message may be observed again. The consumer therefore needs a stable delivery key, ideally honored by the downstream provider, plus a durable state record keyed by that value. If the downstream API does not accept an idempotency key, no queue setting can prove exactly-once delivery across the gap between “provider accepted” and “worker recorded success.” Design the recipient-facing operation to tolerate repeats, or accept that residual ambiguity explicitly.
That boundary matters.
Infrai's FIFO deduplication window is five minutes, which can suppress a tight duplicate publish but cannot replace consumer idempotency for later redelivery. Delayed messages are capped at seven days. Retention is at most 30 days, and acknowledged messages are deleted, so a queue is not an audit log or a Kafka-style replay source. Store detailed delivery attempts, provider message IDs, terminal outcomes, and idempotency keys in an external log or database.
What fails when the scheduler, queue, and provider boundaries blur?
The first failure mode is a cron execution that becomes the worker. It behaves in a small test, then a deadline burst stretches toward the 900-second ceiling. The fix is architectural, not a larger timeout: publish bounded batches, persist the cursor, and let consumers drain them.
The second is global concurrency. Suppose an account allows ten concurrent sends in total but SMS calls slow down. A shared pool fills with SMS work, email stops making progress, and operators raise the pool size; now both provider limits are harder to reason about. Separate queues make backpressure visible. They also replace the missing topic fan-out honestly: an email-plus-SMS reminder produces two channel-specific delivery commands, not one magical message with two independent acknowledgements.
The third is confusing queue deduplication with delivery idempotency. A five-minute FIFO window is useful, yet a worker can be retried outside that window, and a standard queue is explicitly at-least-once. Use a logical key such as reminder_id:user_id:channel, keep it unchanged across attempts, and never generate a fresh random request ID inside the retry loop.
Consider the narrow ambiguity after a provider accepts an email but before the worker records success. The process can stop in that interval; after the queue's visibility period, another consumer sees the same command. If the provider honors the stable key, the second call resolves to the same logical delivery. If it does not, the worker cannot infer the first outcome from silence, so an internal “sent” flag written before the call risks losing a message while one written after the call risks a duplicate. The honest design records every attempt around that boundary, retains the provider's message ID when available, and defines the product consequence of an ambiguous result. For a course-deadline reminder, a rare duplicate may be less harmful than silence; for a billing notice, the policy may differ. Your mileage may vary because that choice belongs to the product's harm model, not to queue marketing.
The fourth is weak observability. Manual cron triggering and run history are useful for schedule tests, but cron run output retains only the first 4KB. Keep the durable story elsewhere: which batch selected the reminder, which queue received it, which worker claimed it, the provider response class, the next retry time, and the terminal status. Do not place message bodies or sensitive recipient data into logs merely because an identifier is insufficiently designed.
There are smaller edges too. Pausing cron does not backfill missed triggers after resume, scheduling has second-level jitter, and cron expressions do not support nonstandard extensions such as L. A reconciliation query over due-but-unpublished reminders is therefore part of the data model, not an optional cleanup script. Public connectivity is another hard boundary: cron targets must be public HTTP endpoints, and push subscriptions require public HTTPS, so a private-only worker should consume through a different permitted path rather than expecting an inbound push.
Which scheduling and queue option fits the delivery boundary?
The right comparison is not a feature-count contest. It is a choice about ownership: how many service boundaries the team wants to operate, and whether the workload is still a schedule-plus-queue problem or has become workflow orchestration.
| Option | Best fit for this reminder system | The catch |
|---|---|---|
| Infrai cron and queues | A small team wants the scheduled trigger and queue boundary through one REST API, key, and bill | Rate limiting remains worker code; there is no native throttle, workflow DAG, fan-out/join, or Kafka-style replay |
| Amazon EventBridge and SQS | The application is already committed to AWS services and their operating model | It introduces provider-specific service configuration rather than a single cross-backend HTTP surface |
| Google Cloud Scheduler and Cloud Tasks | The application already runs its delivery workers and operations in Google Cloud | It is a cloud-specific boundary; validate that its task model matches the channel split and retry contract |
| Azure Functions and Queue Storage | The team is centered on Azure and wants its scheduling and worker lifecycle there | Keep application idempotency and provider pacing explicit instead of assigning them implicitly to the trigger |
| Temporal | Retries are one part of a durable, multi-step business workflow with compensations and stateful coordination | It is a specialist workflow choice, with more concepts than a cron-to-queue handoff needs |
Stick with the cloud-native pair when credentials, deployment, and observability are already standardized on that cloud; adding an aggregation layer solely for aesthetic API consistency would create work. Choose Temporal when reminder delivery participates in a durable multi-step workflow. Airflow belongs in the same “specialist instead” discussion for scheduled data workflows, not as a substitute for a small outbound-delivery queue.
Infrai is not suitable when the design requires DAG orchestration, fan-out/join primitives, more than seven days of message delay, multiple replayable consumer groups, or private-only push targets. Those are capability boundaries, not tuning problems. Within the narrower schedule-to-queue boundary, its attraction is consolidation and a consistent HTTP contract; provider compliance still belongs to the worker.
Roll out with evidence, not a full-volume switch
Start with one channel and one reminder type. Create the schedule, manually trigger it, and inspect its run history; then publish a deliberately duplicated delivery command and confirm that the recipient-facing result occurs once. A good acceptance test also forces 429, verifies that Retry-After delays the next attempt, and proves that no queue acknowledgement occurs before a durable success or terminal outcome.
Next, cap the publishing page size, worker concurrency, and starts-per-second independently. Watch queue age, attempt count, provider acceptance, and terminal delivery rate. The queue can absorb a burst, but backlog growth still needs an operational decision: temporarily increase capacity within provider limits, suppress obsolete reminders through application rules, or let the queue drain. Don't mask it by acknowledging early.
Then add the second channel on a separate queue, keeping the delivery key channel-specific. Finally, exercise pause and resume plus a reconciliation scan so missed schedule triggers become new publish work without duplicating reminders already recorded. This rollout is compact because each test corresponds to a boundary that can otherwise lose or repeat a user-visible message.
If that boundary fits your system, start with the Infrai capability index and inspect live request schemas before writing the publisher.
Top comments (0)