merged from gramsrv upstream
This commit is contained in:
parent
79c64ee916
commit
21a0856587
651 changed files with 54774 additions and 4590 deletions
|
|
@ -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))
|
||||
|
|
|
|||
174
internal/app/userprojection/contact_cache_sparse.go
Normal file
174
internal/app/userprojection/contact_cache_sparse.go
Normal 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{},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
142
internal/app/userprojection/durable_user_facts.go
Normal file
142
internal/app/userprojection/durable_user_facts.go
Normal 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
|
||||
}
|
||||
153
internal/app/userprojection/durable_user_facts_test.go
Normal file
153
internal/app/userprojection/durable_user_facts_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
179
internal/app/userprojection/projection_sparse.go
Normal file
179
internal/app/userprojection/projection_sparse.go
Normal 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)
|
||||
}
|
||||
115
internal/app/userprojection/projection_sparse_test.go
Normal file
115
internal/app/userprojection/projection_sparse_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue