mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-09 14:22:15 +00:00
refactor: remove completed migration logic
This commit is contained in:
+1
-2
@@ -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 {
|
||||
|
||||
@@ -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}},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
//
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user