feat(lol): make match cache fallback-only

This commit is contained in:
2026-07-01 16:41:59 +07:00
parent 2fb10ff632
commit 275c83b8a6
8 changed files with 287 additions and 50 deletions
+20 -20
View File
@@ -6,8 +6,8 @@
// client (no registration). If Riot ever rotates it, lift the new value
// from their public JS bundle.
//
// Cache strategy: KV-backed cacheRecord with a 120s fresh window and a
// 60-minute stale fallback (stale-while-error).
// Cache strategy: live-first fetches with a KV-backed 60-minute stale fallback
// for current schedule windows.
package lol
import (
@@ -33,8 +33,6 @@ const (
// #nosec G101
apiKey = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z"
userAgent = "miti99bot/0.1 (https://t.me/miti99bot)"
// CacheTTL: schedule data changes minute-by-minute during live events.
cacheTTL = 120 * time.Second
// staleMaxAge: how long to fall back to a cached payload when the
// upstream call fails outright.
staleMaxAge = 60 * 60 * time.Second
@@ -113,11 +111,11 @@ type schedulePage struct {
} `json:"data"`
}
// cacheRecord is the store value: fetch timestamp + events.
// cacheRecord is the store value: fetch timestamp + events. The Mongo store
// owns updatedAt separately; LoL uses ts for cache freshness in every backend.
type cacheRecord struct {
Ts int64 `json:"ts" bson:"ts"` // ms-since-epoch when fetched
FetchedAt *time.Time `json:"fetchedAt,omitempty" bson:"fetchedAt,omitempty"` // Mongo TTL anchor for matches:* cache docs
Events []ScheduleEvent `json:"events" bson:"events"`
Ts int64 `json:"ts" bson:"ts"` // ms-since-epoch when fetched
Events []ScheduleEvent `json:"events" bson:"events"`
}
// CacheStore is the typed store for schedule cache records.
@@ -285,23 +283,25 @@ func cacheKey(from, to time.Time) string {
return "matches:" + from.UTC().Format(time.RFC3339) + ":" + to.UTC().Format(time.RFC3339)
}
// GetEventsCached is the cache-first lookup. Returns fresh cache within
// cacheTTL, else fetches upstream and writes back, else falls back to
// stale cache (within staleMaxAge), else propagates the error.
func (c *Client) GetEventsCached(ctx context.Context, cache CacheStore, from, to time.Time) ([]ScheduleEvent, error) {
// GetEventsLive fetches the requested range directly from upstream without
// consulting or writing the fallback cache.
func (c *Client) GetEventsLive(ctx context.Context, from, to time.Time) ([]ScheduleEvent, error) {
return c.fetchEventsInRange(ctx, from, to, 3)
}
// GetEventsWithFallback is live-first. It always tries upstream, writes a
// successful response to cache, and uses a recent cached response only when the
// upstream call fails.
func (c *Client) GetEventsWithFallback(ctx context.Context, cache CacheStore, from, to time.Time) ([]ScheduleEvent, error) {
key := cacheKey(from, to)
nowTime := time.Now().UTC()
now := nowTime.UnixMilli()
now := time.Now().UTC().UnixMilli()
cached, _, cacheErr := cache.Get(ctx, key)
hasCached := cacheErr == nil
if hasCached && now-cached.Ts < cacheTTL.Milliseconds() {
return cached.Events, nil
}
events, fetchErr := c.fetchEventsInRange(ctx, from, to, 3)
events, fetchErr := c.GetEventsLive(ctx, from, to)
if fetchErr == nil {
rec := cacheRecord{Ts: now, FetchedAt: &nowTime, Events: events}
rec := cacheRecord{Ts: now, Events: events}
if err := cache.Put(ctx, key, rec); err != nil {
log.Warn("lol_kv_put_fail", "err", err)
}
@@ -309,7 +309,7 @@ func (c *Client) GetEventsCached(ctx context.Context, cache CacheStore, from, to
}
// Upstream failed — fall back to stale cache if recent enough.
if hasCached && cached.Events != nil && now-cached.Ts < staleMaxAge.Milliseconds() {
if hasCached && now-cached.Ts < staleMaxAge.Milliseconds() {
log.Warn("lol_stale_fallback", "err", fetchErr)
return cached.Events, nil
}
+26 -19
View File
@@ -57,14 +57,14 @@ const sampleBody = `{
}
}`
func TestGetEventsCached_FirstHitFetchesUpstream(t *testing.T) {
func TestGetEventsWithFallback_FirstHitFetchesUpstreamAndCaches(t *testing.T) {
srv, count := mkServer(t, sampleBody)
c := &Client{HTTP: srv.Client(), URL: srv.URL}
cache := newCacheStore()
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
events, err := c.GetEventsCached(context.Background(), cache, from, to)
events, err := c.GetEventsWithFallback(context.Background(), cache, from, to)
if err != nil {
t.Fatalf("first fetch: %v", err)
}
@@ -84,35 +84,27 @@ func TestGetEventsCached_FirstHitFetchesUpstream(t *testing.T) {
if cached.Ts <= 0 {
t.Fatalf("cached ts = %d, want positive", cached.Ts)
}
if cached.FetchedAt == nil {
t.Fatal("cached fetchedAt is nil")
}
if cached.FetchedAt.UnixMilli() != cached.Ts {
t.Fatalf("cached fetchedAt = %d, want ts %d", cached.FetchedAt.UnixMilli(), cached.Ts)
}
}
func TestGetEventsCached_SecondHitUsesCache(t *testing.T) {
func TestGetEventsWithFallback_AlwaysFetchesWhenUpstreamAvailable(t *testing.T) {
srv, count := mkServer(t, sampleBody)
c := &Client{HTTP: srv.Client(), URL: srv.URL}
cache := newCacheStore()
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
// First fetch primes the cache.
if _, err := c.GetEventsCached(context.Background(), cache, from, to); err != nil {
if _, err := c.GetEventsWithFallback(context.Background(), cache, from, to); err != nil {
t.Fatal(err)
}
// Second fetch within TTL must NOT hit upstream.
if _, err := c.GetEventsCached(context.Background(), cache, from, to); err != nil {
if _, err := c.GetEventsWithFallback(context.Background(), cache, from, to); err != nil {
t.Fatal(err)
}
if got := atomic.LoadInt32(count); got != 1 {
t.Errorf("upstream calls = %d, want 1 (cache should serve second call)", got)
if got := atomic.LoadInt32(count); got != 2 {
t.Errorf("upstream calls = %d, want 2 (live-first should refetch)", got)
}
}
func TestGetEventsCached_StaleFallback(t *testing.T) {
func TestGetEventsWithFallback_StaleFallback(t *testing.T) {
// Prime the typed cache store with a stale-but-still-fresh-enough record.
cache := newCacheStore()
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
@@ -134,7 +126,7 @@ func TestGetEventsCached_StaleFallback(t *testing.T) {
defer srv.Close()
c := &Client{HTTP: srv.Client(), URL: srv.URL}
got, err := c.GetEventsCached(context.Background(), cache, from, to)
got, err := c.GetEventsWithFallback(context.Background(), cache, from, to)
if err != nil {
t.Fatalf("stale fallback should succeed: %v", err)
}
@@ -143,7 +135,7 @@ func TestGetEventsCached_StaleFallback(t *testing.T) {
}
}
func TestGetEventsCached_HardFailureWhenNoCache(t *testing.T) {
func TestGetEventsWithFallback_HardFailureWhenNoCache(t *testing.T) {
cache := newCacheStore()
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
@@ -154,12 +146,27 @@ func TestGetEventsCached_HardFailureWhenNoCache(t *testing.T) {
defer srv.Close()
c := &Client{HTTP: srv.Client(), URL: srv.URL}
_, err := c.GetEventsCached(context.Background(), cache, from, to)
_, err := c.GetEventsWithFallback(context.Background(), cache, from, to)
if err == nil {
t.Errorf("expected error when upstream fails AND no cache")
}
}
func TestGetEventsLive_DoesNotWriteCache(t *testing.T) {
srv, _ := mkServer(t, sampleBody)
c := &Client{HTTP: srv.Client(), URL: srv.URL}
cache := newCacheStore()
from := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
to := from.Add(24 * time.Hour)
if _, err := c.GetEventsLive(context.Background(), from, to); err != nil {
t.Fatalf("live fetch: %v", err)
}
if _, _, err := cache.Get(context.Background(), cacheKey(from, to)); !errors.Is(err, storage.ErrNotFound) {
t.Fatalf("live fetch cache entry err = %v, want ErrNotFound", err)
}
}
func TestFetchSchedulePage_DropsShowEvents(t *testing.T) {
srv, _ := mkServer(t, sampleBody)
c := &Client{HTTP: srv.Client(), URL: srv.URL}
+1 -1
View File
@@ -183,7 +183,7 @@ func runDailyPush(ctx context.Context, s *state, sender messageSender) error {
from := ictDayStartOf(s.now())
to := addDays(from, 1)
events, err := s.client.GetEventsCached(ctx, s.cache, from, to)
events, err := s.client.GetEventsWithFallback(ctx, s.cache, from, to)
if err != nil {
return fmt.Errorf("lol daily push: fetch matches: %w", err)
}
+9 -3
View File
@@ -3,6 +3,8 @@ package lol
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
@@ -66,8 +68,8 @@ func newTestStore(t *testing.T) (SubscriberStore, PushDateStore, CacheStore) {
storage.Typed[cacheRecord](col)
}
// seedFreshCache writes a cacheRecord with `now` as timestamp so the first
// GetEventsCached call returns it without hitting the network.
// seedFreshCache writes a cacheRecord with `now` as timestamp so
// GetEventsWithFallback can serve it when the test upstream fails.
func seedFreshCache(t *testing.T, cache CacheStore, events []ScheduleEvent) {
t.Helper()
from := ictDayStartOf(fixedNow())
@@ -84,11 +86,15 @@ func seedFreshCache(t *testing.T, cache CacheStore, events []ScheduleEvent) {
func newTestState(t *testing.T) *state {
t.Helper()
subs, pd, cache := newTestStore(t)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(upstream.Close)
return &state{
subscribers: subs,
pushDate: pd,
cache: cache,
client: &Client{}, // zero value; tests must seed cache to avoid HTTP
client: &Client{HTTP: upstream.Client(), URL: upstream.URL},
nowFn: fixedNow,
}
}
+14 -4
View File
@@ -2,6 +2,7 @@ package lol
import (
"context"
"strings"
"sync"
"time"
@@ -46,7 +47,8 @@ func (s *state) handleSchedule(ctx context.Context, b *bot.Bot, update *models.U
if !parsed.OK {
return chathelper.Reply(ctx, b, msg, parsed.Error)
}
return s.replyForRange(ctx, b, msg, parsed.Date, addDays(parsed.Date, 1), false)
useFallback := strings.TrimSpace(arg) == ""
return s.replyForRange(ctx, b, msg, parsed.Date, addDays(parsed.Date, 1), false, useFallback)
}
// handleWeek is /lol_this_week — the current ICT calendar week
@@ -57,13 +59,21 @@ func (s *state) handleWeek(ctx context.Context, b *bot.Bot, update *models.Updat
return nil
}
from := ictWeekStartOf(s.now())
return s.replyForRange(ctx, b, msg, from, addDays(from, 7), true)
return s.replyForRange(ctx, b, msg, from, addDays(from, 7), true, true)
}
// replyForRange fetches + filters + renders a date window. week=true uses
// RenderWeek; false uses RenderToday.
func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Message, from, to time.Time, week bool) error {
events, err := s.client.GetEventsCached(ctx, s.cache, from, to)
func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Message, from, to time.Time, week, useFallback bool) error {
var (
events []ScheduleEvent
err error
)
if useFallback {
events, err = s.client.GetEventsWithFallback(ctx, s.cache, from, to)
} else {
events, err = s.client.GetEventsLive(ctx, from, to)
}
if err != nil {
log.Error("lol_fetch_fail", "err", err, "from", from, "to", to)
hint := "Could not fetch matches. Try again later."
+52
View File
@@ -223,3 +223,55 @@ func TestHandleSchedule_UpstreamFailureGivesFriendlyError(t *testing.T) {
t.Errorf("expected friendly fetch-error reply; got %q", got)
}
}
func TestHandleSchedule_CustomDateDoesNotUseFallbackCache(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer upstream.Close()
rb := testutil.NewRecordingBot(t)
col := storage.NewMemoryProvider().Collection("lol")
cache := storage.Typed[cacheRecord](col)
s := &state{
subscribers: storage.Typed[subscribersDoc](col),
pushDate: storage.Typed[lastPushDoc](col),
cache: cache,
client: &Client{HTTP: upstream.Client(), URL: upstream.URL},
nowFn: func() time.Time { return time.UnixMilli(fakeNowMs).UTC() },
}
from := ictDayStartOf(s.now())
to := addDays(from, 1)
if err := cache.Put(context.Background(), cacheKey(from, to), cacheRecord{
Ts: time.Now().UTC().UnixMilli(),
Events: []ScheduleEvent{{
StartTime: "2026-05-09T05:00:00Z",
State: "unstarted",
League: League{Name: "LCK", Slug: "lck"},
Match: Match{
Teams: []Team{{Code: "T1"}, {Code: "GEN"}},
Strategy: Strategy{Count: 3},
},
}},
}); err != nil {
t.Fatalf("seed cache: %v", err)
}
cmd := modules.Command{Name: "lol", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule}
reg := &modules.Registry{
Modules: []modules.Module{{Name: "lol", Commands: []modules.Command{cmd}}},
AllCommands: map[string]modules.Command{cmd.Name: cmd},
}
modules.Install(rb.Bot, reg, modules.Auth{})
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol"))
if got := rb.LastSent().Text(); !strings.Contains(got, "T1 vs GEN") {
t.Fatalf("default /lol should use stale fallback; got %q", got)
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol 09-05-2026"))
if got := rb.LastSent().Text(); !strings.Contains(got, "Could not fetch") {
t.Fatalf("custom-date /lol should not use stale fallback; got %q", got)
}
}
+101 -1
View File
@@ -14,6 +14,7 @@ import (
const (
matchCacheTTLIndexName = "lol_match_cache_ttl"
matchCacheTTLField = "updatedAt"
matchCacheKeyPrefix = "matches:"
matchCacheKeyUpper = "matches;"
matchCacheTTL = 30 * 24 * time.Hour
@@ -24,6 +25,9 @@ const (
// no-op.
func InitStore(ctx context.Context, lolColl storage.Collection) error {
if mongoColl, ok := storage.MongoCollection(lolColl); ok {
if err := deleteMatchCacheDocs(ctx, mongoColl); err != nil {
return err
}
if err := ensureMatchCacheTTLIndex(ctx, mongoColl); err != nil {
return err
}
@@ -32,8 +36,11 @@ func InitStore(ctx context.Context, lolColl storage.Collection) error {
}
func ensureMatchCacheTTLIndex(ctx context.Context, coll *mongo.Collection) error {
if err := dropConflictingMatchCacheTTLIndex(ctx, coll); err != nil {
return err
}
model := mongo.IndexModel{
Keys: bson.D{{Key: "fetchedAt", Value: 1}},
Keys: bson.D{{Key: matchCacheTTLField, Value: 1}},
Options: options.Index().
SetName(matchCacheTTLIndexName).
SetExpireAfterSeconds(int32(matchCacheTTL / time.Second)).
@@ -45,9 +52,102 @@ func ensureMatchCacheTTLIndex(ctx context.Context, coll *mongo.Collection) error
return nil
}
func dropConflictingMatchCacheTTLIndex(ctx context.Context, coll *mongo.Collection) error {
cur, err := coll.Indexes().List(ctx)
if err != nil {
return fmt.Errorf("list lol indexes: %w", err)
}
defer func() { _ = cur.Close(ctx) }()
for cur.Next(ctx) {
var doc bson.M
if err := cur.Decode(&doc); err != nil {
return fmt.Errorf("decode lol index: %w", err)
}
if doc["name"] != matchCacheTTLIndexName {
continue
}
if matchCacheTTLIndexMatches(doc) {
return nil
}
if err := coll.Indexes().DropOne(ctx, matchCacheTTLIndexName); err != nil {
return fmt.Errorf("drop old lol match cache ttl index: %w", err)
}
return nil
}
if err := cur.Err(); err != nil {
return fmt.Errorf("iterate lol indexes: %w", err)
}
return nil
}
func matchCacheTTLIndexMatches(doc bson.M) bool {
if !singleAscendingKey(doc["key"], matchCacheTTLField) {
return false
}
if got, ok := int64BSON(doc["expireAfterSeconds"]); !ok || got != int64(matchCacheTTL/time.Second) {
return false
}
partial, ok := bsonDocument(doc["partialFilterExpression"])
if !ok {
return false
}
idFilter, ok := bsonDocument(partial["_id"])
if !ok {
return false
}
return idFilter["$gte"] == matchCacheKeyPrefix && idFilter["$lt"] == matchCacheKeyUpper
}
func deleteMatchCacheDocs(ctx context.Context, coll *mongo.Collection) error {
if _, err := coll.DeleteMany(ctx, bson.D{{Key: "_id", Value: matchCacheIDRange()}}); err != nil {
return fmt.Errorf("delete old lol match cache docs: %w", err)
}
return nil
}
func matchCacheIDRange() bson.D {
return bson.D{
{Key: "$gte", Value: matchCacheKeyPrefix},
{Key: "$lt", Value: matchCacheKeyUpper},
}
}
func singleAscendingKey(v any, field string) bool {
doc, ok := bsonDocument(v)
if !ok || len(doc) != 1 {
return false
}
got, ok := int64BSON(doc[field])
return ok && got == 1
}
func bsonDocument(v any) (bson.M, bool) {
switch d := v.(type) {
case bson.M:
return d, true
case map[string]any:
return bson.M(d), true
case bson.D:
out := bson.M{}
for _, e := range d {
out[e.Key] = e.Value
}
return out, true
default:
return nil, false
}
}
func int64BSON(v any) (int64, bool) {
switch n := v.(type) {
case int:
return int64(n), true
case int32:
return int64(n), true
case int64:
return n, true
default:
return 0, false
}
}
+64 -2
View File
@@ -2,17 +2,22 @@ package lol
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"github.com/tiennm99/miti99bot/internal/storage"
)
func TestInitStore_MongoCreatesMatchCacheTTLIndex(t *testing.T) {
const legacyFetchedAtField = "fetchedAt"
uri := os.Getenv("MONGODB_TEST_URL")
if uri == "" {
t.Skip("MONGODB_TEST_URL not set; skipping MongoDB integration test")
@@ -41,9 +46,47 @@ func TestInitStore_MongoCreatesMatchCacheTTLIndex(t *testing.T) {
t.Fatal("lol collection is not Mongo-backed")
}
legacyFrom := time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC)
legacyTo := legacyFrom.Add(24 * time.Hour)
legacyMatchID := cacheKey(legacyFrom, legacyTo)
legacyFetchedAt := time.Date(2026, 6, 1, 2, 3, 4, 0, time.UTC)
_, err = rawLolColl.Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: legacyFetchedAtField, Value: 1}},
Options: options.Index().
SetName(matchCacheTTLIndexName).
SetExpireAfterSeconds(int32(matchCacheTTL / time.Second)).
SetPartialFilterExpression(bson.D{{Key: "_id", Value: matchCacheIDRange()}}),
})
if err != nil {
t.Fatalf("create legacy ttl index: %v", err)
}
_, err = rawLolColl.InsertMany(ctx, []any{
bson.D{
{Key: "_id", Value: legacyMatchID},
{Key: "version", Value: int64(1)},
{Key: "updatedAt", Value: legacyFetchedAt},
{Key: "ts", Value: legacyFetchedAt.UnixMilli()},
{Key: legacyFetchedAtField, Value: legacyFetchedAt},
{Key: "events", Value: bson.A{}},
},
bson.D{
{Key: "_id", Value: "subscribers"},
{Key: "version", Value: int64(1)},
{Key: "updatedAt", Value: legacyFetchedAt},
{Key: legacyFetchedAtField, Value: legacyFetchedAt},
{Key: "subscribers", Value: bson.A{}},
},
})
if err != nil {
t.Fatalf("insert legacy docs: %v", err)
}
if err := InitStore(ctx, lolColl); err != nil {
t.Fatalf("InitStore: %v", err)
}
if err := InitStore(ctx, lolColl); err != nil {
t.Fatalf("InitStore second run: %v", err)
}
cur, err := rawLolColl.Indexes().List(ctx)
if err != nil {
@@ -76,8 +119,8 @@ func TestInitStore_MongoCreatesMatchCacheTTLIndex(t *testing.T) {
if !ok {
t.Fatalf("index key is not a document: %#v", found["key"])
}
if got, _ := int64FromBSON(keyDoc["fetchedAt"]); got != 1 {
t.Fatalf("index key fetchedAt = %v, want 1", keyDoc["fetchedAt"])
if got, _ := int64FromBSON(keyDoc[matchCacheTTLField]); got != 1 {
t.Fatalf("index key %s = %v, want 1", matchCacheTTLField, keyDoc[matchCacheTTLField])
}
partial, ok := bsonDoc(found["partialFilterExpression"])
@@ -91,6 +134,16 @@ func TestInitStore_MongoCreatesMatchCacheTTLIndex(t *testing.T) {
if idFilter["$gte"] != matchCacheKeyPrefix || idFilter["$lt"] != matchCacheKeyUpper {
t.Fatalf("partial _id filter = %#v, want [%q, %q)", idFilter, matchCacheKeyPrefix, matchCacheKeyUpper)
}
var matchDoc bson.M
err = rawLolColl.FindOne(ctx, bson.M{"_id": legacyMatchID}).Decode(&matchDoc)
if !errors.Is(err, mongo.ErrNoDocuments) {
t.Fatalf("legacy match doc lookup err = %v, want ErrNoDocuments; doc=%#v", err, matchDoc)
}
subscriberDoc := rawLolDoc(t, ctx, rawLolColl, "subscribers")
if _, ok := subscriberDoc[legacyFetchedAtField]; !ok {
t.Fatalf("non-match doc was deleted or changed unexpectedly: %#v", subscriberDoc)
}
}
func bsonDoc(v any) (bson.M, bool) {
@@ -122,3 +175,12 @@ func int64FromBSON(v any) (int64, bool) {
return 0, false
}
}
func rawLolDoc(t *testing.T, ctx context.Context, coll *mongo.Collection, id string) bson.M {
t.Helper()
var doc bson.M
if err := coll.FindOne(ctx, bson.M{"_id": id}).Decode(&doc); err != nil {
t.Fatalf("find raw lol doc %s: %v", id, err)
}
return doc
}