Skip to main content

Command Palette

Search for a command to run...

Idempotency: The Most Important Word in Cron You’re Probably Ignoring

How to design cron jobs that survive retries, overlaps, and partial failures

Updated
6 min read
Idempotency: The Most Important Word in Cron You’re Probably Ignoring

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 pending

  • You 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

Understanding Cron from First Principles to Production

Part 2 of 12

A practical series exploring cron from core concepts and architecture to real-world Yii & HumHub implementations, focusing on background jobs, queues, scheduling tradeoffs, and how time-based systems behave in production.

Up next

Cron Lies: When Scheduled Jobs Don’t Run

Why scheduled jobs quietly fail—and how to design systems that don’t trust time blindly