package dialogs import ( "context" "encoding/binary" "hash/fnv" "sync" "time" "telesrv/internal/app/readmodel" "telesrv/internal/domain" "telesrv/internal/readmodelcache" ) const ( dialogListSnapshotTTL = 5 * time.Minute dialogListSnapshotMaxEntries = 10000 dialogListSnapshotMaxHeaders = 1000000 dialogListSnapshotLoadLimit = 10000 ) type dialogListSnapshotKey struct { userID int64 } type dialogListSnapshot struct { dialogs []domain.Dialog messages []domain.Message users []domain.User hash int64 state domain.UpdateState archive *domain.DialogArchiveSummary channelIDs []int64 ownerHash int64 dependencyHash int64 } type dialogListSnapshotCache struct { cache *readmodelcache.Cache[dialogListSnapshotKey, *dialogListSnapshot] indexMu sync.Mutex channelKeys map[int64]map[dialogListSnapshotKey]struct{} keyChannels map[dialogListSnapshotKey][]int64 } func newDialogListSnapshotCache(maxEntries int, maxHeaders int64, ttl time.Duration) *dialogListSnapshotCache { if maxEntries <= 0 { maxEntries = dialogListSnapshotMaxEntries } if maxHeaders <= 0 { maxHeaders = dialogListSnapshotMaxHeaders } if ttl <= 0 { ttl = dialogListSnapshotTTL } c := &dialogListSnapshotCache{ channelKeys: make(map[int64]map[dialogListSnapshotKey]struct{}), keyChannels: make(map[dialogListSnapshotKey][]int64), } c.cache = readmodelcache.New[dialogListSnapshotKey, *dialogListSnapshot](readmodelcache.Config[dialogListSnapshotKey, *dialogListSnapshot]{ MaxEntries: maxEntries, MaxWeight: maxHeaders, Weight: func(snap *dialogListSnapshot) int64 { if snap == nil { return 1 } // The historical knob is expressed in header-equivalent units. A // materialized message/channel is wider than an ordering header, so // charge conservative multiples and keep the old global bound useful. memberProjections := 0 for _, dialog := range snap.dialogs { if dialog.ChannelMember != nil { memberProjections++ } } weight := len(snap.dialogs) + memberProjections*2 + len(snap.messages)*4 + len(snap.users)*2 if weight < 1 { return 1 } return int64(weight) }, TTL: ttl, OnStore: c.indexSnapshot, OnRemove: c.unindexSnapshot, }) return c } func dialogSnapshotKey(userID int64, filter domain.DialogFilter) (dialogListSnapshotKey, bool) { if userID == 0 || filter.Folder != nil { return dialogListSnapshotKey{}, false } if filter.HasFolderID { if filter.FolderID != domain.DialogMainFolderID && filter.FolderID != domain.DialogArchiveFolderID { return dialogListSnapshotKey{}, false } } return dialogListSnapshotKey{userID: userID}, true } func (c *dialogListSnapshotCache) getOrLoad(ctx context.Context, key dialogListSnapshotKey, load func() (*dialogListSnapshot, error)) (*dialogListSnapshot, error) { if c == nil || c.cache == nil { return load() } return c.cache.GetOrLoad(ctx, key, load) } func (c *dialogListSnapshotCache) getOrLoadVersioned( ctx context.Context, key dialogListSnapshotKey, ownerHash int64, load func() (*dialogListSnapshot, error), ) (*dialogListSnapshot, error) { if c == nil || c.cache == nil { return load() } return c.cache.GetOrLoadVersioned(ctx, key, ownerHash, load) } func (c *dialogListSnapshotCache) invalidateOwner(userID int64) { if c == nil || c.cache == nil || userID == 0 { return } c.cache.InvalidateWhere(func(key dialogListSnapshotKey) bool { return key.userID == userID }) } func (c *dialogListSnapshotCache) invalidateChannel(channelID int64) { if c == nil || c.cache == nil || channelID == 0 { return } c.indexMu.Lock() indexed := c.channelKeys[channelID] keys := make([]dialogListSnapshotKey, 0, len(indexed)) for key := range indexed { keys = append(keys, key) } c.indexMu.Unlock() c.cache.Invalidate(keys...) } func (c *dialogListSnapshotCache) flush() { if c != nil && c.cache != nil { c.cache.Flush() c.indexMu.Lock() c.channelKeys = make(map[int64]map[dialogListSnapshotKey]struct{}) c.keyChannels = make(map[dialogListSnapshotKey][]int64) c.indexMu.Unlock() } } func (c *dialogListSnapshotCache) indexSnapshot(key dialogListSnapshotKey, snap *dialogListSnapshot) { if c == nil { return } c.indexMu.Lock() defer c.indexMu.Unlock() c.unindexSnapshotLocked(key) if snap == nil || len(snap.channelIDs) == 0 { return } ids := append([]int64(nil), snap.channelIDs...) c.keyChannels[key] = ids for _, channelID := range ids { keys := c.channelKeys[channelID] if keys == nil { keys = make(map[dialogListSnapshotKey]struct{}) c.channelKeys[channelID] = keys } keys[key] = struct{}{} } } func (c *dialogListSnapshotCache) unindexSnapshot(key dialogListSnapshotKey, _ *dialogListSnapshot) { if c == nil { return } c.indexMu.Lock() c.unindexSnapshotLocked(key) c.indexMu.Unlock() } func (c *dialogListSnapshotCache) unindexSnapshotLocked(key dialogListSnapshotKey) { for _, channelID := range c.keyChannels[key] { keys := c.channelKeys[channelID] delete(keys, key) if len(keys) == 0 { delete(c.channelKeys, channelID) } } delete(c.keyChannels, key) } func newDialogListSnapshot(list domain.DialogList) *dialogListSnapshot { channelIDs := make([]int64, 0, len(list.Dialogs)) seen := make(map[int64]struct{}, len(list.Dialogs)) for _, dialog := range list.Dialogs { if dialog.Peer.Type != domain.PeerTypeChannel || dialog.Peer.ID == 0 { continue } if _, ok := seen[dialog.Peer.ID]; ok { continue } seen[dialog.Peer.ID] = struct{}{} channelIDs = append(channelIDs, dialog.Peer.ID) } archive := cloneDialogArchiveSummary(list.ArchiveSummary) structuralHash := dialogOwnerSnapshotStructuralHash(list.Dialogs, list.Hash) return &dialogListSnapshot{ dialogs: cloneDialogSlice(list.Dialogs), messages: cloneDialogMessages(list.Messages), users: cloneDialogUsers(list.Users), hash: dialogHashWithDrafts(structuralHash, list.Dialogs), state: list.State, archive: archive, channelIDs: channelIDs, } } func dialogListSnapshotPageHeaders(snap *dialogListSnapshot, filter domain.DialogFilter) domain.DialogList { if snap == nil { return domain.DialogList{} } dialogs := dialogListSnapshotVariant(snap.dialogs, filter) start := dialogSnapshotPageStart(dialogs, filter) limit := filter.Limit if limit <= 0 || limit > 100 { limit = 100 } end := start + limit if end > len(dialogs) { end = len(dialogs) } if start > end { start = end } hash := readmodel.MixHashes(snap.hash, dialogSnapshotVariantIdentity(filter)) if snap.ownerHash != 0 && snap.dependencyHash != 0 { hash = readmodel.MixHashes(hash, snap.ownerHash, snap.dependencyHash) } out := domain.DialogList{Count: len(dialogs), Hash: hash, State: snap.state} out.Dialogs = cloneDialogSlice(dialogs[start:end]) payloadPeers := make([]domain.Peer, 0, len(out.Dialogs)+1) for _, dialog := range out.Dialogs { payloadPeers = append(payloadPeers, dialog.Peer) } if dialogSnapshotIncludesArchiveSummary(filter) && snap.archive != nil { if !filter.PinnedOnly || snap.archive.Pinned { summary := *snap.archive out.ArchiveSummary = &summary if summary.TopPeer.ID != 0 { payloadPeers = append(payloadPeers, summary.TopPeer) } } } appendDialogSnapshotPayload(snap, payloadPeers, &out) return out } func appendDialogSnapshotPayload(snap *dialogListSnapshot, peers []domain.Peer, out *domain.DialogList) { if snap == nil || out == nil || len(peers) == 0 { return } keep := make(map[domain.Peer]struct{}, len(peers)) for _, peer := range peers { if peer.Type != "" && peer.ID != 0 { keep[peer] = struct{}{} } } for _, msg := range snap.messages { if _, ok := keep[msg.Peer]; ok { out.Messages = append(out.Messages, cloneMessageForDialogCache(msg)) } } userIDs := make(map[int64]struct{}, len(keep)) for peer := range keep { switch peer.Type { case domain.PeerTypeUser: userIDs[peer.ID] = struct{}{} } } for _, user := range snap.users { if _, ok := userIDs[user.ID]; ok { out.Users = append(out.Users, cloneDialogUser(user)) } } } func dialogOwnerSnapshotStructuralHash(dialogs []domain.Dialog, provided int64) int64 { if provided != 0 { return provided } h := fnv.New64a() var buf [96]byte for _, dialog := range dialogs { clear(buf[:]) binary.LittleEndian.PutUint64(buf[:8], uint64(dialog.Peer.ID)) binary.LittleEndian.PutUint32(buf[8:12], uint32(dialog.FolderID)) binary.LittleEndian.PutUint32(buf[12:16], uint32(dialog.TopMessage)) binary.LittleEndian.PutUint32(buf[16:20], uint32(dialog.TopMessageDate)) binary.LittleEndian.PutUint32(buf[20:24], uint32(dialog.ReadInboxMaxID)) binary.LittleEndian.PutUint32(buf[24:28], uint32(dialog.ReadOutboxMaxID)) binary.LittleEndian.PutUint32(buf[28:32], uint32(dialog.UnreadCount)) binary.LittleEndian.PutUint32(buf[32:36], uint32(dialog.UnreadMentions)) binary.LittleEndian.PutUint32(buf[36:40], uint32(dialog.UnreadReactions)) binary.LittleEndian.PutUint32(buf[40:44], uint32(dialog.PinnedOrder)) if dialog.Pinned { buf[44] = 1 } else { buf[44] = 0 } if dialog.UnreadMark { buf[45] = 1 } else { buf[45] = 0 } if dialog.PeerSettingsBarHidden { buf[46] = 1 } else { buf[46] = 0 } buf[47] = byte(len(dialog.Peer.Type)) binary.LittleEndian.PutUint32(buf[48:52], uint32(dialog.HistoryClearAnchorID)) binary.LittleEndian.PutUint32(buf[52:56], uint32(dialog.HistoryClearAnchorDate)) binary.LittleEndian.PutUint32(buf[56:60], uint32(dialog.TTLPeriod)) binary.LittleEndian.PutUint32(buf[60:64], uint32(dialog.Pts)) if dialog.ChannelLeft { buf[64] = 1 } if dialog.HasScheduled { buf[65] = 1 } if dialog.ViewForumAsMessages { buf[66] = 1 } if dialog.TopMessageMentioned { buf[67] = 1 } if dialog.TopMessageMediaUnread { buf[68] = 1 } if dialog.TopMessageUnreadProjected { buf[69] = 1 } if dialog.DefaultSendAs != nil { binary.LittleEndian.PutUint64(buf[72:80], uint64(dialog.DefaultSendAs.ID)) buf[80] = byte(len(dialog.DefaultSendAs.Type)) } _, _ = h.Write(buf[:]) _, _ = h.Write([]byte(dialog.Peer.Type)) _, _ = h.Write([]byte(dialog.ThemeEmoticon)) if dialog.DefaultSendAs != nil { _, _ = h.Write([]byte(dialog.DefaultSendAs.Type)) } } sum := int64(h.Sum64() & 0x7fffffffffffffff) if sum == 0 { return 1 } return sum } func dialogListSnapshotVariant(dialogs []domain.Dialog, filter domain.DialogFilter) []domain.Dialog { folderID := domain.DialogMainFolderID if filter.HasFolderID { folderID = filter.FolderID } out := make([]domain.Dialog, 0, len(dialogs)) for _, dialog := range dialogs { if dialog.FolderID != folderID || filter.PinnedOnly && !dialog.Pinned || filter.ExcludePinned && dialog.Pinned { continue } out = append(out, dialog) } return out } func dialogSnapshotVariantIdentity(filter domain.DialogFilter) int64 { folderID := domain.DialogMainFolderID if filter.HasFolderID { folderID = filter.FolderID } identity := int64(folderID + 1) if filter.PinnedOnly { identity |= 1 << 8 } if filter.ExcludePinned { identity |= 1 << 9 } return identity } func dialogSnapshotIncludesArchiveSummary(filter domain.DialogFilter) bool { if filter.HasFolderID && filter.FolderID != domain.DialogMainFolderID { return false } if filter.ExcludePinned { return false } return filter.OffsetID == 0 && filter.OffsetDate == 0 && !filter.HasOffsetPeer } func dialogSnapshotPageStart(dialogs []domain.Dialog, filter domain.DialogFilter) int { if filter.OffsetID == 0 && filter.OffsetDate == 0 && !filter.HasOffsetPeer { return 0 } if filter.HasOffsetPeer { for i, dialog := range dialogs { if dialog.Peer == filter.OffsetPeer && (filter.OffsetID == 0 || dialog.TopMessage == filter.OffsetID) && (filter.OffsetDate == 0 || dialog.TopMessageDate == filter.OffsetDate) { return i + 1 } } } for i, dialog := range dialogs { if dialogAfterSnapshotOffset(dialog, filter) { return i } } return len(dialogs) } func dialogAfterSnapshotOffset(dialog domain.Dialog, filter domain.DialogFilter) bool { if filter.OffsetDate > 0 { if dialog.TopMessageDate != filter.OffsetDate { return dialog.TopMessageDate < filter.OffsetDate } if filter.OffsetID <= 0 { return false } } if filter.OffsetID > 0 { if dialog.TopMessage != filter.OffsetID { return dialog.TopMessage < filter.OffsetID } if filter.HasOffsetPeer { return dialog.Peer.ID < filter.OffsetPeer.ID } return false } return filter.HasOffsetPeer && dialog.Peer != filter.OffsetPeer }