Files
pad/internal
xarmian 4a6a748c85 feat(events): identify the shared Redis ID space, behind a two-phase flip (BUG-2736)
The activity event counter lives in Redis and is shared by every instance, so
no instance can compute an identity for it the way MemoryBus computes its own
incarnation base. If that counter is ever reset -- evicted under maxmemory,
deleted by hand, a fresh Redis after a restore -- IDs start again from 1, and
a replica buffering the old sequence cannot tell the new 101 from the old 101.
It merges two ID spaces into one replay buffer and answers a resume across the
boundary as though nothing was missed.

Numeric detection alone cannot see it. By the time the new sequence passes the
replica's high-water mark it looks like ordinary progress -- which is the case
the epoch exists for, and the high-water check is what catches the OTHER case
(a publisher that never learned the epoch), so both are kept.

So the identity travels WITH each message, as an opaque token in a
"<epoch>|<id>|<json>" prefix. A prefix rather than an envelope field: an older
instance would unmarshal an envelope object SILENTLY -- no matching keys, no
error, a zero-valued Event delivered to its clients -- and fails loudly on the
prefix instead.

TWO PHASES, because the failure is asymmetric. Every instance ACCEPTS both
wire forms from this release; only emission is gated, on
PAD_EVENTS_PUBLISH_EPOCH. Phase 1 rolls the binary everywhere publishing the
historical bare JSON; phase 2 sets the flag and rolls again. Flipping before
every instance is upgraded is the one direction that LOSES events rather than
resyncing: a pre-phase-1 binary cannot parse the prefix at all. Rollback is
symmetric and safe. docs/deployment.md carries the procedure both ways, what
the reset counters should read during each roll, and what remains unfixed.

Phase 2 also moves ID assignment into one atomic script. The two-call
INCR-then-PUBLISH lets two instances interleave, so a receiving instance can
append 6 before 5 -- a window older than this change, and already wrong, but
load-bearing here because counter-backwards detection reads a descending ID as
a reset. The script carries a dedupe token for the same reason
internal/watchevents' does: go-redis retries a command whose REPLY was lost, so
a publish can happen AND return an error, and the retry would deliver a second
copy that looks perfectly valid.

THE COUNTER-BACKWARDS FLOOR STAYS, and the earlier hope that this unit would
delete it was wrong. Its trigger is mixed-VERSION ordering -- an older binary
assigning and publishing in two calls -- not mixed-FORMAT payloads, so
publish-old-until-flip removes the format window only. It lives for as long as
a deployment can run two publisher versions at once, which is every rolling
upgrade, and the code now says so where it fires.

THE ASYMMETRY WITH MemoryBus IS DECLARED IN BOTH BUSES, in both packages: an
opaque epoch where the counter is shared, a numeric base where one process
owns it. They are not two spellings of one idea and must not be symmetrized.
A numeric base for Redis would close more -- it would refuse cross-incarnation
cursors, which the epoch cannot -- and is deferred rather than rejected: at the
flip, IDs would jump to ~1.8e18 in one step and every un-flipped publisher's
message would read as a massive backwards jump, dropping every buffer across
the whole roll. It is a candidate follow-on once the flip has soaked.

What this does NOT fix is stated in the code and the docs rather than implied:
the client cursor is still a bare integer with no epoch, so an old and a new ID
of the same value remain indistinguishable TO A RESUME even though the buffers
can no longer mix them.

The flip is read inside newObservedEventBus, which now takes the whole Config.
As a hand-picked argument at the two RunE call sites it was untested wiring:
replacing it with `false` compiled, passed the entire tree, and left the
deployment silently on phase 1 -- indistinguishable from a correct phase-1
deployment, since phase 1 is the default. Mutation-checked in both directions,
because a helper that ignores its config and hardcodes either value would pass
a one-directional test.

Also: the epoch and dedupe keys join the namespace assertions (an epoch shared
between two installations is a cross-feed with teeth -- each would read the
other's ID-space changes as its own), and this package's four-key EVAL is now
recorded on BUG-2724's cluster deferral, which had one call site and now has
two.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-22 18:31:02 +00:00
..
2026-03-26 01:52:36 +00:00