diff --git a/cmd/server/main.go b/cmd/server/main.go index 05b9b95..16bc12b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -98,6 +98,9 @@ func main() { if err := stats.InitStore(rootCtx, provider.Collection("stats"), provider.Collection(systemstate.CollectionName)); err != nil { log.Fatal("stats storage init failed", "err", err) } + if err := lol.InitStore(rootCtx, provider.Collection(lol.CollectionName)); err != nil { + log.Fatal("lol storage init failed", "err", err) + } b, err := telegram.NewBot(cfg.TelegramBotToken) if err != nil { diff --git a/internal/modules/lol/api_client.go b/internal/modules/lol/api_client.go index be580e4..0822d5e 100644 --- a/internal/modules/lol/api_client.go +++ b/internal/modules/lol/api_client.go @@ -113,10 +113,11 @@ type schedulePage struct { } `json:"data"` } -// cacheRecord is the store value: fetch timestamp (ms-epoch) + events. +// cacheRecord is the store value: fetch timestamp + events. type cacheRecord struct { - Ts int64 `json:"ts" bson:"ts"` // ms-since-epoch when fetched - Events []ScheduleEvent `json:"events" bson:"events"` + 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"` } // CacheStore is the typed store for schedule cache records. @@ -289,7 +290,8 @@ func cacheKey(from, to time.Time) string { // stale cache (within staleMaxAge), else propagates the error. func (c *Client) GetEventsCached(ctx context.Context, cache CacheStore, from, to time.Time) ([]ScheduleEvent, error) { key := cacheKey(from, to) - now := time.Now().UTC().UnixMilli() + nowTime := time.Now().UTC() + now := nowTime.UnixMilli() cached, _, cacheErr := cache.Get(ctx, key) hasCached := cacheErr == nil @@ -299,7 +301,7 @@ func (c *Client) GetEventsCached(ctx context.Context, cache CacheStore, from, to events, fetchErr := c.fetchEventsInRange(ctx, from, to, 3) if fetchErr == nil { - rec := cacheRecord{Ts: now, Events: events} + rec := cacheRecord{Ts: now, FetchedAt: &nowTime, Events: events} if err := cache.Put(ctx, key, rec); err != nil { log.Warn("lol_kv_put_fail", "err", err) } diff --git a/internal/modules/lol/api_client_test.go b/internal/modules/lol/api_client_test.go index 631c650..efcc6d9 100644 --- a/internal/modules/lol/api_client_test.go +++ b/internal/modules/lol/api_client_test.go @@ -77,6 +77,19 @@ func TestGetEventsCached_FirstHitFetchesUpstream(t *testing.T) { if atomic.LoadInt32(count) != 1 { t.Errorf("upstream calls = %d, want 1", *count) } + cached, _, err := cache.Get(context.Background(), cacheKey(from, to)) + if err != nil { + t.Fatalf("load cached record: %v", err) + } + 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) { diff --git a/internal/modules/lol/startup.go b/internal/modules/lol/startup.go new file mode 100644 index 0000000..b20c673 --- /dev/null +++ b/internal/modules/lol/startup.go @@ -0,0 +1,76 @@ +package lol + +import ( + "context" + "fmt" + "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" +) + +const ( + matchCacheTTLIndexName = "lol_match_cache_ttl" + matchCacheKeyPrefix = "matches:" + matchCacheKeyUpper = "matches;" + matchCacheTTL = 30 * 24 * time.Hour +) + +// InitStore performs LoL collection startup maintenance. It is safe to call +// every boot: MongoDB indexes are created idempotently and memory storage is a +// no-op. +func InitStore(ctx context.Context, lolColl storage.Collection) error { + if mongoColl, ok := storage.MongoCollection(lolColl); ok { + if err := backfillMatchCacheFetchedAt(ctx, mongoColl); err != nil { + return err + } + if err := ensureMatchCacheTTLIndex(ctx, mongoColl); err != nil { + return err + } + } + return nil +} + +func backfillMatchCacheFetchedAt(ctx context.Context, coll *mongo.Collection) error { + filter := bson.D{ + {Key: "_id", Value: matchCacheIDRange()}, + {Key: "fetchedAt", Value: bson.D{{Key: "$exists", Value: false}}}, + {Key: "ts", Value: bson.D{ + {Key: "$type", Value: "number"}, + {Key: "$gt", Value: int64(0)}, + }}, + } + update := mongo.Pipeline{ + bson.D{{Key: "$set", Value: bson.D{ + {Key: "fetchedAt", Value: bson.D{{Key: "$toDate", Value: "$ts"}}}, + }}}, + } + if _, err := coll.UpdateMany(ctx, filter, update); err != nil { + return fmt.Errorf("lol match cache fetchedAt backfill: %w", err) + } + return nil +} + +func ensureMatchCacheTTLIndex(ctx context.Context, coll *mongo.Collection) error { + model := mongo.IndexModel{ + Keys: bson.D{{Key: "fetchedAt", Value: 1}}, + Options: options.Index(). + SetName(matchCacheTTLIndexName). + SetExpireAfterSeconds(int32(matchCacheTTL / time.Second)). + SetPartialFilterExpression(bson.D{{Key: "_id", Value: matchCacheIDRange()}}), + } + if _, err := coll.Indexes().CreateOne(ctx, model); err != nil { + return fmt.Errorf("lol match cache ttl index: %w", err) + } + return nil +} + +func matchCacheIDRange() bson.D { + return bson.D{ + {Key: "$gte", Value: matchCacheKeyPrefix}, + {Key: "$lt", Value: matchCacheKeyUpper}, + } +} diff --git a/internal/modules/lol/startup_mongo_test.go b/internal/modules/lol/startup_mongo_test.go new file mode 100644 index 0000000..b083d13 --- /dev/null +++ b/internal/modules/lol/startup_mongo_test.go @@ -0,0 +1,162 @@ +package lol + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/tiennm99/miti99bot/internal/storage" +) + +func TestInitStore_MongoCreatesMatchCacheTTLIndex(t *testing.T) { + uri := os.Getenv("MONGODB_TEST_URL") + if uri == "" { + t.Skip("MONGODB_TEST_URL not set; skipping MongoDB integration test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + client, err := storage.NewMongoClient(ctx, uri) + if err != nil { + t.Fatalf("NewMongoClient: %v", err) + } + dbName := fmt.Sprintf("miti99bot_lol_test_%d", time.Now().UnixNano()) + db := client.Database(dbName) + defer func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = db.Drop(cleanupCtx) + _ = client.Disconnect(cleanupCtx) + }() + + provider := storage.NewMongoProvider(db) + lolColl := provider.Collection(CollectionName) + rawLolColl, ok := storage.MongoCollection(lolColl) + if !ok { + t.Fatal("lol collection is not Mongo-backed") + } + + fetchedAt := time.Date(2026, 5, 9, 5, 0, 0, 0, time.UTC) + if _, err := rawLolColl.InsertMany(ctx, []any{ + bson.M{ + "_id": "matches:2026-05-09T00:00:00Z:2026-05-10T00:00:00Z", + "version": int64(1), + "updatedAt": time.Now().UTC(), + "ts": fetchedAt.UnixMilli(), + "events": bson.A{}, + }, + bson.M{ + "_id": "subscribers", + "version": int64(1), + "updatedAt": time.Now().UTC(), + "ts": fetchedAt.UnixMilli(), + "subscribers": bson.A{}, + }, + }); err != nil { + t.Fatalf("seed legacy docs: %v", err) + } + + if err := InitStore(ctx, lolColl); err != nil { + t.Fatalf("InitStore: %v", err) + } + + var matchDoc struct { + FetchedAt time.Time `bson:"fetchedAt"` + } + if err := rawLolColl.FindOne(ctx, bson.M{"_id": "matches:2026-05-09T00:00:00Z:2026-05-10T00:00:00Z"}).Decode(&matchDoc); err != nil { + t.Fatalf("load backfilled match doc: %v", err) + } + if !matchDoc.FetchedAt.Equal(fetchedAt) { + t.Fatalf("backfilled fetchedAt = %s, want %s", matchDoc.FetchedAt, fetchedAt) + } + + var subscriberDoc bson.M + if err := rawLolColl.FindOne(ctx, bson.M{"_id": "subscribers"}).Decode(&subscriberDoc); err != nil { + t.Fatalf("load subscriber doc: %v", err) + } + if _, ok := subscriberDoc["fetchedAt"]; ok { + t.Fatalf("subscriber doc was backfilled with fetchedAt: %#v", subscriberDoc) + } + + cur, err := rawLolColl.Indexes().List(ctx) + if err != nil { + t.Fatalf("list indexes: %v", err) + } + defer func() { _ = cur.Close(ctx) }() + + var found bson.M + for cur.Next(ctx) { + var doc bson.M + if err := cur.Decode(&doc); err != nil { + t.Fatalf("decode index: %v", err) + } + if doc["name"] == matchCacheTTLIndexName { + found = doc + break + } + } + if err := cur.Err(); err != nil { + t.Fatalf("index cursor: %v", err) + } + if found == nil { + t.Fatalf("missing index %s", matchCacheTTLIndexName) + } + if got, ok := int64FromBSON(found["expireAfterSeconds"]); !ok || got != int64(matchCacheTTL/time.Second) { + t.Fatalf("expireAfterSeconds = %v, want %d", found["expireAfterSeconds"], int64(matchCacheTTL/time.Second)) + } + + keyDoc, ok := bsonDoc(found["key"]) + 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"]) + } + + partial, ok := bsonDoc(found["partialFilterExpression"]) + if !ok { + t.Fatalf("partialFilterExpression is not a document: %#v", found["partialFilterExpression"]) + } + idFilter, ok := bsonDoc(partial["_id"]) + if !ok { + t.Fatalf("partial _id filter is not a document: %#v", partial["_id"]) + } + if idFilter["$gte"] != matchCacheKeyPrefix || idFilter["$lt"] != matchCacheKeyUpper { + t.Fatalf("partial _id filter = %#v, want [%q, %q)", idFilter, matchCacheKeyPrefix, matchCacheKeyUpper) + } +} + +func bsonDoc(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 int64FromBSON(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 + } +}