Short answer: To schedule recurring reminders with a cron job, use cron to trigger a bounded reconciliation pass, but let durable reservation state and a stable idempotency key decide what runs, what retries, and what must never run twice.
When a logistics system needs to remind a customer about a reservation, a cron job is only the clock. The useful design is the one that makes a repeated clock tick harmless: persist the reminder and reservation state, claim work with a lease or transaction, send an idempotent command, and record the result. A five-minute schedule can be perfectly adequate for a fifteen-minute hold window, provided the expiry decision is made from stored timestamps rather than from the process that happens to be awake.
The bill is mostly not the scheduler. It is retention: reservation rows, delivery attempts, payloads, and enough audit data to explain why a reminder was sent or suppressed. Keep every attempt forever and the operational record becomes the workload. Retain the reservation state and a compact outcome, then discard message bodies and successful attempt details after the period in which support and reconciliation need them. That reduces durable data, but it also means an old dispute may have less evidence. I would make that loss explicit in a retention policy instead of pretending a cron expression solves it.
What should a recurring reminder actually mean?
For this example, a reservation is held for 30 minutes. A reminder is due at expires_at - 10 minutes, and an expiry action is due at expires_at. Those are two different facts, even if one cron process discovers both. A customer reminder says what may happen; expiry changes whether the inventory can be offered again.
The durable record needs a stable reservation identifier, the scheduled instant, a state, and an idempotency key. The key should be derived from the business event, such as reservation-8472-expiry, rather than from the current timestamp. If the process restarts after sending but before acknowledging the send, the next attempt can safely present the same key to the delivery boundary. The receiving boundary must honor that key; generating one inside a retry loop defeats the purpose.
I keep the scheduling rule separate from the work item. A rule can say “every weekday,” while a materialized reminder says “reservation 8472 is due at 14:20 UTC.” This makes a changed customer preference auditable and prevents a later rule edit from silently moving an already-created operational action.
How does a cron tick survive overlap and restart?
Cron has calendar semantics, not a distributed work queue. The Linux crontab(5) specification describes fields for minute, hour, day of month, month, and day of week; it does not promise that one application process will run to completion before another invocation starts. Treat each invocation as a short reconciliation pass.
The pass selects due rows in a bounded batch, claims them, and exits. A claim can be represented by lease_owner and lease_until, or enforced by a database transaction with a state transition. The important invariant is that a stale lease can be reclaimed, while a live lease cannot be taken by a second worker. The worker must also re-check the row before the irreversible action, because a cancellation may have arrived after selection.
The lease is not magic.
If the delivery call runs longer than the lease, another worker may reclaim the row while the first worker is still waiting. That is why the lease duration must exceed the normal call budget, and why the final database transition needs the owner, state, or version check as a fence. Extending a lease helps with slow calls, but it does not turn two independent systems into one transaction; the unknown-outcome case still needs an idempotency key.
No cron expression can provide that guarantee.
Here is the control flow in Python-like pseudocode. The application under discussion is Node.js, but the mechanism is language-neutral; the code deliberately shows the transaction boundaries rather than a framework-specific scheduler API.
def reconcile(now, worker_id):
rows = db.claim_due(
now=now,
limit=100,
lease_owner=worker_id,
lease_seconds=120,
)
for row in rows:
current = db.get_reservation(row.reservation_id)
if current.state != "held":
db.mark_suppressed(row.id, reason="reservation_not_held")
continue
if row.kind == "reminder":
result = delivery.send(
recipient=current.recipient,
body=render_reminder(current),
idempotency_key=row.idempotency_key,
)
db.record_outcome(row.id, result)
elif row.kind == "expiry" and current.expires_at <= now:
db.expire_if_held(current.id, expected_version=current.version)
db.release_lease(row.id, worker_id)
The code leaves one uncomfortable detail visible: send and the database update are not one transaction. That is where retries become an architectural choice. An outbox makes the database commit authoritative and lets a separate dispatcher retry delivery. An idempotent delivery endpoint makes duplicate dispatch safe. A distributed transaction may offer stronger coordination, but it also adds operational coupling that a fifteen-minute reservation may not justify.
Which failure is acceptable: delay, duplicate, or lost evidence?
There is no useful “exactly once” claim without naming the boundaries. A killed worker can produce a delayed reminder, a duplicate reminder, or an ambiguous outcome after the remote service accepted the request. Pick the failure that the business can tolerate and instrument it.
| Boundary | Failure after the action | Defensive mechanism | What to measure |
|---|---|---|---|
| Cron to worker | Two passes overlap | Lease with expiry and bounded claims | Claim conflicts, lease age |
| Worker to database | Process dies during state change | Conditional update and version check | Transition conflicts |
| Worker to delivery | Response is lost | Stable idempotency key and retry policy | Unknown outcomes, retry age |
| Retention to audit | Evidence is deleted | Defined retention classes and export path | Purge count, audit lookup failures |
The concrete decision rule is simple: never let a retry create a new business event. A retry may create a new transport attempt, but it must reuse the same event identity. For expiry, the conditional update should resemble “change held to expired only if the version and state still match.” A second successful update then has nothing left to do.
Consider the ordinary ugly sequence. The 14:20 pass claims reservation 8472 and sends the reminder. The delivery system accepts it, then the network drops the response. The worker marks nothing and exits. At 14:21, a new pass sees the expired lease. It retries with reservation-8472-reminder, not a fresh timestamp-based key. The delivery boundary can return the prior result, and the worker records the outcome. If that boundary cannot deduplicate, the system must admit that a duplicate is possible and choose between delaying the retry, accepting the duplicate, or adding an outbox and a deduplication store. Calling this “exactly once” would hide the real decision. The reservation expiry still uses a conditional state transition, so a retry cannot release inventory twice.
I initially treated the cron interval as the accuracy setting. That was the wrong abstraction. The meaningful value is the lateness budget: if a reminder may arrive within two minutes of its due time, a one-minute pass plus queue and database latency can fit; if a reservation must release inventory at the exact second, a coarse periodic pass is a poor enforcement mechanism. The schedule should be chosen from that budget, not from a habit of writing */5 * * * *.
When is cron the wrong boundary?
This design has a hard limitation: it is a poor fit for sub-second deadlines, very large due-work bursts, or workflows that require a provider to confirm each transition before the next one begins. In those cases, a durable queue, a database-native timing mechanism, or a dedicated workflow engine may be the better boundary. The trade-off is extra moving parts and a different operational contract; the benefit is tighter control over wake-up latency and backpressure.
For a 30-minute logistics hold with a small lateness budget, the periodic pass remains a reasonable choice. For a reservation that releases scarce inventory at an exact external cutoff, I would choose the mechanism that owns that cutoff and keep cron as a reconciliation safety net.
What should be retained after the reservation is gone?
Retention is part of correctness because it determines what can be reconstructed. Keep the reservation identifier, event kind, due time, final outcome, attempt count, and timestamps long enough to reconcile inventory and answer ordinary support questions. Treat message content and verbose transport responses as separate classes. They can contain personal data, consume storage, and rarely help after a successful delivery has been confirmed.
The cleanup job should use the same claim discipline as the reminder worker. It should select records older than the policy boundary, avoid rows with an unresolved outcome, and record its own summary. Deleting successful attempt details is a deliberate trade: the system becomes quieter and smaller, while forensic reconstruction of an old delivery becomes less complete.
Keep the operational counters. At minimum, watch due work, oldest due age, retry count, unknown delivery outcomes, and expiry transition conflicts. Alert on age and stuck work rather than on the existence of a cron process. A process can be running every minute while making no progress.
A practical operating contract
The deployment contract should state the timezone, the allowed lateness, the batch limit, the lease duration, the retry ceiling, and the retention classes. UTC storage removes one class of daylight-saving surprises, but the customer-facing schedule may still need an explicit local timezone and a documented conversion rule. Test the conversion at the timezone transition instead of trusting a sample date.
Test the unpleasant sequences: two workers claim the same candidate, cancellation races with reminder selection, a delivery succeeds before the worker is killed, the database is temporarily unavailable, and the lease expires while a delivery call is slow. These are not exotic cases. They are the normal consequences of separating a clock, a database, and a delivery boundary.
The result is deliberately modest. Cron wakes the reconciler; it does not own truth. The database owns the reservation state, the event identity survives retries, and retention states what the system will no longer be able to prove later. For recurring reminders, reliable behavior comes from idempotent state transitions and an explicit lateness budget, not from a more elaborate cron expression.
Top comments (0)