119 lines
3.5 KiB
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)
|
|
}
|
|
}
|