The generation model¶
Invalidate has to answer an awkward question: what happens to an attempt that
is already running when you invalidate?
Cancelling it fails callers who are innocently waiting. Letting it publish
silently undoes the invalidation — the caller who invalidated gets the very value
they discarded back on their next Get. Neither is acceptable, and the design
that resolves it is a generation counter.
The rule¶
Every resolver holds a generation, starting at zero. Invalidate advances it and
drops the held value. Every attempt records the generation it started in, and:
An attempt may only publish into the generation it started in.
An attempt overtaken by an invalidation still runs to completion, still returns its result to the callers waiting on it — and is simply not held.
That gives the three properties that matter at once: waiters are never failed, the invalidation is never silently undone, and no cancellation machinery is needed.
Single-flight is per generation, not per resolver¶
The counter alone is not enough, and this is the subtle half.
If single-flight remained per resolver, a caller arriving after an invalidation would join the attempt that invalidation superseded — and be handed the value it just discarded. In the case this strategy exists for, that caller is the one who invalidated, retrying immediately. It is precisely the call that must not get the stale answer.
So at most one attempt is in flight per generation. Attempts at different
generations may overlap; a Get never joins one from an older generation.
Wave-collapsing is unaffected, because a concurrent wave shares a generation.
A starts (gen 0)
│
Invalidate ────┼──────► gen becomes 1
│
Get ───────────┼──────► does NOT join A; starts B at gen 1
│
A finishes ──► returns its value to A's waiters
refused publication (gen 0 ≠ gen 1)
does not clear B
The consequence: invalidation is the one operation that adds concurrency¶
Because generations are created by the caller, concurrent attempts are bounded by how often you invalidate rather than by one.
That is the right trade — an unbounded-but-correct resolver beats a bounded one serving stale values — but it makes the invalidation rule load-bearing rather than advisory. A caller that invalidates on any failure aims a growing number of resolutions at a provider that is already refusing them. See invalidating a stale credential.
Invalidation is expected to be rare and consumer-driven. If yours is not, something upstream is wrong.
Two invariants that look like details¶
The generation check and the publish occupy one critical section. Checking the generation and then publishing outside the lock would let an invalidation land between the two, and the superseded value would be held anyway — the exact bug the counter exists to prevent, while passing every test that does not interleave at that precise point.
An attempt clears the in-flight slot only if the slot is still its own. The
unconditional clear is obviously correct with one generation and wrong with two:
a superseded attempt finishing would otherwise clear the current generation's
attempt, and the next Get would start a third build alongside a running second.
Both are guarded by tests written against mutants that break them, because both are the kind of line a later reader simplifies back.
Why Memoised cannot be invalidated¶
Memoised and Invalidatable share this one state machine — a resolver nobody
invalidates simply never leaves generation zero. But Memoised returns a value
that exposes Get alone, and a test asserts that it cannot be type-asserted to
Invalidator.
The reason is not purity. It is that if inv, ok := r.(Invalidator[T]); ok {
inv.Invalidate() } is a reasonable thing for generic middleware to write, means
"invalidate if supported", and would silently upgrade a resolver whose author
chose Memoised precisely to say hold this forever — with nothing anywhere
reporting it. The narrowing wrapper makes that promise unforgeable.
Where this is specified¶
P-16 of org spec 0003.