Caching
Redis cache keys, TTLs, and singleflight coalescing across all stock-analysis endpoints
Every stock-analysis endpoint is Redis-backed. Cache keys are centralised in the umbrella's cachekey.go; per-feature handlers mirror the same string locally to avoid an import cycle into the parent package. A observability_pins_test walks the tree and asserts both copies stay byte-equivalent.
TTL summary
| Endpoint | Key pattern | TTL |
|---|---|---|
GET /companies/{symbol}/fundamentals/snapshot | nepse:fundamentals:snapshot:{SYM} | 5 min |
GET /companies/{symbol}/fundamentals/statements | nepse:fundamentals:statements:{SYM}:{type}:{period}:y{years} | 10 min |
GET /companies/{symbol}/fundamentals/ratios | nepse:fundamentals:ratios:{SYM}:{tab} | 10 min |
GET /companies/{symbol}/fundamentals/dividends | nepse:fundamentals:dividends:{SYM}:y{years}:d{upcomingDays} | 30 min |
GET /companies/{symbol}/fundamentals/ownership | nepse:fundamentals:ownership:{SYM} | 1 hr |
GET /companies/{symbol}/fundamentals/peers | nepse:fundamentals:peers:{FOCAL}:{sortedPeers} | 10 min |
GET /companies/{symbol}/fundamentals/announcements | nepse:fundamentals:announcements:{SYM}:t{sortedTypes}:f{from}:x{to}:l{limit} | 5 min |
GET /companies/{symbol}/fundamentals/fair-value | nepse:fundamentals:fair-value:{SYM} | 10 min |
GET /upcoming-book-closes | nepse:fundamentals:upcoming-book-closes:d{days}:l{limit} | 1 hr |
Cache-prefix constants
Defined once in internal/modules/nepse/fundamentals/stock_analysis/cachekey.go:
const (
PrefixSnapshot = "nepse:fundamentals:snapshot:"
PrefixDividends = "nepse:fundamentals:dividends:"
PrefixUpcomingBookCloses = "nepse:fundamentals:upcoming-book-closes:"
PrefixStatements = "nepse:fundamentals:statements:"
PrefixRatios = "nepse:fundamentals:ratios:"
PrefixOwnership = "nepse:fundamentals:ownership:"
PrefixPeers = "nepse:fundamentals:peers:"
PrefixAnnouncements = "nepse:fundamentals:announcements:"
PrefixFairValue = "nepse:fundamentals:fair-value:"
)Each handler keeps a local const cachePrefix = "..." mirror to avoid importing the parent package (cyclic). Drift between the two is caught by internal/goldenjson/observability_pins_test.go.
Singleflight coalescing
Every endpoint uses cache.GetOrSetTyped(ctx, cache, key, ttl, loader). The helper de-duplicates concurrent requests for the same key — a stampede on a popular ticker triggers exactly one DB round-trip set, and every concurrent caller receives the same response.
val, _, err := cache.GetOrSetTyped(
ctx, h.cache, key, cacheTTL,
func(ctx context.Context) (*Response, error) {
return h.svc.<Feature>(ctx, …)
},
)No write-side invalidation
The stock-analysis module is read-only. There are no write endpoints, and the cache has no manual bust path — TTL alone governs freshness.
The cadences are:
| Upstream signal | Refresh interval | Affected endpoint TTL |
|---|---|---|
| Live price (LTP) poller | every few seconds | 5 min on snapshot, 10 min on the rest |
| Financial reports sync | weekly | 5–60 min |
| Dividend / corporate-action sync | nightly | 30 min – 1 hr |
| Phase A operator uploads | manual | 1 hr (ownership) |
| Company profile (BOD, management) | weekly | 1 hr (ownership) |
The TTLs are intentionally short for surfaces with fast-moving inputs (LTP → P/E ratio) and long for surfaces with slow-moving inputs (BOD, company profile).
When the cache layer is nil (test harness, local dev with CACHE_DISABLED=true), handlers fall through to direct service calls. No code path silently 500s because of an absent Redis.
Pagination + sorting affect the cache key
Query parameters that change the response shape are part of the cache key:
statements:type,period,yearsratios:tabdividends:years,upcomingDayspeers: focal symbol + the sorted comma-joined peer list (so different orderings collapse to one entry)announcements: sorted chip set,from,to,limitupcoming-book-closes(global):days,limit
Sorting before forming the cache key is a deliberate cache-hit optimisation — ?symbols=A,B,C and ?symbols=C,B,A produce the same comparison; they should hit the same cache entry.
Cache miss handling
A miss never blocks the response. cache.GetOrSetTyped falls through to the loader on any cache error and logs a slog.Warn — Postgres remains the source of truth.
val, _, err := cache.GetOrSetTyped(ctx, h.cache, key, ttl, loader)
// err != nil only when both cache AND loader failed.