mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 06:18:06 +00:00
Telegram expires an inline query and then rejects the answer with "query is too old and response timeout expired or query ID is invalid". The picker invited that: it listed the names and then read the store once per name — up to 50 round trips per keystroke — and it was the only handler in the module with no deadline of its own. Updates are dispatched one at a time, so a single slow answer also held up the queries queued behind it, each ageing while it waited, and one slow read expired a whole burst of typing. Add DocStore.Scan, which reads a key prefix with its values in one round trip, ordered by key. The picker and /aliases both use it, so neither grows a round trip per saved alias. Bound the inline handler at 3s: an answer later than that is rejected anyway, and giving up frees the worker for the fresher query behind it. When Telegram does reject an answer, the error now carries how long it took, which separates a slow handler from a query that was already stale on arrival. The 50-result cap now counts results the picker can show, so a video-note alias — which has no cached inline type — no longer consumes a slot.
219 lines
7.2 KiB
Go
219 lines
7.2 KiB
Go
package storage
|
|
|
|
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"
|
|
)
|
|
|
|
// Reserved document-root field names owned by the store. The caller's payload
|
|
// fields are hoisted to the root alongside these. `_id` holds the key.
|
|
const (
|
|
mongoIDField = "_id"
|
|
mongoVersionField = "version"
|
|
mongoUpdatedAtField = "updatedAt"
|
|
)
|
|
|
|
// mongoPutAttempts bounds the get→put-versioned retry loop a plain Put uses to
|
|
// overwrite unconditionally while still bumping the version.
|
|
const mongoPutAttempts = 5
|
|
|
|
// mongoCollection is a Collection backed by a single MongoDB collection (one per
|
|
// module — collection-per-module IS the isolation, so no key prefix is needed).
|
|
type mongoCollection struct {
|
|
coll *mongo.Collection
|
|
module string
|
|
}
|
|
|
|
func (mongoCollection) isCollection() {}
|
|
|
|
// storedDoc is the on-disk document shape. The payload's fields are hoisted to
|
|
// the document root via bson inline, so the value renders as an expandable,
|
|
// queryable native document in Compass — never wrapped in a `value` envelope.
|
|
// Reserved metadata (version, updatedAt) sit alongside the payload fields.
|
|
type storedDoc[T any] struct {
|
|
ID string `bson:"_id"`
|
|
Version int64 `bson:"version"`
|
|
UpdatedAt time.Time `bson:"updatedAt"`
|
|
Payload T `bson:",inline"`
|
|
}
|
|
|
|
// mongoDocStore is a typed DocStore over one collection.
|
|
type mongoDocStore[T any] struct {
|
|
coll *mongo.Collection
|
|
module string
|
|
}
|
|
|
|
func (s *mongoDocStore[T]) Get(ctx context.Context, id string) (T, int64, error) {
|
|
var out storedDoc[T]
|
|
if err := validateKey(id); err != nil {
|
|
return out.Payload, 0, err
|
|
}
|
|
err := s.coll.FindOne(ctx, bson.M{mongoIDField: id}).Decode(&out)
|
|
if err != nil {
|
|
if errors.Is(err, mongo.ErrNoDocuments) {
|
|
return out.Payload, 0, ErrNotFound
|
|
}
|
|
return out.Payload, 0, fmt.Errorf("mongo get %s/%s: %w", s.module, id, err)
|
|
}
|
|
return out.Payload, out.Version, nil
|
|
}
|
|
|
|
// replacement builds the whole document to store. Using a full replacement
|
|
// (never $set) guarantees stale fields from a previous value cannot survive an
|
|
// overwrite.
|
|
func (s *mongoDocStore[T]) replacement(id string, version int64, val T) storedDoc[T] {
|
|
return storedDoc[T]{ID: id, Version: version, UpdatedAt: time.Now().UTC(), Payload: val}
|
|
}
|
|
|
|
// PutVersioned writes val only if the stored version equals expectedVersion,
|
|
// then bumps it.
|
|
//
|
|
// - expectedVersion == 0 → upsert matching {_id} with version absent or 0; a
|
|
// key that already has version ≥ 1 fails the filter, the upsert attempts an
|
|
// insert on the same _id, and the unique-_id index returns duplicate-key →
|
|
// ErrConflict (linearizable single-winner for the first write).
|
|
// - expectedVersion > 0 → ReplaceOne on {_id, version:expected}; MatchedCount
|
|
// of 0 means the version moved → ErrConflict.
|
|
func (s *mongoDocStore[T]) PutVersioned(ctx context.Context, id string, expectedVersion int64, val T) error {
|
|
if err := validateKey(id); err != nil {
|
|
return err
|
|
}
|
|
if expectedVersion == 0 {
|
|
_, err := s.coll.ReplaceOne(ctx,
|
|
bson.M{
|
|
mongoIDField: id,
|
|
"$or": bson.A{
|
|
bson.M{mongoVersionField: bson.M{"$exists": false}},
|
|
bson.M{mongoVersionField: int64(0)},
|
|
},
|
|
},
|
|
s.replacement(id, 1, val),
|
|
options.Replace().SetUpsert(true),
|
|
)
|
|
if err != nil {
|
|
if mongo.IsDuplicateKeyError(err) {
|
|
return ErrConflict
|
|
}
|
|
return fmt.Errorf("mongo put-versioned %s/%s: %w", s.module, id, err)
|
|
}
|
|
return nil
|
|
}
|
|
res, err := s.coll.ReplaceOne(ctx,
|
|
bson.M{mongoIDField: id, mongoVersionField: expectedVersion},
|
|
s.replacement(id, expectedVersion+1, val),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("mongo put-versioned %s/%s: %w", s.module, id, err)
|
|
}
|
|
if res.MatchedCount == 0 {
|
|
return ErrConflict
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Put overwrites unconditionally and bumps the version, implemented as a bounded
|
|
// get→put-versioned loop so it shares the whole-document replacement (stale-field
|
|
// removal) and version semantics with PutVersioned.
|
|
func (s *mongoDocStore[T]) Put(ctx context.Context, id string, val T) error {
|
|
if err := validateKey(id); err != nil {
|
|
return err
|
|
}
|
|
for attempt := 0; attempt < mongoPutAttempts; attempt++ {
|
|
_, version, err := s.Get(ctx, id)
|
|
switch {
|
|
case errors.Is(err, ErrNotFound):
|
|
version = 0
|
|
case err != nil:
|
|
return err
|
|
}
|
|
err = s.PutVersioned(ctx, id, version, val)
|
|
if errors.Is(err, ErrConflict) {
|
|
continue // a concurrent writer moved the version; re-read and retry.
|
|
}
|
|
return err
|
|
}
|
|
return fmt.Errorf("mongo put %s/%s: %w after %d attempts", s.module, id, ErrConflict, mongoPutAttempts)
|
|
}
|
|
|
|
func (s *mongoDocStore[T]) Delete(ctx context.Context, id string) error {
|
|
if err := validateKey(id); err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.coll.DeleteOne(ctx, bson.M{mongoIDField: id}); err != nil {
|
|
return fmt.Errorf("mongo delete %s/%s: %w", s.module, id, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// List returns all document IDs whose key starts with prefix, via a half-open
|
|
// range scan on _id (reusing prefixSuccessor) so it uses the _id index and
|
|
// avoids regex injection. Empty prefix returns the whole collection.
|
|
func (s *mongoDocStore[T]) List(ctx context.Context, prefix string) ([]string, error) {
|
|
if err := validatePrefix(prefix); err != nil {
|
|
return nil, err
|
|
}
|
|
filter := bson.M{}
|
|
if prefix != "" {
|
|
filter[mongoIDField] = bson.M{"$gte": prefix, "$lt": prefixSuccessor(prefix)}
|
|
}
|
|
cur, err := s.coll.Find(ctx, filter, options.Find().SetProjection(bson.M{mongoIDField: 1}))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mongo list %s prefix=%q: %w", s.module, prefix, err)
|
|
}
|
|
defer func() { _ = cur.Close(ctx) }()
|
|
|
|
var keys []string
|
|
for cur.Next(ctx) {
|
|
var doc struct {
|
|
ID string `bson:"_id"`
|
|
}
|
|
if err := cur.Decode(&doc); err != nil {
|
|
return nil, fmt.Errorf("mongo list %s prefix=%q: decode: %w", s.module, prefix, err)
|
|
}
|
|
keys = append(keys, doc.ID)
|
|
}
|
|
if err := cur.Err(); err != nil {
|
|
return nil, fmt.Errorf("mongo list %s prefix=%q: %w", s.module, prefix, err)
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
// Scan reads every document under prefix in one Find, sorted by _id so the
|
|
// caller inherits key order from the index instead of sorting afterwards.
|
|
//
|
|
// Unlike List it takes no projection: the payload is the point. The prefix uses
|
|
// the same half-open range as List, so the read stays an index scan.
|
|
func (s *mongoDocStore[T]) Scan(ctx context.Context, prefix string) ([]Doc[T], error) {
|
|
if err := validatePrefix(prefix); err != nil {
|
|
return nil, err
|
|
}
|
|
filter := bson.M{}
|
|
if prefix != "" {
|
|
filter[mongoIDField] = bson.M{"$gte": prefix, "$lt": prefixSuccessor(prefix)}
|
|
}
|
|
cur, err := s.coll.Find(ctx, filter, options.Find().SetSort(bson.D{{Key: mongoIDField, Value: 1}}))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mongo scan %s prefix=%q: %w", s.module, prefix, err)
|
|
}
|
|
defer func() { _ = cur.Close(ctx) }()
|
|
|
|
docs := make([]Doc[T], 0)
|
|
for cur.Next(ctx) {
|
|
var out storedDoc[T]
|
|
if err := cur.Decode(&out); err != nil {
|
|
return nil, fmt.Errorf("mongo scan %s prefix=%q: decode: %w", s.module, prefix, err)
|
|
}
|
|
docs = append(docs, Doc[T]{ID: out.ID, Val: out.Payload})
|
|
}
|
|
if err := cur.Err(); err != nil {
|
|
return nil, fmt.Errorf("mongo scan %s prefix=%q: %w", s.module, prefix, err)
|
|
}
|
|
return docs, nil
|
|
}
|