mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
72336aacb5
`GET /api/v1/events` reserved its admission slot, then blocked in `SubscribeAndReplaySince` while the workspace's Redis subscription was dialled and — since BUG-2747 — acknowledged. Nothing propagated the request's cancellation into that wait, so a client that disconnected during establishment left a process-wide slot, a per-principal slot and a per-workspace slot held for the whole of it. The connection was gone; the capacity was not. Cancellation is now DEREGISTRATION, and `wsCounts` — which already answers "is anyone still here" — decides everything downstream. No ownership hand-off and no reaper: the arbiter already existed. (One thing IS handed off, and only one — the remainder of the confirmation wait; see below.) The two cancellation positions take different paths, and only one of them owes the joiners anything: - Before the install: the existing post-dial critical section already abandons and retires correctly when nobody is left. It needed one ordering rule — the departed establisher stops being counted IN THAT SAME SECTION, before the count is read. If joiners registered while we dialled, the count is still non-zero and they get the subscription; that is the hand-off the filing asked about, expressed as a count rather than a transfer of ownership. - During the confirmation wait: the subscription is already installed with its receive loop running, so the connection is not at risk — but the WAIT is what releases the joiners, and dropping it would admit them into a subscription Redis has not acknowledged while telling them nothing. That is BUG-2747's defect re-created at the seam between the two designs. So the remainder of the wait moves to a goroutine that finishes exactly as the caller would have: same arms, same `markUnconfirmedAdmission` on the bound, same `finishPending`. Bounded by `confirmTimeout`; no reaper needed, because teardown stays count-driven. A departure is not a refusal. `ok bool` is replaced by a `SubscribeOutcome` enum across the three `EventBus` Subscribe methods, so `SubscribeWorkspaceLimit` and `SubscribeCancelled` cannot be collapsed: answering a departed client with 429 would have written a limit refusal into the logs and counters that anyone would use to tune that limit. An enum rather than a second bool or an error because the switch has to name the case — by construction rather than by argument. Caller population, with its search boundary: 3 production implementations (events.MemoryBus, events.RedisBus, metrics.InstrumentedBus), 1 test double (server.gapEventBus, which embeds the interface), 2 production call sites (both in handlers_events.go). Searched this repo four ways — the three method names, `.Subscribe(`, method declarations, and interface embedding. collab.OpBus and watchevents.Bus are different interfaces and are out of scope; no other repo links this package. WHAT THIS DOES NOT FIX, verified in go-redis v9.22.0 rather than inferred from its doc comment (which says Subscribe "does not wait on a response from Redis" and so reads as though no dial happens on the request path — it does; only the reply is unawaited). On plaintext, dialConn derives its per-attempt deadline from the caller's context and the default dialer is net.Dialer.DialContext, so cancellation aborts the dial. Under TLS the same dialer calls tls.DialWithDialer, which takes no context, so the dial stays bounded by DialTimeout alone. On a TLS deployment this shrinks the held slot from (dial + confirm bound) to (dial), not to zero. Review round 2 (codex) found a P1 in this unit's own first draft, of exactly the shape the filing warned about. A cancellation check at the top of the establish loop could return while the caller still OWNED an unretired establishment record: section 1 had already named it the establisher, so the record stayed in pendingSubs with nobody behind it, its done channel never closed. The next subscriber for that workspace would join it and wait forever — and its own registration keeps wsCounts non-zero, so no later caller would establish either. A permanently dead stream that looks alive, produced by a guard whose only purpose was to save a dial. The guard is gone: a cancelled caller now goes THROUGH establishSubscription, which is the only code that knows how to put the record down. Regression test included, and reinstating the guard turns it red. Round 2 also found a P2 shutdown regression: routing the dial to the caller's context alone took away Close()'s ability to interrupt a stalled dial, which it had before. The dial now runs on a context ended by EITHER the caller or the bus, and each half is pinned by its own test — dropping either one is detected. Review round 1 (codex): no P1. One nit fixed as a class — three comments elsewhere in the file asserted the dial was "NOT bounded by the context we pass", which this change falsified; the sweep found and corrected all three (establishSubscription, defaultSubscribeConfirmTimeout, Subscribe). The TLS half of its P2 is filed as BUG-2754: the fix belongs at client construction, where it covers every Redis call rather than this one. Class sweep filed separately as BUG-2751 (lead-ruled: one region, one design per diff): internal/watchevents has no per-request establishment, but its resume path blocks on a 250ms settle window bound to the bus's context rather than the request's, while /api/v1/events/stream holds the same admission slots across it. Tests: five cancellation cases in internal/events (before install, during the wait alone, during the wait with a joiner, a cancelled joiner, an already-dead caller), a dial-binding assertion, and the handler-level binding in internal/server asserting the admission slot itself is released — the half of the bug that does not live in the bus. Mutation matrix, 8 mutations: 7 detected, each by the test named for it. The one survivor is the ctx term in the retry re-decide, and it survives because it is an OPTIMISATION rather than a correctness guard — a departed caller that mints a second record still establishes, deregisters and retires correctly; the term only saves a pointless dial. The code says so rather than implying the guard is load-bearing. The earlier draft's entry guard and loop-top break formed a redundant pair the matrix could only detect when both were removed. That redundancy was the smell, and round 2 found the substance under it: one of the two was not redundant, it was wrong. With it gone the entry guard is detected on its own.
199 lines
7.9 KiB
Go
199 lines
7.9 KiB
Go
package events
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// BUG-2736: a restarted process used to reuse its ID space, so a cursor issued
|
|
// by a PREVIOUS incarnation could be answered from the CURRENT one's buffer as
|
|
// though the two were the same sequence.
|
|
//
|
|
// The fix carries the ID space's identity in the ID's VALUE (see
|
|
// internal/idspace), so these tests assert BOTH halves of that: the
|
|
// mechanism (the spaces are disjoint, and disjoint in the right direction) and
|
|
// the behaviour it produces (the stale cursor is refused). Asserting only the
|
|
// behaviour would pass on a bus that answers nil unconditionally, which is the
|
|
// false-positive inversion this family has to keep ruling out; asserting only
|
|
// the mechanism would pass on a bus that computes a fine base and then ignores
|
|
// it when answering a resume.
|
|
|
|
// publishN publishes n events to a workspace and returns the IDs assigned, in
|
|
// order. The IDs are the observable half of the ID space, which is what these
|
|
// tests are about — reading them back through a subscriber rather than
|
|
// recomputing them keeps the test honest about what the bus actually issued.
|
|
func publishN(b *MemoryBus, workspaceID string, n int) []int64 {
|
|
types := make([]string, n)
|
|
for i := range types {
|
|
types[i] = ItemUpdated
|
|
}
|
|
return publishTyped(b, workspaceID, types...)
|
|
}
|
|
|
|
// publishTyped is publishN for tests that care which event types land, and is
|
|
// the reason neither helper recomputes an ID: since BUG-2736 the IDs a bus
|
|
// issues depend on when the process started, so a test that spells one out is
|
|
// asserting the clock. Every test in this package indexes off what the bus
|
|
// actually assigned.
|
|
func publishTyped(b *MemoryBus, workspaceID string, types ...string) []int64 {
|
|
ids := make([]int64, 0, len(types))
|
|
ch, _, _ := b.Subscribe(context.Background(), workspaceID)
|
|
defer b.Unsubscribe(ch)
|
|
for _, typ := range types {
|
|
b.Publish(Event{Type: typ, WorkspaceID: workspaceID})
|
|
ids = append(ids, (<-ch).ID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func TestRestartCursorIsRefusedBecauseTheIDSpacesAreDisjoint(t *testing.T) {
|
|
const ws = "ws-1"
|
|
|
|
// Two BUS INCARNATIONS in one process, which is what a test can build:
|
|
// a genuine process restart is not reproducible in-process, and the thing
|
|
// under test is the bus's identity rather than the operating system's. The
|
|
// two are equivalent here because a bus takes its base at construction,
|
|
// exactly as it does at process start.
|
|
//
|
|
// Incarnation 1: the client streams and leaves holding a cursor.
|
|
first := New()
|
|
firstIDs := publishN(first, ws, 3)
|
|
first.Close()
|
|
cursor := firstIDs[1] // the client's Last-Event-ID, from the DEAD space
|
|
|
|
// Incarnation 2: the process restarted. Same workspace, fresh bus.
|
|
second := New()
|
|
secondIDs := publishN(second, ws, 5)
|
|
|
|
// PREMISE, asserted rather than assumed. The whole fix is that these two
|
|
// spaces cannot overlap; if they did, everything below would be testing a
|
|
// coincidence. Note the DIRECTION matters too — the dead cursor must sit
|
|
// BELOW the new space, because that is what makes the coverage check
|
|
// (sinceID+1 < knownFrom) the thing that refuses it.
|
|
for _, id := range secondIDs {
|
|
if id == cursor {
|
|
t.Fatalf("id spaces overlap: dead cursor %d was reissued by the new incarnation (ids %v)", cursor, secondIDs)
|
|
}
|
|
}
|
|
if cursor >= secondIDs[0] {
|
|
t.Fatalf("dead cursor %d must sit below the new space's first id %d", cursor, secondIDs[0])
|
|
}
|
|
|
|
// The cursor names a position in a sequence this bus never had. It cannot
|
|
// say what the client missed, so the only honest answer is sync_required.
|
|
if got := second.EventsSince(ws, cursor); got != nil {
|
|
t.Fatalf("resuming from a previous incarnation's cursor %d must be a gap, got %d events (ids %v)", cursor, len(got), idsOf(got))
|
|
}
|
|
|
|
// NEGATIVE CONTROL. A cursor issued by THIS incarnation must still be
|
|
// served, with the right events — otherwise the fix is "answer nil
|
|
// always", which would resync every ordinary reconnect.
|
|
live := secondIDs[1]
|
|
got := second.EventsSince(ws, live)
|
|
if got == nil {
|
|
t.Fatalf("a cursor issued by this incarnation (%d) must still be servable", live)
|
|
}
|
|
if want := secondIDs[2:]; len(got) != len(want) || idsOf(got)[0] != want[0] {
|
|
t.Fatalf("resuming from %d: want ids %v, got %v", live, want, idsOf(got))
|
|
}
|
|
}
|
|
|
|
func TestFirstIDOfAnIncarnationIsAboveItsBase(t *testing.T) {
|
|
// Guards the wiring rather than the component (team CONVE-19): a base can
|
|
// be computed correctly and then not reach the ID that is actually
|
|
// assigned. Two buses' first IDs must differ, and each must sit inside its
|
|
// own bus's space.
|
|
a, b := New(), New()
|
|
idsA := publishN(a, "ws-1", 1)
|
|
idsB := publishN(b, "ws-1", 1)
|
|
|
|
if idsA[0] <= a.base {
|
|
t.Fatalf("first id %d must be above its own incarnation base %d", idsA[0], a.base)
|
|
}
|
|
if idsB[0] <= b.base {
|
|
t.Fatalf("first id %d must be above its own incarnation base %d", idsB[0], b.base)
|
|
}
|
|
if idsA[0] == idsB[0] {
|
|
t.Fatalf("two incarnations assigned the same first id %d", idsA[0])
|
|
}
|
|
}
|
|
|
|
func TestTheAdjacentCursorAcrossIncarnationsIsRefused(t *testing.T) {
|
|
// The one case the stride invariant does NOT cover on its own, and
|
|
// therefore the only case that distinguishes the explicit base check in
|
|
// EventsSince from the coverage check inside replayBuffer.since.
|
|
//
|
|
// since() serves sinceID+1 == knownFrom, reasoning that no ID lies
|
|
// strictly between the cursor and our first event. That is true only
|
|
// WITHIN one ID space. A cursor of exactly `base` is adjacent to this
|
|
// incarnation's first ID (base+1) and belongs to a space that ended — so
|
|
// the adjacency reasoning would hand it a replay across a boundary it
|
|
// cannot see.
|
|
//
|
|
// Reachable only at the stride boundary in production (a previous
|
|
// incarnation would have to have published 2^20 events per millisecond of
|
|
// its life to issue `base`). Pinned anyway: the guard is what makes the
|
|
// case impossible rather than improbable, and a test that only exercised
|
|
// the probable half would pass with the guard deleted.
|
|
bus := New()
|
|
ids := publishN(bus, "ws-1", 1)
|
|
if ids[0] != bus.base+1 {
|
|
t.Fatalf("fixture: first id %d should be base+1 (%d)", ids[0], bus.base+1)
|
|
}
|
|
|
|
if got := bus.EventsSince("ws-1", bus.base); got != nil {
|
|
t.Fatalf("a cursor at the base is adjacent to our first id but belongs to a dead space; must be a gap, got %d events", len(got))
|
|
}
|
|
|
|
// Control, one ID higher: the first ID this incarnation issued is served.
|
|
if got := bus.EventsSince("ws-1", bus.base+1); got == nil {
|
|
t.Fatal("a cursor at this incarnation's first id must be served")
|
|
}
|
|
}
|
|
|
|
func idsOf(events []Event) []int64 {
|
|
ids := make([]int64, 0, len(events))
|
|
for _, e := range events {
|
|
ids = append(ids, e.ID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func TestConcurrentPublishesLeaveTheBufferInIDOrder(t *testing.T) {
|
|
// codex round 18. The id used to be assigned BEFORE the replay lock, so
|
|
// two concurrent publishes could take N and N+1 and append in the other
|
|
// order. replayBuffer.since computes oldest and newest by POSITION, so a
|
|
// buffer holding [N+1, N] reports N as its newest and answers a resume
|
|
// from N+1 with sync_required — a client told to resync at the moment it
|
|
// was exactly current.
|
|
bus := New()
|
|
const n = 300
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < n; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
bus.Publish(Event{Type: ItemUpdated, WorkspaceID: "ws-1"})
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
buffered := bus.EventsSince("ws-1", 0)
|
|
if len(buffered) != n {
|
|
t.Fatalf("expected %d buffered events, got %d", n, len(buffered))
|
|
}
|
|
for i := 1; i < len(buffered); i++ {
|
|
if buffered[i].ID <= buffered[i-1].ID {
|
|
t.Fatalf("buffer out of id order at index %d: %d after %d", i, buffered[i].ID, buffered[i-1].ID)
|
|
}
|
|
}
|
|
// The consequence, asserted rather than inferred: every id in the buffer
|
|
// must be servable as a cursor. Under the race the newest ones were not.
|
|
for _, e := range buffered {
|
|
if got := bus.EventsSince("ws-1", e.ID); got == nil {
|
|
t.Fatalf("cursor %d is inside the buffer and must be servable", e.ID)
|
|
}
|
|
}
|
|
}
|