Short answer: the best approach for combining a cron job trigger with a worker-based queue is to create one durable report intent, then let workers claim and retry that intent. The scheduler should never perform the long report build or send the webhook itself. Give each daily report a stable idempotency key, record delivery attempts, and make the receiver reject a key it has already applied.
That boundary matters in an education platform. A report may aggregate enrollments, attendance, and assessment results while a downstream system is temporarily slow. If the scheduler waits, one stuck run can delay tomorrow's run. If it fires a second run after a timeout, the recipient can receive two emails or apply the same webhook twice. I have been paged for both classes of failure; the visible symptom is different, but the missing piece is usually the same: no durable record of what the system meant to do.
What is the right approach to combining a cron job trigger with worker-based queues?
The cron process should enqueue a report job containing a tenant identifier, reporting date, destination, and an idempotency key such as tenant-42:daily:2026-10-24. It should finish after the job is durably accepted. A queue message is a request to attempt work, not proof that work succeeded.
That is the whole contract.
The worker then loads the report intent and moves it through explicit states: pending, generating, ready, delivering, delivered, or failed. A lease with an expiry protects against a worker disappearing while holding generating. The lease is a recovery mechanism, not a duplicate-prevention mechanism; a second worker may legitimately retry after expiry. That is why the key belongs to the business operation and survives every attempt.
A unique constraint on (tenant_id, report_date, kind) is a useful second line of defense. It prevents two scheduler invocations from creating two intents for the same daily report, including the ordinary overlap caused by a slow deployment or an operator replay. Do not use the queue message ID for this constraint. A retrying queue can assign a new message ID to the same operation.
This approach has a boundary. A tiny, fast task that has no external side effect may be simpler as a direct scheduled process; leases, attempt records, and replay tests add operational work. The worker-based design earns that complexity when report generation is long-running, when tasks can overlap, or when delivery must survive a process restart.
That trade-off is deliberate.
It is also a poor fit for a one-off administrative export where a person is watching the result and can safely rerun it. For the daily education report, the decision changes because a recipient may be offline, the dataset can take minutes to assemble, and the scheduler has no useful way to distinguish a slow response from a committed webhook. Combining a cron job trigger with a queue creates more state to operate, but that state gives the team a place to inspect ownership, retry history, and the exact reason a report was not delivered.
Why can a retry still send the same report twice?
Because a network timeout does not tell the sender what happened. The receiver may have committed the webhook and lost the response before the worker observed it. Retrying is correct for availability, but it is only safe when the receiver treats the request as an idempotent operation.
It is a small distinction with a large incident radius.
The receiver should persist the key and the result of applying it in the same transaction as the business change. On a first request, it validates the payload, applies the report-ready event, and records the key. On a repeat, it returns the recorded outcome without applying the event again. A sender-side delivered flag still matters for monitoring, but it cannot replace receiver-side deduplication because the sender can crash between the remote commit and its own update.
Here is the shape I use for the worker boundary. It deliberately separates report generation from delivery, and it treats an unknown outcome as retryable until the receiver's idempotency contract says otherwise.
type ReportJob struct { TenantID string ReportDate string IdempotencyKey string Destination string } type DeliveryResult struct { StatusCode int Body []byte } func processReport(ctx context.Context, job ReportJob, store Store, client WebhookClient) error { if err := store.Claim(ctx, job.IdempotencyKey); err != nil { return err } payload, err := store.BuildReport(ctx, job.TenantID, job.ReportDate) if err != nil { return store.FailOrRelease(ctx, job.IdempotencyKey, err) } result, err := client.Deliver(ctx, job.Destination, job.IdempotencyKey, payload) if err != nil { return store.RecordAttempt(ctx, job.IdempotencyKey, "unknown", err) } if result.StatusCode >= 200 && result.StatusCode < 300 { return store.MarkDelivered(ctx, job.IdempotencyKey) } if result.StatusCode == 409 || result.StatusCode == 429 || result.StatusCode >= 500 { return store.RecordAttempt(ctx, job.IdempotencyKey, "retryable", nil) } return store.MarkFailed(ctx, job.IdempotencyKey) } The omitted interfaces are intentional: the database, queue, and HTTP client can vary, while the contract cannot. In production, bound the report-generation context and attach a deadline to delivery. A long job needs a lease longer than its expected work time, with renewal for known slow stages. The queue visibility timeout and the database lease must be designed together; otherwise a queue redelivery can arrive while the first worker is still healthy.
How do you choose retry timing for long-running report tasks without creating a storm?
Use exponential backoff with jitter, a maximum attempt count, and a dead-letter path that preserves the original key and error. A fixed one-minute retry is easy to explain and easy to synchronize across thousands of tenants. That synchronization becomes an outage multiplier. The exact delay is a policy choice; the invariant is that retries are bounded, observable, and safe to replay.
Classify responses before retrying. A temporary network failure, rate limit, or server error normally deserves another attempt. A malformed payload, rejected signature, or unknown tenant needs operator attention or corrected data, not an immediate loop. Honor a server's Retry-After when present, and cap it so a job does not remain invisible forever.
Keep attempt history compact but useful: key, attempt number, start and end time, response class, worker version, and a hashed destination identifier. Payloads can contain student information, so logs should not become a second reporting database. Metrics should show age of the oldest pending job, lease expirations, retry counts by response class, and time from report intent to accepted delivery.
What should verification and rollback test?
Test the failure window, not just the happy path. Generate a report, force the worker to crash after the receiver commits, and verify that the retry produces one receiver-side effect. Force a queue redelivery while the first lease is valid and verify that only one worker proceeds. Then expire the lease and verify that the replacement worker can continue without creating a second report intent.
A useful pre-deployment check is a replay test using a fixed key and identical payload. The receiver should return a stable result or a documented conflict, never perform the action twice. Also test a changed payload with the same key. Treat that as a conflict, because silently associating two different reports with one operation key makes later investigation nearly impossible.
For rollback, keep the job schema backward-compatible across at least the versions that can coexist during deployment. Stop new claims if the worker build is suspect, let already leased jobs expire or finish, and resume with the previous worker only if it understands the same idempotency records. Do not delete failed intents to make a dashboard look healthy. They are the evidence needed to decide whether a replay is safe.
The decision rule is straightforward: schedule durable intent, perform slow work under a renewable lease, and make the externally visible operation idempotent. Cron provides cadence. The queue provides isolation and redelivery. Neither one can prove that a remote webhook was not applied before a timeout. The ledger and the receiver contract close that gap.
References
- OWASP, “Server Side Request Forgery Prevention Cheat Sheet”: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
- RFC 9110, “HTTP Semantics,” status codes and retry-related semantics: https://www.rfc-editor.org/rfc/rfc9110
- RFC 9457, “Problem Details for HTTP APIs”: https://www.rfc-editor.org/rfc/rfc9457
- RFC 3339, “Date and Time on the Internet”: https://www.rfc-editor.org/rfc/rfc3339
