perf(dispatch): typed Context.Command/CommandArgs/RegexMatch fields

Move the three conventional Values keys ("command", "command_args", "regex_match") to typed fields on Context. Router and group routing write the fields directly; the Values map is allocated lazily via the new Set method and reserved for user-defined custom keys.

Allocation impact (M4 Max, b.Loop()):

  DispatchCommand:   5 allocs/op -> 1, 153ns -> 69ns (-55%)

  DispatchTextRegex: 5 allocs/op -> 2, 181ns -> 107ns (-41%)

  DispatchFilter:    2 allocs/op -> 1, 32ns -> 19ns (-41%)

  NewContext:        5.79ns -> 1.60ns

Trade-off: Context struct grew from ~48B to ~96B (three new fields), so filter-only paths pay ~50B more per dispatch. Command/regex paths save ~320B + 4 allocs each, which dominates for typical bot workloads.

Handlers reading c.Values["command"], c.Values["command_args"], or c.Values["regex_match"] now get nil; the typed fields c.Command, c.CommandArgs, c.RegexMatch are the new accessors. Custom keys still work via c.Set(k, v) and c.Values[k].
This commit is contained in:
2026-05-10 02:32:14 +01:00
parent da27421521
commit 0ee539e991
9 changed files with 107 additions and 31 deletions
+27
View File
@@ -0,0 +1,27 @@
After: static-headers + bool-fast-path + lazy-Context.Values
goos: darwin
goarch: arm64
cpu: Apple M4 Max
pkg: github.com/lukaszraczylo/go-telegram/client
BenchmarkCall_BoolResponse-16 4597374 526.3 ns/op 1842 B/op 14 allocs/op
BenchmarkCall_StructResponse-16 3740096 679.6 ns/op 1973 B/op 16 allocs/op
BenchmarkEncodeJSONBody-16 41997352 58.35 ns/op 96 B/op 2 allocs/op
BenchmarkDecodeResult_Bool-16 863273641 2.872 ns/op 0 B/op 0 allocs/op
BenchmarkDecodeResult_Struct-16 19988096 100.3 ns/op 144 B/op 2 allocs/op
pkg: github.com/lukaszraczylo/go-telegram/dispatch
BenchmarkRouter_DispatchCommand-16 14912385 156.4 ns/op 416 B/op 5 allocs/op
BenchmarkRouter_DispatchTextRegex-16 12495229 187.7 ns/op 428 B/op 5 allocs/op
BenchmarkRouter_DispatchFilter-16 148631502 16.11 ns/op 48 B/op 1 allocs/op
BenchmarkRouter_NewContext-16 1000000000 1.608 ns/op 0 B/op 0 allocs/op
BenchmarkExtractCommand-16 25267845 92.52 ns/op 0 B/op 0 allocs/op
Cumulative deltas vs baseline:
Call_BoolResponse: 18 -> 14 allocs (-4)
Call_StructResponse: 18 -> 16 allocs (-2)
DecodeResult_Bool: 2 -> 0 allocs (-2, also 50ns -> 2.87ns)
DispatchFilter: 2 -> 1 alloc (-1, also 32ns -> 16ns)
NewContext: 5.79ns -> 1.61ns (-72%)
DispatchCommand: 5 -> 5 allocs (flat — map alloc shifted from NewContext to first Set)
DispatchTextRegex: 5 -> 5 allocs (flat — same reason)
+27
View File
@@ -0,0 +1,27 @@
After: static-headers + bool-fast-path + lazy-Values + typed-fields
goos: darwin
goarch: arm64
cpu: Apple M4 Max
pkg: github.com/lukaszraczylo/go-telegram/client
BenchmarkCall_BoolResponse-16 4597374 526.3 ns/op 1842 B/op 14 allocs/op
BenchmarkCall_StructResponse-16 3740096 679.6 ns/op 1973 B/op 16 allocs/op
BenchmarkEncodeJSONBody-16 41997352 58.35 ns/op 96 B/op 2 allocs/op
BenchmarkDecodeResult_Bool-16 863273641 2.872 ns/op 0 B/op 0 allocs/op
BenchmarkDecodeResult_Struct-16 19988096 100.3 ns/op 144 B/op 2 allocs/op
pkg: github.com/lukaszraczylo/go-telegram/dispatch
BenchmarkRouter_DispatchCommand-16 34631486 69.19 ns/op 96 B/op 1 allocs/op
BenchmarkRouter_DispatchTextRegex-16 23260198 106.6 ns/op 112 B/op 2 allocs/op
BenchmarkRouter_DispatchFilter-16 126697654 19.03 ns/op 96 B/op 1 allocs/op
BenchmarkRouter_NewContext-16 1000000000 1.600 ns/op 0 B/op 0 allocs/op
BenchmarkExtractCommand-16 27345622 87.25 ns/op 0 B/op 0 allocs/op
Cumulative deltas vs baseline (allocs/op):
Call_BoolResponse: 18 -> 14 allocs (-4)
Call_StructResponse: 18 -> 16 allocs (-2)
DecodeResult_Bool: 2 -> 0 allocs (-2, also 50ns -> 2.87ns)
DispatchCommand: 5 -> 1 alloc (-4, also 153ns -> 69ns)
DispatchTextRegex: 5 -> 2 allocs (-3, also 181ns -> 107ns)
DispatchFilter: 2 -> 1 alloc (-1, but +48B from larger Context struct)
NewContext: 5.79ns -> 1.60ns (-72%)
+34 -9
View File
@@ -21,20 +21,45 @@ import (
// Update is the raw update; payload-typed handlers also receive a // Update is the raw update; payload-typed handlers also receive a
// narrowed pointer to one of its sub-fields. // narrowed pointer to one of its sub-fields.
// //
// Values is a per-update bag matchers populate. Conventional keys: // Command, CommandArgs and RegexMatch are populated by the router for
// the matching route kind; they replace the previous "command",
// "command_args" and "regex_match" entries in Values, which were the
// only conventional keys. Values remains for user-defined custom keys.
// //
// "command": string, the matched bot command (e.g. "/start") // Command is the matched bot command (e.g. "/start"); empty when the
// "command_args": string, everything after the command // route is not a command match.
// "regex_match": []string, regex sub-matches when OnText matches //
// CommandArgs is everything after the command; empty when no command
// matched or the command had no trailing text.
//
// RegexMatch is the regex sub-matches when an OnText/OnCallback regex
// route matched; nil otherwise.
//
// Values is lazily allocated for user-defined keys. Handlers that don't
// write pay no allocation. Reads against a nil map return the zero
// value. Writers must use Set instead of indexing the map directly.
type Context struct { type Context struct {
Ctx context.Context Ctx context.Context
Bot *client.Bot Bot *client.Bot
Update *api.Update Update *api.Update
Values map[string]any Command string
CommandArgs string
RegexMatch []string
Values map[string]any
} }
// NewContext constructs a Context. Used by Router internally; exposed for // NewContext constructs a Context. Used by Router internally; exposed for
// custom test harnesses. // custom test harnesses.
func NewContext(ctx context.Context, b *client.Bot, u *api.Update) *Context { func NewContext(ctx context.Context, b *client.Bot, u *api.Update) *Context {
return &Context{Ctx: ctx, Bot: b, Update: u, Values: map[string]any{}} return &Context{Ctx: ctx, Bot: b, Update: u}
}
// Set writes key/val into Values, allocating the map on first use. Use
// this instead of `c.Values[k] = v` so the no-write path stays
// allocation-free.
func (c *Context) Set(key string, val any) {
if c.Values == nil {
c.Values = make(map[string]any, 2)
}
c.Values[key] = val
} }
+3 -3
View File
@@ -138,8 +138,8 @@ func (r *Router) runGroupHandlers(c *Context, m *api.Message, g int) (matched bo
if route.group != g || route.cmd != cmd { if route.group != g || route.cmd != cmd {
continue continue
} }
c.Values["command"] = cmd c.Command = cmd
c.Values["command_args"] = args c.CommandArgs = args
if err := route.handler(c, m); err != nil { if err := route.handler(c, m); err != nil {
if errors.Is(err, ErrContinueGroups) { if errors.Is(err, ErrContinueGroups) {
continue continue
@@ -159,7 +159,7 @@ func (r *Router) runGroupHandlers(c *Context, m *api.Message, g int) (matched bo
if subs == nil { if subs == nil {
continue continue
} }
c.Values["regex_match"] = subs c.RegexMatch = subs
if err := route.handler(c, m); err != nil { if err := route.handler(c, m); err != nil {
if errors.Is(err, ErrContinueGroups) { if errors.Is(err, ErrContinueGroups) {
continue continue
+4 -4
View File
@@ -467,8 +467,8 @@ func (r *Router) handleMessage(c *Context, m *api.Message) error {
if cmd, args, ok := extractCommand(m); ok { if cmd, args, ok := extractCommand(m); ok {
for _, route := range r.commands { for _, route := range r.commands {
if route.cmd == cmd { if route.cmd == cmd {
c.Values["command"] = cmd c.Command = cmd
c.Values["command_args"] = args c.CommandArgs = args
return route.handler(c, m) return route.handler(c, m)
} }
} }
@@ -477,7 +477,7 @@ func (r *Router) handleMessage(c *Context, m *api.Message) error {
if m.Text != "" { if m.Text != "" {
for _, route := range r.texts { for _, route := range r.texts {
if subs := route.re.FindStringSubmatch(m.Text); subs != nil { if subs := route.re.FindStringSubmatch(m.Text); subs != nil {
c.Values["regex_match"] = subs c.RegexMatch = subs
return route.handler(c, m) return route.handler(c, m)
} }
} }
@@ -495,7 +495,7 @@ func (r *Router) handleMessage(c *Context, m *api.Message) error {
func (r *Router) handleCallback(c *Context, q *api.CallbackQuery) error { func (r *Router) handleCallback(c *Context, q *api.CallbackQuery) error {
for _, route := range r.callbacks { for _, route := range r.callbacks {
if subs := route.re.FindStringSubmatch(q.Data); subs != nil { if subs := route.re.FindStringSubmatch(q.Data); subs != nil {
c.Values["regex_match"] = subs c.RegexMatch = subs
return route.handler(c, q) return route.handler(c, q)
} }
} }
+6 -10
View File
@@ -52,7 +52,7 @@ func TestRouter_OnCommandMatches(t *testing.T) {
r := New(b) r := New(b)
hit := make(chan string, 1) hit := make(chan string, 1)
r.OnCommand("/start", func(c *Context, m *api.Message) error { r.OnCommand("/start", func(c *Context, m *api.Message) error {
hit <- c.Values["command"].(string) hit <- c.Command
return nil return nil
}) })
@@ -82,7 +82,7 @@ func TestRouter_OnText(t *testing.T) {
r := New(client.New("t")) r := New(client.New("t"))
hit := make(chan []string, 1) hit := make(chan []string, 1)
r.OnText(`^hello (\w+)$`, func(c *Context, m *api.Message) error { r.OnText(`^hello (\w+)$`, func(c *Context, m *api.Message) error {
hit <- c.Values["regex_match"].([]string) hit <- c.RegexMatch
return nil return nil
}) })
@@ -164,10 +164,7 @@ func TestRouter_NonASCIICommand(t *testing.T) {
r := New(client.New("t")) r := New(client.New("t"))
hit := make(chan [2]string, 1) hit := make(chan [2]string, 1)
r.OnCommand("/старт", func(c *Context, m *api.Message) error { r.OnCommand("/старт", func(c *Context, m *api.Message) error {
hit <- [2]string{ hit <- [2]string{c.Command, c.CommandArgs}
c.Values["command"].(string),
c.Values["command_args"].(string),
}
return nil return nil
}) })
@@ -180,16 +177,15 @@ func TestRouter_NonASCIICommand(t *testing.T) {
require.Equal(t, "аргумент", got[1]) require.Equal(t, "аргумент", got[1])
} }
// TestRouter_CommandValuesNotLeakedOnNoMatch verifies that c.Values["command"] // TestRouter_CommandValuesNotLeakedOnNoMatch verifies that c.Command is
// is not set when a command entity is present but no route matches, so a // empty when a command entity is present but no route matches, so a
// subsequent text handler doesn't see stale values. // subsequent text handler doesn't see stale values.
func TestRouter_CommandValuesNotLeakedOnNoMatch(t *testing.T) { func TestRouter_CommandValuesNotLeakedOnNoMatch(t *testing.T) {
r := New(client.New("t")) r := New(client.New("t"))
// Register a text handler that should fire as fallback. // Register a text handler that should fire as fallback.
leaked := make(chan bool, 1) leaked := make(chan bool, 1)
r.OnText(`.*`, func(c *Context, m *api.Message) error { r.OnText(`.*`, func(c *Context, m *api.Message) error {
_, hasCmd := c.Values["command"] leaked <- c.Command != ""
leaked <- hasCmd
return nil return nil
}) })
// No OnCommand registered, so the command entity won't match any route. // No OnCommand registered, so the command entity won't match any route.
+1 -1
View File
@@ -21,7 +21,7 @@ func handleStart(c *dispatch.Context, m *api.Message) error {
} }
func handleCallback(c *dispatch.Context, q *api.CallbackQuery) error { func handleCallback(c *dispatch.Context, q *api.CallbackQuery) error {
groups := c.Values["regex_match"].([]string) groups := c.RegexMatch
current, _ := strconv.Atoi(groups[1]) current, _ := strconv.Atoi(groups[1])
if groups[2] == "inc" { if groups[2] == "inc" {
current++ current++
+4 -2
View File
@@ -42,7 +42,7 @@ const (
func makeCtx(bot *client.Bot, upd *api.Update, extra map[string]any) *dispatch.Context { func makeCtx(bot *client.Bot, upd *api.Update, extra map[string]any) *dispatch.Context {
c := dispatch.NewContext(context.Background(), bot, upd) c := dispatch.NewContext(context.Background(), bot, upd)
for k, v := range extra { for k, v := range extra {
c.Values[k] = v c.Set(k, v)
} }
return c return c
} }
@@ -82,7 +82,9 @@ func TestHandleStart_SendsInitialKeyboard(t *testing.T) {
func callbackCtx(bot *client.Bot, q *api.CallbackQuery, groups []string) *dispatch.Context { func callbackCtx(bot *client.Bot, q *api.CallbackQuery, groups []string) *dispatch.Context {
upd := &api.Update{UpdateID: 1, CallbackQuery: q} upd := &api.Update{UpdateID: 1, CallbackQuery: q}
return makeCtx(bot, upd, map[string]any{"regex_match": groups}) c := makeCtx(bot, upd, nil)
c.RegexMatch = groups
return c
} }
func callbackQuery(data string, msgID int64, chatID int64) *api.CallbackQuery { func callbackQuery(data string, msgID int64, chatID int64) *api.CallbackQuery {
+1 -2
View File
@@ -111,8 +111,7 @@ func main() {
// page:<n> callbacks — edit message in-place. // page:<n> callbacks — edit message in-place.
router.OnCallback(`^page:(\d+)$`, func(c *dispatch.Context, q *api.CallbackQuery) error { router.OnCallback(`^page:(\d+)$`, func(c *dispatch.Context, q *api.CallbackQuery) error {
groups := c.Values["regex_match"].([]string) page, _ := strconv.Atoi(c.RegexMatch[1])
page, _ := strconv.Atoi(groups[1])
// Acknowledge the tap first. // Acknowledge the tap first.
_, _ = api.AnswerCallbackQuery(c.Ctx, c.Bot, &api.AnswerCallbackQueryParams{ _, _ = api.AnswerCallbackQuery(c.Ctx, c.Bot, &api.AnswerCallbackQueryParams{