owpengram-server/internal/app/dialogs/read_model_cache.go
2026-09-01 12:06:31 +03:00

571 lines
17 KiB
Go

package dialogs
import (
"context"
"time"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
const (
dialogLightReadModel = readmodel.ModelDialogLight
defaultDialogPeerReadModelTTL = 24 * time.Hour
defaultDialogPeerReadModelMaxEntries = 500000
defaultDialogPeerReadModelMaxBytes int64 = 256 << 20
)
type dialogPeerCacheKey struct {
userID int64
peer domain.Peer
}
// dialogPeerReadModelCache 由统一缓存原语承载(epoch 守卫 / LRU / clone)。它走 per-peer
// 外部构建:Service 按 peer 查缓存、把 miss 合批打一次后端、再 per-peer 写回。版本闸门用
// 值自带的 DialogList.Hash 比对(原语存 hash=0,版本由值携带)。返回的是整批原始 list(非
// per-peer 切片重组),故不用 GetOrLoadBatch——它返回 per-key 值会丢非 peer 归属的全局元素。
type dialogPeerReadModelCache struct {
cache *readmodelcache.Cache[dialogPeerCacheKey, domain.DialogList]
}
func newDialogPeerReadModelCache(ttl time.Duration) *dialogPeerReadModelCache {
return newDialogPeerReadModelCacheWithLimits(defaultDialogPeerReadModelMaxEntries, defaultDialogPeerReadModelMaxBytes, ttl)
}
func newDialogPeerReadModelCacheWithLimits(maxEntries int, maxBytes int64, ttl time.Duration) *dialogPeerReadModelCache {
if ttl <= 0 {
ttl = defaultDialogPeerReadModelTTL
}
return &dialogPeerReadModelCache{
cache: readmodelcache.New[dialogPeerCacheKey, domain.DialogList](readmodelcache.Config[dialogPeerCacheKey, domain.DialogList]{
MaxEntries: maxEntries,
MaxWeight: maxBytes,
Weight: dialogPeerListApproxBytes,
TTL: ttl,
Clone: cloneDialogList,
}),
}
}
func (s *Service) userPeerDialogsReadModel(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
if s == nil {
return domain.DialogList{}, nil
}
unique := uniqueUserPeers(peers)
if len(unique) == 0 {
return domain.DialogList{}, nil
}
return s.cachedPeerDialogsReadModel(ctx, userID, unique, s.dialogHashes, s.loadUserPeerDialogs)
}
func (s *Service) channelPeerDialogsReadModel(ctx context.Context, userID int64, channelIDs []int64) (domain.DialogList, error) {
if s == nil {
return domain.DialogList{}, nil
}
unique := uniqueChannelPeers(channelIDs)
if len(unique) == 0 {
return domain.DialogList{}, nil
}
return s.loadChannelPeerDialogsByPeers(ctx, userID, unique)
}
func (s *Service) cachedPeerDialogsReadModel(
ctx context.Context,
userID int64,
peers []domain.Peer,
hashesFor func(context.Context, int64, []domain.Peer) (map[domain.Peer]int64, error),
load func(context.Context, int64, []domain.Peer) (domain.DialogList, error),
) (domain.DialogList, error) {
if s.privatePeerCache == nil || s.versions == nil {
return load(ctx, userID, peers)
}
hashes, err := hashesFor(ctx, userID, peers)
if err != nil {
return domain.DialogList{}, err
}
loadEpoch := s.privatePeerCache.cacheEpoch()
var out domain.DialogList
misses := make([]domain.Peer, 0, len(peers))
for _, peer := range peers {
hash := hashes[peer]
if hash != 0 {
if cached, ok := s.privatePeerCache.lookup(dialogPeerCacheKey{userID: userID, peer: peer}, hash); ok {
out = mergeDialogLists(out, cached)
continue
}
}
misses = append(misses, peer)
}
if len(misses) == 0 {
out.Count = len(out.Dialogs)
return out, nil
}
list, err := load(ctx, userID, misses)
if err != nil {
return domain.DialogList{}, err
}
for _, peer := range misses {
hash := hashes[peer]
if hash == 0 {
continue
}
peerList := dialogListForPeer(list, peer)
peerList.Hash = hash
s.privatePeerCache.putIfEpoch(dialogPeerCacheKey{userID: userID, peer: peer}, peerList, hash, loadEpoch)
}
if len(out.Dialogs) > 0 || len(out.Messages) > 0 || len(out.ChannelMessages) > 0 || len(out.Users) > 0 || len(out.Channels) > 0 {
return mergeDialogLists(out, list), nil
}
return list, nil
}
func (s *Service) loadUserPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
if s == nil || s.dialogs == nil || len(peers) == 0 {
return domain.DialogList{}, nil
}
list, err := s.dialogs.ListByPeers(ctx, userID, peers)
if err != nil {
return domain.DialogList{}, err
}
for i := range list.Dialogs {
list.Dialogs[i].Draft = nil
}
return list, nil
}
func (s *Service) loadChannelPeerDialogsByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
if s == nil || s.channels == nil || len(peers) == 0 {
return domain.DialogList{}, nil
}
channelIDs := channelPeerIDs(peers)
if len(channelIDs) == 0 {
return domain.DialogList{}, nil
}
list, err := s.channels.GetChannelDialogs(ctx, userID, channelIDs)
if err != nil {
return domain.DialogList{}, err
}
out := mergeChannelDialogs(domain.DialogList{}, list)
out, err = s.appendMissingChannelPeerPreviews(ctx, userID, channelIDs, out)
if err != nil {
return domain.DialogList{}, err
}
return out, nil
}
func (s *Service) dialogHashes(ctx context.Context, userID int64, peers []domain.Peer) (map[domain.Peer]int64, error) {
keys := make([]store.ReadModelKey, 0, len(peers))
for _, peer := range peers {
keys = append(keys, store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID})
}
rows, err := s.versions.ReadModelHashes(ctx, keys)
if err != nil {
return nil, err
}
out := make(map[domain.Peer]int64, len(peers))
for _, peer := range peers {
out[peer] = rows[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}]
}
return out, nil
}
// lookup 命中且版本(值自带 DialogList.Hash)匹配才返回;原语已在返回边界 clone。
func (c *dialogPeerReadModelCache) lookup(key dialogPeerCacheKey, currentHash int64) (domain.DialogList, bool) {
if c == nil {
return domain.DialogList{}, false
}
list, ok := c.cache.Peek(key)
if !ok || (currentHash != 0 && list.Hash != currentHash) {
return domain.DialogList{}, false
}
return list, true
}
func (c *dialogPeerReadModelCache) putIfEpoch(key dialogPeerCacheKey, list domain.DialogList, hash int64, expectedEpoch uint64) {
if c == nil || key.userID == 0 || key.peer.Type == "" || key.peer.ID == 0 || hash == 0 {
return
}
list.Hash = hash
c.cache.StoreIfEpoch(key, list, expectedEpoch)
}
func (c *dialogPeerReadModelCache) invalidate(key dialogPeerCacheKey) {
if c == nil {
return
}
c.cache.Invalidate(key)
}
func (c *dialogPeerReadModelCache) flush() {
if c == nil {
return
}
c.cache.Flush()
}
func (c *dialogPeerReadModelCache) cacheEpoch() uint64 {
if c == nil {
return 0
}
return c.cache.LoadEpoch()
}
func (s *Service) InvalidateDialog(userID int64, peer domain.Peer) {
if s == nil || userID == 0 {
return
}
s.InvalidateDialogOwner(userID)
if s.draftCache != nil && peer.Type != "" && peer.ID != 0 {
s.draftCache.invalidate(dialogPeerCacheKey{userID: userID, peer: peer})
}
if s.privatePeerCache != nil && peer.Type == domain.PeerTypeUser && peer.ID != 0 {
s.privatePeerCache.invalidate(dialogPeerCacheKey{userID: userID, peer: peer})
}
}
// InvalidateDialogOwner invalidates owner-list L1 state without inventing an
// exact peer. Redis L2 values are version-addressed and validated, so old keys
// expire naturally rather than requiring a global/key-pattern delete.
func (s *Service) InvalidateDialogOwner(userID int64) {
if s == nil || userID == 0 {
return
}
s.invalidateDialogListHashes(userID)
if s.listCache != nil {
s.listCache.invalidateOwner(userID)
}
}
// InvalidateDialogListsForChannel invalidates only local bounded owner
// snapshots that actually contain the changed shared channel. The listener
// invokes it for channel_base; reconnect flush remains the missed-NOTIFY guard.
func (s *Service) InvalidateDialogListsForChannel(channelID int64) {
if s != nil && s.listCache != nil {
s.listCache.invalidateChannel(channelID)
}
}
func (s *Service) FlushReadModelCache() {
if s == nil {
return
}
if s.privatePeerCache != nil {
s.privatePeerCache.flush()
}
if s.draftCache != nil {
s.draftCache.flush()
}
if s.listHashCache != nil {
s.listHashCache.flush()
}
if s.listCache != nil {
s.listCache.flush()
}
}
func dialogPeerListApproxBytes(list domain.DialogList) int64 {
weight := int64(256 + len(list.Dialogs)*256 + len(list.Messages)*512 + len(list.Users)*512)
for _, dialog := range list.Dialogs {
weight += int64(len(dialog.ThemeEmoticon))
}
for _, msg := range list.Messages {
weight += int64(len(msg.Body) + len(msg.Entities)*64)
if msg.ReplyTo != nil {
weight += int64(128 + len(msg.ReplyTo.QuoteText) + len(msg.ReplyTo.QuoteEntities)*64)
}
if msg.Forward != nil {
weight += int64(96 + len(msg.Forward.FromName))
}
if msg.RichMessage != nil {
weight += int64(len(msg.RichMessage.Blocks) + len(msg.RichMessage.BotAPIProjection) + len(msg.RichMessage.Photos)*256 + len(msg.RichMessage.Documents)*256)
}
}
for _, user := range list.Users {
weight += int64(len(user.Phone) + len(user.FirstName) + len(user.LastName) + len(user.About) + len(user.Username) + len(user.PhotoStripped))
}
if weight < 1 {
return 1
}
return weight
}
func (s *Service) invalidateDialogListHashes(userID int64) {
if s == nil || s.listHashCache == nil || userID == 0 {
return
}
s.listHashCache.invalidateOwner(userID)
}
func uniqueUserPeers(peers []domain.Peer) []domain.Peer {
return uniquePeersOfType(peers, domain.PeerTypeUser)
}
func uniqueChannelPeers(channelIDs []int64) []domain.Peer {
out := make([]domain.Peer, 0, len(channelIDs))
seen := make(map[int64]struct{}, len(channelIDs))
for _, id := range channelIDs {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, domain.Peer{Type: domain.PeerTypeChannel, ID: id})
}
return out
}
func uniquePeersOfType(peers []domain.Peer, peerType domain.PeerType) []domain.Peer {
out := make([]domain.Peer, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if peer.Type != peerType || peer.ID == 0 {
continue
}
if _, ok := seen[peer]; ok {
continue
}
seen[peer] = struct{}{}
out = append(out, peer)
}
return out
}
func channelPeerIDs(peers []domain.Peer) []int64 {
out := make([]int64, 0, len(peers))
for _, peer := range peers {
if peer.Type == domain.PeerTypeChannel && peer.ID != 0 {
out = append(out, peer.ID)
}
}
return out
}
func dialogListForPeer(list domain.DialogList, peer domain.Peer) domain.DialogList {
out := domain.DialogList{Hash: list.Hash}
for _, dialog := range list.Dialogs {
if dialog.Peer == peer {
out.Dialogs = append(out.Dialogs, cloneDialog(dialog))
}
}
for _, msg := range list.Messages {
if msg.Peer == peer {
out.Messages = append(out.Messages, cloneMessageForDialogCache(msg))
}
}
for _, msg := range list.ChannelMessages {
if msg.ChannelID == peer.ID && peer.Type == domain.PeerTypeChannel {
out.ChannelMessages = append(out.ChannelMessages, cloneChannelMessageForDialogCache(msg))
}
}
switch peer.Type {
case domain.PeerTypeUser:
for _, user := range list.Users {
if user.ID == peer.ID {
out.Users = append(out.Users, cloneDialogUser(user))
}
}
case domain.PeerTypeChannel:
var linkedID int64
for _, channel := range list.Channels {
if channel.ID == peer.ID {
out.Channels = append(out.Channels, cloneDialogChannel(channel))
// monoforum 与母广播频道互为 linked_monoforum_id。per-peer 缓存必须把同批下发的关联频道
// 一并保留,否则缓存命中时 getPeerDialogs 只回该 peer 自身、丢掉关联频道,客户端无法
// resolve linked_monoforum_id(GetChannelDialogs 的同批下发在缓存层被抹掉)。
if channel.LinkedMonoforumID != 0 && (channel.Monoforum || channel.BroadcastMessagesAllowed) {
linkedID = channel.LinkedMonoforumID
}
}
}
if linkedID != 0 {
for _, channel := range list.Channels {
if channel.ID == linkedID {
out.Channels = append(out.Channels, cloneDialogChannel(channel))
}
}
}
}
out.Count = len(out.Dialogs)
return out
}
func cloneDialogList(in domain.DialogList) domain.DialogList {
in.Dialogs = cloneDialogSlice(in.Dialogs)
in.Messages = cloneDialogMessages(in.Messages)
in.ChannelMessages = cloneDialogChannelMessages(in.ChannelMessages)
in.Users = cloneDialogUsers(in.Users)
in.Channels = cloneDialogChannels(in.Channels)
in.ArchiveSummary = cloneDialogArchiveSummary(in.ArchiveSummary)
return in
}
func cloneDialogArchiveSummary(in *domain.DialogArchiveSummary) *domain.DialogArchiveSummary {
if in == nil {
return nil
}
out := *in
if in.TopDialog != nil {
dialog := cloneDialog(*in.TopDialog)
out.TopDialog = &dialog
}
return &out
}
func cloneDialogSlice(in []domain.Dialog) []domain.Dialog {
out := make([]domain.Dialog, len(in))
for i := range in {
out[i] = cloneDialog(in[i])
}
return out
}
func cloneDialog(in domain.Dialog) domain.Dialog {
if in.DefaultSendAs != nil {
peer := *in.DefaultSendAs
in.DefaultSendAs = &peer
}
if in.ChannelMember != nil {
member := *in.ChannelMember
in.ChannelMember = &member
}
if in.Draft != nil {
draft := cloneDraft(*in.Draft)
in.Draft = &draft
}
return in
}
func cloneDialogMessages(in []domain.Message) []domain.Message {
out := make([]domain.Message, len(in))
for i := range in {
out[i] = cloneMessageForDialogCache(in[i])
}
return out
}
func cloneMessageForDialogCache(msg domain.Message) domain.Message {
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
msg.RichMessage = cloneRichMessage(msg.RichMessage)
if msg.ReplyTo != nil {
reply := *msg.ReplyTo
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)
msg.ReplyTo = &reply
}
if msg.Forward != nil {
forward := *msg.Forward
msg.Forward = &forward
}
return msg
}
func cloneDialogChannelMessages(in []domain.ChannelMessage) []domain.ChannelMessage {
out := make([]domain.ChannelMessage, len(in))
for i := range in {
out[i] = cloneChannelMessageForDialogCache(in[i])
}
return out
}
func cloneChannelMessageForDialogCache(msg domain.ChannelMessage) domain.ChannelMessage {
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
msg.RichMessage = cloneRichMessage(msg.RichMessage)
if msg.ReplyTo != nil {
reply := *msg.ReplyTo
reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...)
msg.ReplyTo = &reply
}
if msg.Forward != nil {
forward := *msg.Forward
msg.Forward = &forward
}
if msg.SendAs != nil {
sendAs := *msg.SendAs
msg.SendAs = &sendAs
}
if msg.Reactions != nil {
reactions := *msg.Reactions
reactions.Results = append([]domain.ChannelMessageReactionCount(nil), msg.Reactions.Results...)
reactions.Recent = append([]domain.ChannelMessagePeerReaction(nil), msg.Reactions.Recent...)
msg.Reactions = &reactions
}
if msg.ReplyMarkup != nil {
msg.ReplyMarkup = cloneReplyMarkupForDialogCache(msg.ReplyMarkup)
}
if msg.Action != nil {
action := *msg.Action
action.UserIDs = append([]int64(nil), msg.Action.UserIDs...)
action.Completed = append([]int(nil), msg.Action.Completed...)
action.Incompleted = append([]int(nil), msg.Action.Incompleted...)
action.TodoItems = append([]domain.MessageTodoItem(nil), msg.Action.TodoItems...)
msg.Action = &action
}
return msg
}
func cloneReplyMarkupForDialogCache(in *domain.MessageReplyMarkup) *domain.MessageReplyMarkup {
if in == nil {
return nil
}
out := &domain.MessageReplyMarkup{
Type: in.Type,
Resize: in.Resize,
SingleUse: in.SingleUse,
Selective: in.Selective,
Persistent: in.Persistent,
Placeholder: in.Placeholder,
}
if len(in.Inline) > 0 {
out.Inline = make([][]domain.MarkupButton, len(in.Inline))
for i, row := range in.Inline {
out.Inline[i] = make([]domain.MarkupButton, len(row))
for j, button := range row {
out.Inline[i][j] = button
out.Inline[i][j].Data = append([]byte(nil), button.Data...)
}
}
}
if len(in.Keyboard) > 0 {
out.Keyboard = make([][]domain.MarkupButton, len(in.Keyboard))
for i, row := range in.Keyboard {
out.Keyboard[i] = append([]domain.MarkupButton(nil), row...)
}
}
return out
}
func cloneDialogUsers(in []domain.User) []domain.User {
out := make([]domain.User, len(in))
for i := range in {
out[i] = cloneDialogUser(in[i])
}
return out
}
func cloneDialogUser(in domain.User) domain.User {
if in.PhotoStripped != nil {
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
}
if in.RestrictionReasons != nil {
in.RestrictionReasons = append([]domain.UserRestrictionReason(nil), in.RestrictionReasons...)
}
return in
}
func cloneDialogChannels(in []domain.Channel) []domain.Channel {
out := make([]domain.Channel, len(in))
for i := range in {
out[i] = cloneDialogChannel(in[i])
}
return out
}
func cloneDialogChannel(in domain.Channel) domain.Channel {
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
in.ReactionPolicy.Emoticons = append([]string(nil), in.ReactionPolicy.Emoticons...)
in.ReactionPolicy.CustomEmojiIDs = append([]int64(nil), in.ReactionPolicy.CustomEmojiIDs...)
return in
}