skip to content

After two operands of a short-circuiting condition were swapped for speed, an audit counter stopped incrementing — why?

level: seniorimportance: should knowfreq 46%

answer

  1. the condition also sequences effects
  2. left runs always, right sometimes
  3. positions are not interchangeable
  4. commutes on values, not on effects
  5. the drop matches the left operand's pass rate

basics

~20 s

The counter lived inside the operand that was moved to the right, where a short-circuiting operator evaluates it only when the left operand has not already decided the result. Swapping operands preserves the boolean value but not which effects happen.

solid answer

~50 s

A short-circuiting operator evaluates left to right and stops as soon as the outcome is settled, so the two positions are not equivalent: the left operand runs on every input, the right one only on the inputs the left did not decide. Moving the operand that increments the counter into the right position therefore narrowed the set of requests it counts, which is a behaviour change even though the condition still answers the same question. Short-circuiting operators commute on the *value* only when both operands are effect-free and both terminate; with an effect in one of them, `A AND B` and `B AND A` are different programs. The second, nastier version of the same mistake is losing a guard: if the operand that was moved right had been protecting the other from being evaluated in a state it cannot handle, the swap trades a lost count for a failure.

go deeper

for a junior

Remember that the left operand of a short-circuiting condition runs on every input and the right one does not, so their positions are not interchangeable.

for a middle

Explain why commutativity applies to values and not to operands, and list what must hold — no effects, no failure, no guard — before a swap is safe.

for a senior

Show the diagnosis: trace the silent metric to a write inside a condition, match the drop against the left operand's pass rate, and name the loud variant where a guard was lost instead.

for a principal

Set the rule that keeps this from recurring — effects out of conditions, a review trigger on calls inside them, and an invariant assertion rather than a dashboard as the detector.

## What the swap actually changed The optimisation was reasonable on its face: one operand is a cheap in-memory test, the other does real work, so put the cheap one first and let it reject most inputs. What it overlooked is that the expensive operand was not only expensive — it also **incremented an audit counter** as a side effect of answering. Before the swap it sat on the left, so it ran for **every** input. After the swap it sits on the right, where a short-circuiting operator reaches it only when the left operand fails to settle the result. The condition still computes the same answer. It now counts a strict subset of what it used to count, and nobody edited the counter. | Expression | Always evaluated | Evaluated only when | |---|---|---| | `recordAttempt(r) AND hasToken(r)` | `recordAttempt` | `recordAttempt` returned true | | `hasToken(r) AND recordAttempt(r)` | `hasToken` | `hasToken` returned true | ## Commutativity holds for values, not for programs The operator is commutative in the sense a truth table means: for the same two boolean values, order does not change the result. That is a statement about **values**, and an operand is not a value — it is code that has not run yet. Swapping two operands of a short-circuiting operator preserves the result of the condition only when: - neither operand has an observable effect, - neither operand's result depends on an effect of the other, - both operands terminate and neither fails, and - neither operand was acting as a guard that makes the other safe to evaluate. Break any of those and the two orderings are different programs that happen to agree on a boolean most of the time. ## Two shapes of failure **The lost effect.** This one. An effect that used to run unconditionally now runs conditionally. It is quiet: the system behaves correctly on the path everyone tests, and a dashboard drifts. Typical victims are counters, audit and access logs, cache warming, last-seen timestamps and rate-limit accounting — all things whose absence looks like lower traffic rather than like a bug. **The lost guard.** The operand moved rightwards was establishing a precondition — the record exists, the collection is non-empty, the index is in range — and the operand moved leftwards now runs in the state that precondition excluded. This one is loud and lands the same day, which paradoxically makes it the cheaper mistake. A third, rarer shape is worth naming: if the left operand's effect is what makes the right operand's answer true, swapping changes the value as well as the effects, because the two operands were never independent. ## How to diagnose it 1. **Find the effect, not the bug.** Start from the metric that stopped moving and locate every place it is written. If one of those writes is inside a boolean condition rather than on its own statement, you have your candidate before you read any history. 2. **Check the position.** Ask which operand position the write now occupies and what the operands to its left evaluate to on real traffic. The drop ratio should match the pass rate of the left operand — that agreement is the confirmation. 3. **Read the change that moved it.** A performance-motivated reordering is easy to miss in review precisely because it looks value-preserving. ## How to prevent it - **Keep effects out of conditions.** Run the effect on its own statement, bind its boolean to a name, and put the name in the condition. Operand order then really is free, which is what the optimiser wanted in the first place. - **Treat a call inside a condition as a review trigger.** A comparison in a condition is a test; a call may be a test or may be control flow in disguise, and only reading it tells you which. - **Make the intent explicit when the order is load-bearing.** If the left operand is a guard, a nested selection says so in a way no reordering can silently undo. - **Assert on the invariant, not the count.** A check that every handled request appears in the audit stream fails on the deploy, rather than after somebody notices a flat graph. The general lesson is the one this leaf keeps returning to: a condition is control flow. Once an operand does something, the condition sequences effects as well as selecting a branch, and the algebra you remember from truth tables no longer licenses rewriting it.

  • What would have made the same reordering safe?
    Hoisting the effect out of the condition: call the recording operation on its own statement, keep the boolean it returns in a local, and use that local as an operand. Both operands are then pure tests, so the order becomes a pure cost decision and any reordering is genuinely behaviour-preserving.
  • The swap could have caused a crash instead of a lost count. How?
    If the operand moved to the right was a guard — a test that the record exists or the index is in range — the operand moved left is now evaluated in exactly the state the guard excluded. The effect is immediate and loud, which is easier to catch than a metric quietly drifting down.
  • How would you confirm the swap, rather than a traffic change, explains the drop?
    Compare the drop ratio with the pass rate of the new left operand over the same window. If the counter now sees roughly the fraction of requests that the left operand admits, the position explains it. A genuine traffic change moves the counter and the request rate together.

saying these in an interview costs you the question

  • Says operand order is free because the operator commutes
  • Calls a reordering behaviour-preserving without checking for effects
  • Assumes the counter drop must mean traffic fell
  • Believes both operands are evaluated before the operator decides
  • Treats a call in a condition as equivalent to a comparison