feat: sync Bot API gateway support
This commit is contained in:
parent
9a501f900a
commit
4c0cc2b7a7
44 changed files with 4609 additions and 49 deletions
95
internal/app/channels/bot_member_ids_cache.go
Normal file
95
internal/app/channels/bot_member_ids_cache.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"hash/fnv"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
activeBotMemberIDsMaxEntries = 8192
|
||||
)
|
||||
|
||||
type activeBotMemberIDsCacheKey struct {
|
||||
viewerUserID int64
|
||||
channelID int64
|
||||
limit int
|
||||
}
|
||||
|
||||
type activeBotMemberIDsCache struct {
|
||||
cache *readmodelcache.Cache[activeBotMemberIDsCacheKey, []int64]
|
||||
}
|
||||
|
||||
func newActiveBotMemberIDsCache() *activeBotMemberIDsCache {
|
||||
return &activeBotMemberIDsCache{
|
||||
cache: readmodelcache.New[activeBotMemberIDsCacheKey, []int64](readmodelcache.Config[activeBotMemberIDsCacheKey, []int64]{
|
||||
MaxEntries: activeBotMemberIDsMaxEntries,
|
||||
Clone: cloneInt64s,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *activeBotMemberIDsCache) getOrLoad(ctx context.Context, key activeBotMemberIDsCacheKey, load func() ([]int64, error)) ([]int64, error) {
|
||||
if c == nil {
|
||||
return load()
|
||||
}
|
||||
return c.cache.GetOrLoad(ctx, key, load)
|
||||
}
|
||||
|
||||
func (c *activeBotMemberIDsCache) getOrLoadVersioned(ctx context.Context, key activeBotMemberIDsCacheKey, hash int64, load func() ([]int64, error)) ([]int64, error) {
|
||||
if c == nil {
|
||||
return load()
|
||||
}
|
||||
return c.cache.GetOrLoadVersioned(ctx, key, hash, load)
|
||||
}
|
||||
|
||||
func (c *activeBotMemberIDsCache) invalidateChannel(channelID int64) {
|
||||
if c == nil || channelID == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.InvalidateWhere(func(key activeBotMemberIDsCacheKey) bool {
|
||||
return key.channelID == channelID
|
||||
})
|
||||
}
|
||||
|
||||
func (c *activeBotMemberIDsCache) flush() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.cache.Flush()
|
||||
}
|
||||
|
||||
func (s *Service) channelBotMemberIDsHash(ctx context.Context, viewerUserID, channelID int64, key activeBotMemberIDsCacheKey) (int64, error) {
|
||||
keys := []store.ReadModelKey{
|
||||
{Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID},
|
||||
{Model: readmodel.ModelChannelParticipants, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID},
|
||||
{Model: readmodel.ModelChannelMember, OwnerUserID: viewerUserID, PeerType: domain.PeerTypeChannel, PeerID: channelID},
|
||||
}
|
||||
rows, err := s.versions.ReadModelHashes(ctx, keys)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
base := rows[keys[0]]
|
||||
participants := rows[keys[1]]
|
||||
if base == 0 || participants == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return readmodel.MixHashes(base, participants, rows[keys[2]], botMemberIDsKeyHash(key)), nil
|
||||
}
|
||||
|
||||
func botMemberIDsKeyHash(key activeBotMemberIDsCacheKey) int64 {
|
||||
h := fnv.New64a()
|
||||
var buf [8]byte
|
||||
binary.LittleEndian.PutUint64(buf[:], uint64(key.limit))
|
||||
_, _ = h.Write(buf[:])
|
||||
sum := int64(h.Sum64() & 0x7fffffffffffffff)
|
||||
if sum == 0 {
|
||||
return 1
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
|
@ -106,20 +106,72 @@ func (s *Service) skippedBotDeliveryUserIDs(ctx context.Context, req domain.Send
|
|||
if s.bots == nil || req.ChannelID == 0 || req.UserID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if lister, ok := s.channels.(activeChannelBotMemberIDLister); ok {
|
||||
memberIDs, err := lister.ListActiveChannelBotMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxSynchronousChannelDialogFanout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.skippedBotDeliveryUserIDsForIDs(ctx, req, memberIDs)
|
||||
}
|
||||
memberIDs, err := s.channels.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxSynchronousChannelDialogFanout)
|
||||
memberIDs, err := s.loadActiveBotMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxSynchronousChannelDialogFanout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.skippedBotDeliveryUserIDsForIDs(ctx, req, memberIDs)
|
||||
}
|
||||
|
||||
// ActiveBotMemberIDs returns active bot members for non-privacy-critical producers
|
||||
// such as Bot API update queue fanout. Privacy delivery decisions use
|
||||
// loadActiveBotMemberIDs directly to avoid stale-cache leaks.
|
||||
func (s *Service) ActiveBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) {
|
||||
if s == nil || s.channels == nil || viewerUserID == 0 || channelID == 0 {
|
||||
return nil, domain.ErrChannelInvalid
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout {
|
||||
limit = domain.MaxSynchronousChannelDialogFanout
|
||||
}
|
||||
key := activeBotMemberIDsCacheKey{viewerUserID: viewerUserID, channelID: channelID, limit: limit}
|
||||
if s.versions != nil {
|
||||
hash, err := s.channelBotMemberIDsHash(ctx, viewerUserID, channelID, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hash != 0 {
|
||||
return s.botMemberIDsCache.getOrLoadVersioned(ctx, key, hash, func() ([]int64, error) {
|
||||
return s.loadActiveBotMemberIDs(ctx, viewerUserID, channelID, limit)
|
||||
})
|
||||
}
|
||||
return s.loadActiveBotMemberIDs(ctx, viewerUserID, channelID, limit)
|
||||
}
|
||||
return s.botMemberIDsCache.getOrLoad(ctx, key, func() ([]int64, error) {
|
||||
return s.loadActiveBotMemberIDs(ctx, viewerUserID, channelID, limit)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) loadActiveBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) {
|
||||
if s == nil || s.channels == nil || viewerUserID == 0 || channelID == 0 {
|
||||
return nil, domain.ErrChannelInvalid
|
||||
}
|
||||
if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout {
|
||||
limit = domain.MaxSynchronousChannelDialogFanout
|
||||
}
|
||||
if lister, ok := s.channels.(activeChannelBotMemberIDLister); ok {
|
||||
return lister.ListActiveChannelBotMemberIDs(ctx, viewerUserID, channelID, limit)
|
||||
}
|
||||
if s.bots == nil {
|
||||
return nil, nil
|
||||
}
|
||||
memberIDs, err := s.channels.ListActiveChannelMemberIDs(ctx, viewerUserID, channelID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles, err := s.botProfiles(ctx, memberIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]int64, 0, len(profiles))
|
||||
for _, id := range uniqueNonZero(memberIDs) {
|
||||
if _, found := profiles[id]; found {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) skippedBotDeliveryUserIDsForIDs(ctx context.Context, req domain.SendChannelMessageRequest, memberIDs []int64) ([]int64, error) {
|
||||
profiles, err := s.botProfiles(ctx, memberIDs)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -17,11 +17,12 @@ type Service struct {
|
|||
versions store.ReadModelVersionStore
|
||||
sendGate SendPermissionChecker
|
||||
|
||||
viewCache *channelViewReadModelCache
|
||||
resolveCache *channelResolveReadModelCache
|
||||
mediaCountCache *mediaCountReadModelCache
|
||||
participantCache *participantsReadModelCache
|
||||
activeIDsCache *activeChannelIDsReadModelCache
|
||||
viewCache *channelViewReadModelCache
|
||||
resolveCache *channelResolveReadModelCache
|
||||
mediaCountCache *mediaCountReadModelCache
|
||||
participantCache *participantsReadModelCache
|
||||
activeIDsCache *activeChannelIDsReadModelCache
|
||||
botMemberIDsCache *activeBotMemberIDsCache
|
||||
}
|
||||
|
||||
type Option func(*Service)
|
||||
|
|
@ -33,12 +34,13 @@ type SendPermissionChecker interface {
|
|||
// NewService creates a channel service.
|
||||
func NewService(channels store.ChannelStore, opts ...Option) *Service {
|
||||
s := &Service{
|
||||
channels: channels,
|
||||
viewCache: newChannelViewReadModelCache(defaultChannelViewReadModelTTL),
|
||||
resolveCache: newChannelResolveReadModelCache(defaultChannelResolveReadModelTTL),
|
||||
mediaCountCache: newMediaCountReadModelCache(defaultMediaCountReadModelTTL),
|
||||
participantCache: newParticipantsReadModelCache(defaultParticipantsReadModelTTL),
|
||||
activeIDsCache: newActiveChannelIDsReadModelCache(defaultActiveChannelIDsReadModelTTL),
|
||||
channels: channels,
|
||||
viewCache: newChannelViewReadModelCache(defaultChannelViewReadModelTTL),
|
||||
resolveCache: newChannelResolveReadModelCache(defaultChannelResolveReadModelTTL),
|
||||
mediaCountCache: newMediaCountReadModelCache(defaultMediaCountReadModelTTL),
|
||||
participantCache: newParticipantsReadModelCache(defaultParticipantsReadModelTTL),
|
||||
activeIDsCache: newActiveChannelIDsReadModelCache(defaultActiveChannelIDsReadModelTTL),
|
||||
botMemberIDsCache: newActiveBotMemberIDsCache(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
|
|
@ -238,6 +240,7 @@ func (s *Service) InviteToChannel(ctx context.Context, userID, channelID int64,
|
|||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(0, res.Members)...)
|
||||
s.participantCache.invalidateChannel(channelID)
|
||||
s.invalidateActiveBotMemberIDs(channelID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
|
@ -251,6 +254,7 @@ func (s *Service) JoinChannel(ctx context.Context, userID, channelID int64, date
|
|||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(userID)
|
||||
s.participantCache.invalidateChannel(channelID)
|
||||
s.invalidateActiveBotMemberIDs(channelID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
|
@ -264,6 +268,7 @@ func (s *Service) LeaveChannel(ctx context.Context, userID, channelID int64, dat
|
|||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(userID)
|
||||
s.participantCache.invalidateChannel(channelID)
|
||||
s.invalidateActiveBotMemberIDs(channelID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
|
@ -325,6 +330,7 @@ func (s *Service) EditAdmin(ctx context.Context, userID int64, req domain.EditCh
|
|||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(req.MemberID)
|
||||
s.participantCache.invalidateChannel(req.ChannelID)
|
||||
s.invalidateActiveBotMemberIDs(req.ChannelID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
|
@ -344,6 +350,7 @@ func (s *Service) TransferOwnership(ctx context.Context, userID int64, req domai
|
|||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(req.UserID, req.NewOwnerID)
|
||||
s.participantCache.invalidateChannel(req.ChannelID)
|
||||
s.invalidateActiveBotMemberIDs(req.ChannelID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
|
@ -363,6 +370,7 @@ func (s *Service) EditMemberRank(ctx context.Context, userID int64, req domain.E
|
|||
res, err := s.channels.EditChannelMemberRank(ctx, req)
|
||||
if err == nil {
|
||||
s.participantCache.invalidateChannel(req.ChannelID)
|
||||
s.invalidateActiveBotMemberIDs(req.ChannelID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
|
@ -382,6 +390,7 @@ func (s *Service) EditBanned(ctx context.Context, userID int64, req domain.EditC
|
|||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(req.Participant.ID)
|
||||
s.participantCache.invalidateChannel(req.ChannelID)
|
||||
s.invalidateActiveBotMemberIDs(req.ChannelID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
|
@ -414,6 +423,7 @@ func (s *Service) DeleteChannel(ctx context.Context, userID int64, req domain.De
|
|||
res, err := s.channels.DeleteChannel(ctx, req)
|
||||
if err == nil {
|
||||
s.invalidateActiveChannelIDs(uniqueUserIDs(append([]int64{userID}, res.Recipients...)...)...)
|
||||
s.invalidateActiveBotMemberIDs(req.ChannelID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
|
@ -2124,6 +2134,24 @@ func (s *Service) invalidateActiveChannelIDs(userIDs ...int64) {
|
|||
s.activeIDsCache.invalidateUsers(userIDs...)
|
||||
}
|
||||
|
||||
func (s *Service) invalidateActiveBotMemberIDs(channelID int64) {
|
||||
if s == nil || s.botMemberIDsCache == nil {
|
||||
return
|
||||
}
|
||||
s.botMemberIDsCache.invalidateChannel(channelID)
|
||||
}
|
||||
|
||||
func (s *Service) InvalidateActiveBotMemberIDsReadModel(channelID int64) {
|
||||
s.invalidateActiveBotMemberIDs(channelID)
|
||||
}
|
||||
|
||||
func (s *Service) FlushActiveBotMemberIDsReadModel() {
|
||||
if s == nil || s.botMemberIDsCache == nil {
|
||||
return
|
||||
}
|
||||
s.botMemberIDsCache.flush()
|
||||
}
|
||||
|
||||
func normalizeChannelUsername(username string) string {
|
||||
username = strings.TrimSpace(username)
|
||||
username = strings.TrimPrefix(username, "@")
|
||||
|
|
|
|||
|
|
@ -57,15 +57,16 @@ func (p testBotProfiles) BotInfo(_ context.Context, botUserID int64) (domain.Bot
|
|||
|
||||
type countingChannelStore struct {
|
||||
*memory.ChannelStore
|
||||
mu sync.Mutex
|
||||
getChannelCalls int
|
||||
resolveChannelCalls int
|
||||
countMediaCalls int
|
||||
getParticipantCalls int
|
||||
listActiveIDsCalls int
|
||||
resolveStarted chan struct{}
|
||||
resolveRelease <-chan struct{}
|
||||
resolveStartOnce sync.Once
|
||||
mu sync.Mutex
|
||||
getChannelCalls int
|
||||
resolveChannelCalls int
|
||||
countMediaCalls int
|
||||
getParticipantCalls int
|
||||
listActiveIDsCalls int
|
||||
listActiveMemberIDsCalls int
|
||||
resolveStarted chan struct{}
|
||||
resolveRelease <-chan struct{}
|
||||
resolveStartOnce sync.Once
|
||||
}
|
||||
|
||||
func (s *countingChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) {
|
||||
|
|
@ -102,6 +103,11 @@ func (s *countingChannelStore) ListActiveChannelIDsForUser(ctx context.Context,
|
|||
return s.ChannelStore.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit)
|
||||
}
|
||||
|
||||
func (s *countingChannelStore) ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) {
|
||||
s.listActiveMemberIDsCalls++
|
||||
return s.ChannelStore.ListActiveChannelMemberIDs(ctx, viewerUserID, channelID, limit)
|
||||
}
|
||||
|
||||
type fakeReadModelVersions struct {
|
||||
hashes map[store.ReadModelKey]int64
|
||||
}
|
||||
|
|
@ -343,6 +349,102 @@ func TestActiveChannelIDsForUserCachesEmptyMissingReadModelHash(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestActiveBotMemberIDsCachesAndInvalidatesOnMembershipWrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := &countingChannelStore{ChannelStore: memory.NewChannelStore()}
|
||||
bots := testBotProfiles{
|
||||
1003: {BotUserID: 1003},
|
||||
1004: {BotUserID: 1004},
|
||||
}
|
||||
service := NewService(base, WithBotProfileResolver(bots))
|
||||
created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Bot Cache",
|
||||
MemberUserIDs: []int64{1002, 1003},
|
||||
Date: 1700004115,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat: %v", err)
|
||||
}
|
||||
channelID := created.Channel.ID
|
||||
|
||||
first, err := service.ActiveBotMemberIDs(ctx, 1001, channelID, domain.MaxSynchronousChannelDialogFanout)
|
||||
if err != nil {
|
||||
t.Fatalf("ActiveBotMemberIDs first: %v", err)
|
||||
}
|
||||
if want := []int64{1003}; !slices.Equal(first, want) {
|
||||
t.Fatalf("ActiveBotMemberIDs first = %v, want %v", first, want)
|
||||
}
|
||||
first[0] = 9999
|
||||
second, err := service.ActiveBotMemberIDs(ctx, 1001, channelID, domain.MaxSynchronousChannelDialogFanout)
|
||||
if err != nil {
|
||||
t.Fatalf("ActiveBotMemberIDs second: %v", err)
|
||||
}
|
||||
if want := []int64{1003}; !slices.Equal(second, want) {
|
||||
t.Fatalf("ActiveBotMemberIDs cached = %v, want %v", second, want)
|
||||
}
|
||||
if base.listActiveMemberIDsCalls != 1 {
|
||||
t.Fatalf("ListActiveChannelMemberIDs calls = %d, want 1 after cache hit", base.listActiveMemberIDsCalls)
|
||||
}
|
||||
|
||||
if _, err := service.InviteToChannel(ctx, 1001, channelID, []int64{1004}, 1700004116); err != nil {
|
||||
t.Fatalf("InviteToChannel bot: %v", err)
|
||||
}
|
||||
third, err := service.ActiveBotMemberIDs(ctx, 1001, channelID, domain.MaxSynchronousChannelDialogFanout)
|
||||
if err != nil {
|
||||
t.Fatalf("ActiveBotMemberIDs after invite: %v", err)
|
||||
}
|
||||
if want := []int64{1003, 1004}; !slices.Equal(third, want) {
|
||||
t.Fatalf("ActiveBotMemberIDs after invite = %v, want %v", third, want)
|
||||
}
|
||||
if base.listActiveMemberIDsCalls != 2 {
|
||||
t.Fatalf("ListActiveChannelMemberIDs calls = %d, want reload after invalidation", base.listActiveMemberIDsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveBotMemberIDsReloadsOnReadModelHashChange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
base := &countingChannelStore{ChannelStore: memory.NewChannelStore()}
|
||||
bots := testBotProfiles{1003: {BotUserID: 1003}}
|
||||
creator := NewService(base, WithBotProfileResolver(bots))
|
||||
created, err := creator.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{
|
||||
Title: "Bot Hash",
|
||||
MemberUserIDs: []int64{1002, 1003},
|
||||
Date: 1700004117,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMegagroupFromCreateChat: %v", err)
|
||||
}
|
||||
channelID := created.Channel.ID
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
|
||||
baseKey := store.ReadModelKey{Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}
|
||||
participantsKey := store.ReadModelKey{Model: readmodel.ModelChannelParticipants, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}
|
||||
memberKey := store.ReadModelKey{Model: readmodel.ModelChannelMember, OwnerUserID: 1001, PeerType: peer.Type, PeerID: peer.ID}
|
||||
versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
baseKey: 401,
|
||||
participantsKey: 402,
|
||||
memberKey: 403,
|
||||
}}
|
||||
service := NewService(base, WithBotProfileResolver(bots), WithReadModelVersions(versions))
|
||||
|
||||
if _, err := service.ActiveBotMemberIDs(ctx, 1001, channelID, domain.MaxSynchronousChannelDialogFanout); err != nil {
|
||||
t.Fatalf("ActiveBotMemberIDs first: %v", err)
|
||||
}
|
||||
if _, err := service.ActiveBotMemberIDs(ctx, 1001, channelID, domain.MaxSynchronousChannelDialogFanout); err != nil {
|
||||
t.Fatalf("ActiveBotMemberIDs cached: %v", err)
|
||||
}
|
||||
if base.listActiveMemberIDsCalls != 1 {
|
||||
t.Fatalf("ListActiveChannelMemberIDs calls = %d, want 1 before hash change", base.listActiveMemberIDsCalls)
|
||||
}
|
||||
|
||||
versions.hashes[participantsKey] = 404
|
||||
if _, err := service.ActiveBotMemberIDs(ctx, 1001, channelID, domain.MaxSynchronousChannelDialogFanout); err != nil {
|
||||
t.Fatalf("ActiveBotMemberIDs after hash change: %v", err)
|
||||
}
|
||||
if base.listActiveMemberIDsCalls != 2 {
|
||||
t.Fatalf("ListActiveChannelMemberIDs calls = %d, want reload after hash change", base.listActiveMemberIDsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveChannelIDsCacheInvalidatesOnMembershipWrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ type TempAuthKeyRetentionStore interface {
|
|||
DeleteExpired(ctx context.Context, expiredBefore int64, limit int) (int, error)
|
||||
}
|
||||
|
||||
// BotAPIUpdateRetentionStore 回收 Bot API getUpdates 投递队列的死行(性能审计 H1):
|
||||
// 已确认且超过宽限期的行 + 按消息 date 超过保留期的行(官方 Bot API updates 最多保留 24h)。
|
||||
type BotAPIUpdateRetentionStore interface {
|
||||
DeleteDeliveredOrExpired(ctx context.Context, confirmedGrace, maxAge time.Duration, limit int) (int, error)
|
||||
}
|
||||
|
||||
// botAPIConfirmedGrace 是已确认 Bot API update 行的删除宽限:确认水位之下的行不会再被
|
||||
// getUpdates 读取(fromID 恒 > confirmed),宽限仅防御 offset 回拨调试;回收目标是清堆积。
|
||||
const botAPIConfirmedGrace = 15 * time.Minute
|
||||
|
||||
// tempAuthKeyExpiryGrace 是 temp key 过期后的回收宽限:ResolveAuthKey 对
|
||||
// 「已过期但 perm 已授权」的绑定是容忍的,立即删除会突然断掉这批宽限中的
|
||||
// 连接;回收目标是清堆积,晚一天无妨。
|
||||
|
|
@ -31,12 +41,14 @@ const tempAuthKeyExpiryGrace = 24 * time.Hour
|
|||
// 丢消息。详见 docs/performance-audit.md 与 docs/compatibility-matrix.md。user_update_events
|
||||
// 长期膨胀作为已知 todo。
|
||||
type RetentionWorker struct {
|
||||
outbox DispatchOutboxRetentionStore
|
||||
tempKeys TempAuthKeyRetentionStore // 可为 nil(不回收 temp key 绑定)
|
||||
logger *zap.Logger
|
||||
retention time.Duration
|
||||
interval time.Duration
|
||||
batch int
|
||||
outbox DispatchOutboxRetentionStore
|
||||
tempKeys TempAuthKeyRetentionStore // 可为 nil(不回收 temp key 绑定)
|
||||
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil(不回收 Bot API 队列)
|
||||
logger *zap.Logger
|
||||
retention time.Duration
|
||||
botAPIRetention time.Duration
|
||||
interval time.Duration
|
||||
batch int
|
||||
}
|
||||
|
||||
func NewRetentionWorker(outbox DispatchOutboxRetentionStore, tempKeys TempAuthKeyRetentionStore, logger *zap.Logger, retention, interval time.Duration, batch int) *RetentionWorker {
|
||||
|
|
@ -62,6 +74,16 @@ func NewRetentionWorker(outbox DispatchOutboxRetentionStore, tempKeys TempAuthKe
|
|||
}
|
||||
}
|
||||
|
||||
// WithBotAPIUpdateRetention 启用 bot_api_updates 队列回收;retention <=0 时用官方语义默认 24h。
|
||||
func (w *RetentionWorker) WithBotAPIUpdateRetention(store BotAPIUpdateRetentionStore, retention time.Duration) *RetentionWorker {
|
||||
if retention <= 0 {
|
||||
retention = 24 * time.Hour
|
||||
}
|
||||
w.botAPIUpdates = store
|
||||
w.botAPIRetention = retention
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *RetentionWorker) Run(ctx context.Context) {
|
||||
w.runOnce(ctx)
|
||||
ticker := time.NewTicker(w.interval)
|
||||
|
|
@ -92,4 +114,12 @@ func (w *RetentionWorker) runOnce(ctx context.Context) {
|
|||
w.logger.Info("回收过期 temp auth key 绑定完成", zap.Int("deleted", tempDeleted))
|
||||
}
|
||||
}
|
||||
if w.botAPIUpdates != nil {
|
||||
botAPIDeleted, err := w.botAPIUpdates.DeleteDeliveredOrExpired(ctx, botAPIConfirmedGrace, w.botAPIRetention, w.batch)
|
||||
if err != nil {
|
||||
w.logger.Warn("回收 bot_api_updates 队列失败", zap.Error(err))
|
||||
} else if botAPIDeleted > 0 {
|
||||
w.logger.Info("回收 bot_api_updates 队列完成", zap.Int("deleted", botAPIDeleted))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,3 +57,45 @@ func TestRetentionWorkerSkipsNilTempKeyStore(t *testing.T) {
|
|||
t.Fatalf("outbox calls = %d, want 1", outbox.calls)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeBotAPIRetention struct {
|
||||
calls int
|
||||
confirmedGrace time.Duration
|
||||
maxAge time.Duration
|
||||
limit int
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIRetention) DeleteDeliveredOrExpired(_ context.Context, confirmedGrace, maxAge time.Duration, limit int) (int, error) {
|
||||
f.calls++
|
||||
f.confirmedGrace = confirmedGrace
|
||||
f.maxAge = maxAge
|
||||
f.limit = limit
|
||||
return 5, nil
|
||||
}
|
||||
|
||||
func TestRetentionWorkerReclaimsBotAPIUpdates(t *testing.T) {
|
||||
outbox := &fakeOutboxRetention{}
|
||||
botAPI := &fakeBotAPIRetention{}
|
||||
w := NewRetentionWorker(outbox, nil, zap.NewNop(), time.Hour, time.Hour, 100).
|
||||
WithBotAPIUpdateRetention(botAPI, 24*time.Hour)
|
||||
|
||||
w.runOnce(context.Background())
|
||||
|
||||
if botAPI.calls != 1 {
|
||||
t.Fatalf("bot api retention calls = %d, want 1", botAPI.calls)
|
||||
}
|
||||
if botAPI.confirmedGrace != botAPIConfirmedGrace || botAPI.maxAge != 24*time.Hour || botAPI.limit != 100 {
|
||||
t.Fatalf("bot api retention args = (%v, %v, %d), want (%v, 24h, 100)",
|
||||
botAPI.confirmedGrace, botAPI.maxAge, botAPI.limit, botAPIConfirmedGrace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetentionWorkerBotAPIRetentionDefaultsTo24h(t *testing.T) {
|
||||
botAPI := &fakeBotAPIRetention{}
|
||||
w := NewRetentionWorker(&fakeOutboxRetention{}, nil, zap.NewNop(), time.Hour, time.Hour, 100).
|
||||
WithBotAPIUpdateRetention(botAPI, 0)
|
||||
w.runOnce(context.Background())
|
||||
if botAPI.maxAge != 24*time.Hour {
|
||||
t.Fatalf("default bot api retention = %v, want 24h", botAPI.maxAge)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,20 @@ func (s *Service) CurrentState(ctx context.Context, userID int64) (domain.Update
|
|||
return s.currentState(ctx, userID)
|
||||
}
|
||||
|
||||
// ConfirmedState returns the device-local confirmed update state, if any,
|
||||
// without bootstrapping it to the account-current pts.
|
||||
func (s *Service) ConfirmedState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, bool, error) {
|
||||
if s == nil || s.states == nil {
|
||||
return domain.UpdateState{}, false, nil
|
||||
}
|
||||
st, found, err := s.states.Get(ctx, authKeyID, userID)
|
||||
if err != nil {
|
||||
return domain.UpdateState{}, false, err
|
||||
}
|
||||
st.Seq = 0
|
||||
return st, found, nil
|
||||
}
|
||||
|
||||
// AcknowledgeCurrentState 返回账号当前最大连续状态,并把该设备的确认水位推进到此。
|
||||
//
|
||||
// 供 updates.getState 使用:协议语义是客户端宣告「从现在开始同步」,启动期的
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue