diff --git a/cmd/server/main.go b/cmd/server/main.go index 16bc12b..a51d123 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -29,7 +29,6 @@ import ( "github.com/tiennm99/miti99bot/internal/modules/wordle" "github.com/tiennm99/miti99bot/internal/server" "github.com/tiennm99/miti99bot/internal/storage" - "github.com/tiennm99/miti99bot/internal/systemstate" "github.com/tiennm99/miti99bot/internal/telegram" ) @@ -95,7 +94,7 @@ func main() { } defer closeProvider() - if err := stats.InitStore(rootCtx, provider.Collection("stats"), provider.Collection(systemstate.CollectionName)); err != nil { + if err := stats.InitStore(rootCtx, provider.Collection("stats")); err != nil { log.Fatal("stats storage init failed", "err", err) } if err := lol.InitStore(rootCtx, provider.Collection(lol.CollectionName)); err != nil { diff --git a/internal/modules/lol/startup.go b/internal/modules/lol/startup.go index b20c673..88d3cf6 100644 --- a/internal/modules/lol/startup.go +++ b/internal/modules/lol/startup.go @@ -24,9 +24,6 @@ const ( // 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 } @@ -34,26 +31,6 @@ func InitStore(ctx context.Context, lolColl storage.Collection) error { 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}}, diff --git a/internal/modules/lol/startup_mongo_test.go b/internal/modules/lol/startup_mongo_test.go index b083d13..d5a6197 100644 --- a/internal/modules/lol/startup_mongo_test.go +++ b/internal/modules/lol/startup_mongo_test.go @@ -41,48 +41,10 @@ func TestInitStore_MongoCreatesMatchCacheTTLIndex(t *testing.T) { 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) diff --git a/internal/modules/lol/subscribers.go b/internal/modules/lol/subscribers.go index 3c9804f..7f567b3 100644 --- a/internal/modules/lol/subscribers.go +++ b/internal/modules/lol/subscribers.go @@ -2,7 +2,6 @@ package lol import ( "context" - "encoding/json" "errors" "fmt" @@ -36,15 +35,6 @@ type SubscriberStore = storage.DocStore[subscribersDoc] // listSubscribers returns the current subscriber list, or an empty slice // if none have ever subscribed. -// -// Decoder accepts two shapes for forward-compatibility with rows written -// before topic-aware subscriptions existed: -// - current: [{"chat_id":123,"thread_id":7}, ...] -// - legacy: [123, 456, ...] (decoded with ThreadID = 0) -// -// The decoder handles legacy rows indefinitely; any successful add/remove -// rewrites the slot in the new shape, but a no-op duplicate add leaves the -// legacy bytes in place — that's fine, the fallback path stays correct. func listSubscribers(ctx context.Context, store SubscriberStore) ([]Subscriber, error) { doc, _, err := store.Get(ctx, subscribersKey) switch { @@ -59,25 +49,6 @@ func listSubscribers(ctx context.Context, store SubscriberStore) ([]Subscriber, return nil, nil } -// listSubscribersLegacy decodes a raw JSON byte slice that may be either the -// current [{chat_id,thread_id}] shape or the legacy [int64] shape. Used by -// the legacy-migration path when a key was written by an older version. -func listSubscribersLegacy(raw []byte) ([]Subscriber, error) { - var subs []Subscriber - if err := json.Unmarshal(raw, &subs); err == nil { - return subs, nil - } - var legacy []int64 - if err := json.Unmarshal(raw, &legacy); err != nil { - return nil, fmt.Errorf("lol listSubscribers decode: %w", err) - } - out := make([]Subscriber, len(legacy)) - for i, id := range legacy { - out[i] = Subscriber{ChatID: id} - } - return out, nil -} - // addSubscriber appends (chatID, threadID) if that exact pair is absent. // Returns true on first-add, false when already subscribed (idempotent). // diff --git a/internal/modules/lol/subscribers_test.go b/internal/modules/lol/subscribers_test.go index 7a1a486..9b22990 100644 --- a/internal/modules/lol/subscribers_test.go +++ b/internal/modules/lol/subscribers_test.go @@ -2,7 +2,6 @@ package lol import ( "context" - "encoding/json" "testing" "github.com/tiennm99/miti99bot/internal/storage" @@ -130,10 +129,7 @@ func TestSubscribers_RemoveAllForChat(t *testing.T) { } } -// TestSubscribers_LegacyDecode locks in backward-compat reads of rows -// written before topic-aware subscriptions (raw []int64 JSON). The legacy -// helper decodes both shapes independently of the store. -func TestSubscribers_LegacyDecode(t *testing.T) { +func TestSubscribers_CurrentShapeRoundTrip(t *testing.T) { ctx := context.Background() store := newSubscriberStore() @@ -157,32 +153,7 @@ func TestSubscribers_LegacyDecode(t *testing.T) { } } - // Verify the legacy JSON helper handles the old []int64 shape. - legacyRaw := []byte(`[11,22,33]`) - legacySubs, err := listSubscribersLegacy(legacyRaw) - if err != nil { - t.Fatalf("listSubscribersLegacy: %v", err) - } - if len(legacySubs) != 3 { - t.Fatalf("legacy decode length = %d, want 3", len(legacySubs)) - } - for i, s := range legacySubs { - if s != want[i] { - t.Errorf("legacy[%d]: got %v, want %v", i, s, want[i]) - } - } - - // Verify the legacy helper also handles the current [{chat_id,...}] shape. - currentRaw, _ := json.Marshal(currentSubs) - currentDecoded, err := listSubscribersLegacy(currentRaw) - if err != nil { - t.Fatalf("listSubscribersLegacy (current shape): %v", err) - } - if len(currentDecoded) != 3 { - t.Fatalf("current shape via legacy helper = %d, want 3", len(currentDecoded)) - } - - // Next mutation rewrites in the new shape — verify subscribers wrap correctly. + // Next mutation verifies subscribers wrap correctly. if _, err := addSubscriber(ctx, store, 44, 7); err != nil { t.Fatal(err) } diff --git a/internal/modules/stats/startup.go b/internal/modules/stats/startup.go index 06df223..4bc61ce 100644 --- a/internal/modules/stats/startup.go +++ b/internal/modules/stats/startup.go @@ -2,140 +2,33 @@ package stats import ( "context" - "errors" "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" - "github.com/tiennm99/miti99bot/internal/systemstate" ) const ( - miscCommandRenameMigrationName = "stats-command-renames-misc-20260701" - - statsCommandUsersIndexName = "stats_cmd_n_user" - statsUserCommandsIndexName = "stats_uid_n_cmd" - statsUsernameLookupIndexName = "stats_user_uid" - statsLegacyUserUpdatedIndexName = "stats_user_updated_at" + statsCommandUsersIndexName = "stats_cmd_n_user" + statsUserCommandsIndexName = "stats_uid_n_cmd" + statsUsernameLookupIndexName = "stats_user_uid" ) -type commandRename struct { - old string - new string -} - -var miscCommandRenames = []commandRename{ - {old: "mstats", new: "ping_stats"}, - {old: "fortytwo", new: "the_answer"}, -} - // InitStore performs stats collection startup maintenance. It is safe to call -// every boot: MongoDB indexes are created idempotently and one-time migrations -// are guarded by the shared system collection. -func InitStore(ctx context.Context, statsColl, systemColl storage.Collection) error { +// every boot: MongoDB indexes are created idempotently and memory storage is a +// no-op. +func InitStore(ctx context.Context, statsColl storage.Collection) error { if mongoColl, ok := storage.MongoCollection(statsColl); ok { if err := ensureUsageIndexes(ctx, mongoColl); err != nil { return err } - if err := dropIndexIfExists(ctx, mongoColl, statsLegacyUserUpdatedIndexName); err != nil { - return err - } - } - if err := runMiscCommandRenameMigration(ctx, statsColl, systemColl); err != nil { - return err } return nil } -func runMiscCommandRenameMigration(ctx context.Context, statsColl, systemColl storage.Collection) error { - sys := systemstate.New(systemColl) - key := "migration:" + miscCommandRenameMigrationName - if rec, ok, err := sys.Get(ctx, key); err != nil { - return fmt.Errorf("stats migration state %s: %w", miscCommandRenameMigrationName, err) - } else if ok && rec.Status == "complete" { - return nil - } - - count, err := migrateCommandStats(ctx, storage.Typed[usageEntry](statsColl), miscCommandRenames) - if err != nil { - return err - } - - now := time.Now().UTC().UnixMilli() - if err := sys.Put(ctx, key, systemstate.Record{ - Kind: "migration", - Name: miscCommandRenameMigrationName, - Status: "complete", - Count: count, - CompletedAt: now, - UpdatedAt: now, - }); err != nil { - return fmt.Errorf("stats migration mark complete %s: %w", miscCommandRenameMigrationName, err) - } - return nil -} - -func migrateCommandStats(ctx context.Context, docs storage.DocStore[usageEntry], renames []commandRename) (int64, error) { - renameByOld := make(map[string]string, len(renames)) - for _, r := range renames { - renameByOld[r.old] = r.new - } - - keys, err := docs.List(ctx, "") - if err != nil { - return 0, fmt.Errorf("stats command rename list: %w", err) - } - - var moved int64 - for _, key := range keys { - src, _, err := docs.Get(ctx, key) - if err != nil { - return moved, fmt.Errorf("stats command rename get %s: %w", key, err) - } - if src.Cmd == "" || src.Deleted { - continue - } - newCmd, ok := renameByOld[src.Cmd] - if !ok { - continue - } - - dstKey := usageKey(newCmd, src.UserID) - dst, _, err := docs.Get(ctx, dstKey) - switch { - case errors.Is(err, storage.ErrNotFound): - dst = usageEntry{} - case err != nil: - return moved, fmt.Errorf("stats command rename get %s: %w", dstKey, err) - } - - dst.Cmd = newCmd - dst.N += src.N - dst.Deleted = false - if src.UserID != 0 { - dst.UserID = src.UserID - if dst.Username == "" { - dst.Username = src.Username - } - } else { - dst.UserID = 0 - dst.Username = "" - } - if err := docs.Put(ctx, dstKey, dst); err != nil { - return moved, fmt.Errorf("stats command rename put %s: %w", dstKey, err) - } - if err := docs.Delete(ctx, key); err != nil { - return moved, fmt.Errorf("stats command rename delete %s: %w", key, err) - } - moved += src.N - } - return moved, nil -} - func ensureUsageIndexes(ctx context.Context, coll *mongo.Collection) error { models := []mongo.IndexModel{ { @@ -156,31 +49,3 @@ func ensureUsageIndexes(ctx context.Context, coll *mongo.Collection) error { } return nil } - -func dropIndexIfExists(ctx context.Context, coll *mongo.Collection, name string) error { - cur, err := coll.Indexes().List(ctx) - if err != nil { - return fmt.Errorf("stats list indexes before drop %s: %w", name, err) - } - defer func() { _ = cur.Close(ctx) }() - - for cur.Next(ctx) { - var doc struct { - Name string `bson:"name"` - } - if err := cur.Decode(&doc); err != nil { - return fmt.Errorf("stats decode index before drop %s: %w", name, err) - } - if doc.Name != name { - continue - } - if err := coll.Indexes().DropOne(ctx, name); err != nil { - return fmt.Errorf("stats drop legacy index %s: %w", name, err) - } - return nil - } - if err := cur.Err(); err != nil { - return fmt.Errorf("stats index cursor before drop %s: %w", name, err) - } - return nil -} diff --git a/internal/modules/stats/startup_mongo_test.go b/internal/modules/stats/startup_mongo_test.go index 9e3de7a..6fdc296 100644 --- a/internal/modules/stats/startup_mongo_test.go +++ b/internal/modules/stats/startup_mongo_test.go @@ -7,10 +7,6 @@ import ( "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" ) @@ -38,23 +34,16 @@ func TestInitStore_MongoCreatesIndexes(t *testing.T) { provider := storage.NewMongoProvider(db) statsColl := provider.Collection("stats") - systemColl := provider.Collection("system") rawStatsColl, ok := storage.MongoCollection(statsColl) if !ok { t.Fatal("stats collection is not Mongo-backed") } - if _, err := rawStatsColl.Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{bsonField("user", 1), bsonField("updatedAt", -1)}, - Options: options.Index().SetName(statsLegacyUserUpdatedIndexName), - }); err != nil { - t.Fatalf("seed legacy index: %v", err) - } - if err := InitStore(ctx, statsColl, systemColl); err != nil { + if err := InitStore(ctx, statsColl); err != nil { t.Fatalf("InitStore: %v", err) } - if err := InitStore(ctx, statsColl, systemColl); err != nil { + if err := InitStore(ctx, statsColl); err != nil { t.Fatalf("InitStore second run: %v", err) } @@ -82,7 +71,4 @@ func TestInitStore_MongoCreatesIndexes(t *testing.T) { t.Fatalf("missing index %s; indexes=%v", name, found) } } - if found[statsLegacyUserUpdatedIndexName] { - t.Fatalf("legacy index %s was not dropped; indexes=%v", statsLegacyUserUpdatedIndexName, found) - } } diff --git a/internal/modules/stats/startup_test.go b/internal/modules/stats/startup_test.go deleted file mode 100644 index b8c295b..0000000 --- a/internal/modules/stats/startup_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package stats - -import ( - "context" - "errors" - "testing" - - "github.com/tiennm99/miti99bot/internal/storage" - "github.com/tiennm99/miti99bot/internal/systemstate" -) - -func TestInitStore_RenamesMiscCommandStatsOnce(t *testing.T) { - ctx := context.Background() - provider := storage.NewMemoryProvider() - statsColl := provider.Collection("stats") - systemColl := provider.Collection(systemstate.CollectionName) - docs := storage.Typed[usageEntry](statsColl) - - seed := map[string]usageEntry{ - usageKey("mstats", 0): {Cmd: "mstats", N: 2}, - usageKey("mstats", 7): {Cmd: "mstats", UserID: 7, Username: "alice", N: 3}, - usageKey("ping_stats", 7): {Cmd: "ping_stats", UserID: 7, Username: "alice", N: 5}, - usageKey("fortytwo", 0): {Cmd: "fortytwo", N: 1}, - usageKey("the_answer", 0): {Cmd: "the_answer", N: 4}, - usageKey("fortytwo", 100): {Cmd: "fortytwo", UserID: 100, Username: "owner", N: 8}, - usageKey("the_answer", 100): {Cmd: "the_answer", UserID: 100, Username: "owner", N: 9}, - } - for key, entry := range seed { - if err := docs.Put(ctx, key, entry); err != nil { - t.Fatalf("seed %s: %v", key, err) - } - } - - if err := InitStore(ctx, statsColl, systemColl); err != nil { - t.Fatalf("InitStore first run: %v", err) - } - if err := InitStore(ctx, statsColl, systemColl); err != nil { - t.Fatalf("InitStore second run: %v", err) - } - - want := map[string]int64{ - usageKey("ping_stats", 0): 2, - usageKey("ping_stats", 7): 8, - usageKey("the_answer", 0): 5, - usageKey("the_answer", 100): 17, - } - for key, wantN := range want { - got, _, err := docs.Get(ctx, key) - if err != nil { - t.Fatalf("get %s: %v", key, err) - } - if got.N != wantN { - t.Fatalf("%s N = %d, want %d", key, got.N, wantN) - } - } - - for _, key := range []string{ - usageKey("mstats", 0), - usageKey("mstats", 7), - usageKey("fortytwo", 0), - usageKey("fortytwo", 100), - } { - if _, _, err := docs.Get(ctx, key); !errors.Is(err, storage.ErrNotFound) { - t.Fatalf("old stats key %s still exists: %v", key, err) - } - } - - rec, ok, err := systemstate.New(systemColl).Get(ctx, "migration:"+miscCommandRenameMigrationName) - if err != nil { - t.Fatalf("migration marker get: %v", err) - } - if !ok || rec.Status != "complete" || rec.Count != 14 { - t.Fatalf("migration marker = %+v, ok=%v; want complete count 14", rec, ok) - } -}