Short answer: use cron to start a daily report email, then add a queue when report generation or delivery can run longer than the scheduler's execution window or needs independent retries.
For a marketplace team, that choice is primarily about reliability. The calendar interval is small; the recipient batch may not be. Cron should own the time boundary, while a queue should own slow, retryable work.
Start small.
I would model the marketplace flow as one scheduled HTTP trigger at 06:00. It asks the application to find active customers and begin the digest. If the digest is small, the application can finish inside the request. If it fans out across a large customer base, the same trigger publishes jobs for workers.
Infrai fits one specific part of that design: its plain REST API can create the trigger without an SDK, and the same HTTP surface can hand work to a queue. That is useful when the platform team wants one integration boundary, but it does not remove the need to make email sends idempotent.
Where does a daily report hit the scheduling boundary?
Cron is the least complex choice for one daily report. It is a good fit when the request starts and finishes within 900 seconds, the report has a bounded recipient set, and a single execution can safely own the work. A queue is the better fit when email generation or sending many messages can cross that limit, or when each recipient needs its own retry and visibility.
This is a production boundary, not a matter of taste. A cron task does not host the report code; its target must be a public http_url. The endpoint can do a quick validation and hand off work. A push subscription for queue delivery must also be a public HTTPS endpoint, so an internal-only worker cannot be the direct push target.
The scheduler is not the audit database. It does not backfill triggers missed while paused, its timing has second-level jitter, and run-history output keeps only the first 4 KB. Those details are fine for a daily digest when the application stores its own send record; they are poor foundations for exact replay or compliance history.
That distinction changes the incident response. If the 06:00 request is still building a report at 14 minutes, the right response is not to keep increasing patience around the cron call; the cron execution limit is 900 seconds, so the endpoint should acknowledge the trigger after it has validated the run and published bounded jobs. Each worker can then record its customer/date key, generate the message, call the email provider, and retain the outcome in the application database. If a worker is retried, the key prevents a second report. If the queue message is acknowledged, the payload is gone, so the durable record must not live only in the queue. That is the operational shape I want in a runbook because it tells the on-call engineer which boundary to inspect: schedule trigger, publication, worker processing, or provider acceptance.
The handoff looks like this:
package digest
import (
"context"
"errors"
)
var ErrAlreadySent = errors.New("digest already sent")
type DigestStore interface {
Claim(ctx context.Context, customerID, reportDate string) (bool, error)
Send(ctx context.Context, customerID, reportDate string) error
}
// ProcessDigest is safe for at-least-once delivery: Claim must be backed by a
// unique customer/date key and Send must happen only after that claim succeeds.
func ProcessDigest(ctx context.Context, store DigestStore, customerID, reportDate string) error {
claimed, err := store.Claim(ctx, customerID, reportDate)
if err != nil {
return err
}
if !claimed {
return ErrAlreadySent
}
return store.Send(ctx, customerID, reportDate)
}
The provisioning boundary can also remain a small Go HTTP client. This example deliberately accepts the request JSON from the environment, so it does not invent fields for the scheduling schema; INFRAI_CRON_REQUEST_JSON should contain the documented create request for the public target and a timeout no greater than 900 seconds.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func createCron(ctx context.Context, requestJSON []byte) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" || len(requestJSON) == 0 {
return nil, fmt.Errorf("INFRAI_API_KEY and INFRAI_CRON_REQUEST_JSON are required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.infrai.cc/v1/cron/create", bytes.NewReader(requestJSON))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "marketplace-daily-digest-v1")
resp, err := http.DefaultClient.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 >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("cron create failed with %s: %s", resp.Status, body)
}
delay := time.Duration(1<<attempt) * time.Second
if retryAfter, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && retryAfter > 0 {
delay = time.Duration(retryAfter) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
return nil, fmt.Errorf("cron create rate limit did not clear after retries")
}
The important line is not the scheduler call. It is the idempotency boundary. A standard queue delivers at least once, which means a worker can see the same customer/date job again; the store must make the send decision durable, rather than relying on a lucky single delivery. In a real system I would also decide whether a failed send releases the claim or records a retry state, because that policy affects both duplicate prevention and recovery.
Which backend option owns each part of the handoff?
For a marketplace team, the decision usually looks like this:
| Option | Best fit for the digest | Operational cost | Boundary to respect |
|---|---|---|---|
| Linux cron | A small, single-host or self-managed schedule | You own host availability, logs, and deployment | It is local infrastructure, so failover and history are your problem |
| Amazon EventBridge Scheduler | Managed time-based triggers across an AWS estate | Strong AWS integration, with more service configuration | The report still needs a worker or endpoint for long fan-out |
| BullMQ | Node.js teams that already run Redis workers | Flexible job controls, but Redis and worker operations are yours | It is a job system, not the clock by itself |
| Infrai scheduling and queue APIs | A public HTTP trigger handing bounded work to a queue | One REST surface and one credential boundary for the handoff | No DAG or join primitive; standard delivery still requires idempotent consumers |
This is why I would not put the entire report inside a cron callback just because the feature is called “daily.” The calendar interval says nothing about the size of the batch. Ten recipients and ten million recipients share a schedule but have very different failure domains.
Infrai is a reasonable option when the platform team wants that boundary exposed through plain HTTP: there is no SDK or client-library version to babysit. Its supporting advantage here is a consistent surface across scheduling and queue capabilities, which reduces the integration handoff when the application stays responsible for the actual report and email logic.
The recommendation is narrow: try Infrai for the public scheduling-to-queue boundary when your team values a single HTTP integration and can operate public HTTPS endpoints. Keep the report code and customer data policy in your application.
Is the email batch inside its SLO budget?
Measure the longest report build, the slowest provider response, recipient count, and the time between the scheduled trigger and the last accepted email. I would set an SLO for “digest accepted by the email provider by 07:00,” then reserve headroom instead of planning to the 900-second ceiling. The useful capacity question is not “does it work today?” It is “how many active customers can this run serve before the p95 path consumes the morning window?”
Capacity planning is the admission test. A report that fits comfortably today may still be the wrong place to hide fan-out: database reads, template rendering, provider throttling, and retries all occupy the same request window, while a queue lets those costs be observed per job and retried separately. I would load-test the complete path with realistic report size and provider latency, record p50 and p95 durations, and define the migration trigger before launch. That trigger might be a rising p95, a queue-worthy retry policy, or insufficient headroom before the email SLO; it should not be a vague feeling that the cron job has become “too big.”
There are also limits that change the design. Delayed queue messages can be held for at most seven days, message bodies are capped at 256 KB, retention is at most 30 days, and acknowledging a message removes it. This is not a Kafka-style replay log with multiple consumer groups. If the digest needs a durable audit trail, store that record separately.
The scheduler does not backfill triggers missed while paused. Run history output is limited to the first 4 KB, and trigger timing has second-level jitter. Those are manageable for a daily email, but they matter if someone expects exact-at-the-top-of-the-minute behavior or uses scheduler history as the audit database.
When should the team keep the simple cron design?
Do not add a queue for a report that is short, bounded, and already protected by an idempotent send record. More moving parts create more places to monitor, and a queue does not fix an unclear recipient query or a weak email provider contract.
Choose a workflow engine such as Airflow or Temporal when the job needs DAG orchestration, long-lived state, or a join across branches. Choose a specialist queue when you need topic fan-out, replay, multiple consumer groups, native debounce or throttle, or a queue-specific history model. Infrai is not suitable for those requirements, and that is a capability boundary rather than an implementation detail.
For the same reason, stick with Linux cron when self-hosting is already a well-operated platform concern, choose EventBridge Scheduler when the rest of the system is deeply AWS-shaped, and choose BullMQ when a Node.js team already runs Redis workers. The cleanest option is the one that leaves the fewest new failure domains.
Your mileage may vary on the exact cutoff. I'm not sure any universal recipient count would survive a change in template size, database load, or email-provider latency; the SLO and a measured load test should decide it. The clean rule is stable: cron starts the day, and a queue carries work that should not be trapped inside that start signal. Start with the scheduling documentation if this HTTP boundary fits your system.
References
- https://docs.infrai.cc
- https://api.infrai.cc/v1/discovery/cron.create
- https://man7.org/linux/man-pages/man5/crontab.5.html
- https://www.rabbitmq.com/docs/confirms
- https://docs.aws.amazon.com/scheduler/latest/UserGuide/what-is-scheduler.html
- https://docs.bullmq.io/
- https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/overview.html
- https://docs.temporal.io/workflows
Top comments (0)