feat(loadtest): sync add real 500-session capacity harness

This commit is contained in:
iamxvbaba 2026-08-02 12:02:07 +08:00
parent ac0566f779
commit 141f2f20c4
39 changed files with 4157 additions and 42 deletions

226
cmd/telesrv-load/main.go Normal file
View file

@ -0,0 +1,226 @@
// Command telesrv-load provisions and drives real encrypted MTProto sessions.
// It is intentionally separate from the server process so a load generator can
// run on the M2 host without sharing server memory, database connections or
// internal handler shortcuts.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"telesrv/internal/loadharness"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "telesrv-load:", err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string) error {
if len(args) == 0 {
return usageError()
}
switch args[0] {
case "keygen":
return runKeygen(args[1:])
case "provision":
return runProvision(ctx, args[1:])
case "run":
return runLoad(ctx, args[1:])
case "summarize":
return runSummarize(args[1:])
case "help", "-h", "--help":
fmt.Fprintln(os.Stdout, usageText)
return nil
default:
return usageError()
}
}
func runKeygen(args []string) error {
flags := flag.NewFlagSet("keygen", flag.ContinueOnError)
path := flags.String("out", filepath.FromSlash("data/loadtest/session.key"), "owner-only session encryption key file")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("keygen accepts no positional arguments")
}
if err := loadharness.GenerateSessionKey(*path); err != nil {
return err
}
fmt.Fprintf(os.Stdout, "session encryption key written to %s\n", *path)
return nil
}
func runProvision(ctx context.Context, args []string) error {
flags := flag.NewFlagSet("provision", flag.ContinueOnError)
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "output manifest")
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
server := flags.String("server", "127.0.0.1:2398", "MTProto server address")
dc := flags.Int("dc", 2, "wire DC label")
rsaKey := flags.String("rsa-key", filepath.FromSlash("data/server_rsa.pem"), "server RSA private/public PEM")
apiID := flags.Int("api-id", 1, "test application ID")
apiHash := flags.String("api-hash", "hash", "test application hash")
accounts := flags.Int("accounts", 450, "unique accounts")
extraDevices := flags.Int("extra-devices", 50, "accounts receiving a second independent session")
concurrency := flags.Int("concurrency", 8, "parallel provisioning workers (max 64)")
phonePrefix := flags.String("phone-prefix", "+155500", "E.164 prefix followed by a six-digit account index")
firstName := flags.String("first-name-prefix", "Load", "generated first-name prefix")
obfuscated := flags.Bool("obfuscated", true, "use TDesktop-like Obfuscated2 + abridged transport")
pfs := flags.Bool("pfs", true, "bind temporary auth keys using PFS")
tempKeyTTL := flags.Int("temp-key-ttl", 86400, "temporary auth-key lifetime in seconds")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("provision accepts no positional arguments")
}
code := os.Getenv("TELESRV_LOAD_LOGIN_CODE")
if code == "" {
return errors.New("TELESRV_LOAD_LOGIN_CODE must contain the test environment login code")
}
cfg := loadharness.ProvisionConfig{
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyPath: *rsaKey,
Endpoint: loadharness.Endpoint{
Address: *server, DC: *dc, APIID: *apiID, APIHash: *apiHash, RSAKeyPath: *rsaKey,
Obfuscated: *obfuscated, PFS: *pfs, TempKeyTTL: *tempKeyTTL,
},
Accounts: *accounts, ExtraDevices: *extraDevices, Concurrency: *concurrency,
PhonePrefix: *phonePrefix, Code: code, FirstNamePrefix: *firstName,
}
result, err := loadharness.Provision(ctx, cfg, func(event loadharness.ProvisionEvent) {
status := "ok"
if event.Resumed {
status = "resumed"
}
if event.Err != nil {
status = "error"
}
fmt.Fprintf(os.Stdout, "provision %d/%d session=%d account=%d device=%d status=%s\n",
event.Completed, event.Total, event.Session.Index, event.Session.AccountIndex, event.Session.DeviceIndex, status)
})
if err != nil {
return err
}
fmt.Fprintf(os.Stdout, "provisioned %d real MTProto sessions into %s\n", len(result.Sessions), *manifest)
return nil
}
func runLoad(ctx context.Context, args []string) error {
flags := flag.NewFlagSet("run", flag.ContinueOnError)
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
report := flags.String("report", filepath.FromSlash("data/loadtest/report.json"), "final JSON report")
events := flags.String("events", filepath.FromSlash("data/loadtest/events.ndjson"), "periodic NDJSON evidence")
fileFixture := flags.String("file-fixture", "", "reusable fixture JSON; empty stores beside manifest")
serverMetrics := flags.String("server-metrics", "http://127.0.0.1:6060/metrics", "server metrics URL; empty disables")
sessions := flags.Int("sessions", 0, "limit selected sessions; 0 uses all")
duration := flags.Duration("duration", 30*time.Minute, "sustained load duration")
recovery := flags.Duration("recovery", 7*time.Minute, "post-disconnect reclamation observation")
ramp := flags.Duration("ramp", 2*time.Minute, "connection ramp duration")
rpcInterval := flags.Duration("rpc-interval", 5*time.Second, "per-session background RPC interval")
messageInterval := flags.Duration("message-interval", 30*time.Second, "per-primary-session message interval; negative disables")
fileInterval := flags.Duration("file-interval", time.Minute, "per-session upload.getFile interval")
fileSize := flags.Int("file-size", 4<<20, "generated shared download fixture bytes; 0 disables")
fileChunk := flags.Int("file-chunk", 1<<20, "upload.getFile bytes per request (max 1MiB)")
setupTimeout := flags.Duration("setup-timeout", 90*time.Second, "maximum first-time file fixture setup duration")
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one workload RPC")
sampleInterval := flags.Duration("sample-interval", 10*time.Second, "evidence and server scrape interval")
offlineFraction := flags.Float64("offline-fraction", 0.20, "fraction disconnected during offline window; 0 disables")
offlineAt := flags.Duration("offline-at", 10*time.Minute, "offline window start from run start")
offlineFor := flags.Duration("offline-for", 2*time.Minute, "offline window duration")
readyRatio := flags.Float64("min-ready-ratio", 0.98, "minimum peak ready ratio")
expectRestart := flags.Bool("expect-server-restart", false, "allow classified connection loss but require all selected sessions to reconnect")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("run accepts no positional arguments")
}
result, err := loadharness.Run(ctx, loadharness.RunConfig{
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
ReportPath: *report, EventsPath: *events, FileFixturePath: *fileFixture, ServerMetricsURL: *serverMetrics,
SessionLimit: *sessions, Duration: *duration, RecoveryDuration: *recovery, RampDuration: *ramp,
RPCInterval: *rpcInterval, MessageInterval: *messageInterval, SampleInterval: *sampleInterval,
FileInterval: *fileInterval, FileSizeBytes: *fileSize, FileChunkBytes: *fileChunk, SetupTimeout: *setupTimeout,
OperationTimeout: *operationTimeout,
OfflineFraction: *offlineFraction, OfflineAt: *offlineAt, OfflineFor: *offlineFor,
MinimumReadyRatio: *readyRatio,
ExpectServerRestart: *expectRestart,
})
if err != nil {
return err
}
printSummary(result)
if !result.Pass {
return fmt.Errorf("load acceptance failed; see %s", *report)
}
return nil
}
func runSummarize(args []string) error {
flags := flag.NewFlagSet("summarize", flag.ContinueOnError)
path := flags.String("report", filepath.FromSlash("data/loadtest/report.json"), "JSON report")
if err := flags.Parse(args); err != nil {
return err
}
data, err := os.ReadFile(*path)
if err != nil {
return err
}
var report loadharness.RunReport
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&report); err != nil {
return err
}
printSummary(&report)
if !report.Pass {
return errors.New("report did not pass")
}
return nil
}
func printSummary(report *loadharness.RunReport) {
fmt.Fprintf(os.Stdout, "pass=%v sessions=%d peak_ready=%d reconnects=%d disconnects=%d flood_waits=%d fatal_errors=%d\n",
report.Pass, report.ExpectedSessions, report.PeakReadySessions, report.Reconnects, report.Disconnects,
totalFloodWaits(report), report.WorkerFatalErrors)
for _, failure := range report.Failures {
fmt.Fprintln(os.Stdout, "failure:", failure)
}
}
func totalFloodWaits(report *loadharness.RunReport) uint64 {
var total uint64
for _, operation := range report.Operations {
total += operation.FloodWaits
}
return total
}
func usageError() error {
return errors.New("expected one of: keygen, provision, run, summarize, help")
}
const usageText = `telesrv-load commands:
keygen generate an owner-only AES-256 session key
provision create accounts and encrypted sessions through real MTProto auth
run execute sustained real-client load, offline recovery and reclamation
summarize print the acceptance summary from a JSON report
Use "telesrv-load <command> -h" for command flags.`

View file

@ -66,6 +66,7 @@ import (
"telesrv/internal/config"
"telesrv/internal/domain"
"telesrv/internal/mtprotoedge"
obsmetrics "telesrv/internal/observability/metrics"
"telesrv/internal/officialgifts"
"telesrv/internal/otpdelivery"
otpsmtp "telesrv/internal/otpdelivery/smtp"
@ -234,7 +235,7 @@ func newTranslationOptions(cfg config.Config, limiter translationapp.RateLimiter
// - /debug/pprof/allocs 累计分配(带宽/序列化热点常与之相关)
//
// mutex/block 采样在低流量测试环境开销可忽略;高流量生产如担心扰动,置空 DebugAddr 关闭整端点。
func startDebugServer(ctx context.Context, addr string, logger *zap.Logger) {
func startDebugServer(ctx context.Context, addr string, metricsHandler http.Handler, logger *zap.Logger) {
if addr == "" {
return
}
@ -247,6 +248,9 @@ func startDebugServer(ctx context.Context, addr string, logger *zap.Logger) {
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
if metricsHandler != nil {
mux.Handle("/metrics", metricsHandler)
}
srv := &http.Server{Addr: addr, Handler: mux}
go func() {
@ -264,6 +268,54 @@ func startDebugServer(ctx context.Context, addr string, logger *zap.Logger) {
}()
}
func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
return []obsmetrics.GaugeSample{
{Name: "telesrv_go_goroutines", Value: float64(runtime.NumGoroutine())},
{Name: "telesrv_go_heap_alloc_bytes", Value: float64(mem.HeapAlloc)},
{Name: "telesrv_go_heap_inuse_bytes", Value: float64(mem.HeapInuse)},
{Name: "telesrv_go_heap_objects", Value: float64(mem.HeapObjects)},
{Name: "telesrv_go_stack_inuse_bytes", Value: float64(mem.StackInuse)},
{Name: "telesrv_go_sys_bytes", Value: float64(mem.Sys)},
{Name: "telesrv_go_gc_cycles", Value: float64(mem.NumGC)},
{Name: "telesrv_go_gc_pause_seconds", Value: time.Duration(mem.PauseTotalNs).Seconds()},
}
}
func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetrics.GaugeSample {
return []obsmetrics.GaugeSample{
{Name: "telesrv_mtproto_raw_connections", Value: float64(snapshot.RawConnections)},
{Name: "telesrv_mtproto_raw_connection_limit", Value: float64(snapshot.RawConnectionLimit)},
{Name: "telesrv_mtproto_handshakes_active", Value: float64(snapshot.Handshakes)},
{Name: "telesrv_mtproto_handshake_limit", Value: float64(snapshot.HandshakeLimit)},
{Name: "telesrv_mtproto_sessions", Labels: []obsmetrics.Label{{Name: "state", Value: "active"}}, Value: float64(snapshot.ActiveSessions)},
{Name: "telesrv_mtproto_sessions", Labels: []obsmetrics.Label{{Name: "state", Value: "provisional"}}, Value: float64(snapshot.ProvisionalSessions)},
{Name: "telesrv_mtproto_logical_sessions", Labels: []obsmetrics.Label{{Name: "state", Value: "retained"}}, Value: float64(snapshot.LogicalSessions)},
{Name: "telesrv_mtproto_logical_sessions", Labels: []obsmetrics.Label{{Name: "state", Value: "offline"}}, Value: float64(snapshot.OfflineLogicalSessions)},
{Name: "telesrv_mtproto_logical_outbox_frames", Value: float64(snapshot.LogicalOutboxFrames)},
{Name: "telesrv_mtproto_logical_outbox_bytes", Value: float64(snapshot.LogicalOutboxBytes)},
{Name: "telesrv_mtproto_pending_push_bytes", Value: float64(snapshot.PendingPushBytes)},
{Name: "telesrv_mtproto_inbound_rpc_tasks", Value: float64(snapshot.InboundRPCTasks)},
{Name: "telesrv_mtproto_inbound_rpc_bytes", Value: float64(snapshot.InboundRPCBytes)},
{Name: "telesrv_mtproto_inbound_rpc_ready_connections", Value: float64(snapshot.InboundRPCReadyConnections)},
{Name: "telesrv_mtproto_inbound_rpc_task_limit", Value: float64(snapshot.InboundRPCMaxTasks)},
{Name: "telesrv_mtproto_inbound_rpc_byte_limit", Value: float64(snapshot.InboundRPCMaxBytes)},
{Name: "telesrv_mtproto_inbound_frame_bytes", Value: float64(snapshot.InboundFrameBytes)},
{Name: "telesrv_mtproto_inbound_frame_byte_limit", Value: float64(snapshot.InboundFrameMaxBytes)},
{Name: "telesrv_mtproto_outbound_tracked_bytes", Labels: []obsmetrics.Label{{Name: "kind", Value: "body"}}, Value: float64(snapshot.OutboundTrackedBytes)},
{Name: "telesrv_mtproto_outbound_tracked_bytes", Labels: []obsmetrics.Label{{Name: "kind", Value: "control"}}, Value: float64(snapshot.OutboundControlBytes)},
{Name: "telesrv_mtproto_outbound_tracked_byte_limit", Labels: []obsmetrics.Label{{Name: "kind", Value: "body"}}, Value: float64(snapshot.OutboundTrackedMaxBytes)},
{Name: "telesrv_mtproto_outbound_tracked_byte_limit", Labels: []obsmetrics.Label{{Name: "kind", Value: "control"}}, Value: float64(snapshot.OutboundControlMaxBytes)},
{Name: "telesrv_mtproto_outbound_write_bytes", Value: float64(snapshot.OutboundWriteBytes)},
{Name: "telesrv_mtproto_outbound_write_byte_limit", Value: float64(snapshot.OutboundWriteMaxBytes)},
{Name: "telesrv_mtproto_rpc_result_owners", Value: float64(snapshot.RPCResultOwners)},
{Name: "telesrv_mtproto_rpc_result_receipts", Value: float64(snapshot.RPCResultReceipts)},
{Name: "telesrv_mtproto_rpc_result_receipt_bytes", Value: float64(snapshot.RPCResultReceiptBytes)},
{Name: "telesrv_mtproto_rpc_result_subscribers", Value: float64(snapshot.RPCResultSubscribers)},
}
}
// externalMediaOption 按配置启用外链媒体抓取;禁用时返回 nilNewService 跳过 nil option
// liveStreamDep 把可能为 nil 的 *livestream.Service 转成 rpc.LiveStreamsService
// 避免 typed-nil interfacenil 具体指针装进接口后 != nil 的坑)。
@ -496,10 +548,12 @@ func run(logger *zap.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
metricRegistry := obsmetrics.New()
metricRegistry.AddGaugeProvider(goRuntimeGaugeSamples)
// pprof 调试端点telesrv 是宿主进程(不在 docker 内docker stats 看不到它CPU/内存/
// goroutine/锁竞争的定位全靠此端点。早于重负载初始化启动,连 seed/预热阶段也可剖析。
startDebugServer(ctx, cfg.DebugAddr, logger)
startDebugServer(ctx, cfg.DebugAddr, metricRegistry, logger)
// 持久化依赖:先迁移 schema再建立连接。auth key 与业务事实落 PostgreSQL
// Redis 只承载可重建的短 TTL 状态、缓存、计数器和限流。
@ -521,6 +575,20 @@ func run(logger *zap.Logger) error {
return fmt.Errorf("connect postgres: %w", err)
}
defer pool.Close()
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
stat := pool.Stat()
return []obsmetrics.GaugeSample{
{Name: "telesrv_postgres_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "total"}}, Value: float64(stat.TotalConns())},
{Name: "telesrv_postgres_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "acquired"}}, Value: float64(stat.AcquiredConns())},
{Name: "telesrv_postgres_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "idle"}}, Value: float64(stat.IdleConns())},
{Name: "telesrv_postgres_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "constructing"}}, Value: float64(stat.ConstructingConns())},
{Name: "telesrv_postgres_pool_max_connections", Value: float64(stat.MaxConns())},
{Name: "telesrv_postgres_pool_acquire_count", Value: float64(stat.AcquireCount())},
{Name: "telesrv_postgres_pool_acquire_wait_seconds", Value: stat.AcquireDuration().Seconds()},
{Name: "telesrv_postgres_pool_empty_acquire_count", Value: float64(stat.EmptyAcquireCount())},
{Name: "telesrv_postgres_pool_canceled_acquire_count", Value: float64(stat.CanceledAcquireCount())},
}
})
var telegramLoginService *telegramloginapp.Service
var telegramLoginIDTokens *telegramloginapp.IDTokenIssuer
@ -561,6 +629,19 @@ func run(logger *zap.Logger) error {
return fmt.Errorf("connect redis: %w", err)
}
defer func() { _ = rdb.Close() }()
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
stat := rdb.PoolStats()
return []obsmetrics.GaugeSample{
{Name: "telesrv_redis_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "total"}}, Value: float64(stat.TotalConns)},
{Name: "telesrv_redis_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "idle"}}, Value: float64(stat.IdleConns)},
{Name: "telesrv_redis_pool_pending_requests", Value: float64(stat.PendingRequests)},
{Name: "telesrv_redis_pool_hits", Value: float64(stat.Hits)},
{Name: "telesrv_redis_pool_misses", Value: float64(stat.Misses)},
{Name: "telesrv_redis_pool_timeouts", Value: float64(stat.Timeouts)},
{Name: "telesrv_redis_pool_wait_count", Value: float64(stat.WaitCount)},
{Name: "telesrv_redis_pool_wait_seconds", Value: time.Duration(stat.WaitDurationNs).Seconds()},
}
})
logger.Info("持久化依赖就绪", zap.String("redis", cfg.RedisAddr))
if cfg.TelegramLoginEnabled {
telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{
@ -1178,6 +1259,7 @@ func run(logger *zap.Logger) error {
TURN: turnService,
LangPack: langPackService,
Sessions: activeSessions,
Metrics: metricRegistry,
Inline: inlineRegistryStore,
Limiter: rateLimiter,
}, logger.Named("rpc"), clock.System)
@ -1309,6 +1391,7 @@ func run(logger *zap.Logger) error {
rpc.WithOutboxBatch(cfg.OutboxBatch),
rpc.WithOutboxInterval(cfg.OutboxInterval),
rpc.WithOutboxPushTimeout(cfg.OutboundPushTimeout),
rpc.WithOutboxMetrics(metricRegistry),
rpc.WithOutboxUpdateBuilder(router.BuildOutboxUpdates),
).Run(ctx)
go rpc.NewBootstrapUpdateDispatcher(router, logger.Named("rpc").Named("bootstrap")).Run(ctx)
@ -1404,6 +1487,7 @@ func run(logger *zap.Logger) error {
LayerRPC: router,
AuthKeys: authKeyStore,
ActiveSessions: activeSessions,
Metrics: metricRegistry,
ObfuscatedTCP: true,
WebSocket: cfg.WebSocketEnable,
WebSocketAllowedOrigins: cfg.WebSocketAllowedOrigins,
@ -1436,6 +1520,9 @@ func run(logger *zap.Logger) error {
)
},
})
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
return mtprotoRuntimeGaugeSamples(srv.RuntimeSnapshot())
})
// This is intentionally the final startup operation. ListenAndServe owns the
// public listener so no seed/prewarm work can run after port 2398 is exposed.
return srv.ListenAndServe(ctx, cfg.ListenAddr)