Files
pad/internal/collab/bus_test.go
T
xarmian 264f5b0041 feat(collab): add OpBus interface + in-process MemoryOpBus (TASK-1253) (#451)
* feat(collab): add OpBus interface + in-process MemoryOpBus (TASK-1253)

New internal/collab package for the dumb-relay collab server in
PLAN-1248. Defines the OpBus pub/sub interface and ships
MemoryOpBus, the in-process implementation used by every shipping
target today (single-binary self-host, single-replica pad-cloud).

OpBus shape mirrors internal/events.MemoryBus so a future RedisOpBus
is a drop-in for multi-replica deployments — that's filed as a
separate IDEA at PLAN-1248 close, since the dumb-relay design
intentionally keeps Redis off the self-host dependency surface.

OpEvent carries:
- ItemID    fan-out filter
- ClientID  Yjs client id, used by designated-applier election
            (TASK-1257); the bus itself does not interpret it
- Type      "sync" (Y.Doc binary update — persisted by the room
            manager) or "awareness" (cursor/presence — broadcast
            only, never persisted)
- Data      raw y-protocol message; opaque to the server
- Timestamp UnixMilli, auto-stamped on Publish

MemoryOpBus semantics:
- 64-event buffered subscriber channels (matches internal/events
  default — sized against keystroke-rate workload).
- Non-blocking Publish: a slow subscriber whose channel is full has
  events DROPPED with a warn log rather than back-pressuring the
  broadcast loop. The room manager (TASK-1255) is responsible for
  closing genuinely unhealthy peers; the bus only protects itself.
- Idempotent Unsubscribe (no panic on double-unsubscribe).
- Close clears the subscriber map and closes every channel under
  the same write lock that gates Publish, so a final inflight
  Publish can't race a Close into delivering on a closed channel.

Tests cover: subscribe/publish fan-out per item filter, unsubscribe
closes the channel, slow-consumer drop without blocking, accurate
SubscriberCount across subscribe/unsubscribe, Close cleans up every
channel regardless of itemID, and concurrent-publishers race-clean
under -race (small drop count tolerated — that's the slow-consumer
contract; an exact-delivery test would defeat its own purpose).

No external dependencies beyond stdlib + slog. RedisOpBus stub is
intentionally NOT included — separate IDEA per the Plan body.

Parent: PLAN-1248. Phase 1 — Backend foundation.

* fix(collab): clone OpEvent.Data + document recovery contract per Codex review (round 1)

P1 — Sync-op drops were undocumented as recoverable. Sync drops ARE
recoverable in the dumb-relay design: the room manager (TASK-1255)
appends to the op-log BEFORE Publish, so any peer that misses a sync
op via channel-full drop can replay since their last cursor on
reconnect (TASK-1252's LoadYjsUpdatesSince + Yjs state-vector
negotiation). The room manager is responsible for detecting slow
channels and force-closing the owning WebSocket, which kicks the
peer into a fresh reconnect + replay. The bus does not take that
action itself because it has no concept of which peer owns which
channel — that mapping is the room manager's domain. Doc comment now
spells this out explicitly.

P2 — OpEvent.Data is a []byte; the same slice header was queued to
every subscriber, so a publisher's later buffer reuse OR any
subscriber's mutation could corrupt the bytes other subscribers
observe. gorilla/websocket's ReadMessage is allowed to reuse its
read buffer between messages, so this hazard is real for the
production publisher (the WS handler in TASK-1254). Clone Data once
at the publish boundary; document the per-receiver immutability
expectation in the same comment block.

No behavioral test change — the existing slow-consumer-drop test
still passes; the clone path adds one allocation per Publish but
nothing observable to callers beyond the immutability guarantee.
2026-05-08 13:56:21 -04:00

273 lines
7.5 KiB
Go

package collab
import (
"bytes"
"sync"
"testing"
"time"
)
// TestMemoryOpBusSubscribeAndPublish verifies basic fan-out: a
// subscriber receives every event for its itemID and ignores events
// for other items.
func TestMemoryOpBusSubscribeAndPublish(t *testing.T) {
bus := NewMemoryOpBus()
defer bus.Close()
chA := bus.Subscribe("item-a")
chB := bus.Subscribe("item-b")
bus.Publish(OpEvent{ItemID: "item-a", Type: OpTypeSync, Data: []byte{1}})
bus.Publish(OpEvent{ItemID: "item-b", Type: OpTypeSync, Data: []byte{2}})
bus.Publish(OpEvent{ItemID: "item-a", Type: OpTypeAwareness, Data: []byte{3}})
got := drain(chA, 2)
if len(got) != 2 {
t.Fatalf("subA: want 2 events, got %d", len(got))
}
if got[0].Type != OpTypeSync || !bytes.Equal(got[0].Data, []byte{1}) {
t.Errorf("subA[0] mismatch: %+v", got[0])
}
if got[1].Type != OpTypeAwareness || !bytes.Equal(got[1].Data, []byte{3}) {
t.Errorf("subA[1] mismatch: %+v", got[1])
}
if got[0].Timestamp == 0 {
t.Errorf("Timestamp should be auto-stamped on Publish")
}
gotB := drain(chB, 1)
if len(gotB) != 1 || !bytes.Equal(gotB[0].Data, []byte{2}) {
t.Fatalf("subB: want 1 event with data [2], got %+v", gotB)
}
}
// TestMemoryOpBusUnsubscribeClosesChannel confirms Unsubscribe removes
// the subscriber AND closes its channel, so handler `for ev := range
// ch { ... }` loops exit cleanly.
func TestMemoryOpBusUnsubscribeClosesChannel(t *testing.T) {
bus := NewMemoryOpBus()
defer bus.Close()
ch := bus.Subscribe("item-a")
if bus.SubscriberCount("item-a") != 1 {
t.Fatalf("want 1 subscriber, got %d", bus.SubscriberCount("item-a"))
}
bus.Unsubscribe(ch)
if bus.SubscriberCount("item-a") != 0 {
t.Fatalf("want 0 subscribers after Unsubscribe, got %d", bus.SubscriberCount("item-a"))
}
// Reading from a closed channel returns the zero value with ok=false.
if _, ok := <-ch; ok {
t.Errorf("channel should be closed after Unsubscribe")
}
// Idempotent: a second Unsubscribe is a no-op (no panic).
bus.Unsubscribe(ch)
}
// TestMemoryOpBusSlowSubscriberDrops verifies that a subscriber whose
// channel buffer is full has events dropped instead of blocking the
// publisher. Without this, one stuck consumer would back-pressure the
// entire collab room.
func TestMemoryOpBusSlowSubscriberDrops(t *testing.T) {
bus := NewMemoryOpBus()
defer bus.Close()
ch := bus.Subscribe("item-a")
// Fill the buffer (default 64) without draining.
for i := 0; i < 64; i++ {
bus.Publish(OpEvent{ItemID: "item-a", Type: OpTypeSync, Data: []byte{byte(i)}})
}
// One more Publish — this MUST NOT block. We exercise the drop path
// by giving Publish a hard wall-clock budget; without the
// non-blocking select, this would hang the test.
done := make(chan struct{})
go func() {
bus.Publish(OpEvent{ItemID: "item-a", Type: OpTypeSync, Data: []byte{0xff}})
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Publish blocked on a slow subscriber instead of dropping")
}
// Confirm the buffer still has the original 64 entries (the dropped
// 65th is ABSENT — drop path is silent at the data level, only logs).
got := drain(ch, 64)
if len(got) != 64 {
t.Fatalf("want 64 events drained, got %d", len(got))
}
}
// TestMemoryOpBusSubscriberCount tracks the count across subscribe
// and unsubscribe.
func TestMemoryOpBusSubscriberCount(t *testing.T) {
bus := NewMemoryOpBus()
defer bus.Close()
if c := bus.SubscriberCount("item-a"); c != 0 {
t.Errorf("empty bus: want 0, got %d", c)
}
chA1 := bus.Subscribe("item-a")
chA2 := bus.Subscribe("item-a")
chB := bus.Subscribe("item-b")
if c := bus.SubscriberCount("item-a"); c != 2 {
t.Errorf("two on item-a: want 2, got %d", c)
}
if c := bus.SubscriberCount("item-b"); c != 1 {
t.Errorf("one on item-b: want 1, got %d", c)
}
if c := bus.SubscriberCount("item-c"); c != 0 {
t.Errorf("none on item-c: want 0, got %d", c)
}
bus.Unsubscribe(chA1)
if c := bus.SubscriberCount("item-a"); c != 1 {
t.Errorf("after one unsub: want 1, got %d", c)
}
bus.Unsubscribe(chA2)
bus.Unsubscribe(chB)
if c := bus.SubscriberCount("item-a"); c != 0 {
t.Errorf("after all unsub item-a: want 0, got %d", c)
}
if c := bus.SubscriberCount("item-b"); c != 0 {
t.Errorf("after all unsub item-b: want 0, got %d", c)
}
}
// TestMemoryOpBusCloseClosesAllChannels confirms Close cleans up
// every subscriber, regardless of which item they were watching.
func TestMemoryOpBusCloseClosesAllChannels(t *testing.T) {
bus := NewMemoryOpBus()
chA := bus.Subscribe("item-a")
chB := bus.Subscribe("item-b")
bus.Close()
if _, ok := <-chA; ok {
t.Errorf("chA should be closed after bus.Close")
}
if _, ok := <-chB; ok {
t.Errorf("chB should be closed after bus.Close")
}
if c := bus.SubscriberCount("item-a"); c != 0 {
t.Errorf("after Close: want 0 subscribers on item-a, got %d", c)
}
}
// TestMemoryOpBusConcurrentPublish confirms the bus survives many
// concurrent publishers without panicking or tripping the race
// detector on the subscriber map. Some events legitimately get dropped
// under contention (that's the slow-subscriber-drop contract — a
// dedicated test exercises that path), so we don't assert "all events
// received" here; the goal is race-cleanliness, not zero loss.
func TestMemoryOpBusConcurrentPublish(t *testing.T) {
bus := NewMemoryOpBus()
defer bus.Close()
ch := bus.Subscribe("item-a")
const writers = 8
const each = 50
const want = writers * each
// Drain continuously so the buffer doesn't sit full for long and
// every publisher gets to make progress. Stop when publishers are
// done AND the channel is empty.
stopDrain := make(chan struct{})
receivedCh := make(chan int, 1)
go func() {
count := 0
for {
select {
case _, ok := <-ch:
if !ok {
receivedCh <- count
return
}
count++
case <-stopDrain:
// Drain remaining buffered events without blocking.
for {
select {
case _, ok := <-ch:
if !ok {
receivedCh <- count
return
}
count++
default:
receivedCh <- count
return
}
}
}
}
}()
var wg sync.WaitGroup
for w := 0; w < writers; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := 0; i < each; i++ {
bus.Publish(OpEvent{
ItemID: "item-a",
ClientID: uint64(id),
Type: OpTypeSync,
Data: []byte{byte(i)},
})
}
}(w)
}
wg.Wait()
// Give the drain goroutine a beat to flush anything still buffered
// before signalling stop. This isn't strictly required for race
// cleanliness but keeps the received-count meaningful for the
// sanity assertion below.
time.Sleep(50 * time.Millisecond)
close(stopDrain)
select {
case got := <-receivedCh:
// Sanity: at least some events landed. Exact count varies
// because of the legitimate slow-consumer drop path.
if got == 0 {
t.Fatalf("want at least one event received, got 0 (publish path appears broken)")
}
if got > want {
t.Fatalf("got more events than published: %d > %d", got, want)
}
t.Logf("concurrent publish: %d/%d events delivered (drops are expected and OK)", got, want)
case <-time.After(5 * time.Second):
t.Fatal("drain goroutine did not return")
}
}
// drain reads up to n events from ch with a short timeout so a
// missing/slow event doesn't hang the test.
func drain(ch chan OpEvent, n int) []OpEvent {
out := make([]OpEvent, 0, n)
for i := 0; i < n; i++ {
select {
case ev, ok := <-ch:
if !ok {
return out
}
out = append(out, ev)
case <-time.After(1 * time.Second):
return out
}
}
return out
}