mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-10-05 14:14:50 +00:00
chore(sticker): drop the retired per-user pack records at startup
The module stores nothing: /addsticker takes its pack from STICKER_PACK_NAME and the set owner from OWNER_ID, and the factory ignores the collection it is handed. Everything still in the sticker collection is therefore unreachable by any code path — pack documents keyed by owner ID, "slug:" name reservations and "pending-delete:" confirmations, all orphaned when the per-user commands were removed. InitStore lists and deletes them once per database, guarded by a systemstate marker in the same shape as the stock and stats migrations. It aborts without writing the marker so a partial run retries on the next boot, and deletes are idempotent. A collection that is already empty is the normal case on a fresh deploy and on the memory backend. This permanently removes data. Back up the sticker collection before the first deploy that carries it.
This commit is contained in:
1 parent
be91d2eb41
commit
b903d14fc2
4 files changed
+262
No files matched your search
@@ -132,6 +132,9 @@ func main() {
|
||||
if err := lol.InitStore(rootCtx, provider.Collection(lol.CollectionName)); err != nil {
|
||||
log.Fatal("lol storage init failed", "err", err)
|
||||
}
|
||||
if err := initStickerStore(rootCtx, provider); err != nil {
|
||||
log.Fatal("sticker storage init failed", "err", err)
|
||||
}
|
||||
migrationCtx, cancelMigration := context.WithTimeout(rootCtx, stockMigrationTimeout)
|
||||
if err := initStockStore(migrationCtx, provider); err != nil {
|
||||
cancelMigration()
|
||||
@@ -246,6 +249,20 @@ func initStatsStoreWith(ctx context.Context, provider storage.Provider, init sta
|
||||
)
|
||||
}
|
||||
|
||||
func initStickerStore(ctx context.Context, provider storage.Provider) error {
|
||||
return initStickerStoreWith(ctx, provider, sticker.InitStore)
|
||||
}
|
||||
|
||||
type stickerStoreInitializer func(context.Context, storage.Collection, storage.Collection) error
|
||||
|
||||
func initStickerStoreWith(ctx context.Context, provider storage.Provider, init stickerStoreInitializer) error {
|
||||
return init(
|
||||
ctx,
|
||||
provider.Collection(sticker.CollectionName),
|
||||
provider.Collection(systemstate.CollectionName),
|
||||
)
|
||||
}
|
||||
|
||||
type stockStoreInitializer func(context.Context, storage.Collection, storage.Collection) error
|
||||
|
||||
func initStockStoreWith(ctx context.Context, provider storage.Provider, init stockStoreInitializer) error {
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
bot contributes to. It is the whole of the `sticker` module — one command, no
|
||||
storage, and no per-user packs.
|
||||
|
||||
**The module stores nothing.** Its factory ignores the collection it is handed:
|
||||
the pack comes from the environment and the set owner from `OWNER_ID`, so there
|
||||
is nothing per-user to key. A one-time startup cleanup
|
||||
(`migration:sticker-drop-legacy-packs-v1`) removes the records the retired
|
||||
per-user pack commands left in the `sticker` collection — pack documents keyed
|
||||
by owner ID, `slug:` name reservations, and `pending-delete:` confirmations.
|
||||
It is marker-guarded, so it scans once per database and never touches anything
|
||||
written afterwards.
|
||||
|
||||
| Command | Parameters | Reply to | What it does |
|
||||
|---|---|---|---|
|
||||
| `/addsticker` | `[emoji...]` | sticker, photo, or image document | Adds it to the shared pack and replies with the link |
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
"github.com/tiennm99/miti99bot/internal/systemstate"
|
||||
)
|
||||
|
||||
const legacyPackCleanupMarkerKey = "migration:sticker-drop-legacy-packs-v1"
|
||||
|
||||
// legacyRecord is a placeholder type, not a schema.
|
||||
//
|
||||
// The retired design wrote three different shapes into this collection — pack
|
||||
// records keyed by owner ID, "slug:" name reservations, and "pending-delete:"
|
||||
// confirmations. Cleanup only lists keys and deletes them, and neither
|
||||
// operation decodes a document, so one empty type serves for all three rather
|
||||
// than resurrecting structs whose only remaining purpose would be deletion.
|
||||
type legacyRecord struct{}
|
||||
|
||||
// InitStore removes the per-user sticker pack records the retired pack
|
||||
// commands left behind.
|
||||
//
|
||||
// The module no longer stores anything: /addsticker writes to one shared,
|
||||
// env-configured set and takes the set owner's user ID from OWNER_ID, so there
|
||||
// is nothing per-user to key. Its factory ignores the collection entirely.
|
||||
// These documents are therefore unreachable by any code path — not stale data
|
||||
// that some handler might still read, but orphans.
|
||||
//
|
||||
// Guarded by a completion marker so the scan runs once per database rather than
|
||||
// on every boot, matching the stock and stats migrations. Safe to run against a
|
||||
// collection that is already empty, and safe on the memory backend where the
|
||||
// collection never had anything in it.
|
||||
func InitStore(ctx context.Context, stickerColl, systemColl storage.Collection) error {
|
||||
system := systemstate.New(systemColl)
|
||||
marker, exists, err := system.Get(ctx, legacyPackCleanupMarkerKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sticker legacy pack cleanup: read marker: %w", err)
|
||||
}
|
||||
if exists && marker.Status == "completed" {
|
||||
return nil
|
||||
}
|
||||
|
||||
docs := storage.Typed[legacyRecord](stickerColl)
|
||||
// Empty prefix: the retired design used three disjoint key spaces (bare
|
||||
// owner IDs, "slug:", "pending-delete:") and all of them are dead, so
|
||||
// listing everything is both correct and cheaper than three scans.
|
||||
keys, err := docs.List(ctx, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("sticker legacy pack cleanup: list records: %w", err)
|
||||
}
|
||||
|
||||
var deleted int64
|
||||
for _, key := range keys {
|
||||
if err := docs.Delete(ctx, key); err != nil {
|
||||
// Abort without writing the marker, so the next boot retries the
|
||||
// rest. Deletes are idempotent, so a partial run is safe to repeat.
|
||||
return fmt.Errorf("sticker legacy pack cleanup: delete %s: %w", key, err)
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
if deleted > 0 {
|
||||
log.Info("sticker legacy pack records removed", "count", deleted)
|
||||
}
|
||||
|
||||
marker = completedLegacyPackCleanup(marker, exists, deleted, time.Now().UnixMilli())
|
||||
if err := system.Put(ctx, legacyPackCleanupMarkerKey, marker); err != nil {
|
||||
return fmt.Errorf("sticker legacy pack cleanup: write marker: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func completedLegacyPackCleanup(marker systemstate.Record, exists bool, deleted, now int64) systemstate.Record {
|
||||
if !exists {
|
||||
marker = systemstate.Record{
|
||||
Kind: "migration",
|
||||
Name: "sticker drop legacy packs v1",
|
||||
}
|
||||
}
|
||||
if marker.CompletedAt == 0 {
|
||||
marker.CompletedAt = now
|
||||
}
|
||||
marker.Status = "completed"
|
||||
marker.Count += deleted
|
||||
marker.UpdatedAt = now
|
||||
return marker
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package sticker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/modules"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
"github.com/tiennm99/miti99bot/internal/systemstate"
|
||||
)
|
||||
|
||||
// legacyShape stands in for the retired records: a pack keyed by owner ID, a
|
||||
// "slug:" reservation, and a "pending-delete:" confirmation. Only the keys
|
||||
// matter to the cleanup, so one loose shape covers all three.
|
||||
type legacyShape struct {
|
||||
Slug string `bson:"slug"`
|
||||
OwnerID int64 `bson:"ownerId"`
|
||||
}
|
||||
|
||||
func seedLegacy(t *testing.T, coll storage.Collection, keys ...string) {
|
||||
t.Helper()
|
||||
docs := storage.Typed[legacyShape](coll)
|
||||
for _, k := range keys {
|
||||
if err := docs.Put(context.Background(), k, legacyShape{Slug: "old", OwnerID: 42}); err != nil {
|
||||
t.Fatalf("seed %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func remainingKeys(t *testing.T, coll storage.Collection) []string {
|
||||
t.Helper()
|
||||
keys, err := storage.Typed[legacyShape](coll).List(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// All three retired key spaces go, in one pass.
|
||||
func TestInitStore_RemovesEveryLegacyKeySpace(t *testing.T) {
|
||||
provider := storage.NewMemoryProvider()
|
||||
stickerColl := provider.Collection(CollectionName)
|
||||
systemColl := provider.Collection(systemstate.CollectionName)
|
||||
|
||||
seedLegacy(t, stickerColl,
|
||||
"123456789", // pack record, keyed by owner ID
|
||||
"987654321", // another owner's pack
|
||||
"slug:mypack", // name reservation
|
||||
"pending-delete:1234567", // /delpack confirmation
|
||||
)
|
||||
|
||||
if err := InitStore(context.Background(), stickerColl, systemColl); err != nil {
|
||||
t.Fatalf("InitStore: %v", err)
|
||||
}
|
||||
|
||||
if got := remainingKeys(t, stickerColl); len(got) != 0 {
|
||||
t.Errorf("collection still holds %v, want it emptied", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The marker records how many were removed, so the count is auditable after
|
||||
// the fact.
|
||||
func TestInitStore_MarksCompletionWithCount(t *testing.T) {
|
||||
provider := storage.NewMemoryProvider()
|
||||
stickerColl := provider.Collection(CollectionName)
|
||||
systemColl := provider.Collection(systemstate.CollectionName)
|
||||
seedLegacy(t, stickerColl, "1", "2", "slug:x")
|
||||
|
||||
if err := InitStore(context.Background(), stickerColl, systemColl); err != nil {
|
||||
t.Fatalf("InitStore: %v", err)
|
||||
}
|
||||
|
||||
rec, found, err := systemstate.New(systemColl).Get(context.Background(), legacyPackCleanupMarkerKey)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("marker: found=%v err=%v", found, err)
|
||||
}
|
||||
if rec.Status != "completed" {
|
||||
t.Errorf("status = %q, want completed", rec.Status)
|
||||
}
|
||||
if rec.Count != 3 {
|
||||
t.Errorf("count = %d, want 3", rec.Count)
|
||||
}
|
||||
if rec.CompletedAt == 0 || rec.UpdatedAt == 0 {
|
||||
t.Errorf("timestamps unset: %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
// Once marked, the scan must not run again — and specifically must not delete
|
||||
// anything written to this collection later.
|
||||
func TestInitStore_MarkerStopsASecondPass(t *testing.T) {
|
||||
provider := storage.NewMemoryProvider()
|
||||
stickerColl := provider.Collection(CollectionName)
|
||||
systemColl := provider.Collection(systemstate.CollectionName)
|
||||
seedLegacy(t, stickerColl, "1")
|
||||
|
||||
if err := InitStore(context.Background(), stickerColl, systemColl); err != nil {
|
||||
t.Fatalf("first InitStore: %v", err)
|
||||
}
|
||||
|
||||
// Whatever a future version of this module might store.
|
||||
seedLegacy(t, stickerColl, "something-new")
|
||||
|
||||
if err := InitStore(context.Background(), stickerColl, systemColl); err != nil {
|
||||
t.Fatalf("second InitStore: %v", err)
|
||||
}
|
||||
got := remainingKeys(t, stickerColl)
|
||||
if len(got) != 1 || got[0] != "something-new" {
|
||||
t.Errorf("remaining = %v, want only the newly written key", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An already-clean database is the normal case on a fresh deploy, and on the
|
||||
// memory backend where the collection never held anything.
|
||||
func TestInitStore_EmptyCollectionIsFine(t *testing.T) {
|
||||
provider := storage.NewMemoryProvider()
|
||||
stickerColl := provider.Collection(CollectionName)
|
||||
systemColl := provider.Collection(systemstate.CollectionName)
|
||||
|
||||
if err := InitStore(context.Background(), stickerColl, systemColl); err != nil {
|
||||
t.Fatalf("InitStore on an empty collection: %v", err)
|
||||
}
|
||||
|
||||
rec, found, err := systemstate.New(systemColl).Get(context.Background(), legacyPackCleanupMarkerKey)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("marker: found=%v err=%v", found, err)
|
||||
}
|
||||
if rec.Count != 0 {
|
||||
t.Errorf("count = %d, want 0", rec.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// The module itself must keep using none of this: if a future edit gives the
|
||||
// factory a store, the cleanup above would start deleting live data.
|
||||
func TestNew_UsesNoStorage(t *testing.T) {
|
||||
provider := storage.NewMemoryProvider()
|
||||
coll := provider.Collection(CollectionName)
|
||||
seedLegacy(t, coll, "sentinel")
|
||||
|
||||
mod := New(modules.Deps{Store: coll})
|
||||
if len(mod.Commands) != 1 || mod.Commands[0].Name != "addsticker" {
|
||||
t.Fatalf("module commands = %+v, want only /addsticker", mod.Commands)
|
||||
}
|
||||
if got := remainingKeys(t, coll); len(got) != 1 {
|
||||
t.Errorf("factory touched storage; remaining = %v", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user