Files
pad/internal/idspace/idspace.go
T
xarmian 1e40c972ae refactor(events): one home for the asymmetry, one assertion for the count (BUG-2736)
Codex round 14 was a prune pass rather than a defect hunt: 695 comment lines
to 243 code lines in internal/events and internal/idspace is a ratio worth
looking at, and my day-51 close records what happens when review prose grows
under a diff.

MOSTLY DECLINED, and the reason matters more than the decision. The list would
have stripped this diff to terse definitions -- deleting bug archaeology,
rollout tradeoffs and codex-round attributions -- in files whose EXISTING
comments are dense narrative and carry exactly those things, "(codex round 6)"
attributions included. It also named several blocks on code this diff never
touched. Applying it would have made the new code inconsistent with the file
it lives in, which is a style change to someone else's codebase wearing a
review's clothes.

APPLIED, because both match lessons this team already recorded rather than a
reviewer's preference:

- The numeric-base-vs-travelling-epoch argument existed in FOUR copies, in
  both packages. The lead's requirement was that the asymmetry declare itself
  in both buses' comments -- not that the argument be written four times. It
  now lives once, in internal/idspace's package comment (the shared code both
  halves depend on), and the four sites state the asymmetry in a sentence and
  point there. Four copies is four things to update when the deferred
  follow-on lands.

- "Costs at most ONE drop per instance per roll" was a countable claim in
  prose with nothing asserting it. A comment asserting countable behaviour
  belongs in the suite: later messages in the same generation must leave the
  buffers alone, or "bounded" in that sentence is doing no work.

Honest measurement, since the point of the round was volume: the net is +47
-41. The prune did not shrink the diff; it removed three copies of one
argument and turned one claim into an assertion.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-22 20:48:58 +00:00

131 lines
7.0 KiB
Go

// Package idspace mints the ID base that identifies one INCARNATION of an
// in-process event counter.
//
// It exists because two packages need the same guarantee — internal/events and
// internal/watchevents each run an in-memory bus that assigns Last-Event-ID
// values from a process-local counter — and because the guarantee is a subtle
// one that must not drift between two copies. The alternative was a twin
// helper in each package, which is how a CAS invariant ends up correct in one
// place and quietly wrong in the other.
//
// IT DELIBERATELY DOES NOT COVER THE REDIS-BACKED BUSES, and this is the
// canonical statement of that asymmetry — the four sites that live with it
// point here rather than restating it.
//
// An in-memory bus is the SOLE publisher into its space, so it can compute an
// identity at startup and put it in the ID's VALUE, which makes a
// cross-incarnation cursor numerically refusable for free. A Redis-backed bus
// shares its counter ACROSS processes: no instance can compute an identity the
// others would agree with, so the identity has to travel WITH each message
// instead — an epoch. They are not two spellings of one idea and must not be
// symmetrized into one.
//
// A numeric base for the Redis counter would close MORE than its epoch can
// (it would refuse cross-incarnation cursors, which an epoch cannot: a cursor
// carries no epoch). It was weighed and deferred rather than rejected. At
// BUG-2736's phase-2 flip, IDs would jump from small to ~1.8e18 in one step,
// so every publish from an un-flipped instance would read as a massive
// backwards jump and drop every buffer — a resync storm spanning the whole
// roll. It is a candidate follow-on once the flip has shipped and soaked, when
// no un-flipped publisher remains to misread the jump. BUG-2736's trail
// carries the reasoning.
package idspace
import (
"sync/atomic"
"time"
)
// Shift is how far a process's start time is shifted left to form the base its
// event IDs count up from. It is the ID SPACE'S IDENTITY, carried in the ID's
// VALUE rather than in the SSE wire's FORMAT — which is the whole trick,
// because the format has no room for it (BUG-2736).
//
// WHY THIS EXISTS. An in-memory bus assigns IDs from a process-local counter.
// Before this, that counter restarted at 1 on every process start, so a client
// holding cursor 2 from a previous incarnation could reconnect to a restarted
// server, pass every coverage check, 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?" is numerically
// undecidable. BUG-2736 records the other four levers and why each is
// unavailable.
//
// WHAT THE SHIFT BUYS, stated as an invariant rather than a hope: an ID from
// incarnation N can equal one from incarnation N+1 only if the earlier process
// published more than 2^20 (1048576) events per millisecond of its own
// lifetime, because the next start's base is higher by (elapsed ms << Shift).
// That is a deterministic bound, not a probability — which is why this is not
// the "start from a random high number" BUG-2736's body rejects.
//
// IT RESTS ON ONE UNSTATED ASSUMPTION, so here it is stated (codex round 8):
// that a RESTART TAKES AT LEAST ONE MILLISECOND. The bases are separated by
// the clock, at millisecond resolution, and the CAS below separates only buses
// built inside ONE process — a second process starting inside the same
// millisecond as the first would take the same base and reissue its IDs, and a
// cursor from the dead run would then be accepted as live.
//
// The assumption is not a hope either: reaching the constructor means the OS
// reaped the old process and the new one bound its listener, opened its
// database and ran migrations. That is milliseconds at minimum, and is
// observable — a deployment where it were not true would have to be restarting
// faster than it can open a file. Closing the gap for real would need
// persistence, which BUG-2736's body rules out for a separate and stronger
// reason (durable event-bus state in single-process Pad, still lost on any
// data loss). So it is accepted, and named here rather than left for the next
// reader to discover in an incident.
//
// THE BACKWARDS-CLOCK CASE degrades in the SAFE direction, and that is why a
// wall clock is acceptable as the only available monotonic-across-restart
// source. A clock step backwards yields a LOWER base, so a previous
// incarnation's cursor sits ABOVE the new buffer's newest ID and the replay
// buffer refuses it on its foreign-ID check: the client is told sync_required
// rather than handed a wrong replay. It degrades to the old silent skip only
// if the new incarnation then climbs past that cursor, which needs the step
// back to exceed the process's own lifetime AND the new incarnation to
// out-publish the gap.
//
// OVERFLOW BOUND, computed rather than estimated: a base is startMillis<<20,
// so the last start instant that still fits in an int64 is millisecond
// 8796093022207 — 2248-09-26T15:10:22Z. Past that the shift overflows into the
// sign bit. Today's base is ~1.87e18, which is already past JavaScript's
// MAX_SAFE_INTEGER (9007199254740991) — safe only because no client numbers
// the ID: EventSource owns Last-Event-ID internally, and cmd/pad/cmd_watch.go
// carries `id:` as an opaque string. Anything that starts parsing an event ID
// as a JS number breaks here, which is why web's ItemEvent no longer declares
// one.
const Shift = 20
// last is the highest base New has handed out in THIS process, across every
// bus that uses this package. It exists because the wall clock alone does not
// separate two buses constructed inside the same millisecond — which the clock
// reasoning above quietly assumed away, and which a test constructing two
// buses back to back reproduces every time. A process restart takes far longer
// than a millisecond, so this is not the production case; it is the case that
// makes the invariant true unconditionally instead of true-in-practice, and it
// costs one CAS.
var last atomic.Int64
// New returns the ID base for a bus incarnation, STRICTLY GREATER than any
// base this process has already issued.
//
// The clock supplies cross-restart separation (a later start means a higher
// base); the CAS loop supplies within-process separation, including when the
// clock repeats a millisecond or steps backwards while the process is alive.
// When the clock cannot advance the base, the next one is bumped by a full
// stride — one millisecond's worth of ID space — so the 2^20-per-ms invariant
// above holds for these bases exactly as it does for clock-derived ones.
func New() int64 {
candidate := time.Now().UnixMilli() << Shift
for {
prev := last.Load()
if candidate <= prev {
candidate = prev + (1 << Shift)
}
if last.CompareAndSwap(prev, candidate) {
return candidate
}
}
}