DEV Community

Ivan Rossouw
Ivan Rossouw

Posted on

Idempotence Must Survive the Clock

Calling an operation twice with the same input is a useful idempotence check. For scheduled automation, it is not always enough.

The second real invocation does not happen at the same instant. If current time influences classification, rendering, or persistence, then time is another input to the contract—even when the business state has not changed.

That distinction matters because a test can prove same-instant repeatability while missing repeated side effects on every future run.

The comfortable test that was incomplete

Consider a monitor that periodically evaluates a condition and maintains one notification. Its write plan is sensible:

  1. Classify the current state.
  2. Render the notification that should exist.
  3. Compare it with the notification already stored.
  4. Perform no write when the two are equal.

The first regression test might call the planner twice, pass the first output back as the stored value, and assert that the second call returns NoWrite.

That is valuable. It catches an implementation that always updates. But if both calls share a frozen clock, they exercise only one moment. They do not simulate two scheduler runs.

How time turns stable state into different output

The failure appears when the rendered body includes relative age:

The condition has been active for 2.0 hours.
Enter fullscreen mode Exit fullscreen mode

Several minutes later, the underlying condition is identical, but the renderer produces:

The condition has been active for 2.1 hours.
Enter fullscreen mode Exit fullscreen mode

A byte comparison is doing exactly what it was asked to do: the bodies differ, so it plans an update. The monitor writes again, subscribers may be notified again, and the audit trail gains another event even though nothing meaningful happened.

The defect is not in the equality check. It is in the definition of desired output. A volatile presentation value has accidentally become part of message identity.

Separate decision time from message identity

Elapsed time may still be essential to the decision. A monitor often needs to ask whether a condition has lasted long enough to deserve attention. Removing the clock entirely would weaken the behaviour.

The cleaner design is to separate classification from representation:

var state = Classify(input, clock.UtcNow);
var desired = Render(state, since: input.StartedAt);

return Normalise(previous) == Normalise(desired)
    ? Plan.NoWrite
    : Plan.Update(desired);
Enter fullscreen mode Exit fullscreen mode

Here, current time helps decide whether the state has crossed a threshold. The persisted message uses an immutable event timestamp such as “active since 09:30 UTC”. As long as the underlying event is the same, the desired body remains stable.

The exact language and types will differ between systems. The useful boundary is consistent: use volatile time for decisions; use stable facts for identity.

Test the next scheduled run

The regression test should advance the clock while holding business state fixed:

var first = Plan(input, previous: null, at: t0);

var afterFourHours = Plan(input, previous: first.Body, at: t0.AddHours(4));
var afterOneDay = Plan(input, previous: first.Body, at: t0.AddDays(1));

afterFourHours.Action.Should().Be(NoWrite);
afterOneDay.Action.Should().Be(NoWrite);
Enter fullscreen mode Exit fullscreen mode

Then add the complementary test: change the meaningful state and assert one update. Idempotence does not mean “never write again”. It means “repeat the same side effect only when the contract says the desired state changed”.

For a renderer whose output is intended to be stable, an even sharper assertion compares bodies across widely separated times. This catches any newly introduced current-time field, not just the one that caused the original defect.

The engineering trade-off

Relative wording is pleasant. “Active for two hours” saves the reader from doing mental arithmetic. An immutable timestamp is slightly less immediate.

Stable output, however, avoids redundant writes, rate-limit pressure, noisy audit history, repeated notifications, and eventually alert fatigue. Once people learn that a monitor repeats itself without new information, they stop trusting a signal that may later matter.

There are reasonable middle paths. A client can calculate relative age at display time without rewriting the stored message. A monitor can update only at meaningful threshold crossings, such as warning and critical. Or the volatile age can live in a dashboard while the notification remains a stable pointer to the underlying event.

The right choice depends on who reads the output and what an update triggers. The important part is to choose deliberately.

A practical review checklist

When reviewing scheduled or retryable automation, I now ask:

  • Is the clock injected so tests can move it?
  • Which decisions legitimately depend on current time?
  • Does any persisted or compared output contain a drifting value?
  • Can two runs with unchanged business state produce different bytes?
  • Does a no-op plan truly perform zero external writes?
  • Do tests cover both advanced time and changed state?

The small mental shift is this: do not test only “run it twice”. Test “run it now, then run it later with nothing meaningful changed”.

Where could a friendly relative timestamp be quietly turning your idempotent automation into a recurring side effect?

Top comments (0)