merged from gramsrv upstream
This commit is contained in:
parent
79c64ee916
commit
21a0856587
651 changed files with 54774 additions and 4590 deletions
153
internal/app/dialogs/draft_read_model_cache.go
Normal file
153
internal/app/dialogs/draft_read_model_cache.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDialogDraftReadModelTTL = 24 * time.Hour
|
||||
defaultDialogDraftReadModelMaxEntries = 1000000
|
||||
defaultDialogDraftReadModelMaxBytes int64 = 256 << 20
|
||||
)
|
||||
|
||||
type dialogDraftCacheEntry struct {
|
||||
draft domain.DialogDraft
|
||||
found bool
|
||||
}
|
||||
|
||||
type dialogDraftReadModelCache struct {
|
||||
cache *readmodelcache.Cache[dialogPeerCacheKey, dialogDraftCacheEntry]
|
||||
}
|
||||
|
||||
func newDialogDraftReadModelCache(maxEntries int, maxBytes int64, ttl time.Duration) *dialogDraftReadModelCache {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultDialogDraftReadModelTTL
|
||||
}
|
||||
return &dialogDraftReadModelCache{cache: readmodelcache.New[dialogPeerCacheKey, dialogDraftCacheEntry](readmodelcache.Config[dialogPeerCacheKey, dialogDraftCacheEntry]{
|
||||
MaxEntries: maxEntries,
|
||||
MaxWeight: maxBytes,
|
||||
Weight: dialogDraftEntryApproxBytes,
|
||||
TTL: ttl,
|
||||
Clone: cloneDialogDraftCacheEntry,
|
||||
})}
|
||||
}
|
||||
|
||||
func (s *Service) dialogDraftsReadModel(ctx context.Context, userID int64, peers []domain.Peer) (map[domain.Peer]dialogDraftCacheEntry, error) {
|
||||
out := make(map[domain.Peer]dialogDraftCacheEntry, len(peers))
|
||||
if s == nil || s.dialogs == nil || userID == 0 || len(peers) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
unique := uniqueDialogPeers(peers)
|
||||
if len(unique) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
keys := make([]dialogPeerCacheKey, 0, len(unique))
|
||||
for _, peer := range unique {
|
||||
keys = append(keys, dialogPeerCacheKey{userID: userID, peer: peer})
|
||||
}
|
||||
hashes := map[domain.Peer]int64{}
|
||||
if s.versions != nil {
|
||||
var err error
|
||||
hashes, err = s.dialogHashes(ctx, userID, unique)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var cache *readmodelcache.Cache[dialogPeerCacheKey, dialogDraftCacheEntry]
|
||||
if s.draftCache != nil {
|
||||
cache = s.draftCache.cache
|
||||
}
|
||||
loaded, err := cache.GetOrLoadBatch(ctx, keys,
|
||||
func(key dialogPeerCacheKey) (int64, bool) {
|
||||
hash := hashes[key.peer]
|
||||
return hash, s.versions != nil && hash != 0
|
||||
},
|
||||
func(ctx context.Context, missing []dialogPeerCacheKey) (map[dialogPeerCacheKey]dialogDraftCacheEntry, error) {
|
||||
requested := make([]domain.Peer, 0, len(missing))
|
||||
for _, key := range missing {
|
||||
requested = append(requested, key.peer)
|
||||
}
|
||||
drafts, err := s.dialogs.ListDraftsByPeers(ctx, userID, requested)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make(map[dialogPeerCacheKey]dialogDraftCacheEntry, len(missing))
|
||||
for _, key := range missing {
|
||||
entries[key] = dialogDraftCacheEntry{}
|
||||
}
|
||||
for _, draft := range drafts {
|
||||
if draft.TopMessageID != 0 {
|
||||
continue
|
||||
}
|
||||
key := dialogPeerCacheKey{userID: userID, peer: draft.Peer}
|
||||
if _, ok := entries[key]; ok {
|
||||
entries[key] = dialogDraftCacheEntry{draft: cloneDraft(draft), found: true}
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, entry := range loaded {
|
||||
out[key.peer] = entry
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func uniqueDialogPeers(peers []domain.Peer) []domain.Peer {
|
||||
out := make([]domain.Peer, 0, len(peers))
|
||||
seen := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
out = append(out, peer)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *dialogDraftReadModelCache) invalidate(key dialogPeerCacheKey) {
|
||||
if c != nil {
|
||||
c.cache.Invalidate(key)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *dialogDraftReadModelCache) flush() {
|
||||
if c != nil {
|
||||
c.cache.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func cloneDialogDraftCacheEntry(entry dialogDraftCacheEntry) dialogDraftCacheEntry {
|
||||
if entry.found {
|
||||
entry.draft = cloneDraft(entry.draft)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func dialogDraftEntryApproxBytes(entry dialogDraftCacheEntry) int64 {
|
||||
if !entry.found {
|
||||
return 64
|
||||
}
|
||||
draft := entry.draft
|
||||
weight := int64(256 + len(draft.Message) + len(draft.Entities)*64)
|
||||
if draft.ReplyTo != nil {
|
||||
weight += int64(128 + len(draft.ReplyTo.QuoteText) + len(draft.ReplyTo.QuoteEntities)*64)
|
||||
}
|
||||
if draft.WebPage != nil {
|
||||
weight += int64(64 + len(draft.WebPage.URL))
|
||||
}
|
||||
if draft.RichMessage != nil {
|
||||
weight += int64(len(draft.RichMessage.Blocks) + len(draft.RichMessage.BotAPIProjection) + len(draft.RichMessage.Photos)*256 + len(draft.RichMessage.Documents)*256)
|
||||
}
|
||||
return weight
|
||||
}
|
||||
442
internal/app/dialogs/list_snapshot_cache.go
Normal file
442
internal/app/dialogs/list_snapshot_cache.go
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
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
|
||||
}
|
||||
43
internal/app/dialogs/list_snapshot_cache_test.go
Normal file
43
internal/app/dialogs/list_snapshot_cache_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestDialogOwnerSnapshotStructuralHashCoversMaterializedOwnerFacts(t *testing.T) {
|
||||
base := domain.Dialog{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 10},
|
||||
TopMessage: 20,
|
||||
TopMessageDate: 30,
|
||||
Pts: 40,
|
||||
HistoryClearAnchorID: 50,
|
||||
HistoryClearAnchorDate: 60,
|
||||
TopMessageUnreadProjected: true,
|
||||
DefaultSendAs: &domain.Peer{Type: domain.PeerTypeChannel, ID: 70},
|
||||
}
|
||||
wantDifferent := []domain.Dialog{
|
||||
func() domain.Dialog { out := cloneDialog(base); out.Pts++; return out }(),
|
||||
func() domain.Dialog { out := cloneDialog(base); out.HistoryClearAnchorID++; return out }(),
|
||||
func() domain.Dialog { out := cloneDialog(base); out.TopMessageUnreadProjected = false; return out }(),
|
||||
func() domain.Dialog { out := cloneDialog(base); out.DefaultSendAs.ID++; return out }(),
|
||||
}
|
||||
baseHash := dialogOwnerSnapshotStructuralHash([]domain.Dialog{base}, 0)
|
||||
for index, changed := range wantDifferent {
|
||||
if got := dialogOwnerSnapshotStructuralHash([]domain.Dialog{changed}, 0); got == baseHash {
|
||||
t.Fatalf("materialized owner fact case %d did not change structural hash", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogListSnapshotHashCoversMaterializedDraft(t *testing.T) {
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 10}
|
||||
without := newDialogListSnapshot(domain.DialogList{Dialogs: []domain.Dialog{{Peer: peer}}})
|
||||
with := newDialogListSnapshot(domain.DialogList{Dialogs: []domain.Dialog{{
|
||||
Peer: peer, Draft: &domain.DialogDraft{Peer: peer, Date: 20, Message: "draft"},
|
||||
}}})
|
||||
if without.hash == with.hash {
|
||||
t.Fatalf("draft did not change snapshot hash: %d", without.hash)
|
||||
}
|
||||
}
|
||||
|
|
@ -11,11 +11,10 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
dialogLightReadModel = readmodel.ModelDialogLight
|
||||
channelBaseReadModel = readmodel.ModelChannelBase
|
||||
channelMemberReadModel = readmodel.ModelChannelMember
|
||||
defaultDialogPeerReadModelTTL = 24 * time.Hour
|
||||
dialogPeerReadModelMaxEntries = 8192
|
||||
dialogLightReadModel = readmodel.ModelDialogLight
|
||||
defaultDialogPeerReadModelTTL = 24 * time.Hour
|
||||
defaultDialogPeerReadModelMaxEntries = 500000
|
||||
defaultDialogPeerReadModelMaxBytes int64 = 256 << 20
|
||||
)
|
||||
|
||||
type dialogPeerCacheKey struct {
|
||||
|
|
@ -32,12 +31,18 @@ type dialogPeerReadModelCache struct {
|
|||
}
|
||||
|
||||
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: dialogPeerReadModelMaxEntries,
|
||||
MaxEntries: maxEntries,
|
||||
MaxWeight: maxBytes,
|
||||
Weight: dialogPeerListApproxBytes,
|
||||
TTL: ttl,
|
||||
Clone: cloneDialogList,
|
||||
}),
|
||||
|
|
@ -52,7 +57,7 @@ func (s *Service) userPeerDialogsReadModel(ctx context.Context, userID int64, pe
|
|||
if len(unique) == 0 {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
return s.cachedPeerDialogsReadModel(ctx, userID, unique, s.userDialogHashes, s.loadUserPeerDialogs)
|
||||
return s.cachedPeerDialogsReadModel(ctx, userID, unique, s.dialogHashes, s.loadUserPeerDialogs)
|
||||
}
|
||||
|
||||
func (s *Service) channelPeerDialogsReadModel(ctx context.Context, userID int64, channelIDs []int64) (domain.DialogList, error) {
|
||||
|
|
@ -63,7 +68,7 @@ func (s *Service) channelPeerDialogsReadModel(ctx context.Context, userID int64,
|
|||
if len(unique) == 0 {
|
||||
return domain.DialogList{}, nil
|
||||
}
|
||||
return s.cachedPeerDialogsReadModel(ctx, userID, unique, s.channelDialogHashes, s.loadChannelPeerDialogsByPeers)
|
||||
return s.loadChannelPeerDialogsByPeers(ctx, userID, unique)
|
||||
}
|
||||
|
||||
func (s *Service) cachedPeerDialogsReadModel(
|
||||
|
|
@ -73,20 +78,20 @@ func (s *Service) cachedPeerDialogsReadModel(
|
|||
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.peerCache == nil || s.versions == nil {
|
||||
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.peerCache.cacheEpoch()
|
||||
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.peerCache.lookup(dialogPeerCacheKey{userID: userID, peer: peer}, hash); ok {
|
||||
if cached, ok := s.privatePeerCache.lookup(dialogPeerCacheKey{userID: userID, peer: peer}, hash); ok {
|
||||
out = mergeDialogLists(out, cached)
|
||||
continue
|
||||
}
|
||||
|
|
@ -108,7 +113,7 @@ func (s *Service) cachedPeerDialogsReadModel(
|
|||
}
|
||||
peerList := dialogListForPeer(list, peer)
|
||||
peerList.Hash = hash
|
||||
s.peerCache.putIfEpoch(dialogPeerCacheKey{userID: userID, peer: peer}, peerList, hash, loadEpoch)
|
||||
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
|
||||
|
|
@ -124,11 +129,8 @@ func (s *Service) loadUserPeerDialogs(ctx context.Context, userID int64, peers [
|
|||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.attachDrafts(ctx, userID, &list); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.projectDialogUsers(ctx, userID, &list); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
for i := range list.Dialogs {
|
||||
list.Dialogs[i].Draft = nil
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
|
@ -150,16 +152,10 @@ func (s *Service) loadChannelPeerDialogsByPeers(ctx context.Context, userID int6
|
|||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.attachDrafts(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.projectDialogUsers(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) userDialogHashes(ctx context.Context, userID int64, peers []domain.Peer) (map[domain.Peer]int64, error) {
|
||||
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})
|
||||
|
|
@ -175,32 +171,6 @@ func (s *Service) userDialogHashes(ctx context.Context, userID int64, peers []do
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) channelDialogHashes(ctx context.Context, userID int64, peers []domain.Peer) (map[domain.Peer]int64, error) {
|
||||
keys := make([]store.ReadModelKey, 0, len(peers)*3)
|
||||
for _, peer := range peers {
|
||||
keys = append(keys,
|
||||
store.ReadModelKey{Model: channelBaseReadModel, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID},
|
||||
store.ReadModelKey{Model: channelMemberReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID},
|
||||
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 {
|
||||
base := rows[store.ReadModelKey{Model: channelBaseReadModel, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}]
|
||||
if base == 0 {
|
||||
continue
|
||||
}
|
||||
member := rows[store.ReadModelKey{Model: channelMemberReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}]
|
||||
dialog := rows[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}]
|
||||
out[peer] = readmodel.MixHashes(base, member, dialog)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// lookup 命中且版本(值自带 DialogList.Hash)匹配才返回;原语已在返回边界 clone。
|
||||
func (c *dialogPeerReadModelCache) lookup(key dialogPeerCacheKey, currentHash int64) (domain.DialogList, bool) {
|
||||
if c == nil {
|
||||
|
|
@ -246,23 +216,79 @@ func (s *Service) InvalidateDialog(userID int64, peer domain.Peer) {
|
|||
if s == nil || userID == 0 {
|
||||
return
|
||||
}
|
||||
s.invalidateDialogListHashes(userID)
|
||||
if s.peerCache == nil || peer.Type == "" || peer.ID == 0 {
|
||||
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.peerCache.invalidate(dialogPeerCacheKey{userID: userID, peer: peer})
|
||||
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.peerCache != nil {
|
||||
s.peerCache.flush()
|
||||
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) {
|
||||
|
|
@ -373,9 +399,22 @@ func cloneDialogList(in domain.DialogList) domain.DialogList {
|
|||
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 {
|
||||
|
|
@ -385,6 +424,14 @@ func cloneDialogSlice(in []domain.Dialog) []domain.Dialog {
|
|||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"hash/fnv"
|
||||
"reflect"
|
||||
"sort"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/app/userprojection"
|
||||
|
|
@ -19,17 +20,20 @@ type PremiumChecker func(ctx context.Context, userID int64) bool
|
|||
|
||||
// Service 提供会话列表查询。
|
||||
type Service struct {
|
||||
dialogs store.DialogStore
|
||||
channels store.ChannelStore
|
||||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
premium PremiumChecker
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
peerCache *dialogPeerReadModelCache
|
||||
listHashCache *dialogListHashCache
|
||||
dialogs store.DialogStore
|
||||
channels store.ChannelStore
|
||||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
premium PremiumChecker
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
privatePeerCache *dialogPeerReadModelCache
|
||||
draftCache *dialogDraftReadModelCache
|
||||
listHashCache *dialogListHashCache
|
||||
listCache *dialogListSnapshotCache
|
||||
sharedListCache store.DialogListSnapshotCache
|
||||
}
|
||||
|
||||
// Option adjusts optional dialogs service dependencies.
|
||||
|
|
@ -64,12 +68,41 @@ func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
|||
return func(s *Service) { s.versions = v }
|
||||
}
|
||||
|
||||
// WithDialogHydrationCaches configures the bounded structural-private-peer and
|
||||
// cloud-draft working sets. Channel structure has its own store-level cache and
|
||||
// is deliberately not duplicated here.
|
||||
func WithDialogHydrationCaches(privateMaxEntries int, privateMaxBytes int64, draftMaxEntries int, draftMaxBytes int64) Option {
|
||||
return func(s *Service) {
|
||||
s.privatePeerCache = newDialogPeerReadModelCacheWithLimits(privateMaxEntries, privateMaxBytes, defaultDialogPeerReadModelTTL)
|
||||
s.draftCache = newDialogDraftReadModelCache(draftMaxEntries, draftMaxBytes, defaultDialogDraftReadModelTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// WithDialogListSnapshotCache configures the bounded materialized owner working
|
||||
// set. maxHeaders is retained as the configuration/API name and measures
|
||||
// header-equivalent weighted units, so high-membership owners cannot turn
|
||||
// maxEntries into an unbounded heap commitment.
|
||||
func WithDialogListSnapshotCache(maxEntries int, maxHeaders int64, ttl time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
s.listCache = newDialogListSnapshotCache(maxEntries, maxHeaders, ttl)
|
||||
}
|
||||
}
|
||||
|
||||
// WithSharedDialogListSnapshotCache installs the production Redis L2 for
|
||||
// process-cold materialized owner restoration. Errors are propagated; production
|
||||
// never silently replaces Redis failure with a full PostgreSQL scan.
|
||||
func WithSharedDialogListSnapshotCache(cache store.DialogListSnapshotCache) Option {
|
||||
return func(s *Service) { s.sharedListCache = cache }
|
||||
}
|
||||
|
||||
// NewService 创建 dialogs 服务。
|
||||
func NewService(dialogs store.DialogStore, channels ...store.ChannelStore) *Service {
|
||||
s := &Service{
|
||||
dialogs: dialogs,
|
||||
peerCache: newDialogPeerReadModelCache(defaultDialogPeerReadModelTTL),
|
||||
listHashCache: newDialogListHashCache(defaultDialogListHashCacheTTL),
|
||||
dialogs: dialogs,
|
||||
privatePeerCache: newDialogPeerReadModelCache(defaultDialogPeerReadModelTTL),
|
||||
draftCache: newDialogDraftReadModelCache(defaultDialogDraftReadModelMaxEntries, defaultDialogDraftReadModelMaxBytes, defaultDialogDraftReadModelTTL),
|
||||
listHashCache: newDialogListHashCache(defaultDialogListHashCacheTTL),
|
||||
listCache: newDialogListSnapshotCache(0, 0, 0),
|
||||
}
|
||||
if len(channels) > 0 {
|
||||
s.channels = channels[0]
|
||||
|
|
@ -129,19 +162,403 @@ func (s *Service) getDialogs(ctx context.Context, userID int64, filter domain.Di
|
|||
}
|
||||
filter.Folder = &folder
|
||||
}
|
||||
if filter.Limit <= 0 || filter.Limit > 100 {
|
||||
filter.Limit = 100
|
||||
}
|
||||
if !lightweight {
|
||||
if key, ok := dialogSnapshotKey(userID, filter); ok && s.supportsDialogListSnapshot() {
|
||||
listHashEpoch := s.listHashCache.cacheEpoch()
|
||||
if s.sharedListCache != nil {
|
||||
page, err := s.stableDialogSnapshotPage(ctx, key, filter)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
s.rememberDialogListHash(userID, filter, page, listHashEpoch)
|
||||
return page, nil
|
||||
}
|
||||
snap, err := s.listCache.getOrLoad(ctx, key, func() (*dialogListSnapshot, error) {
|
||||
return s.loadDialogListSnapshot(ctx, key)
|
||||
})
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
page, err := s.hydrateDialogSnapshotPage(ctx, userID, dialogListSnapshotPageHeaders(snap, filter))
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
s.rememberDialogListHash(userID, filter, page, listHashEpoch)
|
||||
return page, nil
|
||||
}
|
||||
}
|
||||
return s.loadDialogs(ctx, userID, filter, lightweight)
|
||||
}
|
||||
|
||||
const dialogListSnapshotStableReadAttempts = 4
|
||||
|
||||
func (s *Service) stableDialogSnapshotPage(
|
||||
ctx context.Context,
|
||||
key dialogListSnapshotKey,
|
||||
filter domain.DialogFilter,
|
||||
) (domain.DialogList, error) {
|
||||
for attempt := 0; attempt < dialogListSnapshotStableReadAttempts; attempt++ {
|
||||
ownerHash, err := s.dialogOwnerHash(ctx, key.userID)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
snap, err := s.listCache.getOrLoadVersioned(ctx, key, ownerHash, func() (*dialogListSnapshot, error) {
|
||||
return s.loadDialogListSnapshotAtOwnerHash(ctx, key, ownerHash)
|
||||
})
|
||||
if errors.Is(err, errDialogListSnapshotGenerationChanged) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
|
||||
page, hydrateErr := s.hydrateDialogSnapshotPage(ctx, key.userID, dialogListSnapshotPageHeaders(snap, filter))
|
||||
currentOwnerHash, hashErr := s.dialogOwnerHash(ctx, key.userID)
|
||||
if hashErr != nil {
|
||||
return domain.DialogList{}, hashErr
|
||||
}
|
||||
if currentOwnerHash != ownerHash {
|
||||
continue
|
||||
}
|
||||
if hydrateErr != nil {
|
||||
return domain.DialogList{}, hydrateErr
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
return domain.DialogList{}, errDialogListSnapshotGenerationChanged
|
||||
}
|
||||
|
||||
type dialogListSnapshotStore interface {
|
||||
ListAllBuiltinDialogSnapshotHeaders(context.Context, int64) (domain.DialogList, error)
|
||||
}
|
||||
|
||||
type channelDialogListSnapshotStore interface {
|
||||
ListAllBuiltinChannelDialogSnapshot(context.Context, int64) (domain.ChannelDialogList, error)
|
||||
}
|
||||
|
||||
type channelDialogSnapshotHydrator interface {
|
||||
HydrateChannelDialogSnapshot(context.Context, int64, []domain.Dialog) (domain.ChannelDialogList, error)
|
||||
}
|
||||
|
||||
type privateDialogPeerIDStore interface {
|
||||
ListPrivateDialogPeerIDs(context.Context, int64, int) ([]int64, error)
|
||||
}
|
||||
|
||||
// PrivateDialogPeerIDs returns the bounded private-peer candidate set used by
|
||||
// transient presence fan-out without entering the full dialogs projection.
|
||||
// A process-cold server first tries the version-addressed shared owner snapshot:
|
||||
// its private dialog headers are fully covered by dialog_owner and can be
|
||||
// sorted into the same narrow result without another PostgreSQL acquisition.
|
||||
func (s *Service) PrivateDialogPeerIDs(ctx context.Context, userID int64, limit int) ([]int64, error) {
|
||||
if s == nil || s.dialogs == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
privateStore, ok := s.dialogs.(privateDialogPeerIDStore)
|
||||
if !ok {
|
||||
return nil, errors.New("dialog store does not provide private peer candidates")
|
||||
}
|
||||
if limit <= 0 || limit > 4096 {
|
||||
limit = 4096
|
||||
}
|
||||
if s.sharedListCache == nil || s.versions == nil {
|
||||
return privateStore.ListPrivateDialogPeerIDs(ctx, userID, limit)
|
||||
}
|
||||
for attempt := 0; attempt < dialogListSnapshotStableReadAttempts; attempt++ {
|
||||
ownerHash, err := s.dialogOwnerHash(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, found, err := s.sharedListCache.GetDialogListSnapshot(
|
||||
ctx,
|
||||
store.DialogListSnapshotCacheKey{UserID: userID, OwnerHash: ownerHash},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ids []int64
|
||||
if found {
|
||||
ids = privateDialogPeerIDsFromDialogs(value.Dialogs, userID, limit)
|
||||
} else {
|
||||
ids, err = privateStore.ListPrivateDialogPeerIDs(ctx, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
currentOwnerHash, err := s.dialogOwnerHash(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentOwnerHash == ownerHash {
|
||||
return ids, nil
|
||||
}
|
||||
}
|
||||
return nil, errDialogListSnapshotGenerationChanged
|
||||
}
|
||||
|
||||
func privateDialogPeerIDsFromDialogs(dialogs []domain.Dialog, userID int64, limit int) []int64 {
|
||||
candidates := make([]domain.Dialog, 0, min(limit, len(dialogs)))
|
||||
seen := make(map[int64]struct{}, min(limit, len(dialogs)))
|
||||
for _, dialog := range dialogs {
|
||||
if dialog.Peer.Type != domain.PeerTypeUser || dialog.Peer.ID == 0 || dialog.Peer.ID == userID {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[dialog.Peer.ID]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[dialog.Peer.ID] = struct{}{}
|
||||
candidates = append(candidates, dialog)
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].TopMessageDate != candidates[j].TopMessageDate {
|
||||
return candidates[i].TopMessageDate > candidates[j].TopMessageDate
|
||||
}
|
||||
if candidates[i].TopMessage != candidates[j].TopMessage {
|
||||
return candidates[i].TopMessage > candidates[j].TopMessage
|
||||
}
|
||||
return candidates[i].Peer.ID > candidates[j].Peer.ID
|
||||
})
|
||||
if len(candidates) > limit {
|
||||
candidates = candidates[:limit]
|
||||
}
|
||||
ids := make([]int64, len(candidates))
|
||||
for index := range candidates {
|
||||
ids[index] = candidates[index].Peer.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (s *Service) supportsDialogListSnapshot() bool {
|
||||
if s == nil || s.listCache == nil {
|
||||
return false
|
||||
}
|
||||
if s.dialogs != nil {
|
||||
if _, ok := s.dialogs.(dialogListSnapshotStore); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if s.channels != nil {
|
||||
if _, ok := s.channels.(channelDialogListSnapshotStore); !ok {
|
||||
return false
|
||||
}
|
||||
if _, ok := s.channels.(channelDialogSnapshotHydrator); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) loadDialogOwnerSnapshotHeaders(ctx context.Context, userID int64) (domain.DialogList, error) {
|
||||
var out domain.DialogList
|
||||
if s.dialogs != nil {
|
||||
headers, err := s.dialogs.(dialogListSnapshotStore).ListAllBuiltinDialogSnapshotHeaders(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
peers := make([]domain.Peer, 0, len(headers.Dialogs))
|
||||
for _, dialog := range headers.Dialogs {
|
||||
if dialog.Peer.Type == domain.PeerTypeUser && dialog.Peer.ID != 0 {
|
||||
peers = append(peers, dialog.Peer)
|
||||
}
|
||||
}
|
||||
materialized := headers
|
||||
if len(peers) > 0 {
|
||||
materialized, err = s.dialogs.ListByPeers(ctx, userID, peers)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
materialized = orderMaterializedDialogList(headers, materialized)
|
||||
}
|
||||
out = mergeDialogLists(out, materialized)
|
||||
}
|
||||
if s.channels != nil {
|
||||
materialized, err := s.channels.(channelDialogListSnapshotStore).ListAllBuiltinChannelDialogSnapshot(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeChannelDialogs(out, materialized)
|
||||
}
|
||||
sortDialogList(out.Dialogs)
|
||||
out.Count = len(out.Dialogs)
|
||||
if err := s.attachArchiveSummaryFromOwnerHeaders(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.attachOwnerSnapshotDrafts(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// attachOwnerSnapshotDrafts materializes the complete bounded cloud-draft set
|
||||
// once under the dialog_owner stable-read fence. Draft writes bump
|
||||
// dialog_light and its aggregate dialog_owner generation, so storing these
|
||||
// overlays in the version-addressed owner snapshot is exact: page reads no
|
||||
// longer need one ListDraftsByPeers query apiece, and a concurrent draft write
|
||||
// forces the snapshot materialization retry before publication.
|
||||
func (s *Service) attachOwnerSnapshotDrafts(ctx context.Context, userID int64, out *domain.DialogList) error {
|
||||
if s == nil || s.dialogs == nil || userID == 0 || out == nil || len(out.Dialogs) == 0 {
|
||||
return nil
|
||||
}
|
||||
drafts, err := s.dialogs.ListDrafts(ctx, userID, domain.MaxDialogDraftsPerUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byPeer := make(map[domain.Peer]domain.DialogDraft, len(drafts))
|
||||
for _, draft := range drafts {
|
||||
if draft.TopMessageID == 0 && draft.Peer.Type != "" && draft.Peer.ID != 0 {
|
||||
byPeer[draft.Peer] = cloneDraft(draft)
|
||||
}
|
||||
}
|
||||
for index := range out.Dialogs {
|
||||
out.Dialogs[index].Draft = nil
|
||||
if draft, found := byPeer[out.Dialogs[index].Peer]; found {
|
||||
draft := cloneDraft(draft)
|
||||
out.Dialogs[index].Draft = &draft
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) attachArchiveSummaryFromOwnerHeaders(ctx context.Context, userID int64, out *domain.DialogList) error {
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
var top domain.Dialog
|
||||
for _, dialog := range out.Dialogs {
|
||||
if dialog.FolderID == domain.DialogArchiveFolderID {
|
||||
top = dialog
|
||||
break
|
||||
}
|
||||
}
|
||||
if top.Peer.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
unreadPeers, unreadMessages := 0, 0
|
||||
if s.dialogs != nil {
|
||||
peers, messages, err := s.dialogs.CountArchiveUnread(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unreadPeers += peers
|
||||
unreadMessages += messages
|
||||
}
|
||||
if s.channels != nil {
|
||||
peers, messages, err := s.channels.CountChannelArchiveUnread(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unreadPeers += peers
|
||||
unreadMessages += messages
|
||||
}
|
||||
archivePinned := true
|
||||
if s.dialogs != nil {
|
||||
pinned, err := s.dialogs.ArchivePinned(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
archivePinned = pinned
|
||||
}
|
||||
out.ArchiveSummary = &domain.DialogArchiveSummary{
|
||||
TopPeer: top.Peer, TopMessage: top.TopMessage,
|
||||
TopDialog: cloneDialogPtr(top),
|
||||
UnreadPeersCount: unreadPeers, UnreadMessagesCount: unreadMessages,
|
||||
Pinned: archivePinned,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) hydrateDialogSnapshotPage(ctx context.Context, userID int64, headers domain.DialogList) (domain.DialogList, error) {
|
||||
hydrated := cloneDialogList(headers)
|
||||
if s.channels != nil {
|
||||
channelDialogs := make([]domain.Dialog, 0, len(hydrated.Dialogs)+1)
|
||||
present := make(map[domain.Peer]struct{}, len(hydrated.Dialogs))
|
||||
for _, dialog := range hydrated.Dialogs {
|
||||
present[dialog.Peer] = struct{}{}
|
||||
if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID != 0 {
|
||||
channelDialogs = append(channelDialogs, dialog)
|
||||
}
|
||||
}
|
||||
if hydrated.ArchiveSummary != nil && hydrated.ArchiveSummary.TopDialog != nil {
|
||||
top := *hydrated.ArchiveSummary.TopDialog
|
||||
if top.Peer.Type == domain.PeerTypeChannel && top.Peer.ID != 0 {
|
||||
if _, ok := present[top.Peer]; !ok {
|
||||
channelDialogs = append(channelDialogs, top)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(channelDialogs) > 0 {
|
||||
projection, err := s.channels.(channelDialogSnapshotHydrator).HydrateChannelDialogSnapshot(ctx, userID, channelDialogs)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
byPeer := make(map[domain.Peer]domain.Dialog, len(projection.Dialogs))
|
||||
for _, dialog := range projection.Dialogs {
|
||||
byPeer[dialog.Peer] = dialog
|
||||
}
|
||||
for index := range hydrated.Dialogs {
|
||||
if dialog, ok := byPeer[hydrated.Dialogs[index].Peer]; ok {
|
||||
hydrated.Dialogs[index] = dialog
|
||||
}
|
||||
}
|
||||
hydrated.ChannelMessages = append(hydrated.ChannelMessages, projection.Messages...)
|
||||
hydrated.Channels = append(hydrated.Channels, projection.Channels...)
|
||||
hydrated.Users = append(hydrated.Users, projection.Users...)
|
||||
}
|
||||
}
|
||||
// Drafts are part of the version-addressed owner snapshot. Re-reading them
|
||||
// per page would discard that materialization and recreate a PostgreSQL
|
||||
// acquisition for every messages.getDialogs cursor.
|
||||
if err := s.projectDialogUsers(ctx, userID, &hydrated); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return hydrated, nil
|
||||
}
|
||||
|
||||
func orderMaterializedDialogList(headers, materialized domain.DialogList) domain.DialogList {
|
||||
materialized.Dialogs = orderMaterializedDialogs(headers.Dialogs, materialized.Dialogs)
|
||||
materialized.Count = len(materialized.Dialogs)
|
||||
return materialized
|
||||
}
|
||||
|
||||
func orderMaterializedDialogs(headers, materialized []domain.Dialog) []domain.Dialog {
|
||||
byPeer := make(map[domain.Peer]domain.Dialog, len(materialized))
|
||||
for _, dialog := range materialized {
|
||||
byPeer[dialog.Peer] = dialog
|
||||
}
|
||||
ordered := make([]domain.Dialog, 0, len(headers))
|
||||
for _, header := range headers {
|
||||
if dialog, ok := byPeer[header.Peer]; ok {
|
||||
ordered = append(ordered, dialog)
|
||||
continue
|
||||
}
|
||||
// Keep the authoritative header so a concurrent disappearance remains
|
||||
// visible to the generation/dependency guard instead of silently
|
||||
// shrinking a page while the read model is being materialized.
|
||||
ordered = append(ordered, header)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func (s *Service) loadDialogs(ctx context.Context, userID int64, filter domain.DialogFilter, lightweight bool) (domain.DialogList, error) {
|
||||
// 在加载任何会话状态前快照 list-hash epoch:若加载/投影期间发生 dialog_light 写失效,
|
||||
// rememberDialogListHash 会据此拒绝写回 stale hash,避免后续 getDialogs 误返 NotModified。
|
||||
listHashEpoch := s.listHashCache.cacheEpoch()
|
||||
var out domain.DialogList
|
||||
if s.dialogs != nil {
|
||||
list, err := s.dialogs.ListByUser(ctx, userID, filter)
|
||||
var list domain.DialogList
|
||||
var err error
|
||||
list, err = s.dialogs.ListByUser(ctx, userID, filter)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
out = mergeDialogLists(out, list)
|
||||
}
|
||||
if s.channels != nil {
|
||||
list, err := s.channels.ListChannelDialogs(ctx, userID, filter)
|
||||
var list domain.ChannelDialogList
|
||||
var err error
|
||||
list, err = s.channels.ListChannelDialogs(ctx, userID, filter)
|
||||
if err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
|
|
@ -246,6 +663,7 @@ func (s *Service) attachArchiveSummary(ctx context.Context, userID int64, filter
|
|||
out.ArchiveSummary = &domain.DialogArchiveSummary{
|
||||
TopPeer: topDialog.Peer,
|
||||
TopMessage: topDialog.TopMessage,
|
||||
TopDialog: cloneDialogPtr(topDialog),
|
||||
UnreadPeersCount: unreadPeers,
|
||||
UnreadMessagesCount: unreadMessages,
|
||||
Pinned: archivePinned,
|
||||
|
|
@ -259,6 +677,11 @@ func (s *Service) attachArchiveSummary(ctx context.Context, userID int64, filter
|
|||
return nil
|
||||
}
|
||||
|
||||
func cloneDialogPtr(dialog domain.Dialog) *domain.Dialog {
|
||||
clone := cloneDialog(dialog)
|
||||
return &clone
|
||||
}
|
||||
|
||||
// GetPeerDialogs 返回指定 peer 的会话摘要。缺失的 peer 由 store 按空会话占位返回。
|
||||
func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) {
|
||||
if s == nil || userID == 0 || len(peers) == 0 {
|
||||
|
|
@ -292,6 +715,12 @@ func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []doma
|
|||
}
|
||||
out = mergeDialogLists(out, channelOut)
|
||||
}
|
||||
if err := s.attachDrafts(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.projectDialogUsers(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
|
@ -836,30 +1265,24 @@ func (s *Service) attachDrafts(ctx context.Context, userID int64, list *domain.D
|
|||
if s == nil || s.dialogs == nil || userID == 0 || list == nil || len(list.Dialogs) == 0 {
|
||||
return nil
|
||||
}
|
||||
drafts, err := s.dialogs.ListDrafts(ctx, userID, domain.MaxDialogDraftsPerUser)
|
||||
peers := make([]domain.Peer, 0, len(list.Dialogs))
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer.ID != 0 {
|
||||
peers = append(peers, dialog.Peer)
|
||||
}
|
||||
}
|
||||
drafts, err := s.dialogDraftsReadModel(ctx, userID, peers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(drafts) == 0 {
|
||||
return nil
|
||||
}
|
||||
byPeer := make(map[domain.Peer]domain.DialogDraft, len(drafts))
|
||||
for _, draft := range drafts {
|
||||
if draft.TopMessageID != 0 {
|
||||
continue
|
||||
}
|
||||
byPeer[draft.Peer] = cloneDraft(draft)
|
||||
}
|
||||
if len(byPeer) == 0 {
|
||||
return nil
|
||||
}
|
||||
attached := false
|
||||
for i := range list.Dialogs {
|
||||
draft, ok := byPeer[list.Dialogs[i].Peer]
|
||||
if !ok {
|
||||
list.Dialogs[i].Draft = nil
|
||||
draft, ok := drafts[list.Dialogs[i].Peer]
|
||||
if !ok || !draft.found {
|
||||
continue
|
||||
}
|
||||
d := cloneDraft(draft)
|
||||
d := cloneDraft(draft.draft)
|
||||
list.Dialogs[i].Draft = &d
|
||||
attached = true
|
||||
}
|
||||
|
|
@ -996,7 +1419,8 @@ func writeDraftRichHash(h interface{ Write([]byte) (int, error) }, buf []byte, r
|
|||
binary.LittleEndian.PutUint64(buf[2:10], uint64(len(rich.Blocks)))
|
||||
binary.LittleEndian.PutUint64(buf[10:18], uint64(len(rich.Photos)))
|
||||
binary.LittleEndian.PutUint64(buf[18:26], uint64(len(rich.Documents)))
|
||||
_, _ = h.Write(buf[:26])
|
||||
binary.LittleEndian.PutUint64(buf[26:34], uint64(rich.EffectiveBlocksLayer()))
|
||||
_, _ = h.Write(buf[:34])
|
||||
_, _ = h.Write(rich.Blocks)
|
||||
for _, photo := range rich.Photos {
|
||||
binary.LittleEndian.PutUint64(buf[:8], uint64(photo.ID))
|
||||
|
|
|
|||
|
|
@ -14,10 +14,241 @@ import (
|
|||
|
||||
type countingDialogStore struct {
|
||||
store.DialogStore
|
||||
listByUserCalls int
|
||||
listByPeersCalls int
|
||||
listByPeersBatches [][]domain.Peer
|
||||
listDraftsCalls int
|
||||
listByUserCalls int
|
||||
listByPeersCalls int
|
||||
listByPeersBatches [][]domain.Peer
|
||||
listDraftsByPeersCalls int
|
||||
listDraftsByPeersBatches [][]domain.Peer
|
||||
listDraftsByPeersErr error
|
||||
}
|
||||
|
||||
type snapshotDialogStore struct {
|
||||
store.DialogStore
|
||||
list domain.DialogList
|
||||
snapshotCalls int
|
||||
listByPeersCalls int
|
||||
listDraftsCalls int
|
||||
privatePeerCalls int
|
||||
onListByPeers func()
|
||||
onListDrafts func()
|
||||
}
|
||||
|
||||
func (s *snapshotDialogStore) ListAllBuiltinDialogSnapshotHeaders(_ context.Context, _ int64) (domain.DialogList, error) {
|
||||
s.snapshotCalls++
|
||||
return cloneDialogList(s.list), nil
|
||||
}
|
||||
|
||||
func (s *snapshotDialogStore) ListByPeers(_ context.Context, _ int64, peers []domain.Peer) (domain.DialogList, error) {
|
||||
s.listByPeersCalls++
|
||||
if s.onListByPeers != nil {
|
||||
fn := s.onListByPeers
|
||||
s.onListByPeers = nil
|
||||
fn()
|
||||
}
|
||||
wanted := make(map[domain.Peer]struct{}, len(peers))
|
||||
for _, peer := range peers {
|
||||
wanted[peer] = struct{}{}
|
||||
}
|
||||
out := domain.DialogList{}
|
||||
for _, dialog := range s.list.Dialogs {
|
||||
if _, ok := wanted[dialog.Peer]; ok {
|
||||
out.Dialogs = append(out.Dialogs, cloneDialog(dialog))
|
||||
}
|
||||
}
|
||||
for _, message := range s.list.Messages {
|
||||
if _, ok := wanted[message.Peer]; ok {
|
||||
out.Messages = append(out.Messages, cloneMessageForDialogCache(message))
|
||||
}
|
||||
}
|
||||
for _, user := range s.list.Users {
|
||||
if _, ok := wanted[domain.Peer{Type: domain.PeerTypeUser, ID: user.ID}]; ok {
|
||||
out.Users = append(out.Users, cloneDialogUser(user))
|
||||
}
|
||||
}
|
||||
out.Count = len(out.Dialogs)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *snapshotDialogStore) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
|
||||
s.listDraftsCalls++
|
||||
drafts, err := s.DialogStore.ListDrafts(ctx, userID, limit)
|
||||
if s.onListDrafts != nil {
|
||||
fn := s.onListDrafts
|
||||
s.onListDrafts = nil
|
||||
fn()
|
||||
}
|
||||
return drafts, err
|
||||
}
|
||||
|
||||
func (s *snapshotDialogStore) ListPrivateDialogPeerIDs(ctx context.Context, userID int64, limit int) ([]int64, error) {
|
||||
s.privatePeerCalls++
|
||||
return s.DialogStore.(privateDialogPeerIDStore).ListPrivateDialogPeerIDs(ctx, userID, limit)
|
||||
}
|
||||
|
||||
func TestGetDialogsSnapshotReusesOwnerProjectionAcrossCursorPages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
peers := []domain.Peer{
|
||||
{Type: domain.PeerTypeUser, ID: 2003},
|
||||
{Type: domain.PeerTypeUser, ID: 2002},
|
||||
{Type: domain.PeerTypeUser, ID: 2001},
|
||||
}
|
||||
base := memory.NewDialogStore()
|
||||
snapshots := &snapshotDialogStore{DialogStore: base, list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{
|
||||
{Peer: peers[0], TopMessage: 30, TopMessageDate: 300},
|
||||
{Peer: peers[1], TopMessage: 20, TopMessageDate: 200},
|
||||
{Peer: peers[2], TopMessage: 10, TopMessageDate: 100},
|
||||
},
|
||||
Messages: []domain.Message{
|
||||
{ID: 30, Peer: peers[0], From: peers[0], Date: 300, Body: "first"},
|
||||
{ID: 20, Peer: peers[1], From: peers[1], Date: 200, Body: "second"},
|
||||
{ID: 10, Peer: peers[2], From: peers[2], Date: 100, Body: "third"},
|
||||
},
|
||||
Users: []domain.User{{ID: 2003}, {ID: 2002}, {ID: 2001}},
|
||||
Count: 3,
|
||||
Hash: 77,
|
||||
}}
|
||||
service := NewService(snapshots)
|
||||
first, err := service.GetDialogs(ctx, ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("first page: %v", err)
|
||||
}
|
||||
if len(first.Dialogs) != 1 || first.Dialogs[0].Peer != peers[0] || len(first.Messages) != 1 || len(first.Users) != 1 || first.Count != 3 {
|
||||
t.Fatalf("first page = %+v, messages=%d users=%d count=%d", first.Dialogs, len(first.Messages), len(first.Users), first.Count)
|
||||
}
|
||||
second, err := service.GetDialogs(ctx, ownerID, domain.DialogFilter{
|
||||
ExcludePinned: true,
|
||||
Limit: 1,
|
||||
OffsetDate: first.Dialogs[0].TopMessageDate,
|
||||
OffsetID: first.Dialogs[0].TopMessage,
|
||||
HasOffsetPeer: true,
|
||||
OffsetPeer: first.Dialogs[0].Peer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second page: %v", err)
|
||||
}
|
||||
if len(second.Dialogs) != 1 || second.Dialogs[0].Peer != peers[1] || len(second.Messages) != 1 || len(second.Users) != 1 || second.Count != 3 {
|
||||
t.Fatalf("second page = %+v, messages=%d users=%d count=%d", second.Dialogs, len(second.Messages), len(second.Users), second.Count)
|
||||
}
|
||||
if snapshots.snapshotCalls != 1 {
|
||||
t.Fatalf("snapshot calls = %d, want one owner load across pages", snapshots.snapshotCalls)
|
||||
}
|
||||
|
||||
service.InvalidateDialog(ownerID, peers[0])
|
||||
if _, err := service.GetDialogs(ctx, ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 1}); err != nil {
|
||||
t.Fatalf("reload after owner invalidation: %v", err)
|
||||
}
|
||||
if snapshots.snapshotCalls != 2 {
|
||||
t.Fatalf("snapshot calls after invalidation = %d, want 2", snapshots.snapshotCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsSnapshotMaterializesDraftsOnceAcrossCursorPages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1101
|
||||
firstPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2101}
|
||||
secondPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2102}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveDraft(ctx, ownerID, domain.DialogDraft{
|
||||
Peer: secondPeer, Date: 22, Message: "second-page draft",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshots := &snapshotDialogStore{DialogStore: base, list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{
|
||||
{Peer: firstPeer, TopMessage: 2, TopMessageDate: 20},
|
||||
{Peer: secondPeer, TopMessage: 1, TopMessageDate: 10},
|
||||
},
|
||||
Users: []domain.User{{ID: firstPeer.ID}, {ID: secondPeer.ID}},
|
||||
}}
|
||||
service := NewService(snapshots)
|
||||
first, err := service.GetDialogs(ctx, ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := service.GetDialogs(ctx, ownerID, domain.DialogFilter{
|
||||
ExcludePinned: true, Limit: 1,
|
||||
OffsetDate: first.Dialogs[0].TopMessageDate, OffsetID: first.Dialogs[0].TopMessage,
|
||||
HasOffsetPeer: true, OffsetPeer: first.Dialogs[0].Peer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(second.Dialogs) != 1 || second.Dialogs[0].Draft == nil || second.Dialogs[0].Draft.Message != "second-page draft" {
|
||||
t.Fatalf("second page draft = %+v", second.Dialogs)
|
||||
}
|
||||
if snapshots.snapshotCalls != 1 || snapshots.listDraftsCalls != 1 {
|
||||
t.Fatalf("snapshot/draft loads = %d/%d, want 1/1 across pages", snapshots.snapshotCalls, snapshots.listDraftsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogSnapshotChannelDependencyInvalidatesOwnerProjection(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 77}
|
||||
service := NewService(memory.NewDialogStore())
|
||||
key, ok := dialogSnapshotKey(ownerID, domain.DialogFilter{ExcludePinned: true})
|
||||
if !ok {
|
||||
t.Fatal("standard main-folder snapshot key was rejected")
|
||||
}
|
||||
service.listCache.cache.Store(key, newDialogListSnapshot(domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: channelPeer, TopMessage: 1, TopMessageDate: 10}},
|
||||
Channels: []domain.Channel{{ID: channelPeer.ID, Title: "before"}},
|
||||
Count: 1,
|
||||
}))
|
||||
service.InvalidateDialogListsForChannel(channelPeer.ID)
|
||||
if got := service.listCache.cache.Len(); got != 0 {
|
||||
t.Fatalf("snapshot cache entries = %d, want channel dependency invalidation", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogSnapshotCacheBoundsAggregateHeaderWeight(t *testing.T) {
|
||||
cache := newDialogListSnapshotCache(10, 3, time.Hour)
|
||||
first := dialogListSnapshotKey{userID: 1001}
|
||||
second := dialogListSnapshotKey{userID: 1002}
|
||||
cache.cache.Store(first, newDialogListSnapshot(domain.DialogList{Dialogs: []domain.Dialog{
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 1}},
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2}},
|
||||
}}))
|
||||
cache.cache.Store(second, newDialogListSnapshot(domain.DialogList{Dialogs: []domain.Dialog{
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3}},
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 4}},
|
||||
}}))
|
||||
if _, ok := cache.cache.Peek(first); ok {
|
||||
t.Fatal("old owner snapshot should be evicted by aggregate header budget")
|
||||
}
|
||||
if _, ok := cache.cache.Peek(second); !ok {
|
||||
t.Fatal("new owner snapshot should remain within aggregate header budget")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogSnapshotDependencyIndexTracksLRUEvictionAndReplacement(t *testing.T) {
|
||||
cache := newDialogListSnapshotCache(1, 10, time.Hour)
|
||||
first := dialogListSnapshotKey{userID: 1001}
|
||||
second := dialogListSnapshotKey{userID: 1002}
|
||||
cache.cache.Store(first, newDialogListSnapshot(domain.DialogList{Dialogs: []domain.Dialog{
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 7}},
|
||||
}}))
|
||||
cache.cache.Store(second, newDialogListSnapshot(domain.DialogList{Dialogs: []domain.Dialog{
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 8}},
|
||||
}}))
|
||||
cache.indexMu.Lock()
|
||||
_, staleEvicted := cache.channelKeys[7]
|
||||
_, retained := cache.channelKeys[8]
|
||||
cache.indexMu.Unlock()
|
||||
if staleEvicted || !retained {
|
||||
t.Fatalf("dependency index after LRU eviction: channel7=%v channel8=%v", staleEvicted, retained)
|
||||
}
|
||||
cache.cache.Store(second, newDialogListSnapshot(domain.DialogList{Dialogs: []domain.Dialog{
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 9}},
|
||||
}}))
|
||||
cache.indexMu.Lock()
|
||||
_, staleReplaced := cache.channelKeys[8]
|
||||
_, replaced := cache.channelKeys[9]
|
||||
cache.indexMu.Unlock()
|
||||
if staleReplaced || !replaced {
|
||||
t.Fatalf("dependency index after replacement: channel8=%v channel9=%v", staleReplaced, replaced)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *countingDialogStore) ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
|
||||
|
|
@ -31,9 +262,13 @@ func (s *countingDialogStore) ListByPeers(ctx context.Context, userID int64, pee
|
|||
return s.DialogStore.ListByPeers(ctx, userID, peers)
|
||||
}
|
||||
|
||||
func (s *countingDialogStore) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) {
|
||||
s.listDraftsCalls++
|
||||
return s.DialogStore.ListDrafts(ctx, userID, limit)
|
||||
func (s *countingDialogStore) ListDraftsByPeers(ctx context.Context, userID int64, peers []domain.Peer) ([]domain.DialogDraft, error) {
|
||||
s.listDraftsByPeersCalls++
|
||||
s.listDraftsByPeersBatches = append(s.listDraftsByPeersBatches, append([]domain.Peer(nil), peers...))
|
||||
if s.listDraftsByPeersErr != nil {
|
||||
return nil, s.listDraftsByPeersErr
|
||||
}
|
||||
return s.DialogStore.ListDraftsByPeers(ctx, userID, peers)
|
||||
}
|
||||
|
||||
type fakeDialogReadModelVersions struct {
|
||||
|
|
@ -179,6 +414,51 @@ func TestSaveDraftNoopsWhenOnlyDateChanges(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsLoadsDraftsOnlyForCurrentPage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
firstPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
secondPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1003}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveList(ctx, ownerID, domain.DialogList{
|
||||
Dialogs: []domain.Dialog{
|
||||
{Peer: firstPeer, TopMessage: 11, TopMessageDate: 200},
|
||||
{Peer: secondPeer, TopMessage: 12, TopMessageDate: 100},
|
||||
},
|
||||
Messages: []domain.Message{
|
||||
{ID: 11, OwnerUserID: ownerID, Peer: firstPeer, From: firstPeer, Date: 200, Body: "first"},
|
||||
{ID: 12, OwnerUserID: ownerID, Peer: secondPeer, From: secondPeer, Date: 100, Body: "second"},
|
||||
},
|
||||
Users: []domain.User{
|
||||
{ID: firstPeer.ID, FirstName: "First"},
|
||||
{ID: secondPeer.ID, FirstName: "Second"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveList: %v", err)
|
||||
}
|
||||
if err := base.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: firstPeer, Date: 201, Message: "first draft"}); err != nil {
|
||||
t.Fatalf("save first draft: %v", err)
|
||||
}
|
||||
if err := base.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: secondPeer, Date: 202, Message: "second draft"}); err != nil {
|
||||
t.Fatalf("save second draft: %v", err)
|
||||
}
|
||||
counting := &countingDialogStore{DialogStore: base}
|
||||
|
||||
list, err := NewService(counting).GetDialogs(ctx, ownerID, domain.DialogFilter{Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 1 || list.Dialogs[0].Peer != firstPeer || list.Dialogs[0].Draft == nil || list.Dialogs[0].Draft.Message != "first draft" {
|
||||
t.Fatalf("dialogs = %+v, want first page with its draft", list.Dialogs)
|
||||
}
|
||||
if counting.listDraftsByPeersCalls != 1 || len(counting.listDraftsByPeersBatches) != 1 {
|
||||
t.Fatalf("ListDraftsByPeers calls/batches = %d/%d, want 1/1", counting.listDraftsByPeersCalls, len(counting.listDraftsByPeersBatches))
|
||||
}
|
||||
if got := counting.listDraftsByPeersBatches[0]; len(got) != 1 || got[0] != firstPeer {
|
||||
t.Fatalf("draft peer batch = %+v, want current-page peer %+v", got, firstPeer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPeerDialogsCachesPrivatePeerReadModelByHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
|
|
@ -226,16 +506,19 @@ func TestGetPeerDialogsCachesPrivatePeerReadModelByHash(t *testing.T) {
|
|||
if len(second.Dialogs) != 1 || second.Dialogs[0].TopMessage != 7 {
|
||||
t.Fatalf("second dialog = %+v, want cached top message", second.Dialogs)
|
||||
}
|
||||
if counting.listByPeersCalls != 1 || counting.listDraftsCalls != 1 {
|
||||
t.Fatalf("store calls ListByPeers/ListDrafts = %d/%d, want 1/1 after cache hit", counting.listByPeersCalls, counting.listDraftsCalls)
|
||||
if counting.listByPeersCalls != 1 || counting.listDraftsByPeersCalls != 1 {
|
||||
t.Fatalf("store calls ListByPeers/ListDraftsByPeers = %d/%d, want 1/1 after cache hit", counting.listByPeersCalls, counting.listDraftsByPeersCalls)
|
||||
}
|
||||
if got := counting.listDraftsByPeersBatches[0]; len(got) != 1 || got[0] != peer {
|
||||
t.Fatalf("draft peer batch = %+v, want only %+v", got, peer)
|
||||
}
|
||||
|
||||
versions.hashes[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}] = 202
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("third GetPeerDialogs after hash bump: %v", err)
|
||||
}
|
||||
if counting.listByPeersCalls != 2 || counting.listDraftsCalls != 2 {
|
||||
t.Fatalf("store calls after hash bump = %d/%d, want 2/2", counting.listByPeersCalls, counting.listDraftsCalls)
|
||||
if counting.listByPeersCalls != 2 || counting.listDraftsByPeersCalls != 2 {
|
||||
t.Fatalf("store calls after hash bump = %d/%d, want 2/2", counting.listByPeersCalls, counting.listDraftsByPeersCalls)
|
||||
}
|
||||
|
||||
if _, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 72, Message: "new draft"}); err != nil {
|
||||
|
|
@ -244,8 +527,8 @@ func TestGetPeerDialogsCachesPrivatePeerReadModelByHash(t *testing.T) {
|
|||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("GetPeerDialogs after service invalidation: %v", err)
|
||||
}
|
||||
if counting.listByPeersCalls != 3 || counting.listDraftsCalls != 3 {
|
||||
t.Fatalf("store calls after explicit invalidation = %d/%d, want 3/3", counting.listByPeersCalls, counting.listDraftsCalls)
|
||||
if counting.listByPeersCalls != 3 || counting.listDraftsByPeersCalls != 3 {
|
||||
t.Fatalf("store calls after explicit invalidation = %d/%d, want 3/3", counting.listByPeersCalls, counting.listDraftsByPeersCalls)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -295,7 +578,7 @@ func TestGetPeerDialogsReloadsOnlyReadModelCacheMisses(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetPeerDialogsCachesChannelPeerReadModelByCompositeHash(t *testing.T) {
|
||||
func TestGetPeerDialogsUsesStoreChannelProjectionAndVersionedDraftCache(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
dialogStore := &countingDialogStore{DialogStore: memory.NewDialogStore()}
|
||||
|
|
@ -320,9 +603,7 @@ func TestGetPeerDialogsCachesChannelPeerReadModelByCompositeHash(t *testing.T) {
|
|||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: channelBaseReadModel, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}: 11,
|
||||
{Model: channelMemberReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 22,
|
||||
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 33,
|
||||
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 33,
|
||||
}}
|
||||
dialogs := NewService(dialogStore, channelStore).Configure(WithReadModelVersions(versions))
|
||||
|
||||
|
|
@ -339,18 +620,18 @@ func TestGetPeerDialogsCachesChannelPeerReadModelByCompositeHash(t *testing.T) {
|
|||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("second GetPeerDialogs: %v", err)
|
||||
}
|
||||
if channelStore.getChannelDialogsCalls != 1 || dialogStore.listDraftsCalls != 1 {
|
||||
t.Fatalf("store calls GetChannelDialogs/ListDrafts = %d/%d, want 1/1 after channel cache hit",
|
||||
channelStore.getChannelDialogsCalls, dialogStore.listDraftsCalls)
|
||||
if channelStore.getChannelDialogsCalls != 2 || dialogStore.listDraftsByPeersCalls != 1 {
|
||||
t.Fatalf("store calls GetChannelDialogs/ListDraftsByPeers = %d/%d, want 2/1 without duplicate Service channel cache",
|
||||
channelStore.getChannelDialogsCalls, dialogStore.listDraftsByPeersCalls)
|
||||
}
|
||||
|
||||
versions.hashes[store.ReadModelKey{Model: channelMemberReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}] = 44
|
||||
versions.hashes[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}] = 44
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("third GetPeerDialogs after member hash bump: %v", err)
|
||||
t.Fatalf("third GetPeerDialogs after dialog hash bump: %v", err)
|
||||
}
|
||||
if channelStore.getChannelDialogsCalls != 2 || dialogStore.listDraftsCalls != 2 {
|
||||
t.Fatalf("store calls after member hash bump = %d/%d, want 2/2",
|
||||
channelStore.getChannelDialogsCalls, dialogStore.listDraftsCalls)
|
||||
if channelStore.getChannelDialogsCalls != 3 || dialogStore.listDraftsByPeersCalls != 2 {
|
||||
t.Fatalf("store calls after dialog hash bump = %d/%d, want 3/2",
|
||||
channelStore.getChannelDialogsCalls, dialogStore.listDraftsByPeersCalls)
|
||||
}
|
||||
|
||||
if _, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 1700003220, Message: "channel draft"}); err != nil {
|
||||
|
|
@ -359,9 +640,121 @@ func TestGetPeerDialogsCachesChannelPeerReadModelByCompositeHash(t *testing.T) {
|
|||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("GetPeerDialogs after draft invalidation: %v", err)
|
||||
}
|
||||
if channelStore.getChannelDialogsCalls != 3 || dialogStore.listDraftsCalls != 3 {
|
||||
t.Fatalf("store calls after draft invalidation = %d/%d, want 3/3",
|
||||
channelStore.getChannelDialogsCalls, dialogStore.listDraftsCalls)
|
||||
if channelStore.getChannelDialogsCalls != 4 || dialogStore.listDraftsByPeersCalls != 3 {
|
||||
t.Fatalf("store calls after draft invalidation = %d/%d, want 4/3",
|
||||
channelStore.getChannelDialogsCalls, dialogStore.listDraftsByPeersCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHydrationDoesNotEvictPrivatePeerStructure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
privatePeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveList(ctx, ownerID, domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: privatePeer, TopMessage: 7, TopMessageDate: 70}},
|
||||
Messages: []domain.Message{{ID: 7, OwnerUserID: ownerID, Peer: privatePeer, From: privatePeer, Body: "private"}},
|
||||
Users: []domain.User{{ID: privatePeer.ID}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveList: %v", err)
|
||||
}
|
||||
dialogStore := &countingDialogStore{DialogStore: base}
|
||||
channelStore := &countingDialogChannelStore{ChannelStore: memory.NewChannelStore()}
|
||||
channels := appchannels.NewService(channelStore)
|
||||
channelPeers := make([]domain.Peer, 0, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
created, err := channels.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{Title: "channel", Megagroup: true, Date: 100 + i})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateChannel(%d): %v", i, err)
|
||||
}
|
||||
channelPeers = append(channelPeers, domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID})
|
||||
}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: privatePeer.Type, PeerID: privatePeer.ID}: 1,
|
||||
}}
|
||||
dialogs := NewService(dialogStore, channelStore).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithDialogHydrationCaches(1, 1<<20, 10, 1<<20),
|
||||
)
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{privatePeer}); err != nil {
|
||||
t.Fatalf("first private hydration: %v", err)
|
||||
}
|
||||
for _, peer := range channelPeers {
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil {
|
||||
t.Fatalf("channel hydration %+v: %v", peer, err)
|
||||
}
|
||||
}
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{privatePeer}); err != nil {
|
||||
t.Fatalf("second private hydration: %v", err)
|
||||
}
|
||||
if dialogStore.listByPeersCalls != 1 {
|
||||
t.Fatalf("private ListByPeers calls = %d, want 1 after channel churn", dialogStore.listByPeersCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivatePeerCacheReprojectsCurrentViewerUserFacts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveList(ctx, ownerID, domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 7}},
|
||||
Users: []domain.User{{ID: peer.ID, FirstName: "Peer"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveList: %v", err)
|
||||
}
|
||||
counting := &countingDialogStore{DialogStore: base}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 101,
|
||||
}}
|
||||
photos := dialogProfilePhotos{peer.ID: {PhotoID: 1}}
|
||||
dialogs := NewService(counting).Configure(WithReadModelVersions(versions), WithPhotoProvider(photos))
|
||||
first, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer})
|
||||
if err != nil {
|
||||
t.Fatalf("first GetPeerDialogs: %v", err)
|
||||
}
|
||||
photos[peer.ID] = domain.ProfilePhotoRef{PhotoID: 2}
|
||||
second, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer})
|
||||
if err != nil {
|
||||
t.Fatalf("second GetPeerDialogs: %v", err)
|
||||
}
|
||||
if len(first.Users) != 1 || first.Users[0].PhotoID != 1 || len(second.Users) != 1 || second.Users[0].PhotoID != 2 {
|
||||
t.Fatalf("projected photos first/second = %+v/%+v, want 1/2", first.Users, second.Users)
|
||||
}
|
||||
if counting.listByPeersCalls != 1 {
|
||||
t.Fatalf("ListByPeers calls = %d, want one structural load", counting.listByPeersCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftReadErrorIsNotCachedAsNegative(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveList(ctx, ownerID, domain.DialogList{Dialogs: []domain.Dialog{{Peer: peer}}}); err != nil {
|
||||
t.Fatalf("SaveList: %v", err)
|
||||
}
|
||||
if err := base.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Message: "recovered"}); err != nil {
|
||||
t.Fatalf("SaveDraft: %v", err)
|
||||
}
|
||||
counting := &countingDialogStore{DialogStore: base, listDraftsByPeersErr: errors.New("temporary draft read failure")}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 101,
|
||||
}}
|
||||
dialogs := NewService(counting).Configure(WithReadModelVersions(versions))
|
||||
if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err == nil {
|
||||
t.Fatal("first GetPeerDialogs error = nil, want draft read failure")
|
||||
}
|
||||
counting.listDraftsByPeersErr = nil
|
||||
got, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer})
|
||||
if err != nil {
|
||||
t.Fatalf("recovered GetPeerDialogs: %v", err)
|
||||
}
|
||||
if len(got.Dialogs) != 1 || got.Dialogs[0].Draft == nil || got.Dialogs[0].Draft.Message != "recovered" {
|
||||
t.Fatalf("recovered dialogs = %+v", got.Dialogs)
|
||||
}
|
||||
if counting.listDraftsByPeersCalls != 2 {
|
||||
t.Fatalf("ListDraftsByPeers calls = %d, want retry after error", counting.listDraftsByPeersCalls)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
208
internal/app/dialogs/shared_list_snapshot.go
Normal file
208
internal/app/dialogs/shared_list_snapshot.go
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const dialogListSnapshotMaterializeAttempts = 2
|
||||
|
||||
var errDialogListSnapshotGenerationChanged = errors.New("dialog list snapshot owner generation changed")
|
||||
|
||||
func (s *Service) loadDialogListSnapshot(
|
||||
ctx context.Context,
|
||||
key dialogListSnapshotKey,
|
||||
) (*dialogListSnapshot, error) {
|
||||
if s.sharedListCache == nil {
|
||||
list, err := s.loadDialogOwnerSnapshotHeaders(ctx, key.userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newDialogListSnapshot(list), nil
|
||||
}
|
||||
if s.versions == nil {
|
||||
return nil, errors.New("shared dialog list snapshot requires durable read-model versions")
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < dialogListSnapshotMaterializeAttempts; attempt++ {
|
||||
ownerHash, err := s.dialogOwnerHash(ctx, key.userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap, err := s.loadDialogListSnapshotAtOwnerHash(ctx, key, ownerHash)
|
||||
if errors.Is(err, errDialogListSnapshotGenerationChanged) {
|
||||
continue
|
||||
}
|
||||
return snap, err
|
||||
}
|
||||
return nil, errDialogListSnapshotGenerationChanged
|
||||
}
|
||||
|
||||
func (s *Service) loadDialogListSnapshotAtOwnerHash(
|
||||
ctx context.Context,
|
||||
key dialogListSnapshotKey,
|
||||
ownerHash int64,
|
||||
) (*dialogListSnapshot, error) {
|
||||
if s.sharedListCache == nil {
|
||||
list, err := s.loadDialogOwnerSnapshotHeaders(ctx, key.userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newDialogListSnapshot(list), nil
|
||||
}
|
||||
if s.versions == nil {
|
||||
return nil, errors.New("shared dialog list snapshot requires durable read-model versions")
|
||||
}
|
||||
if ownerHash == 0 {
|
||||
return nil, errors.New("dialog_owner read-model generation missing")
|
||||
}
|
||||
|
||||
sharedKey := sharedDialogListSnapshotKey(key, ownerHash)
|
||||
cached, found, err := s.sharedListCache.GetDialogListSnapshot(ctx, sharedKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
snap := dialogListSnapshotFromShared(cached)
|
||||
snap.ownerHash = ownerHash
|
||||
dependencyHash, err := s.dialogListSnapshotDependencyHash(ctx, ownerHash, snap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dependencyHash == cached.DependencyHash {
|
||||
return snap, nil
|
||||
}
|
||||
}
|
||||
|
||||
list, err := s.loadDialogOwnerSnapshotHeaders(ctx, key.userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap := newDialogListSnapshot(list)
|
||||
currentOwnerHash, err := s.dialogOwnerHash(ctx, key.userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentOwnerHash != ownerHash {
|
||||
return nil, errDialogListSnapshotGenerationChanged
|
||||
}
|
||||
dependencyHash, err := s.dialogListSnapshotDependencyHash(ctx, ownerHash, snap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap.ownerHash = ownerHash
|
||||
snap.dependencyHash = dependencyHash
|
||||
value := sharedDialogListSnapshotValue(snap, dependencyHash)
|
||||
if err := s.sharedListCache.PutDialogListSnapshot(ctx, sharedKey, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
func (s *Service) dialogOwnerHash(ctx context.Context, userID int64) (int64, error) {
|
||||
hash, found, err := s.versions.ReadModelHash(
|
||||
ctx, readmodel.ModelDialogOwner, userID, domain.PeerTypeUser, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !found || hash == 0 {
|
||||
return 0, errors.New("dialog_owner read-model generation missing")
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func (s *Service) dialogListSnapshotDependencyHash(
|
||||
ctx context.Context,
|
||||
ownerHash int64,
|
||||
snap *dialogListSnapshot,
|
||||
) (int64, error) {
|
||||
peers := dialogListSnapshotPeers(snap)
|
||||
keys := make([]store.ReadModelKey, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.Type == domain.PeerTypeChannel {
|
||||
keys = append(keys, store.ReadModelKey{
|
||||
Model: readmodel.ModelChannelBase, PeerType: peer.Type, PeerID: peer.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
hashes, err := s.versions.ReadModelHashes(ctx, keys)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
values := make([]int64, 0, len(keys)+1)
|
||||
values = append(values, ownerHash)
|
||||
for _, key := range keys {
|
||||
hash := hashes[key]
|
||||
if hash == 0 {
|
||||
return 0, errors.New("dialog snapshot dependency generation missing")
|
||||
}
|
||||
values = append(values, hash)
|
||||
}
|
||||
return readmodel.MixHashes(values...), nil
|
||||
}
|
||||
|
||||
func dialogListSnapshotPeers(snap *dialogListSnapshot) []domain.Peer {
|
||||
if snap == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[domain.Peer]struct{}, len(snap.dialogs)+1)
|
||||
peers := make([]domain.Peer, 0, len(snap.dialogs)+1)
|
||||
appendPeer := func(peer domain.Peer) {
|
||||
if peer.Type == "" || peer.ID == 0 {
|
||||
return
|
||||
}
|
||||
if _, found := seen[peer]; found {
|
||||
return
|
||||
}
|
||||
seen[peer] = struct{}{}
|
||||
peers = append(peers, peer)
|
||||
}
|
||||
for _, dialog := range snap.dialogs {
|
||||
appendPeer(dialog.Peer)
|
||||
}
|
||||
if snap.archive != nil {
|
||||
appendPeer(snap.archive.TopPeer)
|
||||
}
|
||||
sort.Slice(peers, func(i, j int) bool {
|
||||
if peers[i].Type != peers[j].Type {
|
||||
return peers[i].Type < peers[j].Type
|
||||
}
|
||||
return peers[i].ID < peers[j].ID
|
||||
})
|
||||
return peers
|
||||
}
|
||||
|
||||
func sharedDialogListSnapshotKey(key dialogListSnapshotKey, ownerHash int64) store.DialogListSnapshotCacheKey {
|
||||
return store.DialogListSnapshotCacheKey{
|
||||
UserID: key.userID, OwnerHash: ownerHash,
|
||||
}
|
||||
}
|
||||
|
||||
func sharedDialogListSnapshotValue(snap *dialogListSnapshot, dependencyHash int64) store.DialogListSnapshotCacheValue {
|
||||
value := store.DialogListSnapshotCacheValue{DependencyHash: dependencyHash}
|
||||
if snap == nil {
|
||||
return value
|
||||
}
|
||||
value.Dialogs = cloneDialogSlice(snap.dialogs)
|
||||
value.Messages = cloneDialogMessages(snap.messages)
|
||||
value.Users = cloneDialogUsers(snap.users)
|
||||
value.State = snap.state
|
||||
value.ArchiveSummary = cloneDialogArchiveSummary(snap.archive)
|
||||
return value
|
||||
}
|
||||
|
||||
func dialogListSnapshotFromShared(value store.DialogListSnapshotCacheValue) *dialogListSnapshot {
|
||||
list := domain.DialogList{
|
||||
Dialogs: value.Dialogs, Messages: value.Messages, Users: value.Users,
|
||||
State: value.State, ArchiveSummary: value.ArchiveSummary,
|
||||
}
|
||||
snap := newDialogListSnapshot(list)
|
||||
snap.dependencyHash = value.DependencyHash
|
||||
return snap
|
||||
}
|
||||
445
internal/app/dialogs/shared_list_snapshot_test.go
Normal file
445
internal/app/dialogs/shared_list_snapshot_test.go
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/app/readmodel"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type fakeSharedDialogListSnapshotCache struct {
|
||||
value store.DialogListSnapshotCacheValue
|
||||
found bool
|
||||
getErr error
|
||||
putErr error
|
||||
getCalls int
|
||||
putCalls int
|
||||
putKey store.DialogListSnapshotCacheKey
|
||||
putValue store.DialogListSnapshotCacheValue
|
||||
}
|
||||
|
||||
func (f *fakeSharedDialogListSnapshotCache) GetDialogListSnapshot(
|
||||
_ context.Context,
|
||||
_ store.DialogListSnapshotCacheKey,
|
||||
) (store.DialogListSnapshotCacheValue, bool, error) {
|
||||
f.getCalls++
|
||||
return f.value, f.found, f.getErr
|
||||
}
|
||||
|
||||
func (f *fakeSharedDialogListSnapshotCache) PutDialogListSnapshot(
|
||||
_ context.Context,
|
||||
key store.DialogListSnapshotCacheKey,
|
||||
value store.DialogListSnapshotCacheValue,
|
||||
) error {
|
||||
f.putCalls++
|
||||
f.putKey = key
|
||||
f.putValue = value
|
||||
return f.putErr
|
||||
}
|
||||
|
||||
func TestSharedDialogListSnapshotHitAvoidsAuthoritativeHeaderScan(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 11,
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{
|
||||
found: true,
|
||||
value: store.DialogListSnapshotCacheValue{
|
||||
DependencyHash: readmodel.MixHashes(11),
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 7, TopMessageDate: 70}},
|
||||
},
|
||||
}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore()}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
snap, err := service.loadDialogListSnapshot(context.Background(), dialogListSnapshotKey{userID: ownerID})
|
||||
if err != nil {
|
||||
t.Fatalf("load shared snapshot: %v", err)
|
||||
}
|
||||
if authoritative.snapshotCalls != 0 || shared.getCalls != 1 || shared.putCalls != 0 {
|
||||
t.Fatalf("calls header/get/put = %d/%d/%d, want 0/1/0",
|
||||
authoritative.snapshotCalls, shared.getCalls, shared.putCalls)
|
||||
}
|
||||
if snap == nil || len(snap.dialogs) != 1 || snap.dialogs[0].Peer != peer {
|
||||
t.Fatalf("snapshot = %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateDialogPeerIDsUsesVersionedSharedOwnerSnapshot(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
newer := domain.Peer{Type: domain.PeerTypeUser, ID: 1004}
|
||||
older := domain.Peer{Type: domain.PeerTypeUser, ID: 1003}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 41,
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{found: true, value: store.DialogListSnapshotCacheValue{
|
||||
DependencyHash: readmodel.MixHashes(41),
|
||||
Dialogs: []domain.Dialog{
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}, TopMessageDate: 999},
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2001}, TopMessageDate: 998},
|
||||
{Peer: older, TopMessage: 9, TopMessageDate: 10},
|
||||
{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, TopMessage: 1, TopMessageDate: 20},
|
||||
{Peer: newer, TopMessage: 3, TopMessageDate: 20},
|
||||
},
|
||||
}}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore()}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
ids, err := service.PrivateDialogPeerIDs(context.Background(), ownerID, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != 2 || ids[0] != newer.ID || ids[1] != 1002 {
|
||||
t.Fatalf("private peer ids = %v, want [%d 1002]", ids, newer.ID)
|
||||
}
|
||||
if authoritative.privatePeerCalls != 0 || shared.getCalls != 1 {
|
||||
t.Fatalf("authoritative/shared calls = %d/%d, want 0/1", authoritative.privatePeerCalls, shared.getCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateDialogPeerIDsCacheMissUsesStableNarrowStoreRead(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveList(ctx, ownerID, domain.DialogList{Dialogs: []domain.Dialog{{
|
||||
Peer: peer, TopMessage: 7, TopMessageDate: 70,
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 42,
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{}
|
||||
authoritative := &snapshotDialogStore{DialogStore: base}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
ids, err := service.PrivateDialogPeerIDs(ctx, ownerID, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != 1 || ids[0] != peer.ID || authoritative.privatePeerCalls != 1 || shared.getCalls != 1 {
|
||||
t.Fatalf("ids/calls = %v/%d/%d, want [%d]/1/1", ids, authoritative.privatePeerCalls, shared.getCalls, peer.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedDialogListSnapshotHitServesMaterializedPageWithoutPeerHydration(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 12,
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{
|
||||
found: true,
|
||||
value: store.DialogListSnapshotCacheValue{
|
||||
DependencyHash: readmodel.MixHashes(12),
|
||||
Dialogs: []domain.Dialog{{
|
||||
Peer: peer, TopMessage: 7, TopMessageDate: 70,
|
||||
Draft: &domain.DialogDraft{Peer: peer, Date: 71, Message: "materialized draft"},
|
||||
}},
|
||||
Messages: []domain.Message{{ID: 7, Peer: peer, From: peer, Date: 70, Body: "materialized"}},
|
||||
Users: []domain.User{{ID: peer.ID, FirstName: "cached"}},
|
||||
},
|
||||
}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore()}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
page, err := service.GetDialogs(context.Background(), ownerID, domain.DialogFilter{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("get materialized shared page: %v", err)
|
||||
}
|
||||
if authoritative.snapshotCalls != 0 || authoritative.listByPeersCalls != 0 || authoritative.listDraftsCalls != 0 {
|
||||
t.Fatalf("authoritative snapshot/peer/draft calls = %d/%d/%d, want 0/0/0",
|
||||
authoritative.snapshotCalls, authoritative.listByPeersCalls, authoritative.listDraftsCalls)
|
||||
}
|
||||
if len(page.Dialogs) != 1 || len(page.Messages) != 1 || page.Messages[0].Body != "materialized" ||
|
||||
len(page.Users) != 1 || page.Users[0].ID != peer.ID || page.Dialogs[0].Draft == nil ||
|
||||
page.Dialogs[0].Draft.Message != "materialized draft" {
|
||||
t.Fatalf("materialized page = dialogs:%+v messages:%+v users:%+v", page.Dialogs, page.Messages, page.Users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedDialogListSnapshotDependencyMismatchRebuildsAndPublishes(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 31,
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{
|
||||
found: true,
|
||||
value: store.DialogListSnapshotCacheValue{
|
||||
DependencyHash: 999,
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 1}},
|
||||
},
|
||||
}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore(), list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 8, TopMessageDate: 80}}, Count: 1,
|
||||
}}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
snap, err := service.loadDialogListSnapshot(context.Background(), dialogListSnapshotKey{userID: ownerID})
|
||||
if err != nil {
|
||||
t.Fatalf("rebuild shared snapshot: %v", err)
|
||||
}
|
||||
if authoritative.snapshotCalls != 1 || shared.putCalls != 1 {
|
||||
t.Fatalf("calls header/put = %d/%d, want 1/1", authoritative.snapshotCalls, shared.putCalls)
|
||||
}
|
||||
if shared.putKey.OwnerHash != 31 || shared.putValue.DependencyHash != readmodel.MixHashes(31) {
|
||||
t.Fatalf("published key/value = %+v/%+v", shared.putKey, shared.putValue)
|
||||
}
|
||||
if snap == nil || len(snap.dialogs) != 1 || snap.dialogs[0].TopMessage != 8 {
|
||||
t.Fatalf("rebuilt snapshot = %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedDialogListSnapshotValidatesSharedChannelGeneration(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 2001}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 61,
|
||||
{Model: readmodel.ModelChannelBase, PeerType: peer.Type, PeerID: peer.ID}: 71,
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{
|
||||
found: true,
|
||||
value: store.DialogListSnapshotCacheValue{
|
||||
DependencyHash: readmodel.MixHashes(61, 70),
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 1}},
|
||||
},
|
||||
}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore(), list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 2}}, Count: 1,
|
||||
}}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
_, err := service.loadDialogListSnapshot(context.Background(), dialogListSnapshotKey{userID: ownerID})
|
||||
if err != nil {
|
||||
t.Fatalf("rebuild after channel generation change: %v", err)
|
||||
}
|
||||
if authoritative.snapshotCalls != 1 || shared.putCalls != 1 ||
|
||||
shared.putValue.DependencyHash != readmodel.MixHashes(61, 71) {
|
||||
t.Fatalf("calls/header dependency = %d/%d/%d, want 1/1/%d",
|
||||
authoritative.snapshotCalls, shared.putCalls, shared.putValue.DependencyHash,
|
||||
readmodel.MixHashes(61, 71))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedDialogListSnapshotRedisErrorFailsClosed(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{
|
||||
{Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 51,
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{getErr: errors.New("redis unavailable")}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore()}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
_, err := service.loadDialogListSnapshot(context.Background(), dialogListSnapshotKey{userID: ownerID})
|
||||
if err == nil || authoritative.snapshotCalls != 0 || shared.putCalls != 0 {
|
||||
t.Fatalf("err=%v header_calls=%d put_calls=%d, want fail-closed before PostgreSQL scan",
|
||||
err, authoritative.snapshotCalls, shared.putCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsL1RejectsOldOwnerGenerationBeforeHydration(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
ownerKey := store.ReadModelKey{
|
||||
Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID,
|
||||
PeerType: domain.PeerTypeUser, PeerID: ownerID,
|
||||
}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{ownerKey: 11}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore(), list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 7, TopMessageDate: 70}},
|
||||
Users: []domain.User{{ID: peer.ID}},
|
||||
Count: 1,
|
||||
}}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
filter := domain.DialogFilter{ExcludePinned: true, Limit: 100}
|
||||
first, err := service.GetDialogs(context.Background(), ownerID, filter)
|
||||
if err != nil || len(first.Dialogs) != 1 {
|
||||
t.Fatalf("first GetDialogs = dialogs:%d err:%v", len(first.Dialogs), err)
|
||||
}
|
||||
|
||||
authoritative.list = domain.DialogList{}
|
||||
versions.hashes[ownerKey] = 12
|
||||
second, err := service.GetDialogs(context.Background(), ownerID, filter)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs after owner generation advance: %v", err)
|
||||
}
|
||||
if len(second.Dialogs) != 0 || authoritative.snapshotCalls != 2 {
|
||||
t.Fatalf("second dialogs/snapshot calls = %d/%d, want 0/2", len(second.Dialogs), authoritative.snapshotCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsRetriesWhenOwnerGenerationChangesDuringHydration(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
ownerKey := store.ReadModelKey{
|
||||
Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID,
|
||||
PeerType: domain.PeerTypeUser, PeerID: ownerID,
|
||||
}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{ownerKey: 21}}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore(), list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 7, TopMessageDate: 70}},
|
||||
Users: []domain.User{{ID: peer.ID}},
|
||||
Count: 1,
|
||||
}}
|
||||
authoritative.onListByPeers = func() {
|
||||
authoritative.list = domain.DialogList{}
|
||||
versions.hashes[ownerKey] = 22
|
||||
}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(&fakeSharedDialogListSnapshotCache{}),
|
||||
)
|
||||
|
||||
list, err := service.GetDialogs(context.Background(), ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs across owner generation change: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 0 || authoritative.snapshotCalls != 2 {
|
||||
t.Fatalf("dialogs/snapshot calls = %d/%d, want stable empty generation and 2 loads", len(list.Dialogs), authoritative.snapshotCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsRetriesWhenDraftChangesDuringOwnerSnapshot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
|
||||
ownerKey := store.ReadModelKey{
|
||||
Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID,
|
||||
PeerType: domain.PeerTypeUser, PeerID: ownerID,
|
||||
}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{ownerKey: 31}}
|
||||
base := memory.NewDialogStore()
|
||||
if err := base.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 1, Message: "old"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authoritative := &snapshotDialogStore{DialogStore: base, list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 7, TopMessageDate: 70}},
|
||||
Users: []domain.User{{ID: peer.ID}},
|
||||
Count: 1,
|
||||
}}
|
||||
authoritative.onListDrafts = func() {
|
||||
if err := base.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 2, Message: "new"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions.hashes[ownerKey] = 32
|
||||
}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(&fakeSharedDialogListSnapshotCache{}),
|
||||
)
|
||||
|
||||
list, err := service.GetDialogs(ctx, ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs across draft generation change: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 1 || list.Dialogs[0].Draft == nil || list.Dialogs[0].Draft.Message != "new" {
|
||||
t.Fatalf("stable draft snapshot = %+v", list.Dialogs)
|
||||
}
|
||||
if authoritative.snapshotCalls != 2 || authoritative.listDraftsCalls != 2 {
|
||||
t.Fatalf("snapshot/draft loads = %d/%d, want 2/2 after generation retry",
|
||||
authoritative.snapshotCalls, authoritative.listDraftsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerBaseSnapshotDerivesBuiltInFolderVariantsOnce(t *testing.T) {
|
||||
const ownerID int64 = 1001
|
||||
mainPinned := domain.Peer{Type: domain.PeerTypeUser, ID: 2001}
|
||||
mainRegular := domain.Peer{Type: domain.PeerTypeUser, ID: 2002}
|
||||
archived := domain.Peer{Type: domain.PeerTypeUser, ID: 2003}
|
||||
ownerKey := store.ReadModelKey{
|
||||
Model: readmodel.ModelDialogOwner, OwnerUserID: ownerID,
|
||||
PeerType: domain.PeerTypeUser, PeerID: ownerID,
|
||||
}
|
||||
versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{ownerKey: 81}}
|
||||
authoritative := &snapshotDialogStore{DialogStore: memory.NewDialogStore(), list: domain.DialogList{
|
||||
Dialogs: []domain.Dialog{
|
||||
{Peer: mainPinned, FolderID: domain.DialogMainFolderID, TopMessage: 30, TopMessageDate: 300, Pinned: true, PinnedOrder: 1},
|
||||
{Peer: mainRegular, FolderID: domain.DialogMainFolderID, TopMessage: 20, TopMessageDate: 200},
|
||||
{Peer: archived, FolderID: domain.DialogArchiveFolderID, TopMessage: 10, TopMessageDate: 100},
|
||||
},
|
||||
Users: []domain.User{{ID: mainPinned.ID}, {ID: mainRegular.ID}, {ID: archived.ID}},
|
||||
}}
|
||||
shared := &fakeSharedDialogListSnapshotCache{}
|
||||
service := NewService(authoritative).Configure(
|
||||
WithReadModelVersions(versions),
|
||||
WithSharedDialogListSnapshotCache(shared),
|
||||
)
|
||||
|
||||
main, err := service.GetDialogs(context.Background(), ownerID, domain.DialogFilter{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
excludePinned, err := service.GetDialogs(context.Background(), ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pinned, err := service.GetDialogs(context.Background(), ownerID, domain.DialogFilter{PinnedOnly: true, Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive, err := service.GetDialogs(context.Background(), ownerID, domain.DialogFilter{
|
||||
HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicitMain, err := service.GetDialogs(context.Background(), ownerID, domain.DialogFilter{
|
||||
HasFolderID: true, FolderID: domain.DialogMainFolderID, Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if authoritative.snapshotCalls != 1 || authoritative.listByPeersCalls != 1 || shared.getCalls != 1 || shared.putCalls != 1 {
|
||||
t.Fatalf("base/peer/get/put calls = %d/%d/%d/%d, want 1/1/1/1",
|
||||
authoritative.snapshotCalls, authoritative.listByPeersCalls, shared.getCalls, shared.putCalls)
|
||||
}
|
||||
if len(main.Dialogs) != 2 || main.Dialogs[0].Peer != mainPinned || main.Dialogs[1].Peer != mainRegular || main.ArchiveSummary == nil || main.ArchiveSummary.TopPeer != archived {
|
||||
t.Fatalf("main variant = %+v", main)
|
||||
}
|
||||
if len(excludePinned.Dialogs) != 1 || excludePinned.Dialogs[0].Peer != mainRegular || excludePinned.ArchiveSummary != nil {
|
||||
t.Fatalf("exclude-pinned variant = %+v", excludePinned)
|
||||
}
|
||||
if len(pinned.Dialogs) != 1 || pinned.Dialogs[0].Peer != mainPinned || pinned.ArchiveSummary == nil {
|
||||
t.Fatalf("pinned variant = %+v", pinned)
|
||||
}
|
||||
if len(archive.Dialogs) != 1 || archive.Dialogs[0].Peer != archived || archive.ArchiveSummary != nil {
|
||||
t.Fatalf("archive variant = %+v", archive)
|
||||
}
|
||||
if explicitMain.Hash != main.Hash || main.Hash == 0 || excludePinned.Hash == main.Hash || pinned.Hash == main.Hash || archive.Hash == main.Hash {
|
||||
t.Fatalf("variant hashes main=%d explicit=%d exclude=%d pinned=%d archive=%d",
|
||||
main.Hash, explicitMain.Hash, excludePinned.Hash, pinned.Hash, archive.Hash)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue