mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +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 {
|
if err := lol.InitStore(rootCtx, provider.Collection(lol.CollectionName)); err != nil {
|
||||||
log.Fatal("lol storage init failed", "err", err)
|
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)
|
migrationCtx, cancelMigration := context.WithTimeout(rootCtx, stockMigrationTimeout)
|
||||||
if err := initStockStore(migrationCtx, provider); err != nil {
|
if err := initStockStore(migrationCtx, provider); err != nil {
|
||||||
cancelMigration()
|
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
|
type stockStoreInitializer func(context.Context, storage.Collection, storage.Collection) error
|
||||||
|
|
||||||
func initStockStoreWith(ctx context.Context, provider storage.Provider, init stockStoreInitializer) 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
|
bot contributes to. It is the whole of the `sticker` module — one command, no
|
||||||
storage, and no per-user packs.
|
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 |
|
| 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 |
|
| `/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