Idempotency: The Most Important Word in Cron You’re Probably Ignoring
How to design cron jobs that survive retries, overlaps, and partial failures

If cron has a single moral lesson, it’s this: time does not guarantee uniqueness.
Jobs run twice. Or zero times. Or half a time. Or later than expected. Cron does not promise exactly once execution, and every system that assumes it does eventually pays for that assumption.
Idempotency is how you survive that reality.
This article unpacks what idempotency really means, why cron jobs must be idempotent, what goes wrong when they aren’t, and how to design idempotent jobs specifically in Yii and HumHub, based on my implemented real production patterns—not theory.
What Idempotency Actually Means (Without Hand-Waving)
A piece of logic is idempotent if running it multiple times produces the same final state as running it once.
Not “similar.”
Not “probably fine.”
The same.
Classic examples:
Setting a value:
status = 'active'Rebuilding an index from source-of-truth data
Deleting records older than a cutoff date
Non-idempotent actions:
Incrementing counters
Sending emails
Charging money
Appending rows blindly
“Process everything since last run” without state
Idempotency is not about how often something runs.
It’s about what happens when it runs again.
Cron forces this distinction because repetition is normal, not exceptional.
Why Cron Jobs Must Be Idempotent
Cron has no memory (or stateless, in more technical term).
It does not know:
Whether a job ran before
Whether it finished
Whether it partially failed
Whether it overlapped with itself
Whether the system was down for hours
From cron’s point of view, every run is a fresh attempt.
That means every cron job must assume:
It may run twice
It may run late
It may run concurrently
It may be retried manually
It may be restarted mid-execution
If a job cannot tolerate these conditions, it is fragile by definition.
Idempotency is not a “best practice” here.
It is a precondition for correctness.
Real Non-Idempotent Disasters (All Painfully Common)
1. Duplicate Emails
public function run()
{
foreach ($this->getUsersToNotify() as $user) {
$this->mailer->send($user);
}
}
What happens when:
The job overlaps?
Someone reruns it manually?
The server restarts mid-run?
Users get two emails. Or three. Or five.
Nothing crashes. Logs look normal. Trust erodes quietly.
2. Double Charges / Double Credits
public function run()
{
$orders = Order::find()->where(['status' => 'pending'])->all();
foreach ($orders as $order) {
$this->charge($order);
$order->status = 'paid';
$order->save();
}
}
If the process crashes after charge() but before save():
Next run charges again
Status still says
pendingYou now have financial damage, not a bug
This is how companies end up writing apology emails.
3. “Since Last Run” Logic Without State
$lastRun = strtotime('-1 hour');
$items = Item::find()->where(['>', 'created_at', $lastRun])->all();
This assumes:
The job ran exactly one hour ago
Time moved forward smoothly
No downtime occurred
All three assumptions are false in production.
The Mental Shift: Cron Jobs Are Reconciliation Jobs
The safest cron jobs don’t process events.
They reconcile state.
Instead of:
“Do X for everything that happened since last time”
Think:
“Given the current truth, what should the system look like now?”
That shift is the heart of idempotency.
Yii & HumHub: Practical Idempotency Patterns
Let’s move from theory to code. Yii & Humhub are used to powered my real production implementation.
1. Use State as a Guard, Not Time
Instead of tracking when something last ran, track what has already been done.
$users = User::find()
->where(['notification_sent' => false])
->all();
foreach ($users as $user) {
$this->sendNotification($user);
$user->notification_sent = true;
$user->save();
}
Now:
Reruns do nothing
Partial runs resume safely
Overlaps converge on the same result
This is idempotency via explicit state.
2. Make Jobs Self-Skipping
In HumHub-style cron jobs, it’s completely valid for a job to decide it has nothing to do.
public function run()
{
if (!$this->hasWorkToDo()) {
Yii::info('CleanupJob: nothing to clean');
return;
}
$this->cleanup();
}
A job that runs and does nothing is successful, not broken.
Idempotent jobs are comfortable with no-ops.
3. Use Unique Constraints as a Safety Net
Databases are excellent idempotency enforcers.
UNIQUE (user_id, notification_type)
try {
Notification::create([
'user_id' => $userId,
'type' => 'weekly_summary',
]);
} catch (\yii\db\Exception $e) {
// Already exists → safe to ignore
}
Now:
Double execution collapses into one record
The database enforces correctness
Your job logic stays simple
This is one of the strongest patterns available.
4. Lock Only When You Must
Idempotency reduces the need for locks—but doesn’t eliminate it.
For jobs that must not overlap:
$lock = Yii::$app->mutex;
if (!$lock->acquire('daily_cleanup', 0)) {
return;
}
try {
$this->cleanup();
} finally {
$lock->release('daily_cleanup');
}
Locks prevent concurrency.
Idempotency prevents damage.
They solve different problems. Use both intentionally.
5. Rebuild, Don’t Mutate, When Possible
The most robust cron jobs rebuild derived data from scratch.
SearchIndex::deleteAll();
foreach (Post::find()->all() as $post) {
SearchIndex::index($post);
}
This feels inefficient—but it’s correct.
If the source of truth is reliable, rebuilding is naturally idempotent.
Optimization can come later. Correctness cannot.
Why Idempotency Is Rarely Explained Well
Because it’s uncomfortable.
It forces you to confront:
Partial failure
Repetition
Uncertainty
The illusion of control
Most tutorials assume:
Jobs run once
Systems are up
Time is reliable
Humans don’t rerun things
Production assumes none of that.
Idempotency is not glamorous.
It doesn’t show up in benchmarks.
But it quietly prevents disasters.
Why This Is Immediately Practical
Once you start asking:
“What happens if this runs twice?”
Your design changes immediately.
You stop using counters blindly
You stop trusting timestamps
You stop assuming order
You start encoding intent in state
Cron becomes safer overnight—not because cron changed, but because your thinking did.
The Real Takeaway
Cron doesn’t punish non-idempotent code instantly.
It waits.
It lets the system run.
It lets data accumulate.
It lets assumptions fossilize.
Then one day:
A server restarts
A job overlaps
Someone reruns a command
And the damage appears all at once.
Idempotency is how you make cron boring again.
And boring, in production systems, is the highest compliment there is.
☰ Series Navigation
Core Series
Optional Extras
→ 🔁 Idempotency: The Most Important Word in Cron






