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

442 lines
12 KiB
Go

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
}