Files
goclaw/internal/channels/dispatch_shard_test.go
Conner Mo 9996a16f83 perf(channels): shard outbound dispatch per conversation
A single dispatch goroutine delivered every reply in the process — every
channel, every user — each Send blocking the next. One media upload of a
few seconds froze all outbound traffic for that long, and sustained
throughput was capped at one Send round-trip regardless of lane capacity.

The ordering that loop actually protected is per-conversation: a run emits
block replies, retry notices and a final answer to one ChatID, and those
must arrive in that order. A global order was never required.

Dispatch now shards on channel+ChatID. A conversation always maps to the
same shard and is delivered serially there, so the ordering guarantee is
unchanged, while unrelated conversations proceed in parallel.
GOCLAW_OUTBOUND_SHARDS tunes the worker count (default 8).

Temp media needed a real fix to go with it. Serial dispatch deduped it
implicitly — the first delivery removed the file, and a later message
carrying the same path found os.Stat failing and skipped it. Across
concurrent shards that check alone is a TOCTOU race, so claims are now
taken atomically and released after the send.

Also makes the pool limits that bound this path configurable, defaults
unchanged: GOCLAW_PG_MAX_OPEN_CONNS / GOCLAW_PG_MAX_IDLE_CONNS, and
GOCLAW_HTTP_MAX_IDLE_CONNS / GOCLAW_HTTP_MAX_IDLE_CONNS_PER_HOST. The
per-host limit binds when one provider takes nearly all traffic: every
request past it pays a fresh TCP+TLS handshake, which a deployment
raising LaneMain needs to be able to lift.
2026-07-22 14:14:02 +08:00

317 lines
9.0 KiB
Go

package channels
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bus"
)
// recorderChannel records every delivered message in arrival order, and can
// block inside Send to simulate a slow upload.
type recorderChannel struct {
BaseChannel
mu sync.Mutex
received []bus.OutboundMessage
// blockOn holds Send hostage for messages whose ChatID matches, until
// release is closed. Empty means never block.
blockOn string
release chan struct{}
}
func newRecorderChannel(name string) *recorderChannel {
rc := &recorderChannel{release: make(chan struct{})}
rc.BaseChannel = BaseChannel{name: name}
return rc
}
func (r *recorderChannel) Type() string { return TypeTelegram }
func (r *recorderChannel) Start(_ context.Context) error { return nil }
func (r *recorderChannel) Stop(_ context.Context) error { return nil }
func (r *recorderChannel) IsRunning() bool { return true }
func (r *recorderChannel) IsAllowed(_ string) bool { return true }
func (r *recorderChannel) Send(_ context.Context, msg bus.OutboundMessage) error {
if r.blockOn != "" && msg.ChatID == r.blockOn {
<-r.release
}
r.mu.Lock()
r.received = append(r.received, msg)
r.mu.Unlock()
return nil
}
func (r *recorderChannel) contentsFor(chatID string) []string {
r.mu.Lock()
defer r.mu.Unlock()
var out []string
for _, m := range r.received {
if m.ChatID == chatID {
out = append(out, m.Content)
}
}
return out
}
func (r *recorderChannel) count() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.received)
}
// waitFor polls cond until it holds or the deadline passes.
func waitFor(t *testing.T, timeout time.Duration, cond func() bool) bool {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return true
}
time.Sleep(2 * time.Millisecond)
}
return cond()
}
// distinctShardChats returns two chat IDs that hash to different shards, so a
// test can prove two conversations really do run on separate workers.
func distinctShardChats(t *testing.T, channel string, shards int) (string, string) {
t.Helper()
base := "chat-0"
baseIdx := outboundShardIndex(channel, base, shards)
for i := 1; i < 500; i++ {
other := fmt.Sprintf("chat-%d", i)
if outboundShardIndex(channel, other, shards) != baseIdx {
return base, other
}
}
t.Fatalf("no two chat ids landed on different shards out of %d", shards)
return "", ""
}
// A conversation must always map to the same shard — that mapping is the only
// thing preserving per-chat ordering once dispatch runs in parallel.
func TestOutboundShardIndex_StableAndBounded(t *testing.T) {
t.Parallel()
const shards = 8
for i := range 200 {
chatID := fmt.Sprintf("chat-%d", i)
got := outboundShardIndex("telegram-test", chatID, shards)
if got < 0 || got >= shards {
t.Fatalf("shard index %d out of range [0,%d)", got, shards)
}
if again := outboundShardIndex("telegram-test", chatID, shards); again != got {
t.Fatalf("shard index for %q not stable: %d then %d", chatID, got, again)
}
}
}
// ChatIDs are only unique within a channel, so the channel name must be part
// of the shard key — otherwise two channels sharing a conversation id would
// have their messages interleaved on one worker.
func TestOutboundShardIndex_ChannelIsPartOfKey(t *testing.T) {
t.Parallel()
const shards = 64
same := 0
for i := range 100 {
chatID := fmt.Sprintf("chat-%d", i)
if outboundShardIndex("alpha", chatID, shards) == outboundShardIndex("beta", chatID, shards) {
same++
}
}
// With 64 shards, identical placement for every id would mean the channel
// name was ignored entirely.
if same == 100 {
t.Fatal("channel name does not affect shard placement")
}
}
// The ordering guarantee dispatch actually owes callers: a run emits block
// replies, retry notices and the final answer to one ChatID, and they must
// arrive in that order.
func TestDispatchOutbound_PreservesPerChatOrder(t *testing.T) {
t.Parallel()
mb := bus.New()
mgr := NewManager(mb)
ch := newRecorderChannel("telegram-test")
mgr.channels["telegram-test"] = ch
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go mgr.dispatchOutbound(ctx)
const n = 50
for i := range n {
mb.PublishOutbound(bus.OutboundMessage{
Channel: "telegram-test",
ChatID: "chat-order",
Content: fmt.Sprintf("msg-%d", i),
})
}
if !waitFor(t, 5*time.Second, func() bool { return ch.count() >= n }) {
t.Fatalf("only %d/%d messages delivered", ch.count(), n)
}
got := ch.contentsFor("chat-order")
for i := range n {
want := fmt.Sprintf("msg-%d", i)
if got[i] != want {
t.Fatalf("message %d out of order: got %q, want %q", i, got[i], want)
}
}
}
// The regression this whole change exists for: one stalled conversation must
// not hold up every other conversation in the process.
func TestDispatchOutbound_SlowChatDoesNotBlockOthers(t *testing.T) {
t.Parallel()
shards := outboundShardCount()
slowChat, fastChat := distinctShardChats(t, "telegram-test", shards)
mb := bus.New()
mgr := NewManager(mb)
ch := newRecorderChannel("telegram-test")
ch.blockOn = slowChat
mgr.channels["telegram-test"] = ch
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go mgr.dispatchOutbound(ctx)
// The stuck conversation goes first, exactly as it would when a media
// upload lands ahead of everyone else's replies.
mb.PublishOutbound(bus.OutboundMessage{
Channel: "telegram-test",
ChatID: slowChat,
Content: "slow-upload",
})
mb.PublishOutbound(bus.OutboundMessage{
Channel: "telegram-test",
ChatID: fastChat,
Content: "should-not-wait",
})
if !waitFor(t, 5*time.Second, func() bool { return len(ch.contentsFor(fastChat)) == 1 }) {
close(ch.release)
t.Fatal("a reply on an unrelated conversation was blocked behind a slow send")
}
// The slow one still completes once it is unblocked.
close(ch.release)
if !waitFor(t, 5*time.Second, func() bool { return len(ch.contentsFor(slowChat)) == 1 }) {
t.Fatal("the slow conversation never completed after release")
}
}
// Temp media was deduped by the fact that serial dispatch removed the file
// before the next message was looked at. Concurrent shards make that a TOCTOU
// race, so the claim must be exclusive.
func TestClaimTempMedia_ExclusiveUnderConcurrency(t *testing.T) {
t.Parallel()
mgr := NewManager(bus.New())
path := filepath.Join(t.TempDir(), "shared.jpg")
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatalf("write temp media: %v", err)
}
// claimTempMedia only guards files under os.TempDir(); t.TempDir() lives
// there, so this mirrors the real path.
if !isUnderTempDir(path) {
t.Skipf("t.TempDir() %q is not under os.TempDir() %q on this platform", path, os.TempDir())
}
media := []bus.MediaAttachment{{URL: path, ContentType: "image/jpeg"}}
const racers = 16
var wg sync.WaitGroup
winners := make(chan []string, racers)
for range racers {
wg.Add(1)
go func() {
defer wg.Done()
kept, claimed := mgr.claimTempMedia(media)
if len(kept) > 0 {
winners <- claimed
}
}()
}
wg.Wait()
close(winners)
if n := len(winners); n != 1 {
t.Fatalf("%d dispatchers claimed the same temp file; want exactly 1", n)
}
}
// Non-temp media (workspace files) is never removed after delivery, so it must
// pass through unclaimed — otherwise a second message referencing the same
// workspace file would silently lose it.
func TestClaimTempMedia_PassesThroughNonTempAndSkipsMissing(t *testing.T) {
t.Parallel()
mgr := NewManager(bus.New())
missing := filepath.Join(os.TempDir(), "goclaw-does-not-exist-12345.bin")
workspace := "/workspace/report.pdf"
kept, claimed := mgr.claimTempMedia([]bus.MediaAttachment{
{URL: workspace},
{URL: missing},
})
if len(kept) != 1 || kept[0].URL != workspace {
t.Fatalf("kept = %+v, want only the workspace file", kept)
}
if len(claimed) != 0 {
t.Fatalf("claimed = %v, want no claims on non-temp media", claimed)
}
}
// releaseTempMedia must drop the claim as well as the file, so the claim set
// stays bounded across a long-running process.
func TestReleaseTempMedia_ClearsClaim(t *testing.T) {
t.Parallel()
mgr := NewManager(bus.New())
path := filepath.Join(t.TempDir(), "once.jpg")
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatalf("write temp media: %v", err)
}
if !isUnderTempDir(path) {
t.Skipf("t.TempDir() %q is not under os.TempDir() %q on this platform", path, os.TempDir())
}
media := []bus.MediaAttachment{{URL: path}}
kept, claimed := mgr.claimTempMedia(media)
if len(kept) != 1 || len(claimed) != 1 {
t.Fatalf("first claim failed: kept=%d claimed=%d", len(kept), len(claimed))
}
mgr.releaseTempMedia(claimed)
if _, stillHeld := mgr.mediaClaims.Load(path); stillHeld {
t.Fatal("claim survived release; the claim set would grow without bound")
}
if _, err := os.Stat(path); err == nil {
t.Fatal("temp file survived release")
}
}
func isUnderTempDir(path string) bool {
rel, err := filepath.Rel(os.TempDir(), path)
return err == nil && !filepath.IsAbs(rel) && rel != ".." && len(rel) > 0 && rel[0] != '.'
}