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

@ -19,12 +19,21 @@ const (
// Normal correctness relies on write-path invalidation, not natural expiry.
DefaultContactProjectionCacheTTL = 24 * time.Hour
contactSnapshotMaxViewers = 4096
contactReversePairMaxEntries = 262144
contactPersonalPhotoSnapshotCap = 4096
// DefaultContactSnapshotMaxViewers covers the 10k online target plus bounded
// reconnect overlap. Eviction is exact LRU; reaching the limit must never
// clear every viewer snapshot at once.
DefaultContactSnapshotMaxViewers = 16_384
contactReversePairMaxEntries = 262144
contactProjectionPairMaxEntries = 262144
// One dense request must not monopolize the pair LRU or hold the global
// cache lock while inserting and evicting hundreds of thousands of cells.
// Larger results are still returned; they simply are not admitted per pair.
contactProjectionDenseAdmissionMaxCells = contactProjectionPairMaxEntries / 16
)
type contactAccountSnapshot struct {
// contacts and ordered are immutable after the snapshot is published in
// CachedContactStore.contacts. Readers intentionally retain shallow copies.
contacts map[int64]domain.Contact
ordered []domain.Contact
hash int64
@ -32,6 +41,7 @@ type contactAccountSnapshot struct {
}
type personalPhotoSnapshot struct {
// refs is immutable after the snapshot is published in personalPhotos.
refs map[int64]domain.ProfilePhotoRef
expireAt time.Time
}
@ -42,8 +52,8 @@ type reverseContactKey struct {
}
type reverseContactSnapshot struct {
contact domain.Contact
found bool
// contact is an immutable cached clone. nil is the negative-cache value.
contact *domain.Contact
expireAt time.Time
}
@ -52,6 +62,79 @@ type reverseContactEntry struct {
snapshot reverseContactSnapshot
}
type contactProjectionKey struct {
viewerUserID int64
contactUserID int64
}
// cachedContactProjectionOverlay is the viewer-owned part of a contact row.
// Base user data is loaded and cached independently, so retaining domain.User
// here would multiply a large, viewer-independent value across every pair.
// Values are immutable after publication; noteEntities is cloned on both sides
// of the cache boundary.
type cachedContactProjectionOverlay struct {
firstName string
lastName string
phone string
note string
noteEntities []domain.MessageEntity
mutual bool
closeFriend bool
}
func newCachedContactProjectionOverlay(contact domain.Contact) *cachedContactProjectionOverlay {
return &cachedContactProjectionOverlay{
firstName: contact.FirstName,
lastName: contact.LastName,
phone: contact.Phone,
note: contact.Note,
noteEntities: append([]domain.MessageEntity(nil), contact.NoteEntities...),
mutual: contact.Mutual || contact.User.Mutual,
closeFriend: contact.CloseFriend || contact.User.CloseFriend,
}
}
func (o *cachedContactProjectionOverlay) domainContact(contactUserID int64) domain.Contact {
if o == nil {
return domain.Contact{}
}
return domain.Contact{
User: domain.User{ID: contactUserID},
FirstName: o.firstName,
LastName: o.lastName,
Phone: o.phone,
Note: o.note,
NoteEntities: append([]domain.MessageEntity(nil), o.noteEntities...),
Mutual: o.mutual,
CloseFriend: o.closeFriend,
}
}
type contactProjectionSnapshot struct {
// Positive values point at immutable cached clones; nil is negative. Keeping
// the compact viewer-owned overlay outside the entry makes negative pairs
// consume only two pointers plus their expiry and avoids duplicating a full
// base User for every positive pair.
contact *cachedContactProjectionOverlay
personalPhoto *domain.ProfilePhotoRef
expireAt time.Time
}
// contactProjectionLookup is a transient, caller-owned copy. It deliberately
// retains the old value+found shape so no mutable slice from a cached pointer is
// exposed after the cache lock is released.
type contactProjectionLookup struct {
contact domain.Contact
contactFound bool
personalPhoto domain.ProfilePhotoRef
personalPhotoFound bool
}
type contactProjectionEntry struct {
key contactProjectionKey
snapshot contactProjectionSnapshot
}
type contactSnapshotLoadResult struct {
snap contactAccountSnapshot
stored bool
@ -67,6 +150,21 @@ type personalPhotoSnapshotLoadResult struct {
stored bool
}
type contactProjectionLoadResult struct {
batch domain.ContactProjectionBatch
current bool
}
type contactCacheViewerFence struct {
userID int64
generation uint64
}
type contactCacheFence struct {
flushGeneration uint64
viewers []contactCacheViewerFence
}
// CachedContactStore wraps ContactStore with account-level read model snapshots.
//
// Contact data is low-churn and high-read: TDesktop repeatedly asks for the same
@ -79,34 +177,65 @@ type CachedContactStore struct {
ttl time.Duration
now func() time.Time
mu sync.RWMutex
contacts map[int64]contactAccountSnapshot
personalPhotos map[int64]personalPhotoSnapshot
reverse map[reverseContactKey]*list.Element
reverseLRU *list.List
reverseByOwner map[int64]map[int64]struct{}
reverseCap int
epoch uint64
sf singleflight.Group
mu sync.RWMutex
contacts map[int64]contactAccountSnapshot
contactLRU *list.List
contactElements map[int64]*list.Element
contactCap int
personalPhotos map[int64]personalPhotoSnapshot
personalPhotoLRU *list.List
personalElements map[int64]*list.Element
personalPhotoCap int
reverse map[reverseContactKey]*list.Element
reverseLRU *list.List
reverseByOwner map[int64]map[int64]struct{}
reverseCap int
projection map[contactProjectionKey]*list.Element
projectionLRU *list.List
projectionByViewer map[int64]map[int64]struct{}
projectionByTarget map[int64]map[int64]struct{}
projectionCap int
flushGeneration uint64
viewerGenerations map[int64]uint64
sf singleflight.Group
}
func NewCachedContactStore(inner store.ContactStore, ttl time.Duration) *CachedContactStore {
return NewCachedContactStoreWithMaxViewers(inner, ttl, DefaultContactSnapshotMaxViewers)
}
func NewCachedContactStoreWithMaxViewers(inner store.ContactStore, ttl time.Duration, maxViewers int) *CachedContactStore {
if inner == nil {
return nil
}
if ttl <= 0 {
ttl = DefaultContactProjectionCacheTTL
}
if maxViewers <= 0 {
maxViewers = DefaultContactSnapshotMaxViewers
}
return &CachedContactStore{
inner: inner,
ttl: ttl,
now: time.Now,
contacts: make(map[int64]contactAccountSnapshot, 1024),
personalPhotos: make(map[int64]personalPhotoSnapshot, 1024),
reverse: make(map[reverseContactKey]*list.Element, 4096),
reverseLRU: list.New(),
reverseByOwner: make(map[int64]map[int64]struct{}, 1024),
reverseCap: contactReversePairMaxEntries,
inner: inner,
ttl: ttl,
now: time.Now,
contacts: make(map[int64]contactAccountSnapshot, 1024),
contactLRU: list.New(),
contactElements: make(map[int64]*list.Element, 1024),
contactCap: maxViewers,
personalPhotos: make(map[int64]personalPhotoSnapshot, 1024),
personalPhotoLRU: list.New(),
personalElements: make(map[int64]*list.Element, 1024),
personalPhotoCap: maxViewers,
reverse: make(map[reverseContactKey]*list.Element, 4096),
reverseLRU: list.New(),
reverseByOwner: make(map[int64]map[int64]struct{}, 1024),
reverseCap: contactReversePairMaxEntries,
projection: make(map[contactProjectionKey]*list.Element, 4096),
projectionLRU: list.New(),
projectionByViewer: make(map[int64]map[int64]struct{}, 1024),
projectionByTarget: make(map[int64]map[int64]struct{}, 1024),
projectionCap: contactProjectionPairMaxEntries,
viewerGenerations: make(map[int64]uint64, 1024),
}
}
@ -197,6 +326,157 @@ func (c *CachedContactStore) GetReverseContacts(ctx context.Context, userID int6
return out, nil
}
func (c *CachedContactStore) ContactProjectionForViewers(ctx context.Context, viewerUserIDs, contactUserIDs []int64) (domain.ContactProjectionBatch, error) {
viewers := dedupContactIDs(viewerUserIDs)
targets := dedupContactIDs(contactUserIDs)
if len(viewers) == 0 || len(targets) == 0 {
return domain.ContactProjectionBatch{
Contacts: map[int64]map[int64]domain.Contact{},
PersonalPhotos: map[int64]map[int64]domain.ProfilePhotoRef{},
}, nil
}
for {
out := domain.ContactProjectionBatch{
Contacts: make(map[int64]map[int64]domain.Contact, len(viewers)),
PersonalPhotos: make(map[int64]map[int64]domain.ProfilePhotoRef, len(viewers)),
}
readFence := c.captureCacheFenceSlices(viewers, targets)
now := c.now()
coldViewers := make(map[int64]struct{}, len(viewers))
coldTargets := make(map[int64]struct{}, len(targets))
for _, viewerID := range viewers {
var contactSnap contactAccountSnapshot
contactsWarm := false
if snap, ok := c.lookupContactSnapshot(viewerID, now); ok {
contactsWarm = true
contactSnap = snap
for _, targetID := range targets {
if contact, found := snap.contacts[targetID]; found {
putContactProjectionContact(&out, viewerID, targetID, contact)
}
}
}
personalPhotosWarm := false
if snap, ok := c.lookupPersonalPhotoSnapshot(viewerID, now); ok {
personalPhotosWarm = true
for _, targetID := range targets {
if ref, found := snap.refs[targetID]; found {
putContactProjectionPersonalPhoto(&out, viewerID, targetID, ref)
}
}
}
for _, targetID := range targets {
if contactsWarm && personalPhotosWarm {
continue
}
if contactsWarm {
if _, found := contactSnap.contacts[targetID]; !found {
continue
}
}
if snap, ok := c.lookupContactProjectionPair(viewerID, targetID, now); ok {
if !contactsWarm && snap.contactFound {
putContactProjectionContact(&out, viewerID, targetID, snap.contact)
}
if !personalPhotosWarm && snap.personalPhotoFound {
putContactProjectionPersonalPhoto(&out, viewerID, targetID, snap.personalPhoto)
}
continue
}
coldViewers[viewerID] = struct{}{}
coldTargets[targetID] = struct{}{}
}
}
if !c.cacheFenceCurrent(readFence) {
if err := ctx.Err(); err != nil {
return domain.ContactProjectionBatch{}, err
}
continue
}
if len(coldViewers) == 0 || len(coldTargets) == 0 {
return out, nil
}
cold := make([]int64, 0, len(coldViewers))
for viewerID := range coldViewers {
cold = append(cold, viewerID)
}
coldIDs := make([]int64, 0, len(coldTargets))
for targetID := range coldTargets {
coldIDs = append(coldIDs, targetID)
}
loaded, err := c.loadContactProjectionForViewers(ctx, cold, coldIDs)
if err != nil {
return domain.ContactProjectionBatch{}, err
}
if !c.cacheFenceCurrent(readFence) {
if err := ctx.Err(); err != nil {
return domain.ContactProjectionBatch{}, err
}
continue
}
mergeContactProjectionBatch(&out, loaded)
return out, nil
}
}
func (c *CachedContactStore) loadContactProjectionForViewers(ctx context.Context, viewerUserIDs, contactUserIDs []int64) (domain.ContactProjectionBatch, error) {
viewers := append([]int64(nil), viewerUserIDs...)
targets := append([]int64(nil), contactUserIDs...)
sort.Slice(viewers, func(i, j int) bool { return viewers[i] < viewers[j] })
sort.Slice(targets, func(i, j int) bool { return targets[i] < targets[j] })
sfKey := fmt.Sprintf("contact-projection:%v:%v", viewers, targets)
for {
v, err, _ := c.sf.Do(sfKey, func() (any, error) {
loadFence := c.captureCacheFenceSlices(viewers, targets)
batch, err := c.inner.ContactProjectionForViewers(ctx, viewers, targets)
if err != nil {
return contactProjectionLoadResult{}, err
}
now := c.now()
expireAt := now.Add(c.ttl)
admitPairs := admitDenseContactProjectionPairs(len(viewers), len(targets))
c.mu.Lock()
current := c.cacheFenceCurrentLocked(loadFence)
if current && admitPairs {
for _, viewerID := range viewers {
for _, targetID := range targets {
contact, contactFound := batch.Contacts[viewerID][targetID]
ref, personalPhotoFound := batch.PersonalPhotos[viewerID][targetID]
c.storeContactProjectionPairLocked(
contactProjectionKey{viewerUserID: viewerID, contactUserID: targetID},
contact, contactFound, ref, personalPhotoFound, expireAt,
)
}
}
}
c.mu.Unlock()
return contactProjectionLoadResult{
batch: cloneContactProjectionBatch(batch),
current: current,
}, nil
})
if err != nil {
return domain.ContactProjectionBatch{}, err
}
result := v.(contactProjectionLoadResult)
if result.current {
return result.batch, nil
}
if err := ctx.Err(); err != nil {
return domain.ContactProjectionBatch{}, err
}
}
}
func admitDenseContactProjectionPairs(viewerCount, targetCount int) bool {
if viewerCount <= 0 || targetCount <= 0 || targetCount > contactProjectionDenseAdmissionMaxCells {
return false
}
// Division avoids overflowing int for attacker-controlled vector lengths.
return viewerCount <= contactProjectionDenseAdmissionMaxCells/targetCount
}
// loadReverseContacts performs at most one batched cold-store read for all
// missing owner→viewer pairs, then caches both hits and misses. Privacy
// projection therefore stays memory-only after warm-up instead of repeating a
@ -207,7 +487,7 @@ func (c *CachedContactStore) loadReverseContacts(ctx context.Context, userID int
sfKey := fmt.Sprintf("contact-reverse:%d:%v", userID, owners)
for {
v, err, _ := c.sf.Do(sfKey, func() (any, error) {
loadEpoch := c.cacheEpoch()
loadFence := c.captureCacheFenceSlices(owners, []int64{userID})
contacts, err := c.inner.GetReverseContacts(ctx, userID, owners)
if err != nil {
return reverseContactLoadResult{}, err
@ -215,16 +495,12 @@ func (c *CachedContactStore) loadReverseContacts(ctx context.Context, userID int
now := c.now()
expireAt := now.Add(c.ttl)
c.mu.Lock()
stored := c.epoch == loadEpoch
stored := c.cacheFenceCurrentLocked(loadFence)
if stored {
for _, ownerID := range owners {
key := reverseContactKey{ownerUserID: ownerID, contactUserID: userID}
contact, found := contacts[ownerID]
c.storeReverseContactLocked(key, reverseContactSnapshot{
contact: cloneCachedContact(contact),
found: found,
expireAt: expireAt,
})
c.storeReverseContactLocked(key, contact, found, expireAt)
}
}
c.mu.Unlock()
@ -249,6 +525,9 @@ func (c *CachedContactStore) loadReverseContacts(ctx context.Context, userID int
func (c *CachedContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
contact, err := c.inner.Upsert(ctx, userID, input)
if err == nil {
// Published account snapshots are immutable. Invalidate instead of
// modifying their inner maps/slices in place or publishing a mutation
// payload whose cache-write order may differ from its DB commit order.
c.InvalidateViewers(userID, input.ContactUserID)
}
return contact, err
@ -257,12 +536,11 @@ func (c *CachedContactStore) Upsert(ctx context.Context, userID int64, input dom
func (c *CachedContactStore) UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error) {
contacts, err := c.inner.UpsertMany(ctx, userID, inputs)
if err == nil {
ids := make([]int64, 0, len(inputs)+1)
ids = append(ids, userID)
ids := make([]int64, 0, len(inputs))
for _, input := range inputs {
ids = append(ids, input.ContactUserID)
}
c.InvalidateViewers(ids...)
c.InvalidateViewers(append([]int64{userID}, ids...)...)
}
return contacts, err
}
@ -270,7 +548,9 @@ func (c *CachedContactStore) UpsertMany(ctx context.Context, userID int64, input
func (c *CachedContactStore) UpdateNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error) {
contact, found, err := c.inner.UpdateNote(ctx, userID, contactUserID, note, entities)
if err == nil {
c.InvalidateViewers(userID)
if found {
c.InvalidateViewers(userID)
}
}
return contact, found, err
}
@ -285,7 +565,10 @@ func (c *CachedContactStore) SetCloseFriends(ctx context.Context, userID int64,
func (c *CachedContactStore) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) {
contact, found, err := c.inner.SetPersonalPhoto(ctx, userID, contactUserID, photoID, date)
if err == nil {
if err == nil && found {
// Do not perform a post-commit read followed by write-through: two
// concurrent mutations can complete their cache writes in the opposite
// order and reinsert a stale pair after a newer NOTIFY invalidation.
c.InvalidateViewers(userID)
}
return contact, found, err
@ -314,10 +597,7 @@ func (c *CachedContactStore) PersonalPhotos(ctx context.Context, userID int64, c
func (c *CachedContactStore) Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) {
count, err := c.inner.Delete(ctx, userID, contactUserIDs)
if err == nil {
ids := make([]int64, 0, len(contactUserIDs)+1)
ids = append(ids, userID)
ids = append(ids, contactUserIDs...)
c.InvalidateViewers(ids...)
c.InvalidateViewers(append([]int64{userID}, contactUserIDs...)...)
}
return count, err
}
@ -356,20 +636,16 @@ func (c *CachedContactStore) contactSnapshot(ctx context.Context, userID int64)
if snap, ok := c.lookupContactSnapshot(userID, now); ok {
return contactSnapshotLoadResult{snap: snap, stored: true}, nil
}
loadEpoch := c.cacheEpoch()
loadFence := c.captureCacheFence(userID)
list, err := c.inner.ListByUser(ctx, userID)
if err != nil {
return contactSnapshotLoadResult{}, err
}
snap := buildContactAccountSnapshot(list, now.Add(c.ttl))
c.mu.Lock()
stored := c.epoch == loadEpoch
stored := c.cacheFenceCurrentLocked(loadFence)
if stored {
if len(c.contacts) >= contactSnapshotMaxViewers {
c.contacts = make(map[int64]contactAccountSnapshot, 1024)
c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024)
}
c.contacts[userID] = snap
c.storeContactSnapshotLocked(userID, snap)
}
c.mu.Unlock()
return contactSnapshotLoadResult{snap: snap, stored: stored}, nil
@ -388,18 +664,45 @@ func (c *CachedContactStore) contactSnapshot(ctx context.Context, userID int64)
}
func (c *CachedContactStore) lookupContactSnapshot(userID int64, now time.Time) (contactAccountSnapshot, bool) {
c.mu.RLock()
c.mu.Lock()
snap, ok := c.contacts[userID]
c.mu.RUnlock()
if !ok || !snap.expireAt.After(now) {
if ok {
c.InvalidateViewers(userID)
}
if !ok {
c.mu.Unlock()
return contactAccountSnapshot{}, false
}
if !snap.expireAt.After(now) {
c.advanceViewerGenerationLocked(userID)
c.invalidateViewerLocked(userID)
c.mu.Unlock()
return contactAccountSnapshot{}, false
}
if element := c.contactElements[userID]; element != nil {
c.contactLRU.MoveToFront(element)
}
c.mu.Unlock()
return snap, true
}
func (c *CachedContactStore) storeContactSnapshotLocked(userID int64, snap contactAccountSnapshot) {
if element := c.contactElements[userID]; element != nil {
c.contacts[userID] = snap
c.contactLRU.MoveToFront(element)
return
}
c.contacts[userID] = snap
c.contactElements[userID] = c.contactLRU.PushFront(userID)
for c.contactLRU.Len() > c.contactCap {
oldest := c.contactLRU.Back()
if oldest == nil {
break
}
oldestUserID := oldest.Value.(int64)
delete(c.contacts, oldestUserID)
delete(c.contactElements, oldestUserID)
c.contactLRU.Remove(oldest)
}
}
func (c *CachedContactStore) personalPhotoSnapshot(ctx context.Context, userID int64) (personalPhotoSnapshot, error) {
for {
if snap, ok := c.lookupPersonalPhotoSnapshot(userID, c.now()); ok {
@ -410,7 +713,7 @@ func (c *CachedContactStore) personalPhotoSnapshot(ctx context.Context, userID i
if snap, ok := c.lookupPersonalPhotoSnapshot(userID, now); ok {
return personalPhotoSnapshotLoadResult{snap: snap, stored: true}, nil
}
loadEpoch := c.cacheEpoch()
loadFence := c.captureCacheFence(userID)
contacts, err := c.contactSnapshot(ctx, userID)
if err != nil {
return personalPhotoSnapshotLoadResult{}, err
@ -428,12 +731,9 @@ func (c *CachedContactStore) personalPhotoSnapshot(ctx context.Context, userID i
}
snap := personalPhotoSnapshot{refs: cloneCachedProfilePhotoRefs(refs), expireAt: now.Add(c.ttl)}
c.mu.Lock()
stored := c.epoch == loadEpoch
stored := c.cacheFenceCurrentLocked(loadFence)
if stored {
if len(c.personalPhotos) >= contactPersonalPhotoSnapshotCap {
c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024)
}
c.personalPhotos[userID] = snap
c.storePersonalPhotoSnapshotLocked(userID, snap)
}
c.mu.Unlock()
return personalPhotoSnapshotLoadResult{snap: snap, stored: stored}, nil
@ -452,18 +752,45 @@ func (c *CachedContactStore) personalPhotoSnapshot(ctx context.Context, userID i
}
func (c *CachedContactStore) lookupPersonalPhotoSnapshot(userID int64, now time.Time) (personalPhotoSnapshot, bool) {
c.mu.RLock()
c.mu.Lock()
snap, ok := c.personalPhotos[userID]
c.mu.RUnlock()
if !ok || !snap.expireAt.After(now) {
if ok {
c.InvalidateViewers(userID)
}
if !ok {
c.mu.Unlock()
return personalPhotoSnapshot{}, false
}
if !snap.expireAt.After(now) {
c.advanceViewerGenerationLocked(userID)
c.invalidateViewerLocked(userID)
c.mu.Unlock()
return personalPhotoSnapshot{}, false
}
if element := c.personalElements[userID]; element != nil {
c.personalPhotoLRU.MoveToFront(element)
}
c.mu.Unlock()
return snap, true
}
func (c *CachedContactStore) storePersonalPhotoSnapshotLocked(userID int64, snap personalPhotoSnapshot) {
if element := c.personalElements[userID]; element != nil {
c.personalPhotos[userID] = snap
c.personalPhotoLRU.MoveToFront(element)
return
}
c.personalPhotos[userID] = snap
c.personalElements[userID] = c.personalPhotoLRU.PushFront(userID)
for c.personalPhotoLRU.Len() > c.personalPhotoCap {
oldest := c.personalPhotoLRU.Back()
if oldest == nil {
break
}
oldestUserID := oldest.Value.(int64)
delete(c.personalPhotos, oldestUserID)
delete(c.personalElements, oldestUserID)
c.personalPhotoLRU.Remove(oldest)
}
}
func (c *CachedContactStore) lookupReverseContact(ownerUserID, contactUserID int64, now time.Time) (domain.Contact, bool, bool) {
key := reverseContactKey{ownerUserID: ownerUserID, contactUserID: contactUserID}
c.mu.Lock()
@ -481,10 +808,19 @@ func (c *CachedContactStore) lookupReverseContact(ownerUserID, contactUserID int
}
c.reverseLRU.MoveToFront(element)
c.mu.Unlock()
return cloneCachedContact(snap.contact), snap.found, true
if snap.contact == nil {
return domain.Contact{}, false, true
}
return cloneCachedContact(*snap.contact), true, true
}
func (c *CachedContactStore) storeReverseContactLocked(key reverseContactKey, snapshot reverseContactSnapshot) {
func (c *CachedContactStore) storeReverseContactLocked(key reverseContactKey, contact domain.Contact, found bool, expireAt time.Time) {
var cached *domain.Contact
if found {
clone := cloneCachedContact(contact)
cached = &clone
}
snapshot := reverseContactSnapshot{contact: cached, expireAt: expireAt}
if element, ok := c.reverse[key]; ok {
entry := element.Value.(*reverseContactEntry)
entry.snapshot = snapshot
@ -517,46 +853,222 @@ func (c *CachedContactStore) removeReverseElementLocked(element *list.Element) {
c.reverseLRU.Remove(element)
}
func (c *CachedContactStore) lookupContactProjectionPair(viewerUserID, contactUserID int64, now time.Time) (contactProjectionLookup, bool) {
key := contactProjectionKey{viewerUserID: viewerUserID, contactUserID: contactUserID}
c.mu.Lock()
element, ok := c.projection[key]
if !ok {
c.mu.Unlock()
return contactProjectionLookup{}, false
}
entry := element.Value.(*contactProjectionEntry)
snap := entry.snapshot
if !snap.expireAt.After(now) {
c.removeContactProjectionElementLocked(element)
c.mu.Unlock()
return contactProjectionLookup{}, false
}
c.projectionLRU.MoveToFront(element)
c.mu.Unlock()
result := contactProjectionLookup{}
if snap.contact != nil {
result.contact = snap.contact.domainContact(contactUserID)
result.contactFound = true
}
if snap.personalPhoto != nil {
result.personalPhoto = cloneCachedProfilePhotoRef(*snap.personalPhoto)
result.personalPhotoFound = true
}
return result, true
}
func (c *CachedContactStore) storeContactProjectionPairLocked(
key contactProjectionKey,
contact domain.Contact,
contactFound bool,
personalPhoto domain.ProfilePhotoRef,
personalPhotoFound bool,
expireAt time.Time,
) {
var cachedContact *cachedContactProjectionOverlay
if contactFound {
cachedContact = newCachedContactProjectionOverlay(contact)
}
var cachedPersonalPhoto *domain.ProfilePhotoRef
if personalPhotoFound {
clone := cloneCachedProfilePhotoRef(personalPhoto)
cachedPersonalPhoto = &clone
}
snapshot := contactProjectionSnapshot{
contact: cachedContact,
personalPhoto: cachedPersonalPhoto,
expireAt: expireAt,
}
if element, ok := c.projection[key]; ok {
entry := element.Value.(*contactProjectionEntry)
entry.snapshot = snapshot
c.projectionLRU.MoveToFront(element)
return
}
element := c.projectionLRU.PushFront(&contactProjectionEntry{key: key, snapshot: snapshot})
c.projection[key] = element
if c.projectionByViewer[key.viewerUserID] == nil {
c.projectionByViewer[key.viewerUserID] = make(map[int64]struct{})
}
c.projectionByViewer[key.viewerUserID][key.contactUserID] = struct{}{}
if c.projectionByTarget[key.contactUserID] == nil {
c.projectionByTarget[key.contactUserID] = make(map[int64]struct{})
}
c.projectionByTarget[key.contactUserID][key.viewerUserID] = struct{}{}
for c.projectionLRU.Len() > c.projectionCap {
c.removeContactProjectionElementLocked(c.projectionLRU.Back())
}
}
func (c *CachedContactStore) removeContactProjectionElementLocked(element *list.Element) {
if element == nil {
return
}
entry := element.Value.(*contactProjectionEntry)
delete(c.projection, entry.key)
if targets := c.projectionByViewer[entry.key.viewerUserID]; targets != nil {
delete(targets, entry.key.contactUserID)
if len(targets) == 0 {
delete(c.projectionByViewer, entry.key.viewerUserID)
}
}
if viewers := c.projectionByTarget[entry.key.contactUserID]; viewers != nil {
delete(viewers, entry.key.viewerUserID)
if len(viewers) == 0 {
delete(c.projectionByTarget, entry.key.contactUserID)
}
}
c.projectionLRU.Remove(element)
}
func (c *CachedContactStore) InvalidateViewers(ids ...int64) {
if c == nil || len(ids) == 0 {
return
}
c.mu.Lock()
c.epoch++
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
delete(c.contacts, id)
delete(c.personalPhotos, id)
for contactUserID := range c.reverseByOwner[id] {
if element, ok := c.reverse[reverseContactKey{ownerUserID: id, contactUserID: contactUserID}]; ok {
c.removeReverseElementLocked(element)
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
c.advanceViewerGenerationLocked(id)
c.invalidateViewerLocked(id)
}
c.mu.Unlock()
}
func (c *CachedContactStore) invalidateViewerLocked(id int64) {
delete(c.contacts, id)
if element := c.contactElements[id]; element != nil {
delete(c.contactElements, id)
c.contactLRU.Remove(element)
}
delete(c.personalPhotos, id)
if element := c.personalElements[id]; element != nil {
delete(c.personalElements, id)
c.personalPhotoLRU.Remove(element)
}
for contactUserID := range c.reverseByOwner[id] {
c.removeReverseKeyLocked(reverseContactKey{ownerUserID: id, contactUserID: contactUserID})
}
for contactUserID := range c.projectionByViewer[id] {
c.removeContactProjectionKeyLocked(contactProjectionKey{viewerUserID: id, contactUserID: contactUserID})
}
for viewerUserID := range c.projectionByTarget[id] {
c.removeContactProjectionKeyLocked(contactProjectionKey{viewerUserID: viewerUserID, contactUserID: id})
}
}
func (c *CachedContactStore) removeReverseKeyLocked(key reverseContactKey) {
if element, ok := c.reverse[key]; ok {
c.removeReverseElementLocked(element)
}
}
func (c *CachedContactStore) removeContactProjectionKeyLocked(key contactProjectionKey) {
if element, ok := c.projection[key]; ok {
c.removeContactProjectionElementLocked(element)
}
}
func (c *CachedContactStore) FlushReadModelCache() {
if c == nil {
return
}
c.mu.Lock()
c.epoch++
c.flushGeneration++
c.viewerGenerations = make(map[int64]uint64, 1024)
c.contacts = make(map[int64]contactAccountSnapshot, 1024)
c.contactElements = make(map[int64]*list.Element, 1024)
c.contactLRU.Init()
c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024)
c.personalElements = make(map[int64]*list.Element, 1024)
c.personalPhotoLRU.Init()
c.reverse = make(map[reverseContactKey]*list.Element, 4096)
c.reverseLRU.Init()
c.reverseByOwner = make(map[int64]map[int64]struct{}, 1024)
c.projection = make(map[contactProjectionKey]*list.Element, 4096)
c.projectionLRU.Init()
c.projectionByViewer = make(map[int64]map[int64]struct{}, 1024)
c.projectionByTarget = make(map[int64]map[int64]struct{}, 1024)
c.mu.Unlock()
}
func (c *CachedContactStore) cacheEpoch() uint64 {
func (c *CachedContactStore) captureCacheFence(userIDs ...int64) contactCacheFence {
return c.captureCacheFenceSlices(userIDs, nil)
}
func (c *CachedContactStore) captureCacheFenceSlices(first, second []int64) contactCacheFence {
c.mu.RLock()
epoch := c.epoch
fence := contactCacheFence{
flushGeneration: c.flushGeneration,
viewers: make([]contactCacheViewerFence, 0, len(first)+len(second)),
}
for _, userIDs := range [][]int64{first, second} {
for _, userID := range userIDs {
if userID == 0 {
continue
}
fence.viewers = append(fence.viewers, contactCacheViewerFence{
userID: userID,
generation: c.viewerGenerations[userID],
})
}
}
c.mu.RUnlock()
return epoch
return fence
}
func (c *CachedContactStore) cacheFenceCurrent(fence contactCacheFence) bool {
c.mu.RLock()
current := c.cacheFenceCurrentLocked(fence)
c.mu.RUnlock()
return current
}
func (c *CachedContactStore) cacheFenceCurrentLocked(fence contactCacheFence) bool {
if c.flushGeneration != fence.flushGeneration {
return false
}
for _, viewer := range fence.viewers {
if c.viewerGenerations[viewer.userID] != viewer.generation {
return false
}
}
return true
}
func (c *CachedContactStore) advanceViewerGenerationLocked(userID int64) {
c.viewerGenerations[userID]++
}
func buildContactAccountSnapshot(list domain.ContactList, expireAt time.Time) contactAccountSnapshot {
@ -581,6 +1093,48 @@ func cloneCachedContactMap(in map[int64]domain.Contact) map[int64]domain.Contact
return out
}
func cloneContactProjectionBatch(in domain.ContactProjectionBatch) domain.ContactProjectionBatch {
out := domain.ContactProjectionBatch{
Contacts: make(map[int64]map[int64]domain.Contact, len(in.Contacts)),
PersonalPhotos: make(map[int64]map[int64]domain.ProfilePhotoRef, len(in.PersonalPhotos)),
}
mergeContactProjectionBatch(&out, in)
return out
}
func mergeContactProjectionBatch(dst *domain.ContactProjectionBatch, src domain.ContactProjectionBatch) {
for viewerID, contacts := range src.Contacts {
for targetID, contact := range contacts {
putContactProjectionContact(dst, viewerID, targetID, contact)
}
}
for viewerID, refs := range src.PersonalPhotos {
for targetID, ref := range refs {
putContactProjectionPersonalPhoto(dst, viewerID, targetID, ref)
}
}
}
func putContactProjectionContact(batch *domain.ContactProjectionBatch, viewerID, targetID int64, contact domain.Contact) {
if batch.Contacts == nil {
batch.Contacts = map[int64]map[int64]domain.Contact{}
}
if batch.Contacts[viewerID] == nil {
batch.Contacts[viewerID] = map[int64]domain.Contact{}
}
batch.Contacts[viewerID][targetID] = cloneCachedContact(contact)
}
func putContactProjectionPersonalPhoto(batch *domain.ContactProjectionBatch, viewerID, targetID int64, ref domain.ProfilePhotoRef) {
if batch.PersonalPhotos == nil {
batch.PersonalPhotos = map[int64]map[int64]domain.ProfilePhotoRef{}
}
if batch.PersonalPhotos[viewerID] == nil {
batch.PersonalPhotos[viewerID] = map[int64]domain.ProfilePhotoRef{}
}
batch.PersonalPhotos[viewerID][targetID] = cloneCachedProfilePhotoRef(ref)
}
func dedupContactIDs(ids []int64) []int64 {
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))

View file

@ -0,0 +1,174 @@
package userprojection
import (
"context"
"fmt"
"sort"
"telesrv/internal/domain"
"telesrv/internal/store"
)
var _ store.SparseContactProjectionStore = (*CachedContactStore)(nil)
// ContactProjectionForViewerUserIDs keeps the pair cache useful for sparse
// outbox projection without ever broadening a cold read into viewers x targets.
func (c *CachedContactStore) ContactProjectionForViewerUserIDs(ctx context.Context, requested map[int64][]int64) (domain.ContactProjectionBatch, error) {
pairs := canonicalContactProjectionPairs(requested)
if len(pairs) == 0 {
return emptyContactProjectionBatch(), nil
}
for {
out := emptyContactProjectionBatch()
readFence := c.captureCacheFence(sparseContactProjectionFenceIDs(pairs)...)
now := c.now()
cold := make(map[int64][]int64)
for _, pair := range pairs {
contactKnown := false
if snap, ok := c.lookupContactSnapshot(pair.viewerUserID, now); ok {
contactKnown = true
if contact, found := snap.contacts[pair.contactUserID]; found {
putContactProjectionContact(&out, pair.viewerUserID, pair.contactUserID, contact)
} else {
// Personal photos are rows on contacts and cannot exist when the
// viewer has no contact row for this target.
continue
}
}
photoKnown := false
if snap, ok := c.lookupPersonalPhotoSnapshot(pair.viewerUserID, now); ok {
photoKnown = true
if ref, found := snap.refs[pair.contactUserID]; found {
putContactProjectionPersonalPhoto(&out, pair.viewerUserID, pair.contactUserID, ref)
}
}
if contactKnown && photoKnown {
continue
}
if snap, ok := c.lookupContactProjectionPair(pair.viewerUserID, pair.contactUserID, now); ok {
if !contactKnown && snap.contactFound {
putContactProjectionContact(&out, pair.viewerUserID, pair.contactUserID, snap.contact)
}
if !photoKnown && snap.personalPhotoFound {
putContactProjectionPersonalPhoto(&out, pair.viewerUserID, pair.contactUserID, snap.personalPhoto)
}
continue
}
cold[pair.viewerUserID] = append(cold[pair.viewerUserID], pair.contactUserID)
}
if !c.cacheFenceCurrent(readFence) {
if err := ctx.Err(); err != nil {
return domain.ContactProjectionBatch{}, err
}
continue
}
if len(cold) == 0 {
return out, nil
}
loaded, err := c.loadSparseContactProjection(ctx, cold)
if err != nil {
return domain.ContactProjectionBatch{}, err
}
if !c.cacheFenceCurrent(readFence) {
if err := ctx.Err(); err != nil {
return domain.ContactProjectionBatch{}, err
}
continue
}
mergeContactProjectionBatch(&out, loaded)
return out, nil
}
}
type sparseContactProjectionPair struct {
viewerUserID int64
contactUserID int64
}
func canonicalContactProjectionPairs(requested map[int64][]int64) []sparseContactProjectionPair {
seen := make(map[sparseContactProjectionPair]struct{})
for viewerID, ids := range requested {
if viewerID == 0 {
continue
}
for _, id := range ids {
if id != 0 {
seen[sparseContactProjectionPair{viewerUserID: viewerID, contactUserID: id}] = struct{}{}
}
}
}
out := make([]sparseContactProjectionPair, 0, len(seen))
for pair := range seen {
out = append(out, pair)
}
sort.Slice(out, func(i, j int) bool {
if out[i].viewerUserID == out[j].viewerUserID {
return out[i].contactUserID < out[j].contactUserID
}
return out[i].viewerUserID < out[j].viewerUserID
})
return out
}
func (c *CachedContactStore) loadSparseContactProjection(ctx context.Context, requested map[int64][]int64) (domain.ContactProjectionBatch, error) {
pairs := canonicalContactProjectionPairs(requested)
canonical := make(map[int64][]int64)
for _, pair := range pairs {
canonical[pair.viewerUserID] = append(canonical[pair.viewerUserID], pair.contactUserID)
}
sfKey := fmt.Sprintf("contact-projection-sparse:%v", pairs)
for {
v, err, _ := c.sf.Do(sfKey, func() (any, error) {
loader, ok := c.inner.(store.SparseContactProjectionStore)
if !ok {
return contactProjectionLoadResult{}, fmt.Errorf("contact store does not support sparse projection")
}
loadFence := c.captureCacheFence(sparseContactProjectionFenceIDs(pairs)...)
batch, err := loader.ContactProjectionForViewerUserIDs(ctx, canonical)
if err != nil {
return contactProjectionLoadResult{}, err
}
expireAt := c.now().Add(c.ttl)
admitPairs := len(pairs) <= contactProjectionDenseAdmissionMaxCells
c.mu.Lock()
current := c.cacheFenceCurrentLocked(loadFence)
if current && admitPairs {
for _, pair := range pairs {
contact, contactFound := batch.Contacts[pair.viewerUserID][pair.contactUserID]
ref, photoFound := batch.PersonalPhotos[pair.viewerUserID][pair.contactUserID]
c.storeContactProjectionPairLocked(
contactProjectionKey{viewerUserID: pair.viewerUserID, contactUserID: pair.contactUserID},
contact, contactFound, ref, photoFound, expireAt,
)
}
}
c.mu.Unlock()
return contactProjectionLoadResult{batch: cloneContactProjectionBatch(batch), current: current}, nil
})
if err != nil {
return domain.ContactProjectionBatch{}, err
}
result := v.(contactProjectionLoadResult)
if result.current {
return result.batch, nil
}
if err := ctx.Err(); err != nil {
return domain.ContactProjectionBatch{}, err
}
}
}
func sparseContactProjectionFenceIDs(pairs []sparseContactProjectionPair) []int64 {
ids := make([]int64, 0, len(pairs)*2)
for _, pair := range pairs {
ids = append(ids, pair.viewerUserID, pair.contactUserID)
}
return ids
}
func emptyContactProjectionBatch() domain.ContactProjectionBatch {
return domain.ContactProjectionBatch{
Contacts: map[int64]map[int64]domain.Contact{},
PersonalPhotos: map[int64]map[int64]domain.ProfilePhotoRef{},
}
}

View file

@ -2,9 +2,12 @@ package userprojection
import (
"context"
"fmt"
"reflect"
"sync"
"testing"
"time"
"unsafe"
"telesrv/internal/domain"
"telesrv/internal/store"
@ -19,6 +22,7 @@ type blockingFirstListContactStore struct {
mu sync.Mutex
firstUsed bool
listCalls int
}
type blockingFirstPersonalPhotoStore struct {
@ -31,8 +35,56 @@ type blockingFirstPersonalPhotoStore struct {
firstUsed bool
}
type stalePersonalPhotoWritebackContextKey struct{}
// stalePersonalPhotoWritebackStore deterministically models an older mutation
// that commits first but returns to the cache wrapper after a newer mutation.
// The old implementation performed a post-commit PersonalPhotos read and could
// publish this captured old value after the newer mutation had completed.
type stalePersonalPhotoWritebackStore struct {
store.ContactStore
started chan struct{}
release chan struct{}
mu sync.Mutex
staleReadCalls int
}
func (s *stalePersonalPhotoWritebackStore) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) {
contact, found, err := s.ContactStore.SetPersonalPhoto(ctx, userID, contactUserID, photoID, date)
if err != nil || !found || ctx.Value(stalePersonalPhotoWritebackContextKey{}) != true {
return contact, found, err
}
close(s.started)
select {
case <-s.release:
case <-ctx.Done():
return domain.Contact{}, false, ctx.Err()
}
return contact, found, nil
}
func (s *stalePersonalPhotoWritebackStore) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
if len(contactUserIDs) > 0 && ctx.Value(stalePersonalPhotoWritebackContextKey{}) == true {
s.mu.Lock()
s.staleReadCalls++
s.mu.Unlock()
return map[int64]domain.ProfilePhotoRef{
contactUserIDs[0]: {PhotoID: 9001, Personal: true},
}, nil
}
return s.ContactStore.PersonalPhotos(ctx, userID, contactUserIDs)
}
func (s *stalePersonalPhotoWritebackStore) staleReads() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.staleReadCalls
}
func (s *blockingFirstListContactStore) ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) {
s.mu.Lock()
s.listCalls++
if !s.firstUsed {
s.firstUsed = true
s.mu.Unlock()
@ -48,6 +100,12 @@ func (s *blockingFirstListContactStore) ListByUser(ctx context.Context, userID i
return s.ContactStore.ListByUser(ctx, userID)
}
func (s *blockingFirstListContactStore) callCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.listCalls
}
func (s *blockingFirstPersonalPhotoStore) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
s.mu.Lock()
if !s.firstUsed {
@ -84,6 +142,7 @@ type countingContactStore struct {
listCalls int
getManyCalls int
reverseCalls int
projectionCalls int
personalPhotoCalls int
setPersonalPhotoHit int
}
@ -103,6 +162,11 @@ func (s *countingContactStore) GetReverseContacts(ctx context.Context, userID in
return s.ContactStore.GetReverseContacts(ctx, userID, ownerUserIDs)
}
func (s *countingContactStore) ContactProjectionForViewers(ctx context.Context, viewerUserIDs, contactUserIDs []int64) (domain.ContactProjectionBatch, error) {
s.projectionCalls++
return s.ContactStore.ContactProjectionForViewers(ctx, viewerUserIDs, contactUserIDs)
}
func (s *countingContactStore) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
s.personalPhotoCalls++
return s.ContactStore.PersonalPhotos(ctx, userID, contactUserIDs)
@ -162,6 +226,413 @@ func TestCachedContactStoreCachesProjectionReads(t *testing.T) {
}
}
func TestCachedContactStoreContactSnapshotLRUEvictsOnlyOldestViewer(t *testing.T) {
ctx := context.Background()
base := memory.NewContactStore()
for viewerID := int64(1); viewerID <= 3; viewerID++ {
if _, err := base.Upsert(ctx, viewerID, domain.ContactInput{
ContactUserID: 100 + viewerID,
FirstName: fmt.Sprintf("viewer-%d", viewerID),
}); err != nil {
t.Fatalf("seed viewer %d: %v", viewerID, err)
}
}
counting := &countingContactStore{ContactStore: base}
cached := NewCachedContactStoreWithMaxViewers(counting, time.Hour, 2)
for _, viewerID := range []int64{1, 2, 1, 3} {
if _, err := cached.ListByUser(ctx, viewerID); err != nil {
t.Fatalf("list viewer %d: %v", viewerID, err)
}
}
if counting.listCalls != 3 {
t.Fatalf("ListByUser calls = %d, want 3 before evicted viewer is read", counting.listCalls)
}
cached.mu.RLock()
_, hasOne := cached.contacts[1]
_, hasTwo := cached.contacts[2]
_, hasThree := cached.contacts[3]
contactEntries := cached.contactLRU.Len()
cached.mu.RUnlock()
if !hasOne || hasTwo || !hasThree || contactEntries != 2 {
t.Fatalf("contact LRU state = one:%v two:%v three:%v len:%d, want one+three only", hasOne, hasTwo, hasThree, contactEntries)
}
if _, err := cached.ListByUser(ctx, 1); err != nil {
t.Fatalf("list retained viewer 1: %v", err)
}
if counting.listCalls != 3 {
t.Fatalf("retained viewer caused cold load: calls=%d, want 3", counting.listCalls)
}
if _, err := cached.ListByUser(ctx, 2); err != nil {
t.Fatalf("list evicted viewer 2: %v", err)
}
if counting.listCalls != 4 {
t.Fatalf("evicted viewer did not cold load exactly once: calls=%d, want 4", counting.listCalls)
}
}
func TestCachedContactStoreContactAndPersonalPhotoLRUsAreIndependent(t *testing.T) {
cached := NewCachedContactStoreWithMaxViewers(memory.NewContactStore(), time.Hour, 2)
expireAt := time.Now().Add(time.Hour)
contactSnap := func(userID int64) contactAccountSnapshot {
return buildContactAccountSnapshot(domain.ContactList{Contacts: []domain.Contact{{
User: domain.User{ID: 100 + userID},
}}}, expireAt)
}
photoSnap := func(userID int64) personalPhotoSnapshot {
return personalPhotoSnapshot{
refs: map[int64]domain.ProfilePhotoRef{100 + userID: {PhotoID: 9000 + userID}},
expireAt: expireAt,
}
}
cached.mu.Lock()
cached.storeContactSnapshotLocked(1, contactSnap(1))
cached.storeContactSnapshotLocked(2, contactSnap(2))
cached.storePersonalPhotoSnapshotLocked(1, photoSnap(1))
cached.storePersonalPhotoSnapshotLocked(2, photoSnap(2))
cached.mu.Unlock()
if _, ok := cached.lookupContactSnapshot(1, time.Now()); !ok {
t.Fatal("contact viewer 1 missing before LRU touch")
}
cached.mu.Lock()
cached.storeContactSnapshotLocked(3, contactSnap(3))
cached.mu.Unlock()
cached.mu.RLock()
_, contactOne := cached.contacts[1]
_, contactTwo := cached.contacts[2]
_, contactThree := cached.contacts[3]
_, photoOne := cached.personalPhotos[1]
_, photoTwo := cached.personalPhotos[2]
cached.mu.RUnlock()
if !contactOne || contactTwo || !contactThree {
t.Fatalf("contact LRU = one:%v two:%v three:%v, want one+three", contactOne, contactTwo, contactThree)
}
if !photoOne || !photoTwo {
t.Fatalf("contact eviction crossed into personal-photo LRU: one:%v two:%v", photoOne, photoTwo)
}
if _, ok := cached.lookupPersonalPhotoSnapshot(2, time.Now()); !ok {
t.Fatal("personal-photo viewer 2 missing before LRU touch")
}
cached.mu.Lock()
cached.storePersonalPhotoSnapshotLocked(3, photoSnap(3))
cached.mu.Unlock()
cached.mu.RLock()
_, photoOne = cached.personalPhotos[1]
_, photoTwo = cached.personalPhotos[2]
_, photoThree := cached.personalPhotos[3]
_, contactOne = cached.contacts[1]
_, contactThree = cached.contacts[3]
cached.mu.RUnlock()
if photoOne || !photoTwo || !photoThree {
t.Fatalf("personal-photo LRU = one:%v two:%v three:%v, want two+three", photoOne, photoTwo, photoThree)
}
if !contactOne || !contactThree {
t.Fatalf("personal-photo eviction crossed into contact LRU: one:%v three:%v", contactOne, contactThree)
}
cached.InvalidateViewers(3)
cached.mu.RLock()
_, contactThree = cached.contacts[3]
_, photoThree = cached.personalPhotos[3]
_, contactElement := cached.contactElements[3]
_, photoElement := cached.personalElements[3]
cached.mu.RUnlock()
if contactThree || photoThree || contactElement || photoElement {
t.Fatalf("viewer invalidation left LRU state: contact=%v photo=%v contactElement=%v photoElement=%v",
contactThree, photoThree, contactElement, photoElement)
}
}
func TestCachedContactStoreUnrelatedViewerInvalidationDoesNotRejectRefill(t *testing.T) {
ctx := context.Background()
base := memory.NewContactStore()
if _, err := base.Upsert(ctx, 2, domain.ContactInput{ContactUserID: 20, FirstName: "current"}); err != nil {
t.Fatalf("seed current contact: %v", err)
}
blocking := &blockingFirstListContactStore{
ContactStore: base,
started: make(chan struct{}),
release: make(chan struct{}),
first: domain.ContactList{Contacts: []domain.Contact{{
User: domain.User{ID: 20},
FirstName: "captured",
}}},
}
cached := NewCachedContactStore(blocking, time.Hour)
type readResult struct {
contacts map[int64]domain.Contact
err error
}
resultCh := make(chan readResult, 1)
go func() {
contacts, err := cached.GetMany(ctx, 2, []int64{20})
resultCh <- readResult{contacts: contacts, err: err}
}()
waitForCacheTestSignal(t, blocking.started)
cached.InvalidateViewers(1)
close(blocking.release)
select {
case result := <-resultCh:
if result.err != nil {
t.Fatalf("contact read: %v", result.err)
}
if got := result.contacts[20].FirstName; got != "captured" {
t.Fatalf("unrelated invalidation rejected captured refill: got %q", got)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for contact read")
}
if calls := blocking.callCount(); calls != 1 {
t.Fatalf("ListByUser calls = %d, want 1 after unrelated invalidation", calls)
}
}
func TestCachedContactStoreContactProjectionForViewersUsesViewerOwnedPairCache(t *testing.T) {
ctx := context.Background()
base := memory.NewContactStore()
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil {
t.Fatalf("seed viewer 1 contact: %v", err)
}
if _, _, err := base.SetPersonalPhoto(ctx, 1, 2, 9101, 100); err != nil {
t.Fatalf("seed viewer 1 personal photo: %v", err)
}
if _, err := base.Upsert(ctx, 3, domain.ContactInput{ContactUserID: 2, FirstName: "Bob"}); err != nil {
t.Fatalf("seed viewer 3 contact: %v", err)
}
if _, _, err := base.SetPersonalPhoto(ctx, 3, 2, 9103, 100); err != nil {
t.Fatalf("seed viewer 3 personal photo: %v", err)
}
counting := &countingContactStore{ContactStore: base}
cached := NewCachedContactStore(counting, 0)
if _, err := cached.GetMany(ctx, 1, []int64{2}); err != nil {
t.Fatalf("prime viewer 1 contacts: %v", err)
}
if _, err := cached.PersonalPhotos(ctx, 1, []int64{2}); err != nil {
t.Fatalf("prime viewer 1 photos: %v", err)
}
first, err := cached.ContactProjectionForViewers(ctx, []int64{1, 3}, []int64{2})
if err != nil {
t.Fatalf("first projection: %v", err)
}
if first.Contacts[1][2].FirstName != "Alice" || first.PersonalPhotos[1][2].PhotoID != 9101 {
t.Fatalf("viewer 1 projection = %+v %+v, want warm Alice/9101", first.Contacts[1][2], first.PersonalPhotos[1][2])
}
if first.Contacts[3][2].FirstName != "Bob" || first.PersonalPhotos[3][2].PhotoID != 9103 {
t.Fatalf("viewer 3 projection = %+v %+v, want cold Bob/9103", first.Contacts[3][2], first.PersonalPhotos[3][2])
}
if counting.projectionCalls != 1 {
t.Fatalf("projection calls after first = %d, want 1", counting.projectionCalls)
}
second, err := cached.ContactProjectionForViewers(ctx, []int64{3}, []int64{2})
if err != nil {
t.Fatalf("second projection: %v", err)
}
if second.Contacts[3][2].FirstName != "Bob" || second.PersonalPhotos[3][2].PhotoID != 9103 {
t.Fatalf("cached viewer 3 projection = %+v %+v, want Bob/9103", second.Contacts[3][2], second.PersonalPhotos[3][2])
}
if counting.projectionCalls != 1 {
t.Fatalf("projection calls after cached read = %d, want 1", counting.projectionCalls)
}
cached.InvalidateViewers(3)
if _, err := cached.ContactProjectionForViewers(ctx, []int64{3}, []int64{2}); err != nil {
t.Fatalf("projection after invalidation: %v", err)
}
if counting.projectionCalls != 2 {
t.Fatalf("projection calls after invalidation = %d, want 2", counting.projectionCalls)
}
}
func TestCachedContactStorePairSnapshotsAreCompact(t *testing.T) {
pointerSize := unsafe.Sizeof(uintptr(0))
timeSize := unsafe.Sizeof(time.Time{})
if got, max := unsafe.Sizeof(reverseContactSnapshot{}), timeSize+2*pointerSize; got > max {
t.Fatalf("reverseContactSnapshot size = %d, want <= %d (one value pointer plus expiry)", got, max)
}
if got, max := unsafe.Sizeof(contactProjectionSnapshot{}), timeSize+3*pointerSize; got > max {
t.Fatalf("contactProjectionSnapshot size = %d, want <= %d (two value pointers plus expiry)", got, max)
}
if got, large := unsafe.Sizeof(reverseContactSnapshot{}), unsafe.Sizeof(domain.Contact{}); got >= large {
t.Fatalf("reverseContactSnapshot size = %d, must not embed %d-byte domain.Contact", got, large)
}
if got, large := unsafe.Sizeof(contactProjectionSnapshot{}), unsafe.Sizeof(domain.Contact{}); got >= large {
t.Fatalf("contactProjectionSnapshot size = %d, must not embed %d-byte domain.Contact", got, large)
}
if got, max := unsafe.Sizeof(cachedContactProjectionOverlay{}), uintptr(128); got > max {
t.Fatalf("cachedContactProjectionOverlay size = %d, want <= %d bytes", got, max)
}
if got, large := unsafe.Sizeof(cachedContactProjectionOverlay{}), unsafe.Sizeof(domain.Contact{}); got >= large {
t.Fatalf("cachedContactProjectionOverlay size = %d, must be smaller than %d-byte domain.Contact", got, large)
}
}
func TestCachedContactStorePairSnapshotsUseNilForNegativeAndClonePositiveValues(t *testing.T) {
cached := NewCachedContactStore(memory.NewContactStore(), time.Hour)
now := time.Unix(1000, 0)
expireAt := now.Add(time.Hour)
contact := domain.Contact{
User: domain.User{
ID: 2, AccessHash: 2002, Phone: "global-phone", FirstName: "Global", LastName: "User",
Username: "global_user", Mutual: true, PhotoStripped: []byte{1, 2, 3},
},
FirstName: "Local",
LastName: "Name",
Phone: "known-phone",
Note: "private note",
NoteEntities: []domain.MessageEntity{{
Type: domain.MessageEntityBold, Offset: 0, Length: 3,
}},
CloseFriend: true,
}
photo := domain.ProfilePhotoRef{PhotoID: 9001, Stripped: []byte{4, 5, 6}, Personal: true}
positiveReverseKey := reverseContactKey{ownerUserID: 1, contactUserID: 2}
negativeReverseKey := reverseContactKey{ownerUserID: 3, contactUserID: 2}
positiveProjectionKey := contactProjectionKey{viewerUserID: 1, contactUserID: 2}
negativeProjectionKey := contactProjectionKey{viewerUserID: 1, contactUserID: 99}
cached.mu.Lock()
cached.storeReverseContactLocked(positiveReverseKey, contact, true, expireAt)
cached.storeReverseContactLocked(negativeReverseKey, contact, false, expireAt)
cached.storeContactProjectionPairLocked(positiveProjectionKey, contact, true, photo, true, expireAt)
cached.storeContactProjectionPairLocked(negativeProjectionKey, contact, false, photo, false, expireAt)
positiveReverse := cached.reverse[positiveReverseKey].Value.(*reverseContactEntry).snapshot
negativeReverse := cached.reverse[negativeReverseKey].Value.(*reverseContactEntry).snapshot
positiveProjection := cached.projection[positiveProjectionKey].Value.(*contactProjectionEntry).snapshot
negativeProjection := cached.projection[negativeProjectionKey].Value.(*contactProjectionEntry).snapshot
cached.mu.Unlock()
if positiveReverse.contact == nil || positiveProjection.contact == nil || positiveProjection.personalPhoto == nil {
t.Fatalf("positive snapshots lost values: reverse=%+v projection=%+v", positiveReverse, positiveProjection)
}
if negativeReverse.contact != nil || negativeProjection.contact != nil || negativeProjection.personalPhoto != nil {
t.Fatalf("negative snapshots retained value allocations: reverse=%+v projection=%+v", negativeReverse, negativeProjection)
}
// Publication clones inputs; subsequent caller mutation cannot alter cache.
contact.User.PhotoStripped[0] = 10
contact.NoteEntities[0].Length = 10
photo.Stripped[0] = 10
reverse, found, hit := cached.lookupReverseContact(1, 2, now)
if !hit || !found || reverse.User.PhotoStripped[0] != 1 || reverse.NoteEntities[0].Length != 3 {
t.Fatalf("positive reverse lookup = %+v found=%v hit=%v", reverse, found, hit)
}
reverse.User.PhotoStripped[0] = 11
reverse.NoteEntities[0].Length = 11
reverseAgain, found, hit := cached.lookupReverseContact(1, 2, now)
if !hit || !found || reverseAgain.User.PhotoStripped[0] != 1 || reverseAgain.NoteEntities[0].Length != 3 {
t.Fatalf("reverse lookup shared mutable slices: %+v found=%v hit=%v", reverseAgain, found, hit)
}
if _, found, hit := cached.lookupReverseContact(3, 2, now); !hit || found {
t.Fatalf("negative reverse lookup found=%v hit=%v, want false/true", found, hit)
}
pair, hit := cached.lookupContactProjectionPair(1, 2, now)
if !hit || !pair.contactFound || !pair.personalPhotoFound || pair.personalPhoto.Stripped[0] != 4 {
t.Fatalf("positive projection lookup = %+v hit=%v", pair, hit)
}
if !reflect.DeepEqual(pair.contact.User, domain.User{ID: 2}) {
t.Fatalf("projection pair retained base user data: %+v", pair.contact.User)
}
if pair.contact.FirstName != "Local" || pair.contact.LastName != "Name" || pair.contact.Phone != "known-phone" ||
pair.contact.Note != "private note" || !pair.contact.Mutual || !pair.contact.CloseFriend {
t.Fatalf("projection pair lost viewer-owned overlay: %+v", pair.contact)
}
pair.contact.NoteEntities[0].Length = 12
pair.personalPhoto.Stripped[0] = 12
pairAgain, hit := cached.lookupContactProjectionPair(1, 2, now)
if !hit || !reflect.DeepEqual(pairAgain.contact.User, domain.User{ID: 2}) || pairAgain.contact.NoteEntities[0].Length != 3 || pairAgain.personalPhoto.Stripped[0] != 4 {
t.Fatalf("projection lookup shared mutable slices: %+v hit=%v", pairAgain, hit)
}
negative, hit := cached.lookupContactProjectionPair(1, 99, now)
if !hit || negative.contactFound || negative.personalPhotoFound {
t.Fatalf("negative projection lookup = %+v hit=%v, want cached miss", negative, hit)
}
}
func TestCachedContactStoreLargeDenseProjectionDoesNotPollutePairCache(t *testing.T) {
if !admitDenseContactProjectionPairs(1, contactProjectionDenseAdmissionMaxCells) {
t.Fatal("admission rejected the documented cell limit")
}
if admitDenseContactProjectionPairs(1, contactProjectionDenseAdmissionMaxCells+1) {
t.Fatal("admission accepted a batch above the documented cell limit")
}
counting := &countingContactStore{ContactStore: memory.NewContactStore()}
cached := NewCachedContactStore(counting, time.Hour)
seedKey := contactProjectionKey{viewerUserID: 1, contactUserID: 2}
cached.mu.Lock()
cached.storeContactProjectionPairLocked(
seedKey,
domain.Contact{User: domain.User{ID: 2}, FirstName: "seed"}, true,
domain.ProfilePhotoRef{}, false,
cached.now().Add(time.Hour),
)
cached.mu.Unlock()
viewers := []int64{1001, 1002}
targets := make([]int64, contactProjectionDenseAdmissionMaxCells/len(viewers)+1)
for i := range targets {
targets[i] = int64(100000 + i)
}
for call := 1; call <= 2; call++ {
got, err := cached.ContactProjectionForViewers(context.Background(), viewers, targets)
if err != nil {
t.Fatalf("large dense projection call %d: %v", call, err)
}
if len(got.Contacts) != 0 || len(got.PersonalPhotos) != 0 {
t.Fatalf("large empty projection call %d = %+v", call, got)
}
cached.mu.Lock()
_, seedPresent := cached.projection[seedKey]
pairCount := len(cached.projection)
lruCount := cached.projectionLRU.Len()
cached.mu.Unlock()
if !seedPresent || pairCount != 1 || lruCount != 1 {
t.Fatalf("large dense load polluted pair cache: seed=%v pairs=%d lru=%d", seedPresent, pairCount, lruCount)
}
}
if counting.projectionCalls != 2 {
t.Fatalf("projection calls = %d, want 2 because oversized results are returned but not admitted", counting.projectionCalls)
}
}
func TestCachedContactStoreContactProjectionSkipsColdReadForKnownNonContact(t *testing.T) {
ctx := context.Background()
base := memory.NewContactStore()
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil {
t.Fatalf("seed contact: %v", err)
}
counting := &countingContactStore{ContactStore: base}
cached := NewCachedContactStore(counting, 0)
if _, err := cached.GetMany(ctx, 1, []int64{99}); err != nil {
t.Fatalf("prime viewer contact snapshot: %v", err)
}
got, err := cached.ContactProjectionForViewers(ctx, []int64{1}, []int64{99})
if err != nil {
t.Fatalf("projection: %v", err)
}
if len(got.Contacts[1]) != 0 || len(got.PersonalPhotos[1]) != 0 {
t.Fatalf("known non-contact projection = %+v", got)
}
if counting.projectionCalls != 0 {
t.Fatalf("projection calls = %d, want 0 for known non-contact", counting.projectionCalls)
}
if counting.personalPhotoCalls != 0 {
t.Fatalf("personal photo calls = %d, want 0 for known non-contact", counting.personalPhotoCalls)
}
}
func TestCachedContactStoreCachesLargeReverseContactBatch(t *testing.T) {
ctx := context.Background()
base := memory.NewContactStore()
@ -237,7 +708,7 @@ func TestCachedContactStoreReversePairsUsePerEntryLRU(t *testing.T) {
}
}
func TestCachedContactStoreInvalidatesAccountSnapshot(t *testing.T) {
func TestCachedContactStoreInvalidatesAccountSnapshotAfterMutation(t *testing.T) {
ctx := context.Background()
base := memory.NewContactStore()
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil {
@ -264,7 +735,123 @@ func TestCachedContactStoreInvalidatesAccountSnapshot(t *testing.T) {
t.Fatalf("second = %+v, want Alicia after invalidation", second[2])
}
if counting.listCalls != 2 {
t.Fatalf("ListByUser calls = %d, want 2 after write invalidation", counting.listCalls)
t.Fatalf("ListByUser calls = %d, want 2 after safe invalidation and reload", counting.listCalls)
}
}
func TestCachedContactStorePublishedSnapshotsStayImmutableDuringMutations(t *testing.T) {
tests := []struct {
name string
mutate func(context.Context, *CachedContactStore) error
}{
{
name: "upsert",
mutate: func(ctx context.Context, cached *CachedContactStore) error {
_, err := cached.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "After"})
return err
},
},
{
name: "delete",
mutate: func(ctx context.Context, cached *CachedContactStore) error {
_, err := cached.Delete(ctx, 1, []int64{2})
return err
},
},
{
name: "close_friends",
mutate: func(ctx context.Context, cached *CachedContactStore) error {
_, err := cached.SetCloseFriends(ctx, 1, []int64{2})
return err
},
},
{
name: "personal_photo",
mutate: func(ctx context.Context, cached *CachedContactStore) error {
_, found, err := cached.SetPersonalPhoto(ctx, 1, 2, 9002, 101)
if err == nil && !found {
return fmt.Errorf("contact not found")
}
return err
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
base := memory.NewContactStore()
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Before"}); err != nil {
t.Fatalf("seed contact: %v", err)
}
if _, found, err := base.SetPersonalPhoto(ctx, 1, 2, 9001, 100); err != nil || !found {
t.Fatalf("seed personal photo: %v found=%v", err, found)
}
cached := NewCachedContactStore(base, 0)
if _, err := cached.GetMany(ctx, 1, []int64{2}); err != nil {
t.Fatalf("warm contacts: %v", err)
}
if _, err := cached.PersonalPhotos(ctx, 1, []int64{2}); err != nil {
t.Fatalf("warm personal photos: %v", err)
}
cached.mu.RLock()
contactSnap, contactsWarm := cached.contacts[1]
photoSnap, photosWarm := cached.personalPhotos[1]
cached.mu.RUnlock()
if !contactsWarm || !photosWarm {
t.Fatal("snapshots were not warm before mutation")
}
started := make(chan struct{})
stop := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
signaled := false
for {
contact := contactSnap.contacts[2]
for i := range contactSnap.ordered {
_ = contactSnap.ordered[i].User.ID
}
ref := photoSnap.refs[2]
_, _ = contact.FirstName, ref.PhotoID
if !signaled {
close(started)
signaled = true
}
select {
case <-stop:
return
default:
}
}
}()
waitForCacheTestSignal(t, started)
if err := tc.mutate(ctx, cached); err != nil {
close(stop)
<-done
t.Fatalf("mutation: %v", err)
}
close(stop)
<-done
// A snapshot obtained before invalidation remains a valid immutable
// value for an in-flight reader; only the outer cache entry is removed.
if got := contactSnap.contacts[2]; got.FirstName != "Before" || got.CloseFriend {
t.Fatalf("published contact snapshot mutated in place: %+v", got)
}
if got := photoSnap.refs[2]; got.PhotoID != 9001 {
t.Fatalf("published photo snapshot mutated in place: %+v", got)
}
cached.mu.RLock()
_, contactsWarm = cached.contacts[1]
_, photosWarm = cached.personalPhotos[1]
cached.mu.RUnlock()
if contactsWarm || photosWarm {
t.Fatalf("mutation left stale snapshots published: contacts=%v photos=%v", contactsWarm, photosWarm)
}
})
}
}
@ -363,6 +950,59 @@ func TestCachedContactStoreDoesNotRefillStaleSnapshotAfterInvalidation(t *testin
if cachedHit[2].FirstName != "Alicia" {
t.Fatalf("cached value after stale load retry = %+v, want Alicia", cachedHit[2])
}
if calls := blocking.callCount(); calls != 2 {
t.Fatalf("ListByUser calls = %d, want stale load plus exact-viewer retry", calls)
}
}
func TestCachedContactStoreFlushRejectsEveryInFlightRefill(t *testing.T) {
ctx := context.Background()
base := memory.NewContactStore()
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil {
t.Fatalf("seed contact: %v", err)
}
first, err := base.ListByUser(ctx, 1)
if err != nil {
t.Fatalf("snapshot first contact list: %v", err)
}
blocking := &blockingFirstListContactStore{
ContactStore: base,
started: make(chan struct{}),
release: make(chan struct{}),
first: first,
}
cached := NewCachedContactStore(blocking, time.Hour)
type readResult struct {
contacts map[int64]domain.Contact
err error
}
resultCh := make(chan readResult, 1)
go func() {
contacts, err := cached.GetMany(ctx, 1, []int64{2})
resultCh <- readResult{contacts: contacts, err: err}
}()
waitForCacheTestSignal(t, blocking.started)
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alicia"}); err != nil {
t.Fatalf("update contact while first load is blocked: %v", err)
}
cached.FlushReadModelCache()
close(blocking.release)
select {
case result := <-resultCh:
if result.err != nil {
t.Fatalf("contact read: %v", result.err)
}
if got := result.contacts[2].FirstName; got != "Alicia" {
t.Fatalf("flush allowed stale refill: got %q", got)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for contact read")
}
if calls := blocking.callCount(); calls != 2 {
t.Fatalf("ListByUser calls = %d, want stale load plus post-flush retry", calls)
}
}
func TestCachedContactStoreInvalidatesPersonalPhoto(t *testing.T) {
@ -412,7 +1052,10 @@ func TestCachedContactStoreInvalidatesPersonalPhoto(t *testing.T) {
t.Fatalf("PersonalPhotos calls after invalidation = %d, want 2", counting.personalPhotoCalls)
}
if counting.listCalls != 2 {
t.Fatalf("ListByUser calls after invalidation = %d, want 2", counting.listCalls)
t.Fatalf("ListByUser calls after mutation = %d, want 2 after safe invalidation and reload", counting.listCalls)
}
if counting.setPersonalPhotoHit != 1 {
t.Fatalf("SetPersonalPhoto calls = %d, want 1", counting.setPersonalPhotoHit)
}
}
@ -466,3 +1109,68 @@ func TestCachedContactStoreDoesNotRefillStalePersonalPhotoAfterInvalidation(t *t
t.Fatalf("personal photo after concurrent invalidation = %+v, want 9002", result.refs[2])
}
}
func TestCachedContactStoreOlderPersonalPhotoMutationCannotReinsertStalePair(t *testing.T) {
ctx := context.Background()
base := memory.NewContactStore()
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil {
t.Fatalf("seed contact: %v", err)
}
if _, found, err := base.SetPersonalPhoto(ctx, 1, 2, 9000, 99); err != nil || !found {
t.Fatalf("seed personal photo: %v found=%v", err, found)
}
inner := &stalePersonalPhotoWritebackStore{
ContactStore: base,
started: make(chan struct{}),
release: make(chan struct{}),
}
cached := NewCachedContactStore(inner, 0)
if refs, err := cached.PersonalPhotos(ctx, 1, []int64{2}); err != nil || refs[2].PhotoID != 9000 {
t.Fatalf("warm personal photo = %+v err=%v, want 9000", refs[2], err)
}
olderCtx := context.WithValue(ctx, stalePersonalPhotoWritebackContextKey{}, true)
type setResult struct {
found bool
err error
}
olderResult := make(chan setResult, 1)
go func() {
_, found, err := cached.SetPersonalPhoto(olderCtx, 1, 2, 9001, 100)
olderResult <- setResult{found: found, err: err}
}()
waitForCacheTestSignal(t, inner.started)
// The newer DB commit completes and invalidates the warm snapshot first.
if _, found, err := cached.SetPersonalPhoto(ctx, 1, 2, 9002, 101); err != nil || !found {
t.Fatalf("newer personal photo mutation: %v found=%v", err, found)
}
close(inner.release)
select {
case result := <-olderResult:
if result.err != nil || !result.found {
t.Fatalf("older personal photo mutation: %v found=%v", result.err, result.found)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for older personal photo mutation")
}
if calls := inner.staleReads(); calls != 0 {
t.Fatalf("post-commit stale PersonalPhotos reads = %d, want 0", calls)
}
cached.mu.RLock()
_, contactsWarm := cached.contacts[1]
_, photosWarm := cached.personalPhotos[1]
_, pairWarm := cached.projection[contactProjectionKey{viewerUserID: 1, contactUserID: 2}]
cached.mu.RUnlock()
if contactsWarm || photosWarm || pairWarm {
t.Fatalf("older mutation reinserted stale cache state: contacts=%v photos=%v pair=%v", contactsWarm, photosWarm, pairWarm)
}
refs, err := cached.PersonalPhotos(ctx, 1, []int64{2})
if err != nil {
t.Fatalf("reload current personal photo: %v", err)
}
if got := refs[2].PhotoID; got != 9002 {
t.Fatalf("personal photo after out-of-order completions = %d, want 9002", got)
}
}

View file

@ -0,0 +1,142 @@
package userprojection
import (
"context"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
"telesrv/internal/readmodelcache"
"telesrv/internal/store"
)
type accountFreezeFact struct {
value domain.AccountFreeze
found bool
}
// DurableUserProjectionFacts caches only viewer-independent durable overlays.
// Contact/privacy/presence decisions remain outside and are evaluated after
// these facts are loaded.
type DurableUserProjectionFacts struct {
freezes AccountFreezeProvider
versions store.ReadModelVersionStore
freezeCache *readmodelcache.Cache[int64, accountFreezeFact]
}
func NewDurableUserProjectionFacts(
freezes AccountFreezeProvider,
versions store.ReadModelVersionStore,
maxEntries int,
) *DurableUserProjectionFacts {
return &DurableUserProjectionFacts{
freezes: freezes,
versions: versions,
freezeCache: readmodelcache.New[int64, accountFreezeFact](readmodelcache.Config[int64, accountFreezeFact]{
MaxEntries: maxEntries,
}),
}
}
func (f *DurableUserProjectionFacts) AccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error) {
out := make(map[int64]domain.AccountFreeze)
ids := uniqueDurableFactUserIDs(userIDs)
if f == nil || f.freezes == nil || len(ids) == 0 {
return out, nil
}
hashes, err := f.factHashes(ctx, readmodel.ModelUserVisibility, ids)
if err != nil {
return nil, err
}
loaded, err := f.freezeCache.GetOrLoadBatch(ctx, ids,
func(userID int64) (int64, bool) {
hash := hashes[userID]
return hash, f.versions != nil && hash != 0
},
func(ctx context.Context, missing []int64) (map[int64]accountFreezeFact, error) {
values, err := f.freezes.AccountFreezes(ctx, missing)
if err != nil {
return nil, err
}
entries := make(map[int64]accountFreezeFact, len(missing))
for _, userID := range missing {
entry := accountFreezeFact{}
if value, ok := values[userID]; ok {
entry = accountFreezeFact{value: value, found: true}
}
entries[userID] = entry
}
return entries, nil
})
if err != nil {
return nil, err
}
for userID, entry := range loaded {
if entry.found {
out[userID] = entry.value
}
}
return out, nil
}
// AccountFreeze exposes the same versioned positive/negative cache to scalar
// RPC gates. It deliberately delegates to the batch path so gate reads and
// user/dialog projection cannot drift into separate cache semantics.
func (f *DurableUserProjectionFacts) AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) {
if userID == 0 {
return domain.AccountFreeze{}, false, nil
}
items, err := f.AccountFreezes(ctx, []int64{userID})
if err != nil {
return domain.AccountFreeze{}, false, err
}
value, found := items[userID]
return value, found, nil
}
func (f *DurableUserProjectionFacts) factHashes(ctx context.Context, model string, userIDs []int64) (map[int64]int64, error) {
out := make(map[int64]int64, len(userIDs))
if f == nil || f.versions == nil {
return out, nil
}
keys := make([]store.ReadModelKey, 0, len(userIDs))
for _, userID := range userIDs {
keys = append(keys, store.ReadModelKey{Model: model, OwnerUserID: 0, PeerType: domain.PeerTypeUser, PeerID: userID})
}
rows, err := f.versions.ReadModelHashes(ctx, keys)
if err != nil {
return nil, err
}
for _, key := range keys {
out[key.PeerID] = rows[key]
}
return out, nil
}
func (f *DurableUserProjectionFacts) InvalidateAccountFreezeFact(userID int64) {
if f != nil && userID != 0 {
f.freezeCache.Invalidate(userID)
}
}
func (f *DurableUserProjectionFacts) FlushUserProjectionFactReadModel() {
if f != nil {
f.freezeCache.Flush()
}
}
func uniqueDurableFactUserIDs(ids []int64) []int64 {
out := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}

View file

@ -0,0 +1,153 @@
package userprojection
import (
"context"
"errors"
"sync"
"testing"
"telesrv/internal/app/readmodel"
"telesrv/internal/domain"
"telesrv/internal/store"
)
type durableFactVersions struct {
mu sync.Mutex
hashes map[store.ReadModelKey]int64
}
func (v *durableFactVersions) ReadModelHash(_ context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) {
v.mu.Lock()
defer v.mu.Unlock()
hash := v.hashes[store.ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}]
return hash, hash != 0, nil
}
func (v *durableFactVersions) ReadModelHashes(_ context.Context, keys []store.ReadModelKey) (map[store.ReadModelKey]int64, error) {
v.mu.Lock()
defer v.mu.Unlock()
out := make(map[store.ReadModelKey]int64, len(keys))
for _, key := range keys {
if hash := v.hashes[key]; hash != 0 {
out[key] = hash
}
}
return out, nil
}
func (v *durableFactVersions) set(model string, userID, hash int64) {
v.mu.Lock()
v.hashes[store.ReadModelKey{Model: model, OwnerUserID: 0, PeerType: domain.PeerTypeUser, PeerID: userID}] = hash
v.mu.Unlock()
}
type countingDurableFreezeFacts struct {
mu sync.Mutex
calls int
values map[int64]domain.AccountFreeze
err error
}
func (f *countingDurableFreezeFacts) AccountFreezes(_ context.Context, ids []int64) (map[int64]domain.AccountFreeze, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls++
if f.err != nil {
return nil, f.err
}
out := make(map[int64]domain.AccountFreeze)
for _, id := range ids {
if value, ok := f.values[id]; ok {
out[id] = value
}
}
return out, nil
}
func TestDurableUserProjectionFactsCachesPositiveAndNegativeByVersion(t *testing.T) {
ctx := context.Background()
versions := &durableFactVersions{hashes: make(map[store.ReadModelKey]int64)}
for _, id := range []int64{1, 2} {
versions.set(readmodel.ModelUserVisibility, id, 10+id)
}
freezes := &countingDurableFreezeFacts{values: map[int64]domain.AccountFreeze{1: {UserID: 1, Frozen: true}}}
facts := NewDurableUserProjectionFacts(freezes, versions, 10)
for i := 0; i < 2; i++ {
gotFreezes, err := facts.AccountFreezes(ctx, []int64{1, 2, 1})
if err != nil || len(gotFreezes) != 1 || !gotFreezes[1].Frozen {
t.Fatalf("AccountFreezes(%d) = %+v err=%v", i, gotFreezes, err)
}
}
if freezes.calls != 1 {
t.Fatalf("backend calls freezes = %d, want 1 including negative hits", freezes.calls)
}
versions.set(readmodel.ModelUserVisibility, 2, 32)
freezes.values[2] = domain.AccountFreeze{UserID: 2, Frozen: true}
gotFreezes, err := facts.AccountFreezes(ctx, []int64{1, 2})
if err != nil || !gotFreezes[2].Frozen {
t.Fatalf("AccountFreezes after version bump = %+v err=%v", gotFreezes, err)
}
if freezes.calls != 2 {
t.Fatalf("backend calls after one-key bumps freezes = %d, want 2", freezes.calls)
}
}
func TestDurableUserProjectionFactsScalarFreezeGateReusesVersionedFact(t *testing.T) {
ctx := context.Background()
versions := &durableFactVersions{hashes: make(map[store.ReadModelKey]int64)}
versions.set(readmodel.ModelUserVisibility, 1, 11)
versions.set(readmodel.ModelUserVisibility, 2, 12)
freezes := &countingDurableFreezeFacts{values: map[int64]domain.AccountFreeze{
1: {UserID: 1, Frozen: true},
}}
facts := NewDurableUserProjectionFacts(freezes, versions, 10)
for i := 0; i < 3; i++ {
freeze, found, err := facts.AccountFreeze(ctx, 1)
if err != nil || !found || !freeze.Frozen {
t.Fatalf("positive scalar gate %d = %+v found=%v err=%v", i, freeze, found, err)
}
freeze, found, err = facts.AccountFreeze(ctx, 2)
if err != nil || found || freeze.Frozen {
t.Fatalf("negative scalar gate %d = %+v found=%v err=%v", i, freeze, found, err)
}
}
if freezes.calls != 2 {
t.Fatalf("backend calls = %d, want one positive and one negative fill", freezes.calls)
}
}
func TestDurableUserProjectionFactErrorsAreNotNegativeCached(t *testing.T) {
ctx := context.Background()
versions := &durableFactVersions{hashes: make(map[store.ReadModelKey]int64)}
versions.set(readmodel.ModelUserVisibility, 1, 11)
freezes := &countingDurableFreezeFacts{values: map[int64]domain.AccountFreeze{}, err: errors.New("freeze unavailable")}
facts := NewDurableUserProjectionFacts(freezes, versions, 10)
if _, err := facts.AccountFreezes(ctx, []int64{1}); err == nil {
t.Fatal("AccountFreezes error = nil")
}
freezes.err = nil
if _, err := facts.AccountFreezes(ctx, []int64{1}); err != nil {
t.Fatalf("recovered AccountFreezes: %v", err)
}
if freezes.calls != 2 {
t.Fatalf("backend calls freezes = %d, want retry", freezes.calls)
}
}
func TestDurableUserProjectionFactExplicitInvalidation(t *testing.T) {
ctx := context.Background()
versions := &durableFactVersions{hashes: make(map[store.ReadModelKey]int64)}
versions.set(readmodel.ModelUserVisibility, 1, 11)
freezes := &countingDurableFreezeFacts{values: map[int64]domain.AccountFreeze{}}
facts := NewDurableUserProjectionFacts(freezes, versions, 10)
_, _ = facts.AccountFreezes(ctx, []int64{1})
facts.InvalidateAccountFreezeFact(1)
_, _ = facts.AccountFreezes(ctx, []int64{1})
if freezes.calls != 2 {
t.Fatalf("backend calls after invalidation freezes = %d, want 2", freezes.calls)
}
}

View file

@ -8,11 +8,16 @@ import (
"telesrv/internal/readmodelcache"
)
// DefaultPhotoCacheTTL 是头像投影缓存的兜底有效期;正常正确性依赖写入侧触发
// read_model_versions/NOTIFY 后显式失效,TTL 只负责覆盖进程外漏通知或手工改库。
const DefaultPhotoCacheTTL = 10 * time.Second
const (
// DefaultPhotoCacheTTL 是头像投影缓存的兜底有效期;正常正确性依赖写入侧触发
// read_model_versions/NOTIFY 后显式失效,TTL 只负责覆盖漏通知或手工改库。
// 10s 会让 60s 的 10k 登录突发反复丢失稳定负结果,不能作为正常新鲜度机制。
DefaultPhotoCacheTTL = 24 * time.Hour
const photoCacheMaxEntries = 200000
// DefaultPhotoCacheMaxEntries 覆盖 10k owner 的 profile/fallback 两种 key,
// 并为共享对话引用保留余量。底层是逐项 LRU,不允许整表清空。
DefaultPhotoCacheMaxEntries = 200_000
)
// combinedPhotoProvider 是同时具备 batch 与 kind 两种头像查询能力的底层 provider(postgres
// MediaStore 即满足)。
@ -50,21 +55,32 @@ type CachedPhotoProvider struct {
// NewCachedPhotoProvider 包装底层 provider;ttl<=0 用 DefaultPhotoCacheTTL。
func NewCachedPhotoProvider(inner combinedPhotoProvider, ttl time.Duration) *CachedPhotoProvider {
return newCachedPhotoProviderWithClock(inner, ttl, nil)
return NewCachedPhotoProviderWithMaxEntries(inner, ttl, DefaultPhotoCacheMaxEntries)
}
func NewCachedPhotoProviderWithMaxEntries(inner combinedPhotoProvider, ttl time.Duration, maxEntries int) *CachedPhotoProvider {
return newCachedPhotoProvider(inner, ttl, maxEntries, nil)
}
// newCachedPhotoProviderWithClock 允许注入时钟,仅供测试确定地推进 TTL;now=nil 用真实时钟。
func newCachedPhotoProviderWithClock(inner combinedPhotoProvider, ttl time.Duration, now func() time.Time) *CachedPhotoProvider {
return newCachedPhotoProvider(inner, ttl, DefaultPhotoCacheMaxEntries, now)
}
func newCachedPhotoProvider(inner combinedPhotoProvider, ttl time.Duration, maxEntries int, now func() time.Time) *CachedPhotoProvider {
if inner == nil {
return nil
}
if ttl <= 0 {
ttl = DefaultPhotoCacheTTL
}
if maxEntries <= 0 {
maxEntries = DefaultPhotoCacheMaxEntries
}
return &CachedPhotoProvider{
inner: inner,
cache: readmodelcache.New[photoCacheKey, photoCacheValue](readmodelcache.Config[photoCacheKey, photoCacheValue]{
MaxEntries: photoCacheMaxEntries,
MaxEntries: maxEntries,
TTL: ttl,
Now: now,
Clone: clonePhotoCacheValue,

View file

@ -133,6 +133,55 @@ func TestCachedPhotoProviderCachesHitsAndMisses(t *testing.T) {
}
}
func TestCachedPhotoProviderDefaultTTLRetainsLoginRampWorkingSet(t *testing.T) {
inner := &countingPhotoProvider{refs: map[int64]domain.ProfilePhotoRef{}}
now := time.Unix(1000, 0)
c := newCachedPhotoProvider(inner, 0, DefaultPhotoCacheMaxEntries, func() time.Time { return now })
ctx := context.Background()
if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile); err != nil {
t.Fatalf("first: %v", err)
}
now = now.Add(time.Minute)
if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile); err != nil {
t.Fatalf("within login ramp: %v", err)
}
if inner.kindCalls != 1 {
t.Fatalf("default TTL expired inside 60s login ramp: calls=%d, want 1", inner.kindCalls)
}
now = now.Add(DefaultPhotoCacheTTL)
if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile); err != nil {
t.Fatalf("after safety TTL: %v", err)
}
if inner.kindCalls != 2 {
t.Fatalf("safety TTL did not reload: calls=%d, want 2", inner.kindCalls)
}
}
func TestCachedPhotoProviderConfiguredCapacityEvictsOneLRUKey(t *testing.T) {
inner := &countingPhotoProvider{refs: map[int64]domain.ProfilePhotoRef{}}
now := time.Unix(1000, 0)
c := newCachedPhotoProvider(inner, time.Hour, 2, func() time.Time { return now })
ctx := context.Background()
read := func(ownerID int64) {
t.Helper()
if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{ownerID}, domain.ProfilePhotoKindProfile); err != nil {
t.Fatalf("owner %d: %v", ownerID, err)
}
}
for _, ownerID := range []int64{1, 2, 1, 3, 1, 2} {
read(ownerID)
}
if inner.kindCalls != 4 {
t.Fatalf("kind calls = %d, want 4 with owner 1 touched and only owner 2 evicted", inner.kindCalls)
}
if c.cache.Len() != 2 {
t.Fatalf("cache entries = %d, want configured capacity 2", c.cache.Len())
}
}
func TestCachedPhotoProviderInvalidatesOwnerAndFlushes(t *testing.T) {
inner := &countingPhotoProvider{refs: map[int64]domain.ProfilePhotoRef{1: {PhotoID: 111}}}
now := time.Unix(1000, 0)

View file

@ -118,12 +118,10 @@ func (p *Projector) One(ctx context.Context, viewerUserID int64, user domain.Use
// ForViewers 跨多个 viewer 批量投影同一组 owner 用户(fan-out 模板化)。它把 per-viewer 各跑
// 一遍 ForViewer(=projectBatch) 的成本(O(viewer)×(photos+contacts+privacy) 查询)压成:
// - 一次 profile/fallback 头像批量(跨 viewer 复用)
// - O(owner) 次 GetReverseContacts(改名/电话覆盖,按 owner 反查 viewer)
// - 一次 viewer-owned contact projection(联系人改名/电话覆盖 + personal photo overlay)
// - O(owner) 次 GetMany + 一次 ListPrivacyRules(CanSeeMatrix 内做)
//
// 返回 map[viewerID][]domain.User,每个切片与对应 viewer 的 ForViewer(viewer, users) **字节等价,
// 唯一例外是 personal photo overlay**:v1 简化为 fan-out 模板不做 per-viewer personal photo
// (无 O(owner) 反查接口),客户端下次 getChannelDifference/getHistory 会走 projectBatch 完整投影自愈。
// 返回 map[viewerID][]domain.User,每个切片与对应 viewer 的 ForViewer(viewer, users) 字节等价。
// 调用方传入的 users 不被修改(内部复制)。
func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users []domain.User) (map[int64][]domain.User, error) {
users = sanitizeDeletedUsers(users)
@ -143,26 +141,34 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
}
ids := uniqueUserIDs(users)
// 三组预取互不依赖(共享头像、反向联系人覆盖、privacy 矩阵),并发执行收敛成一波。
// 三组预取互不依赖(共享头像、viewer-owned 联系人投影、privacy 矩阵),并发执行收敛成一波。
var (
profileRefs map[int64]domain.ProfilePhotoRef
fallbackRefs map[int64]domain.ProfilePhotoRef
contactsByViewer map[int64]map[int64]domain.Contact
matrix map[int64]map[int64]map[domain.PrivacyKey]bool
freezes map[int64]domain.AccountFreeze
profileRefs map[int64]domain.ProfilePhotoRef
fallbackRefs map[int64]domain.ProfilePhotoRef
contactsByViewer map[int64]map[int64]domain.Contact
personalRefsByViewer map[int64]map[int64]domain.ProfilePhotoRef
matrix map[int64]map[int64]map[domain.PrivacyKey]bool
freezes map[int64]domain.AccountFreeze
)
g, gctx := errgroup.WithContext(ctx)
// 1) 共享头像:profile/fallback 一次批量,跨全部 viewer 复用;personal photo v1 跳过(见 doc)。
// 1) 共享头像:profile/fallback 一次批量,跨全部 viewer 复用。
g.Go(func() error {
var err error
profileRefs, fallbackRefs, err = p.batchProfileFallbackPhotos(gctx, ids)
return err
})
// 2) 改名/电话覆盖:O(owner) 次 GetReverseContacts(owner, viewers) 重组为 [viewer][owner]Contact,
// 与 projectBatch 的 GetMany(viewer, owners) 命中同一条联系人记录(方向对称)。
// 2) 改名/电话覆盖 + personal photo:按 viewer 拥有的联系人行批量读取,
// 与 projectBatch 的 GetMany/PersonalPhotos(viewer, owners) 命中同一语义。
g.Go(func() error {
var err error
contactsByViewer, err = p.reverseContactsByViewer(gctx, ids, viewers)
if p.contacts == nil || len(ids) == 0 || len(viewers) == 0 {
return nil
}
batch, err := p.contacts.ContactProjectionForViewers(gctx, viewers, ids)
if err != nil {
return err
}
contactsByViewer = batch.Contacts
personalRefsByViewer = batch.PersonalPhotos
return err
})
// 3) privacy 可见性矩阵:O(owner) 查询;nil(无 MatrixPrivacyEvaluator)时 applyPrivacy 回退逐 CanSee。
@ -183,10 +189,10 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
if err := g.Wait(); err != nil {
return nil, err
}
// 4) 逐 viewer 组装,复用与 projectBatch 完全相同的 apply* 链(personalRefs 传 nil)。
// 4) 逐 viewer 组装,复用与 projectBatch 完全相同的 apply* 链。
for _, viewer := range viewers {
projected := make([]domain.User, len(users))
copy(projected, users)
personalRefs := personalRefsByViewer[viewer]
projected := cloneUsers(users)
cache := make(map[int64]domain.User, len(projected))
for i := range projected {
u := projected[i]
@ -201,7 +207,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
projected[i] = pj
continue
}
pj := applyBasePhotos(u, profileRefs, fallbackRefs, nil, viewer)
pj := applyBasePhotos(u, profileRefs, fallbackRefs, personalRefs, viewer)
if viewer != 0 && u.ID != viewer && u.ID != domain.OfficialSystemUserID && !u.Bot {
contact, found := contactsByViewer[viewer][u.ID]
pj = applyContactProjection(pj, contact, found)
@ -211,7 +217,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
}
var perr error
hasKnownContactPhone := found && contact.Phone != ""
pj, perr = applyPrivacy(ctx, p.privacy, viewer, pj, hasKnownContactPhone, vis, profileRefs, fallbackRefs, nil)
pj, perr = applyPrivacy(ctx, p.privacy, viewer, pj, hasKnownContactPhone, vis, profileRefs, fallbackRefs, personalRefs)
if perr != nil {
return nil, perr
}
@ -225,8 +231,8 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
return out, nil
}
// batchProfileFallbackPhotos 取 owner 的 profile/fallback 头像(与 projectBatch 同逻辑),personal
// 头像不取(ForViewers v1 跳过)。photos 为 nil 时返回空 map(applyBasePhotos 视为无头像查询)。
// batchProfileFallbackPhotos 取 owner 的 profile/fallback 头像(与 projectBatch 同逻辑)。
// photos 为 nil 时返回空 map(applyBasePhotos 视为无头像查询)。
func (p *Projector) batchProfileFallbackPhotos(ctx context.Context, ids []int64) (profileRefs, fallbackRefs map[int64]domain.ProfilePhotoRef, err error) {
profileRefs = map[int64]domain.ProfilePhotoRef{}
fallbackRefs = map[int64]domain.ProfilePhotoRef{}
@ -253,30 +259,6 @@ func (p *Projector) batchProfileFallbackPhotos(ctx context.Context, ids []int64)
return refs, fallbackRefs, nil
}
// reverseContactsByViewer 以 O(owner) 次 GetReverseContacts(owner, viewers) 取「每个 viewer 对各
// owner 的联系人记录」并重组为 map[viewer]map[owner]Contact。该记录与 projectBatch 的
// GetMany(viewer, owners)[owner] 是同一条(contacts 表上 (user_id=viewer, contact_user_id=owner)
// 的同一行,两端 store 均如此),用于 applyContactProjection 的改名/电话覆盖与 isContact 判定。
func (p *Projector) reverseContactsByViewer(ctx context.Context, ownerIDs, viewers []int64) (map[int64]map[int64]domain.Contact, error) {
out := make(map[int64]map[int64]domain.Contact, len(viewers))
if p.contacts == nil || len(ownerIDs) == 0 || len(viewers) == 0 {
return out, nil
}
for _, owner := range ownerIDs {
byViewer, err := p.contacts.GetReverseContacts(ctx, owner, viewers)
if err != nil {
return nil, err
}
for viewer, contact := range byViewer {
if out[viewer] == nil {
out[viewer] = make(map[int64]domain.Contact, len(ownerIDs))
}
out[viewer][owner] = contact
}
}
return out, nil
}
func cloneUsers(users []domain.User) []domain.User {
if len(users) == 0 {
return nil
@ -284,6 +266,7 @@ func cloneUsers(users []domain.User) []domain.User {
out := make([]domain.User, len(users))
copy(out, users)
for i := range out {
out[i].PhotoStripped = append([]byte(nil), out[i].PhotoStripped...)
out[i].ContactNoteEntities = append([]domain.MessageEntity(nil), out[i].ContactNoteEntities...)
out[i].RestrictionReasons = append([]domain.UserRestrictionReason(nil), out[i].RestrictionReasons...)
}
@ -332,8 +315,7 @@ func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users [
if err != nil || len(refs) == 0 {
return users
}
out := make([]domain.User, len(users))
copy(out, users)
out := cloneUsers(users)
for i := range out {
if ref, ok := refs[out[i].ID]; ok {
applyPhotoRef(&out[i], ref)
@ -351,8 +333,7 @@ func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID in
if contacts == nil || viewerUserID == 0 || len(users) == 0 {
return users, nil
}
out := make([]domain.User, len(users))
copy(out, users)
out := cloneUsers(users)
cache := make(map[int64]domain.User, len(users))
for i := range out {
u := out[i]
@ -386,8 +367,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
if len(users) == 0 {
return users, nil
}
out := make([]domain.User, len(users))
copy(out, users)
out := cloneUsers(users)
out = sanitizeDeletedUsers(out)
ids := uniqueUserIDs(out)
var (
@ -619,12 +599,20 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
if contact.Phone != "" {
user.Phone = contact.Phone
}
if contact.User.FirstName != "" || contact.User.LastName != "" {
if contact.FirstName != "" || contact.LastName != "" {
// FirstName uses NULLIF at the durable read boundary while LastName is an
// explicit owner-local value. Preserve the base first name when only a
// local last name exists; setting a local first name with an empty last
// name intentionally clears the base last name.
if contact.FirstName != "" {
user.FirstName = contact.FirstName
user.LastName = contact.LastName
} else {
user.LastName = contact.LastName
}
} else if contact.User.FirstName != "" || contact.User.LastName != "" {
user.FirstName = contact.User.FirstName
user.LastName = contact.User.LastName
} else if contact.FirstName != "" || contact.LastName != "" {
user.FirstName = contact.FirstName
user.LastName = contact.LastName
}
return user
}

View file

@ -0,0 +1,179 @@
package userprojection
import (
"context"
"errors"
"golang.org/x/sync/errgroup"
"telesrv/internal/domain"
"telesrv/internal/store"
)
var (
ErrSparseContactProjectionUnsupported = errors.New("sparse contact projection is not supported")
ErrSparsePrivacyProjectionUnsupported = errors.New("sparse privacy projection is not supported")
)
// SparsePrivacyEvaluator evaluates only the supplied viewer->owner pairs. The
// inverse contact rows are supplied from the projector's shared sparse contact
// read so privacy does not issue another contact query.
type SparsePrivacyEvaluator interface {
CanSeeForViewerUserIDs(
ctx context.Context,
ownerUserIDsByViewer map[int64][]int64,
keys []domain.PrivacyKey,
contactsByOwner map[int64]map[int64]domain.Contact,
) (map[int64]map[int64]map[domain.PrivacyKey]bool, error)
}
// ForViewerUserIDs projects a sparse viewer->owner graph. Viewer-independent
// facts are loaded for the union once; viewer-specific facts are read only for
// graph edges that occur in the request (plus their inverse contact edge needed
// by privacy evaluation).
func (p *Projector) ForViewerUserIDs(ctx context.Context, userIDsByViewer map[int64][]int64, baseUsers []domain.User) (map[int64][]domain.User, error) {
requested := normalizeSparseUserIDs(userIDsByViewer)
out := make(map[int64][]domain.User, len(requested))
if len(requested) == 0 {
return out, nil
}
baseUsers = sanitizeDeletedUsers(baseUsers)
baseByID := make(map[int64]domain.User, len(baseUsers))
for _, user := range baseUsers {
if user.ID != 0 {
baseByID[user.ID] = user
}
}
if p == nil {
for viewerID, ids := range requested {
out[viewerID] = sparseBaseUsers(ids, baseByID)
}
return out, nil
}
unionIDs := make([]int64, 0, len(baseByID))
seenUnion := make(map[int64]struct{}, len(baseByID))
contactPairs := make(map[int64][]int64)
privacyPairs := make(map[int64][]int64)
for viewerID, ids := range requested {
for _, ownerID := range ids {
user, found := baseByID[ownerID]
if !found {
continue
}
if _, ok := seenUnion[ownerID]; !ok && !user.Deleted {
seenUnion[ownerID] = struct{}{}
unionIDs = append(unionIDs, ownerID)
}
if user.Deleted || ownerID == viewerID {
continue
}
// Personal-photo overlay applies independently of contact/privacy
// exemptions, so retain every real viewer->owner edge here.
contactPairs[viewerID] = append(contactPairs[viewerID], ownerID)
if ownerID == domain.OfficialSystemUserID || user.Bot {
continue
}
privacyPairs[viewerID] = append(privacyPairs[viewerID], ownerID)
// Privacy's ViewerIsContact is the inverse owner->viewer row. Merge it
// into the same exact-pair store call.
contactPairs[ownerID] = append(contactPairs[ownerID], viewerID)
}
}
var (
profileRefs map[int64]domain.ProfilePhotoRef
fallbackRefs map[int64]domain.ProfilePhotoRef
contactBatch domain.ContactProjectionBatch
visibility map[int64]map[int64]map[domain.PrivacyKey]bool
freezes map[int64]domain.AccountFreeze
)
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
profileRefs, fallbackRefs, err = p.batchProfileFallbackPhotos(gctx, unionIDs)
return err
})
if p.contacts != nil && len(contactPairs) > 0 {
g.Go(func() error {
loader, ok := p.contacts.(store.SparseContactProjectionStore)
if !ok {
return ErrSparseContactProjectionUnsupported
}
var err error
contactBatch, err = loader.ContactProjectionForViewerUserIDs(gctx, contactPairs)
return err
})
}
if p.freezes != nil && len(unionIDs) > 0 {
g.Go(func() error {
var err error
freezes, err = p.freezes.AccountFreezes(gctx, unionIDs)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
}
if p.privacy != nil && len(privacyPairs) > 0 {
evaluator, ok := p.privacy.(SparsePrivacyEvaluator)
if !ok {
return nil, ErrSparsePrivacyProjectionUnsupported
}
var err error
visibility, err = evaluator.CanSeeForViewerUserIDs(ctx, privacyPairs, privacyProjectionKeys, contactBatch.Contacts)
if err != nil {
return nil, err
}
}
for viewerID, ids := range requested {
projected := sparseBaseUsers(ids, baseByID)
personalRefs := contactBatch.PersonalPhotos[viewerID]
for i := range projected {
user := projected[i]
if user.Deleted {
projected[i] = user.DeletedTombstone()
continue
}
user = applyBasePhotos(user, profileRefs, fallbackRefs, personalRefs, viewerID)
if viewerID != 0 && user.ID != viewerID && user.ID != domain.OfficialSystemUserID && !user.Bot {
contact, found := contactBatch.Contacts[viewerID][user.ID]
user = applyContactProjection(user, contact, found)
var vis map[domain.PrivacyKey]bool
if visibility != nil {
vis = visibility[user.ID][viewerID]
}
var err error
user, err = applyPrivacy(ctx, p.privacy, viewerID, user, found && contact.Phone != "", vis, profileRefs, fallbackRefs, personalRefs)
if err != nil {
return nil, err
}
}
user = applyAccountFreezeProjection(user, viewerID, freezes[user.ID])
projected[i] = user
}
out[viewerID] = projected
}
return out, nil
}
func normalizeSparseUserIDs(in map[int64][]int64) map[int64][]int64 {
out := make(map[int64][]int64, len(in))
for viewerID, ids := range in {
if viewerID == 0 {
continue
}
out[viewerID] = dedupNonZeroInt64(ids)
}
return out
}
func sparseBaseUsers(ids []int64, baseByID map[int64]domain.User) []domain.User {
users := make([]domain.User, 0, len(ids))
for _, id := range ids {
if user, ok := baseByID[id]; ok {
users = append(users, user)
}
}
return cloneUsers(users)
}

View file

@ -0,0 +1,115 @@
package userprojection
import (
"context"
"reflect"
"testing"
privacyapp "telesrv/internal/app/privacy"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
type recordingSparseContactStore struct {
store.ContactStore
sparseCalls int
denseCalls int
requested map[int64][]int64
}
func (s *recordingSparseContactStore) ContactProjectionForViewers(ctx context.Context, viewers, owners []int64) (domain.ContactProjectionBatch, error) {
s.denseCalls++
return s.ContactStore.ContactProjectionForViewers(ctx, viewers, owners)
}
func (s *recordingSparseContactStore) ContactProjectionForViewerUserIDs(ctx context.Context, requested map[int64][]int64) (domain.ContactProjectionBatch, error) {
s.sparseCalls++
s.requested = make(map[int64][]int64, len(requested))
for viewerID, ids := range requested {
s.requested[viewerID] = append([]int64(nil), ids...)
}
return s.ContactStore.(store.SparseContactProjectionStore).ContactProjectionForViewerUserIDs(ctx, requested)
}
func TestForViewerUserIDsUsesActualPairsAndMatchesScalarProjection(t *testing.T) {
ctx := context.Background()
const (
viewerA = int64(1101)
viewerB = int64(1102)
ownerA = int64(2101)
ownerB = int64(2102)
)
contacts := memory.NewContactStore()
// Seed both requested and cross-viewer rows. A dense matrix would expose the
// cross aliases/photos; the sparse request must never ask for those pairs.
for _, input := range []struct {
viewer int64
owner int64
name string
photo int64
}{
{viewerA, ownerA, "A for viewer A", 9101},
{viewerA, ownerB, "B cross leak", 9191},
{viewerB, ownerB, "B for viewer B", 9102},
{viewerB, ownerA, "A cross leak", 9192},
// Reverse rows are the privacy ViewerIsContact facts.
{ownerA, viewerA, "viewer A", 0},
{ownerB, viewerB, "viewer B", 0},
} {
if _, err := contacts.Upsert(ctx, input.viewer, domain.ContactInput{ContactUserID: input.owner, FirstName: input.name}); err != nil {
t.Fatalf("upsert %d->%d: %v", input.viewer, input.owner, err)
}
if input.photo != 0 {
if _, _, err := contacts.SetPersonalPhoto(ctx, input.viewer, input.owner, input.photo, 100); err != nil {
t.Fatalf("personal photo %d->%d: %v", input.viewer, input.owner, err)
}
}
}
recording := &recordingSparseContactStore{ContactStore: contacts}
privacy := privacyapp.NewService(memory.NewPrivacyStore(), recording)
projector := New(WithContactStore(recording), WithPrivacyEvaluator(privacy))
base := []domain.User{
{ID: ownerA, AccessHash: 31, Phone: "15552101", FirstName: "Owner A"},
{ID: ownerB, AccessHash: 32, Phone: "15552102", FirstName: "Owner B"},
}
wantA, err := projector.ForViewer(ctx, viewerA, base[:1])
if err != nil {
t.Fatalf("scalar viewer A: %v", err)
}
wantB, err := projector.ForViewer(ctx, viewerB, base[1:])
if err != nil {
t.Fatalf("scalar viewer B: %v", err)
}
recording.sparseCalls = 0
recording.denseCalls = 0
recording.requested = nil
got, err := projector.ForViewerUserIDs(ctx, map[int64][]int64{
viewerA: {ownerA},
viewerB: {ownerB},
}, base)
if err != nil {
t.Fatalf("ForViewerUserIDs: %v", err)
}
if !reflect.DeepEqual(got[viewerA], wantA) || !reflect.DeepEqual(got[viewerB], wantB) {
t.Fatalf("sparse projection = %+v, want scalar A=%+v B=%+v", got, wantA, wantB)
}
if recording.sparseCalls != 1 || recording.denseCalls != 0 {
t.Fatalf("contact projection calls = sparse %d dense %d, want 1/0", recording.sparseCalls, recording.denseCalls)
}
wantPairs := map[int64][]int64{
viewerA: {ownerA},
viewerB: {ownerB},
ownerA: {viewerA},
ownerB: {viewerB},
}
for viewerID, ids := range wantPairs {
if !reflect.DeepEqual(recording.requested[viewerID], ids) {
t.Fatalf("requested[%d] = %v, want %v (all=%+v)", viewerID, recording.requested[viewerID], ids, recording.requested)
}
}
if len(recording.requested) != len(wantPairs) {
t.Fatalf("requested pairs = %+v, contains unexpected cross-viewer edges", recording.requested)
}
}

View file

@ -10,6 +10,15 @@ import (
"telesrv/internal/store/memory"
)
func TestCloneUsersDoesNotSharePhotoStripped(t *testing.T) {
source := []domain.User{{ID: 1, PhotoStripped: []byte{1, 2, 3}}}
cloned := cloneUsers(source)
cloned[0].PhotoStripped[0] = 9
if source[0].PhotoStripped[0] != 1 {
t.Fatalf("cloneUsers shared PhotoStripped backing storage: source=%v clone=%v", source[0].PhotoStripped, cloned[0].PhotoStripped)
}
}
func TestProjectorCombinesProfilePhotosAndViewerContacts(t *testing.T) {
ctx := context.Background()
const viewerID int64 = 1001
@ -224,10 +233,8 @@ func TestProjectorAccountFreezeIsViewerScopedAndReversible(t *testing.T) {
// TestForViewersEquivalentToForViewer 锁定 fan-out 模板化的核心安全网:ForViewers(viewers, users)
// 的每个 viewer 切片必须与逐 viewer 的 ForViewer(viewer, users) 字节等价(隐私/改名/头像投影
// 不能因 O(owner) 模板化而漂移泄漏)。**唯一允许的差异是 personal photo overlay**:v1 模板不做
// per-viewer personal photo,故对「该 viewer 给该 owner 设过 personal photo」的对,比较前 mask 掉
// 5 个头像字段;其余对做完整字节比较。覆盖:默认规则陌生人/联系人改名+电话/status 隐藏/profile
// 头像隐藏走 fallback/self/bot/系统账号/viewer 自身也作为 owner 出现。
// 不能因批量模板化而漂移泄漏)。覆盖:默认规则陌生人/联系人改名+电话/personal photo/status
// 隐藏/profile 头像隐藏走 fallback/self/bot/系统账号/viewer 自身也作为 owner 出现。
func TestForViewersEquivalentToForViewer(t *testing.T) {
ctx := context.Background()
const (
@ -248,7 +255,7 @@ func TestForViewersEquivalentToForViewer(t *testing.T) {
if _, err := contacts.Upsert(ctx, v1, domain.ContactInput{ContactUserID: o2, Phone: "1111", FirstName: "Alice", LastName: "Friend"}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
// v1 给 o2 设 personal photo(仅 v1 视角生效 → ForViewer 会带它,ForViewers v1 跳过 → 该对需 mask)。
// v1 给 o2 设 personal photo:ForViewers 必须与 ForViewer 一样带出 viewer-specific 头像。
if _, _, err := contacts.SetPersonalPhoto(ctx, v1, o2, 9300, 300); err != nil {
t.Fatalf("set personal photo: %v", err)
}
@ -287,13 +294,13 @@ func TestForViewersEquivalentToForViewer(t *testing.T) {
{ID: v1, AccessHash: 16, Phone: "15550000016", FirstName: "Viewer1"}, // viewer 自身也作为 owner 出现
}
// 哪些 (viewer, owner) 对存在 personal photo —— 比较时需 mask 头像字段(v1 模板有意跳过)。
personalPairs := map[[2]int64]bool{{v1, o2}: true}
batch, err := projector.ForViewers(ctx, viewers, users)
if err != nil {
t.Fatalf("ForViewers: %v", err)
}
if got := projectionUser(t, batch[v1], o2); got.PhotoID != 9300 || !got.PhotoPersonal {
t.Fatalf("fanout personal photo = id %d personal %v, want personal 9300", got.PhotoID, got.PhotoPersonal)
}
for _, viewer := range viewers {
want, err := projector.ForViewer(ctx, viewer, users)
if err != nil {
@ -311,10 +318,6 @@ func TestForViewersEquivalentToForViewer(t *testing.T) {
if w.ID != g.ID {
t.Fatalf("viewer %d idx %d id mismatch got=%d want=%d", viewer, i, g.ID, w.ID)
}
if personalPairs[[2]int64{viewer, w.ID}] {
maskPhoto(&w)
maskPhoto(&g)
}
if !reflect.DeepEqual(w, g) {
t.Fatalf("viewer %d owner %d: ForViewers != ForViewer\n got=%+v\nwant=%+v", viewer, w.ID, g, w)
}
@ -322,14 +325,6 @@ func TestForViewersEquivalentToForViewer(t *testing.T) {
}
}
func maskPhoto(u *domain.User) {
u.PhotoID = 0
u.PhotoDCID = 0
u.PhotoStripped = nil
u.PhotoPersonal = false
u.PhotoHasVideo = false
}
func projectionUser(t *testing.T, users []domain.User, id int64) domain.User {
t.Helper()
for _, user := range users {