From 728b28b0c589be8903203f0e09099f315f57e0e8 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sun, 10 May 2026 02:31:46 +0100 Subject: [PATCH] test(bench): add allocation benchmarks for client/transport/dispatch hot paths Hermetic benchmarks (no network) covering Call encode+decode, webhook ServeHTTP body parse, and Router dispatch (command/regex/filter). Use Go 1.24+ b.Loop() idiom. .benchstats/baseline.txt pins the pre-optimisation numbers for benchstat comparisons. --- .benchstats/baseline.txt | 19 +++++++ client/call_bench_test.go | 94 +++++++++++++++++++++++++++++++++ dispatch/router_bench_test.go | 89 +++++++++++++++++++++++++++++++ transport/webhook_bench_test.go | 39 ++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 .benchstats/baseline.txt create mode 100644 client/call_bench_test.go create mode 100644 dispatch/router_bench_test.go create mode 100644 transport/webhook_bench_test.go diff --git a/.benchstats/baseline.txt b/.benchstats/baseline.txt new file mode 100644 index 0000000..512d242 --- /dev/null +++ b/.benchstats/baseline.txt @@ -0,0 +1,19 @@ +goos: darwin +goarch: arm64 +pkg: github.com/lukaszraczylo/go-telegram/client +cpu: Apple M4 Max +BenchmarkCall_BoolResponse-16 1875306 633.9 ns/op 1957 B/op 18 allocs/op +BenchmarkCall_StructResponse-16 1805024 665.2 ns/op 2005 B/op 18 allocs/op +BenchmarkEncodeJSONBody-16 23345811 51.55 ns/op 96 B/op 2 allocs/op +BenchmarkDecodeResult_Bool-16 23832240 50.37 ns/op 80 B/op 2 allocs/op +BenchmarkDecodeResult_Struct-16 13511192 92.64 ns/op 144 B/op 2 allocs/op + +pkg: github.com/lukaszraczylo/go-telegram/transport +BenchmarkWebhook_ServeHTTP-16 465798 2564 ns/op 12707 B/op 24 allocs/op + +pkg: github.com/lukaszraczylo/go-telegram/dispatch +BenchmarkRouter_DispatchCommand-16 7303522 152.7 ns/op 416 B/op 5 allocs/op +BenchmarkRouter_DispatchTextRegex-16 6740305 180.5 ns/op 428 B/op 5 allocs/op +BenchmarkRouter_DispatchFilter-16 39479149 32.18 ns/op 96 B/op 2 allocs/op +BenchmarkRouter_NewContext-16 208260764 5.790 ns/op 0 B/op 0 allocs/op +BenchmarkExtractCommand-16 12988816 92.69 ns/op 0 B/op 0 allocs/op diff --git a/client/call_bench_test.go b/client/call_bench_test.go new file mode 100644 index 0000000..16337cd --- /dev/null +++ b/client/call_bench_test.go @@ -0,0 +1,94 @@ +package client + +import ( + "bytes" + "context" + "io" + "net/http" + "testing" +) + +// stubDoer returns the same canned response body for every request. It +// is intentionally minimal — testify mock has its own overhead that +// would dominate the per-call cost we want to measure. +type stubDoer struct{ body []byte } + +func (s *stubDoer) Do(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(s.body)), + Header: http.Header{}, + }, nil +} + +type benchSendReq struct { + ChatID int64 `json:"chat_id"` + Text string `json:"text"` +} + +type benchMsgResp struct { + MessageID int64 `json:"message_id"` + Date int64 `json:"date"` + Text string `json:"text"` +} + +func BenchmarkCall_BoolResponse(b *testing.B) { + d := &stubDoer{body: []byte(`{"ok":true,"result":true}`)} + bot := New("123:abc", WithHTTPClient(d)) + ctx := context.Background() + req := &benchSendReq{ChatID: 42, Text: "hi"} + b.ReportAllocs() + for b.Loop() { + if _, err := Call[*benchSendReq, bool](ctx, bot, "setMyCommands", req); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkCall_StructResponse(b *testing.B) { + d := &stubDoer{body: []byte(`{"ok":true,"result":{"message_id":1,"date":0,"text":"ok"}}`)} + bot := New("123:abc", WithHTTPClient(d)) + ctx := context.Background() + req := &benchSendReq{ChatID: 42, Text: "hi"} + b.ReportAllocs() + for b.Loop() { + if _, err := Call[*benchSendReq, benchMsgResp](ctx, bot, "sendMessage", req); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkEncodeJSONBody(b *testing.B) { + codec := DefaultCodec{} + req := &benchSendReq{ChatID: 42, Text: "hello, world"} + b.ReportAllocs() + for b.Loop() { + r, err := encodeJSONBody(codec, req) + if err != nil { + b.Fatal(err) + } + _ = r + } +} + +func BenchmarkDecodeResult_Bool(b *testing.B) { + codec := DefaultCodec{} + raw := []byte(`{"ok":true,"result":true}`) + b.ReportAllocs() + for b.Loop() { + if _, err := decodeResult[bool](codec, raw); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkDecodeResult_Struct(b *testing.B) { + codec := DefaultCodec{} + raw := []byte(`{"ok":true,"result":{"message_id":1,"date":0,"text":"ok"}}`) + b.ReportAllocs() + for b.Loop() { + if _, err := decodeResult[benchMsgResp](codec, raw); err != nil { + b.Fatal(err) + } + } +} diff --git a/dispatch/router_bench_test.go b/dispatch/router_bench_test.go new file mode 100644 index 0000000..8b20500 --- /dev/null +++ b/dispatch/router_bench_test.go @@ -0,0 +1,89 @@ +package dispatch + +import ( + "context" + "testing" + + "github.com/lukaszraczylo/go-telegram/api" + "github.com/lukaszraczylo/go-telegram/client" +) + +func BenchmarkRouter_DispatchCommand(b *testing.B) { + r := New(client.New("t")) + r.OnCommand("/start", func(c *Context, m *api.Message) error { return nil }) + u := cmdMessage("/start hello") + ctx := context.Background() + b.ReportAllocs() + for b.Loop() { + c := NewContext(ctx, r.bot, &u) + _ = r.dispatch(c, &u) + } +} + +func BenchmarkRouter_DispatchTextRegex(b *testing.B) { + r := New(client.New("t")) + r.OnText("^hello.*", func(c *Context, m *api.Message) error { return nil }) + u := api.Update{ + UpdateID: 1, + Message: &api.Message{ + MessageID: 1, Date: 0, + Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate}, + Text: "hello world", + }, + } + ctx := context.Background() + b.ReportAllocs() + for b.Loop() { + c := NewContext(ctx, r.bot, &u) + _ = r.dispatch(c, &u) + } +} + +func BenchmarkRouter_DispatchFilter(b *testing.B) { + r := New(client.New("t")) + r.OnMessageFilter( + func(m *api.Message) bool { return m != nil && m.Text == "ping" }, + func(c *Context, m *api.Message) error { return nil }, + ) + u := api.Update{ + UpdateID: 1, + Message: &api.Message{ + MessageID: 1, Date: 0, + Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate}, + Text: "ping", + }, + } + ctx := context.Background() + b.ReportAllocs() + for b.Loop() { + c := NewContext(ctx, r.bot, &u) + _ = r.dispatch(c, &u) + } +} + +func BenchmarkRouter_NewContext(b *testing.B) { + bot := client.New("t") + u := &api.Update{UpdateID: 1} + ctx := context.Background() + b.ReportAllocs() + for b.Loop() { + _ = NewContext(ctx, bot, u) + } +} + +func BenchmarkExtractCommand(b *testing.B) { + text := "/start@BotName hello world" + cmdLen := len("/start@BotName") + m := &api.Message{ + MessageID: 1, Date: 0, + Chat: api.Chat{ID: 1, Type: api.ChatTypePrivate}, + Text: text, + Entities: []api.MessageEntity{ + {Type: api.MessageEntityTypeBotCommand, Offset: 0, Length: int64(cmdLen)}, + }, + } + b.ReportAllocs() + for b.Loop() { + _, _, _ = extractCommand(m) + } +} diff --git a/transport/webhook_bench_test.go b/transport/webhook_bench_test.go new file mode 100644 index 0000000..5b601e9 --- /dev/null +++ b/transport/webhook_bench_test.go @@ -0,0 +1,39 @@ +package transport + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/lukaszraczylo/go-telegram/client" +) + +const benchUpdateBody = `{"update_id":12345,"message":{"message_id":1,"date":1700000000,"chat":{"id":42,"type":"private"},"from":{"id":42,"is_bot":false,"first_name":"User"},"text":"hello world"}}` + +func BenchmarkWebhook_ServeHTTP(b *testing.B) { + w := NewWebhookServer(client.New("t"), WithBufferSize(1024)) + body := []byte(benchUpdateBody) + + done := make(chan struct{}) + go func() { + for { + select { + case <-w.Updates(): + case <-done: + return + } + } + }() + defer close(done) + + b.ReportAllocs() + for b.Loop() { + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + rec := httptest.NewRecorder() + w.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + b.Fatalf("status %d", rec.Code) + } + } +}