Files
pad/internal/idspace/idspace_test.go
T
xarmian c017ad359d fix(events): give each in-memory bus incarnation its own ID space (BUG-2736)
Both in-process buses assigned Last-Event-ID values from a counter that
restarted at 1 on every process start. A client holding cursor 2 from a
previous incarnation could reconnect to a restarted server, pass every
coverage check BUG-2731 added, and be replayed the NEW space's 3, 4, 5 as
though they followed the OLD space's 2 -- silently missing everything the
dead space held above 2.

Nothing local could tell the two 2s apart. The cursor carries no epoch, and
in internal/events per-workspace IDs are non-consecutive by construction, so
"did we issue this ID?" was numerically undecidable. The four adjacent levers
were checked rather than assumed: comparing in memory has nothing to compare
against; persisting the counter makes single-process Pad carry durable
event-bus state and still resets on data loss; refusing cursors we did not
issue is the undecidable one; and a nonce on a second channel is unavailable
because EventSource echoes Last-Event-ID and nothing else, and cannot rewrite
its URL on an automatic reconnect.

So the ID space's identity goes in the ID's VALUE while its FORMAT is
unchanged: still a bare int64, still ParseInt on the way back. internal/idspace
mints a base of processStartUnixMilli<<20 and each bus counts up from it. Two
incarnations can only collide if the earlier process published more than 2^20
events per millisecond of its own lifetime -- a deterministic bound, not the
probabilistic one BUG-2736's body rules out. A CAS makes bases strictly
increasing within a process too, which the clock alone does not do for two
buses constructed in the same millisecond.

A backwards clock step degrades in the SAFE direction: a lower base puts old
cursors ABOVE the new buffer's newest ID, so they are refused rather than
answered wrongly. The overflow bound is computed, not estimated: the last
start instant that fits is 2248-09-26T15:10:22Z.

Each bus then answers the resume question exactly instead of inferring it: a
non-zero cursor at or below this incarnation's base was issued by a dead
space. That is strictly stronger than the coverage check alone, which serves
the ADJACENT cursor on reasoning that only holds within one ID space.

In internal/watchevents the check lives in one helper both entry points call.
Written inline in EventsSince it was absent from SubscribeAndReplaySince --
the path the SSE handler actually uses -- so the component was fixed and its
wiring was not (team CONVE-19). A test now drives both.

web's ItemEvent no longer declares `id?: number`. Nothing read it, which is
the only reason it was harmless; a base of ~1.8e18 is past JavaScript's
MAX_SAFE_INTEGER, so the first reader would have silently got a rounded
number. Defused while still unread.

Tests that spelled out IDs now read back what the bus assigned -- a literal 1
is a cursor from a dead space, which turned two negative controls into their
own opposite. The two watchevents guards (cold buffer, dead incarnation) are
tested separately, because a single test covering both would keep passing
with either deleted.

The Redis half is not here. Its counter is shared across processes, so
identifying its ID space needs an epoch travelling with each message; that is
the next commit on this branch.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-22 18:14:45 +00:00

51 lines
2.0 KiB
Go

package idspace
import "testing"
func TestBasesStrictlyIncreaseWithinAProcess(t *testing.T) {
// The clock separates incarnations across restarts; it does NOT separate
// two buses constructed inside the same millisecond, which is both what a
// test does and what makes the invariant conditional rather than true.
// This pins the CAS half — remove it and this fails, because the loop is
// fast enough to land repeatedly in one millisecond.
const n = 200
prev := int64(0)
for i := 0; i < n; i++ {
base := New()
if base <= prev {
t.Fatalf("base %d at iteration %d did not exceed the previous base %d", base, i, prev)
}
prev = base
}
}
func TestBasesAreShiftedFarEnoughToHoldAMillisecondOfIDs(t *testing.T) {
// The invariant in Shift's comment is arithmetic, and arithmetic in a
// comment is a claim. Two consecutive bases must differ by at least one
// full stride, so a process publishing fewer than 1<<Shift events per
// millisecond of its lifetime cannot collide with its successor.
first := New()
second := New()
if gap := second - first; gap < 1<<Shift {
t.Fatalf("consecutive bases differ by %d, which is less than one stride (%d)", gap, int64(1)<<Shift)
}
}
func TestBasesDoNotOverflowAtTheDocumentedInstant(t *testing.T) {
// The overflow date in Shift's comment names a specific millisecond. Pin
// it: one millisecond later must not fit. Stated as the boundary rather
// than as a date string so the test fails if Shift changes without the
// comment following it.
//
// The shifts run on VARIABLES rather than constants on purpose: the
// overflowing one is a compile error as an untyped constant, which would
// make the boundary unassertable at exactly the point it matters.
lastFitting := int64(8796093022207)
if got := lastFitting << Shift; got <= 0 {
t.Fatalf("millisecond %d should still fit in an int64 base, got %d", lastFitting, got)
}
if got := (lastFitting + 1) << Shift; got > 0 {
t.Fatalf("millisecond %d should overflow the int64 base, got %d", lastFitting+1, got)
}
}