fix(stats): fan out per-key GetItem to avoid handler deadline timeout

Sequential List + N GetItem made /stats latency scale with the number
of tracked commands. On a cold Lambda container with ~25 commands the
cumulative DynamoDB round-trips can exceed the 10s webhook handler
deadline; the trailing chathelper.Reply -> b.SendMessage then fails on
a cancelled ctx and the dispatcher only logs the error, leaving the
user with no reply at all.

Fan the per-key GetJSONs out into goroutines joined by sync.WaitGroup.
Wall-clock latency collapses to ~1 round-trip while preserving the
per-key error isolation (a single GetJSON failure still drops only its
own row). Storage interface unchanged; no race regression vs. the prior
per-item write pattern.
This commit is contained in:
2026-05-22 16:36:57 +07:00
parent 5e40ffe637
commit ed0c5c1d7b
+31 -8
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"sort"
"strings"
"sync"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
@@ -77,20 +78,42 @@ func statsCommand(c *counter) modules.Command {
return chathelper.Reply(ctx, b, update.Message, "No command stats yet.")
}
// Fan-out the per-key GetJSONs concurrently. Sequential reads make
// /stats latency O(N) round-trips to DynamoDB; on a cold Lambda
// container with 20+ commands, that can push the synchronous
// handler past the 10s webhook deadline and the trailing Reply
// SendMessage then fails on a cancelled ctx — the user sees no
// reply at all. Fanning out collapses wall-clock latency to one
// round-trip while keeping the same per-key error isolation.
type row struct {
name string
n int64
ok bool
}
rows := make([]row, 0, len(keys))
for _, k := range keys {
name := strings.TrimPrefix(k, countPrefix)
var entry countEntry
if err := c.kv.GetJSON(ctx, k, &entry); err != nil {
log.Error("stats: kv get failed during render", "key", k, "err", err)
continue
rows := make([]row, len(keys))
var wg sync.WaitGroup
for i, k := range keys {
wg.Add(1)
go func(i int, k string) {
defer wg.Done()
name := strings.TrimPrefix(k, countPrefix)
var entry countEntry
if err := c.kv.GetJSON(ctx, k, &entry); err != nil {
log.Error("stats: kv get failed during render", "key", k, "err", err)
rows[i] = row{name: name}
return
}
rows[i] = row{name: name, n: entry.N, ok: true}
}(i, k)
}
wg.Wait()
kept := rows[:0]
for _, r := range rows {
if r.ok {
kept = append(kept, r)
}
rows = append(rows, row{name: name, n: entry.N})
}
rows = kept
sort.Slice(rows, func(i, j int) bool {
if rows[i].n != rows[j].n {
return rows[i].n > rows[j].n