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%)
+30 -5
View File
@@ -21,20 +21,45 @@ import (
// Update is the raw update; payload-typed handlers also receive a
// 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_args": string, everything after the command
// "regex_match": []string, regex sub-matches when OnText matches
// Command is the matched bot command (e.g. "/start"); empty when the
// route is not a command match.
//
// 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 {
Ctx context.Context
Bot *client.Bot
Update *api.Update
Command string
CommandArgs string
RegexMatch []string
Values map[string]any
}
// NewContext constructs a Context. Used by Router internally; exposed for
// custom test harnesses.
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 {
continue
}
c.Values["command"] = cmd
c.Values["command_args"] = args
c.Command = cmd
c.CommandArgs = args
if err := route.handler(c, m); err != nil {
if errors.Is(err, ErrContinueGroups) {
continue
@@ -159,7 +159,7 @@ func (r *Router) runGroupHandlers(c *Context, m *api.Message, g int) (matched bo
if subs == nil {
continue
}
c.Values["regex_match"] = subs
c.RegexMatch = subs
if err := route.handler(c, m); err != nil {
if errors.Is(err, ErrContinueGroups) {
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 {
for _, route := range r.commands {
if route.cmd == cmd {
c.Values["command"] = cmd
c.Values["command_args"] = args
c.Command = cmd
c.CommandArgs = args
return route.handler(c, m)
}
}
@@ -477,7 +477,7 @@ func (r *Router) handleMessage(c *Context, m *api.Message) error {
if m.Text != "" {
for _, route := range r.texts {
if subs := route.re.FindStringSubmatch(m.Text); subs != nil {
c.Values["regex_match"] = subs
c.RegexMatch = subs
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 {
for _, route := range r.callbacks {
if subs := route.re.FindStringSubmatch(q.Data); subs != nil {
c.Values["regex_match"] = subs
c.RegexMatch = subs
return route.handler(c, q)
}
}
+6 -10
View File
@@ -52,7 +52,7 @@ func TestRouter_OnCommandMatches(t *testing.T) {
r := New(b)
hit := make(chan string, 1)
r.OnCommand("/start", func(c *Context, m *api.Message) error {
hit <- c.Values["command"].(string)
hit <- c.Command
return nil
})
@@ -82,7 +82,7 @@ func TestRouter_OnText(t *testing.T) {
r := New(client.New("t"))
hit := make(chan []string, 1)
r.OnText(`^hello (\w+)$`, func(c *Context, m *api.Message) error {
hit <- c.Values["regex_match"].([]string)
hit <- c.RegexMatch
return nil
})
@@ -164,10 +164,7 @@ func TestRouter_NonASCIICommand(t *testing.T) {
r := New(client.New("t"))
hit := make(chan [2]string, 1)
r.OnCommand("/старт", func(c *Context, m *api.Message) error {
hit <- [2]string{
c.Values["command"].(string),
c.Values["command_args"].(string),
}
hit <- [2]string{c.Command, c.CommandArgs}
return nil
})
@@ -180,16 +177,15 @@ func TestRouter_NonASCIICommand(t *testing.T) {
require.Equal(t, "аргумент", got[1])
}
// TestRouter_CommandValuesNotLeakedOnNoMatch verifies that c.Values["command"]
// is not set when a command entity is present but no route matches, so a
// TestRouter_CommandValuesNotLeakedOnNoMatch verifies that c.Command is
// empty when a command entity is present but no route matches, so a
// subsequent text handler doesn't see stale values.
func TestRouter_CommandValuesNotLeakedOnNoMatch(t *testing.T) {
r := New(client.New("t"))
// Register a text handler that should fire as fallback.
leaked := make(chan bool, 1)
r.OnText(`.*`, func(c *Context, m *api.Message) error {
_, hasCmd := c.Values["command"]
leaked <- hasCmd
leaked <- c.Command != ""
return nil
})
// 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 {
groups := c.Values["regex_match"].([]string)
groups := c.RegexMatch
current, _ := strconv.Atoi(groups[1])
if groups[2] == "inc" {
current++
+4 -2
View File
@@ -42,7 +42,7 @@ const (
func makeCtx(bot *client.Bot, upd *api.Update, extra map[string]any) *dispatch.Context {
c := dispatch.NewContext(context.Background(), bot, upd)
for k, v := range extra {
c.Values[k] = v
c.Set(k, v)
}
return c
}
@@ -82,7 +82,9 @@ func TestHandleStart_SendsInitialKeyboard(t *testing.T) {
func callbackCtx(bot *client.Bot, q *api.CallbackQuery, groups []string) *dispatch.Context {
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 {
+1 -2
View File
@@ -111,8 +111,7 @@ func main() {
// page:<n> callbacks — edit message in-place.
router.OnCallback(`^page:(\d+)$`, func(c *dispatch.Context, q *api.CallbackQuery) error {
groups := c.Values["regex_match"].([]string)
page, _ := strconv.Atoi(groups[1])
page, _ := strconv.Atoi(c.RegexMatch[1])
// Acknowledge the tap first.
_, _ = api.AnswerCallbackQuery(c.Ctx, c.Bot, &api.AnswerCallbackQueryParams{