feat(lol): expire match cache

This commit is contained in:
tiennm99 committed 2026-07-01 13:40:37 +07:00
1 parent e6d5bc3cc9
commit b0b4321fa1
5 files changed
+261 -5

No files matched your search

+3
View File
@@ -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 {
+7 -5
View File
@@ -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)
}
+13
View File
@@ -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) {
+76
View File
@@ -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},
}
}
+162
View File
@@ -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
}
}