Skip to content

tidy-event: the merger's gen counter does not span a Flush - #289

Merged
dinstein merged 1 commit into
mainfrom
tidy-event
Aug 9, 2026
Merged

tidy-event: the merger's gen counter does not span a Flush#289
dinstein merged 1 commit into
mainfrom
tidy-event

Conversation

@dinstein

@dinstein dinstein commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Nightly tidy pass over internal/event (2026-08-10 07:18 round). Records a
behaviour defect; the fix follows on its own branch.

Merger.gen is documented as the answer to "a timer that has begun firing
cannot be stopped". It counts per entry, and a fresh entry starts at zero,
so it answers that question for a settler reset and not for the same race
across Flush:

  1. a key's timer begins firing, so its stop() returns false;
  2. Flush detaches and emits that entry and clears the map;
  3. the same key is Added again — a new entry, at gen 0;
  4. the stranded callback matches the new entry by generation and fires it
    immediately instead of a window later.

A coalescer entry is never reset, so its timer always holds gen 0, which is
what makes the collision reachable rather than theoretical. Reproduced
deterministically through the injectable timerFactory before recording.

The consequence is mild and the report says so: one notification arrives up
to a window early, and the bus contract already makes that safe — consumers
re-read authoritative state rather than treating an event as a log. What is
worth recording is that the mechanism which would prevent it is the one
described two lines above in the same document, applied one level too narrowly.
A monotonic sequence on the Merger gives every armed timer a generation
nothing else can hold, and subsumes the settler case.

  • Pass C — recorded in docs/modules/config.md beside the gen
    invariant it bends.
  • Pass A / Pass B — nothing cleared the bar. bus.go's
    remove-then-close ordering against Publish's read lock is stated and
    correct, and fire already detaches under the lock and builds outside it.

Short lane: docs-only, so go test ./test/buildrules/ is the local
verdict. Green.

Merger.gen is documented as the answer to "a timer that has begun firing
cannot be stopped". It counts per ENTRY, and a fresh entry starts at zero —
so it answers that question for a settler reset and not for the same race
across Flush.

The sequence: a key's timer begins firing, so its stop() returns false; Flush
detaches and emits that entry and clears the map; the same key is Added again,
producing a new entry at gen 0; the stranded callback then matches the new
entry by generation and fires it immediately rather than a window later. A
coalescer entry is never reset, so its timer always holds gen 0, which is what
makes the collision reachable rather than theoretical. Reproduced
deterministically through the injectable timerFactory before recording.

The consequence is mild. One notification arrives up to a window early, and
the bus contract already makes that safe: consumers re-read authoritative
state rather than treating an event as a log. What is worth recording is that
the mechanism which would prevent it is the one already described two lines
above, applied one level too narrowly — a monotonic sequence on the Merger
would give every armed timer a generation nothing else can hold.

That is a change of behaviour with a regression test to write, so it is
recorded beside the invariant it bends rather than made on a tidy night.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dinstein
dinstein merged commit 0d7b0c2 into main Aug 9, 2026
3 checks passed
@dinstein
dinstein deleted the tidy-event branch August 9, 2026 23:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant