owpengram-server/internal/rpc/callback_registry.go
A bf965f610c feat: sync bot keyboards and callbacks
Sync telesrv b96f2dd (feat(bot): complete keyboards callbacks and durable delivery).

Skipped private docs and preserved public README files per sync rules; normalized the appearance seed log label for public naming.
2026-07-19 20:38:48 +08:00

245 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package rpc
import (
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"hash/fnv"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// callbackRegistry keeps local waiter channels and mirrors ownership/answers to
// a short-lived shared store. The shared CAS is the source of truth when wired:
// it lets getBotCallbackAnswer and answerCallbackQuery land on different nodes
// without accepting two answers or trusting a process-local owner map.
type callbackRegistry struct {
mu sync.Mutex
pending map[int64]*pendingCallback
shared store.BotCallbackRegistryStore
}
type pendingCallback struct {
ch chan domain.BotCallbackAnswer
done chan struct{} // deregister 时关闭:唤醒超时退出的等待者,消除「答案投递到已离开 select 的 ch」竞态
botUserID int64
userID int64
}
func newCallbackRegistry(shared ...store.BotCallbackRegistryStore) *callbackRegistry {
var sharedStore store.BotCallbackRegistryStore
if len(shared) > 0 {
sharedStore = shared[0]
}
return &callbackRegistry{pending: make(map[int64]*pendingCallback), shared: sharedStore}
}
// register 登记一次挂起的 callback返回全局唯一 query_id 与接收通道。调用方必须
// defer deregister(queryID),无论是否收到答案(超时三件套之一,防 goroutine/表泄漏)。
func (c *callbackRegistry) register(botUserID, userID int64) (int64, *pendingCallback) {
queryID, pending, _ := c.registerContext(context.Background(), time.Now(), botUserID, userID, botCallbackTimeout)
return queryID, pending
}
func (c *callbackRegistry) registerContext(ctx context.Context, now time.Time, botUserID, userID int64, ttl time.Duration) (int64, *pendingCallback, error) {
for attempts := 0; attempts < 32; attempts++ {
p := &pendingCallback{
ch: make(chan domain.BotCallbackAnswer, 1),
done: make(chan struct{}),
botUserID: botUserID,
userID: userID,
}
c.mu.Lock()
queryID := randomNonZeroInt64()
if _, exists := c.pending[queryID]; exists {
c.mu.Unlock()
continue
}
c.pending[queryID] = p
c.mu.Unlock()
if c.shared == nil {
return queryID, p, nil
}
created, err := c.shared.PutBotCallbackPending(ctx, store.BotCallbackPending{
QueryID: queryID, BotUserID: botUserID, UserID: userID, CreatedAt: now,
}, ttl)
if err != nil {
c.removeLocal(queryID)
return 0, nil, err
}
if created {
return queryID, p, nil
}
c.removeLocal(queryID)
}
return 0, nil, fmt.Errorf("allocate bot callback query id")
}
// deregister 移除挂起条目并关闭 done超时/解挂后必调,幂等)。关闭 done 让仍在
// select 的等待者立即醒来,避免 resolve 把答案投递到一个等待者已离开的 chTOCTOU
func (c *callbackRegistry) deregister(queryID int64) {
c.deregisterContext(context.Background(), 0, queryID)
}
func (c *callbackRegistry) deregisterContext(ctx context.Context, botUserID, queryID int64) {
c.mu.Lock()
if p, ok := c.pending[queryID]; ok {
if botUserID == 0 {
botUserID = p.botUserID
}
delete(c.pending, queryID)
close(p.done)
}
c.mu.Unlock()
if c.shared != nil && botUserID > 0 {
_ = c.shared.DeleteBotCallbackPending(ctx, botUserID, queryID)
}
}
func (c *callbackRegistry) removeLocal(queryID int64) {
c.mu.Lock()
if p, ok := c.pending[queryID]; ok {
delete(c.pending, queryID)
close(p.done)
}
c.mu.Unlock()
}
// size 返回当前挂起条目数(测试用:断言超时/解挂后归零、无泄漏)。
func (c *callbackRegistry) size() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.pending)
}
// resolve 把 bot 的答案投递给等待者。鉴权:仅该 query 的属主 bot 可解挂callerBotID
// 必须等于注册时的 botUserIDI6。返回是否成功投递query 未注册/已超时/非属主 → false
func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool {
resolved, _ := c.resolveContext(context.Background(), callerBotID, queryID, ans)
return resolved
}
func (c *callbackRegistry) resolveContext(ctx context.Context, callerBotID, queryID int64, ans domain.BotCallbackAnswer) (bool, error) {
if c.shared != nil {
resolved, err := c.shared.ResolveBotCallback(ctx, callerBotID, queryID, ans)
if err != nil || !resolved {
return resolved, err
}
c.deliver(callerBotID, queryID, ans)
return true, nil
}
return c.deliver(callerBotID, queryID, ans), nil
}
func (c *callbackRegistry) deliver(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool {
c.mu.Lock()
p, ok := c.pending[queryID]
if !ok || p.botUserID != callerBotID {
c.mu.Unlock()
return false
}
delete(c.pending, queryID)
c.mu.Unlock()
// ch 有 1 容量缓冲,非阻塞投递;等待者已超时退出时缓冲被 GC不阻塞。
select {
case p.ch <- ans:
default:
}
return true
}
func (c *callbackRegistry) sharedAnswer(ctx context.Context, botUserID, queryID int64) (domain.BotCallbackAnswer, bool, error) {
if c.shared == nil {
return domain.BotCallbackAnswer{}, false, nil
}
return c.shared.GetBotCallbackAnswer(ctx, botUserID, queryID)
}
func (r *Router) RunBotCallbackAnswerSubscriber(ctx context.Context) {
if r == nil || r.callbacks == nil || r.callbacks.shared == nil {
return
}
for ctx.Err() == nil {
err := r.callbacks.shared.SubscribeBotCallbackAnswers(ctx, func(_ context.Context, push store.BotCallbackAnswerPush) {
r.callbacks.deliver(push.BotUserID, push.QueryID, push.Answer)
})
if ctx.Err() != nil {
return
}
if err != nil && r.log != nil {
r.log.Warn("bot callback answer subscriber disconnected", zap.Error(err))
}
timer := time.NewTimer(time.Second)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
}
// randomNonZeroInt64 取密码学随机非零 int64。register 在持锁下调用,故此处禁止
// 无限重试——熵源异常时退化为单调序列兜底query_id 只需进程内唯一register 的
// 撞键复核会再保证唯一性),绝不卡住整个 registry。
func randomNonZeroInt64() int64 {
var buf [8]byte
for i := 0; i < 8; i++ {
if _, err := rand.Read(buf[:]); err != nil {
break
}
if v := int64(binary.LittleEndian.Uint64(buf[:])); v != 0 {
return v
}
}
if v := callbackFallbackSeq.Add(1); v != 0 {
return v
}
return 1
}
// callbackFallbackSeq 是熵源失败时的单调兜底序列(极罕见路径)。
var callbackFallbackSeq atomic.Int64
// chatInstanceFor 为 (bot,user) 私聊派生稳定的 chat_instance同一对话多次 callback
// 间恒定I8。当前用确定性 hash 派生(持久化记 todo
func chatInstanceFor(botUserID, userID int64) int64 {
h := fnv.New64a()
var buf [16]byte
binary.LittleEndian.PutUint64(buf[0:8], uint64(botUserID))
binary.LittleEndian.PutUint64(buf[8:16], uint64(userID))
_, _ = h.Write(buf[:])
v := int64(h.Sum64())
if v == 0 {
v = 1
}
return v
}
// chatInstanceForPeer extends the stable hash to non-private chats without allowing a
// channel id to collide with a numerically equal private user id.
func chatInstanceForPeer(botUserID int64, peer domain.Peer) int64 {
h := fnv.New64a()
var buf [17]byte
binary.LittleEndian.PutUint64(buf[0:8], uint64(botUserID))
binary.LittleEndian.PutUint64(buf[8:16], uint64(peer.ID))
switch peer.Type {
case domain.PeerTypeChannel:
buf[16] = 2
default:
buf[16] = 1
}
_, _ = h.Write(buf[:])
v := int64(h.Sum64())
if v == 0 {
return 1
}
return v
}