vojo/apps/ai-bot/concurrency_test.go

119 lines
3.5 KiB
Go

package main
import (
"sync"
"testing"
)
// TestSingleFlightClaim documents the per-(room,thread) single-flight invariant the
// async refactor relies on: at most one generation per conversation at a time, the claim
// is independent per (room,thread), and a release re-arms only that conversation.
// handleEvent takes this claim synchronously in transaction order, so the FIRST message
// for a conversation wins and later ones are dropped until release (never the reverse).
func TestSingleFlightClaim(t *testing.T) {
b := &Bot{inflight: make(map[string]map[string]bool)}
if !b.tryClaim("!a", "") {
t.Fatal("first claim on (!a, main) should win")
}
if b.tryClaim("!a", "") {
t.Fatal("second claim on (!a, main) must fail while in flight")
}
// A DIFFERENT thread in the SAME room must claim independently — the whole point of
// per-(room,thread) single-flight: a slow answer in one conversation cannot block
// another conversation in the same room.
if !b.tryClaim("!a", "$root1") {
t.Fatal("a different thread in the same room must claim independently")
}
if b.tryClaim("!a", "$root1") {
t.Fatal("second claim on (!a, $root1) must fail while in flight")
}
if !b.tryClaim("!b", "") {
t.Fatal("a different room must claim independently")
}
b.release("!a", "")
if !b.tryClaim("!a", "") {
t.Fatal("after release (!a, main) must be claimable again")
}
// Releasing the main timeline must NOT free the thread's claim.
if b.tryClaim("!a", "$root1") {
t.Fatal("releasing (!a, main) must not free (!a, $root1)")
}
}
// TestSingleFlightClaimExactlyOneWinner runs many goroutines racing for the same
// conversation and asserts EXACTLY ONE wins — the property that prevents two concurrent
// generations (double xAI spend) for one conversation. It also races two DIFFERENT
// threads of one room together and asserts each has its own single winner, proving the
// claim is independent per (room,thread), not per room. Run under -race.
func TestSingleFlightClaimExactlyOneWinner(t *testing.T) {
b := &Bot{inflight: make(map[string]map[string]bool)}
const n = 64
var sameWins, threadAWins, threadBWins int64
var mu sync.Mutex
var wg sync.WaitGroup
wg.Add(n * 3)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
if b.tryClaim("!room", "$same") {
mu.Lock()
sameWins++
mu.Unlock()
}
}()
go func() {
defer wg.Done()
if b.tryClaim("!room", "$a") {
mu.Lock()
threadAWins++
mu.Unlock()
}
}()
go func() {
defer wg.Done()
if b.tryClaim("!room", "$b") {
mu.Lock()
threadBWins++
mu.Unlock()
}
}()
}
wg.Wait()
if sameWins != 1 {
t.Fatalf("exactly one goroutine must win (!room, $same), got %d", sameWins)
}
if threadAWins != 1 {
t.Fatalf("exactly one goroutine must win (!room, $a), got %d", threadAWins)
}
if threadBWins != 1 {
t.Fatalf("exactly one goroutine must win (!room, $b), got %d", threadBWins)
}
}
// TestLRUSetConcurrentAddOnce asserts the dedup set's check-and-insert is atomic:
// with many goroutines racing on the same id, Add returns true exactly once. This is
// the in-memory half of markSeen, now called from concurrent per-room goroutines.
// Run under -race.
func TestLRUSetConcurrentAddOnce(t *testing.T) {
s := newLRUSet(1000)
const n = 64
var trues int64
var mu sync.Mutex
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
if s.Add("$evt") {
mu.Lock()
trues++
mu.Unlock()
}
}()
}
wg.Wait()
if trues != 1 {
t.Fatalf("Add must return true exactly once for one id, got %d", trues)
}
}