package mtprotoedge import ( "context" "fmt" "os" "runtime" "sort" "strconv" "sync" "sync/atomic" "testing" "time" "go.uber.org/zap" "github.com/iamxvbaba/td/proto" "github.com/iamxvbaba/td/tg" ) // Connection-layer fan-out / churn load test: focused on SessionManager lock // contention, without going through real sockets / encryption. // // The constructed Conn deliberately does not startOutbound: after pushToUser takes // a locked snapshot of byUser, it calls c.Send for each conn outside the lock, and // at that point c.outbound==nil immediately returns ErrConnClosed (see // outbound.go), so the measurement concentrates on "the locked section + dispatch // overhead", i.e. the global lock hot spot that sharding aims to eliminate. Each // connection uses only struct memory (no 1024-capacity outbound channel, no // goroutine), so it can be registered at the 200k scale. // // Usage: // // go test ./internal/mtprotoedge/ -run '^$' -bench BenchmarkSessionManager -benchmem -cpu 1,4,8 // go test ./internal/mtprotoedge/ -run '^$' -bench BenchmarkSessionManagerPushConcurrent -mutexprofile mu.out // TELESRV_LOAD_CONNS=200000 go test ./internal/mtprotoedge/ -run TestSessionManagerFanoutThroughput -v -timeout 300s func benchConn(sessionID int64, authKeyID [8]byte, userID int64) *Conn { c := &Conn{sessionID: sessionID, authKeyID: authKeyID} if userID != 0 { c.userID.Store(userID) c.userIDResolved.Store(true) } c.receivesUpdates.Store(true) // take fanout's "collect conns→Send outside lock" branch, not pending staging return c } func authKeyIDFromInt(v uint64) [8]byte { var id [8]byte for i := 0; i < 8; i++ { id[i] = byte(v >> (8 * i)) } return id } // seedSessions registers conns connections, binding connsPerUser connections per // user (simulating multiple devices). It returns the number of registered users // (deduplicated, ordered range [1, userCount]). func seedSessions(sm *SessionManager, conns, connsPerUser int) (userCount int) { if connsPerUser < 1 { connsPerUser = 1 } for i := 0; i < conns; i++ { userID := int64(i/connsPerUser) + 1 sm.Register(benchConn(int64(i)+1, authKeyIDFromInt(uint64(i)+1), userID)) } return (conns + connsPerUser - 1) / connsPerUser } // BenchmarkSessionManagerPushConcurrent simulates the real hot spot at 200k online: // many goroutines concurrently pushToUser to different users, all contending for // the same global lock. -mutexprofile will surface SessionManager.mu at the top. func BenchmarkSessionManagerPushConcurrent(b *testing.B) { const conns = 200_000 const connsPerUser = 2 sm := NewSessionManager(zap.NewNop()) userCount := seedSessions(sm, conns, connsPerUser) msg := &tg.UpdatesTooLong{} ctx := context.Background() b.ResetTimer() b.RunParallel(func(pb *testing.PB) { var n uint64 for pb.Next() { n++ userID := int64(n%uint64(userCount)) + 1 _, _ = sm.PushToUser(ctx, userID, proto.MessageFromServer, msg) } }) } // BenchmarkSessionManagerRegisterChurn measures the lock cost of connection // setup/teardown: concurrent Register+Unregister. 200k online means continuous // connect/disconnect churn, each contending for the global write lock. func BenchmarkSessionManagerRegisterChurn(b *testing.B) { sm := NewSessionManager(zap.NewNop()) var seq atomic.Uint64 b.ResetTimer() b.RunParallel(func(pb *testing.PB) { for pb.Next() { id := seq.Add(1) c := benchConn(int64(id), authKeyIDFromInt(id), int64(id)) sm.Register(c) sm.Unregister(c) } }) } // BenchmarkSessionManagerPushFanoutWidth measures the fanout breadth cost of a // single push: one user bound to many connections, and a single PushToUser must // traverse all of them while holding the lock. A real private-chat user has few // devices (2-4), so this is an upper-bound reference. func BenchmarkSessionManagerPushFanoutWidth(b *testing.B) { for _, width := range []int{1, 4, 16, 64} { b.Run(fmt.Sprintf("width=%d", width), func(b *testing.B) { sm := NewSessionManager(zap.NewNop()) const userID = 1 for i := 0; i < width; i++ { sm.Register(benchConn(int64(i)+1, authKeyIDFromInt(uint64(i)+1), userID)) } msg := &tg.UpdatesTooLong{} ctx := context.Background() b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = sm.PushToUser(ctx, userID, proto.MessageFromServer, msg) } }) } } // TestSessionManagerFanoutThroughput is a data-driven throughput test: after // registering N connections, P goroutines continuously push concurrently, measuring // actual push throughput and p99 under the global lock. Small smoke scale by // default; set TELESRV_LOAD_CONNS to scale up to 200k. func TestSessionManagerFanoutThroughput(t *testing.T) { conns := envIntDefault("TELESRV_LOAD_CONNS", 20_000) connsPerUser := envIntDefault("TELESRV_LOAD_CONNS_PER_USER", 2) workers := envIntDefault("TELESRV_LOAD_PUSH_WORKERS", 0) // 0 → GOMAXPROCS duration := time.Duration(envIntDefault("TELESRV_LOAD_SECONDS", 3)) * time.Second if workers <= 0 { workers = runtime.GOMAXPROCS(0) } sm := NewSessionManager(zap.NewNop()) t0 := time.Now() userCount := seedSessions(sm, conns, connsPerUser) seedWall := time.Since(t0) if got := sm.Online(); got != conns { t.Fatalf("online = %d, want %d", got, conns) } msg := &tg.UpdatesTooLong{} ctx := context.Background() var ops atomic.Int64 perWorkerLat := make([][]time.Duration, workers) deadline := time.Now().Add(duration) var wg sync.WaitGroup start := time.Now() for w := 0; w < workers; w++ { wg.Add(1) go func(w int) { defer wg.Done() lat := make([]time.Duration, 0, 1<<16) var n uint64 for time.Now().Before(deadline) { // Check the clock once every batch of 256, reducing time.Now's share. for j := 0; j < 256; j++ { n++ userID := int64(n%uint64(userCount)) + 1 s := time.Now() _, _ = sm.PushToUser(ctx, userID, proto.MessageFromServer, msg) lat = append(lat, time.Since(s)) } ops.Add(256) } perWorkerLat[w] = lat }(w) } wg.Wait() wall := time.Since(start) all := make([]time.Duration, 0, ops.Load()) for _, l := range perWorkerLat { all = append(all, l...) } sortDurations(all) total := ops.Load() thr := float64(total) / wall.Seconds() t.Logf("==== session_manager fan-out throughput ====") t.Logf("config: conns=%d connsPerUser=%d users=%d pushWorkers=%d dur=%s seed=%s", conns, connsPerUser, userCount, workers, duration, seedWall.Round(time.Millisecond)) t.Logf("push: %d ops in %s -> %.0f push/s", total, wall.Round(time.Millisecond), thr) t.Logf("push.lat p50=%s p90=%s p99=%s max=%s", pct(all, 50), pct(all, 90), pct(all, 99), pct(all, 100)) t.Logf("=============================================") } func pct(sorted []time.Duration, p int) time.Duration { if len(sorted) == 0 { return 0 } idx := (p*len(sorted))/100 - 1 if idx < 0 { idx = 0 } if idx >= len(sorted) { idx = len(sorted) - 1 } return sorted[idx] } func sortDurations(d []time.Duration) { sort.Slice(d, func(i, j int) bool { return d[i] < d[j] }) } func envIntDefault(key string, def int) int { if v := os.Getenv(key); v != "" { if n, err := strconv.Atoi(v); err == nil { return n } } return def }