merged from gramsrv upstream

This commit is contained in:
onysd 2026-09-01 12:06:31 +03:00
parent 79c64ee916
commit 21a0856587
651 changed files with 54774 additions and 4590 deletions

View 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
}

View 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
}

View 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)
}
}

View file

@ -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

View file

@ -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))

View file

@ -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)
}
}

View 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
}

View 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)
}
}