fix(modules): fetch stats prices sequentially to reuse pooled connections

Parallelizing the per-symbol fetches opened N simultaneous TLS handshakes into
an empty connection pool. On the memory-constrained Lambda (256MB ~0.15 vCPU)
those CPU-bound handshakes thrashed and each exceeded the per-fetch timeout, so
every ticker rendered "(no price)". Sequential fetches reuse the price client's
keep-alive connection (one handshake), which is why the code was sequential by
design.

- revert stock and coin stats loops to sequential (keep the reply-budget
  sub-context and 3s per-fetch timeout)
- log per-symbol fetch failures instead of silently swallowing them
This commit is contained in:
2026-06-25 14:28:25 +07:00
parent 958da76d33
commit c0cfcfb8d5
4 changed files with 54 additions and 73 deletions
+1 -1
View File
@@ -9,7 +9,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.3
github.com/aws/aws-sdk-go-v2/service/ssm v1.68.6
github.com/go-telegram/bot v1.20.0
golang.org/x/sync v0.20.0
golang.org/x/time v0.15.0
google.golang.org/api v0.274.0
google.golang.org/genai v1.56.0
@@ -53,6 +52,7 @@ require (
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
+18 -35
View File
@@ -7,7 +7,6 @@ import (
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"golang.org/x/sync/errgroup"
"github.com/tiennm99/miti99bot/internal/log"
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
@@ -26,44 +25,28 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
lines := []string{"Coin Account Summary\n", "USD: " + FormatUSD(p.USD)}
totalValue := p.USD
// Fetch every held coin's price concurrently (bounded), under a
// reply-reserved sub-context so a slow provider cannot drain the budget the
// final Reply needs. Per-coin errors degrade to "(price unavailable)";
// results are written by index (no shared-write race) and rendered in order.
symbols := sortedAssetSymbols(p.Assets)
// Fetch sequentially (not concurrently) so the price client's keep-alive
// connection pool is reused across coins rather than opening N simultaneous
// TLS handshakes — the latter thrashes the CPU-constrained Lambda and times
// out. The reply-reserved sub-context bounds the whole loop so the final
// Reply keeps its budget; a slow/failed provider degrades to "(price
// unavailable)" instead of failing the summary.
fetchCtx, cancel := chathelper.FetchContext(ctx)
defer cancel()
type coinResult struct {
line string
value float64
}
results := make([]coinResult, len(symbols))
var g errgroup.Group
g.SetLimit(8)
for i, symbol := range symbols {
i, symbol := i, symbol
g.Go(func() error {
held := p.Assets[symbol]
line := symbol + ": " + FormatCoinQty(held)
if coin, err := ResolveCoinSymbol(symbol); err == nil {
if price, err := s.prices.FetchUSD(fetchCtx, coin); err == nil {
value := held * price.USD
results[i] = coinResult{
line: line + " = " + FormatUSD(value) + " @ " + FormatUSD(price.USD) + " (" + price.Source + ")",
value: value,
}
return nil
}
for _, symbol := range sortedAssetSymbols(p.Assets) {
held := p.Assets[symbol]
line := symbol + ": " + FormatCoinQty(held)
if coin, err := ResolveCoinSymbol(symbol); err == nil {
if price, err := s.prices.FetchUSD(fetchCtx, coin); err == nil {
value := held * price.USD
totalValue += value
line += " = " + FormatUSD(value) + " @ " + FormatUSD(price.USD) + " (" + price.Source + ")"
} else {
log.Error("coin_fetch_price", "symbol", symbol, "err", err)
line += " (price unavailable)"
}
results[i] = coinResult{line: line}
return nil
})
}
_ = g.Wait() // closures never return an error; partial results are intended
for _, r := range results {
lines = append(lines, r.line)
totalValue += r.value
}
lines = append(lines, line)
}
lines = append(lines, "Total value: "+FormatUSD(totalValue))
lines = append(lines, "Invested: "+FormatUSD(p.Meta.Invested))
+23 -37
View File
@@ -9,7 +9,6 @@ import (
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"golang.org/x/sync/errgroup"
"github.com/tiennm99/miti99bot/internal/keylock"
"github.com/tiennm99/miti99bot/internal/log"
@@ -329,8 +328,9 @@ func (s *state) handleConvert(ctx context.Context, b *bot.Bot, update *models.Up
"Currency exchange is not available yet.\n"+s.comingSoonMessage)
}
// handleStats fetches every held ticker's current price (in parallel) and
// renders the portfolio. Read-only — no portfolio mutation, so no keylock.
// handleStats fetches every held ticker's current price sequentially (reusing
// the pooled KBS connection) and renders the portfolio. Read-only — no
// portfolio mutation, so no keylock.
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
userID, ok := senderInfo(update)
if !ok {
@@ -366,42 +366,28 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
if len(heldList) > 0 {
lines = append(lines, "\nStocks:")
// Fetch every held ticker concurrently so total latency is bounded by
// the slowest single fetch, not their sum. Fetches run under a
// reply-reserved sub-context (FetchContext) so a slow upstream cannot
// drain the budget the final Reply needs; per-fetch errors degrade to
// "(no price)" rather than failing the whole summary. Results are
// written by index — no shared-write race — and rendered in order.
// Fetch sequentially, NOT concurrently. The memoised HTTP client keeps a
// keep-alive connection pool across calls (see PriceClient), so serial
// fetches to the same KBS host pay one TLS handshake and reuse the
// connection. Firing them in parallel instead opens N simultaneous
// handshakes into an empty pool; on the memory-constrained Lambda
// (256MB ≈ 0.15 vCPU) those CPU-bound handshakes thrash and each blows
// past the per-fetch timeout. The reply-reserved sub-context bounds the
// whole loop so the final Reply keeps its budget; a failed/slow ticker
// degrades to "(no price)" rather than failing the summary.
fetchCtx, cancel := chathelper.FetchContext(ctx)
defer cancel()
type stockResult struct {
line string
value float64
}
results := make([]stockResult, len(heldList))
var g errgroup.Group
g.SetLimit(8)
for i, h := range heldList {
i, h := i, h
g.Go(func() error {
price, err := s.prices.FetchPrice(fetchCtx, h.symbol)
if err != nil {
results[i] = stockResult{line: " " + h.symbol + " x" + FormatStock(float64(h.qty)) + " (no price)"}
return nil
}
val := float64(h.qty) * price
results[i] = stockResult{
line: " " + h.symbol + " x" + FormatStock(float64(h.qty)) +
" @ " + FormatVND(price) + " = " + FormatVND(val),
value: val,
}
return nil
})
}
_ = g.Wait() // closures never return an error; partial results are intended
for _, r := range results {
lines = append(lines, r.line)
totalValue += r.value
for _, h := range heldList {
price, err := s.prices.FetchPrice(fetchCtx, h.symbol)
if err != nil {
log.Error("stock_fetch_price", "symbol", h.symbol, "err", err)
lines = append(lines, " "+h.symbol+" x"+FormatStock(float64(h.qty))+" (no price)")
continue
}
val := float64(h.qty) * price
totalValue += val
lines = append(lines, " "+h.symbol+" x"+FormatStock(float64(h.qty))+
" @ "+FormatVND(price)+" = "+FormatVND(val))
}
}
@@ -2,6 +2,18 @@
Status: DONE (2026-06-25) — implemented, `make vet` + `make test -race` green, code-review DONE (no critical/high).
## Post-deploy correction (2026-06-25)
First deploy delivered the reply (original bug fixed) but every ticker showed "(no price)".
Cause: parallelizing fetches opened N simultaneous TLS handshakes into an empty connection
pool; on the 256MB Lambda (~0.15 vCPU) the CPU-bound handshakes thrashed and each exceeded
the 3s timeout. Sequential fetches reuse the pooled KBS/provider connection (one handshake),
which is why the original code was sequential by design.
Fix: reverted stock + coin loops to **sequential** (kept `FetchContext` reply reserve and the
3s per-fetch timeout); dropped errgroup (back to indirect); added per-fetch error logging
(`stock_fetch_price` / `coin_fetch_price`) to close the swallowed-error diagnostic gap.
## Problem
Update handler runs under one 10s ctx (`telegram/webhook.go:81`). Stats handlers reuse