1176 lines
42 KiB
Go
1176 lines
42 KiB
Go
package userprojection
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"reflect"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
"telesrv/internal/store/memory"
|
|
)
|
|
|
|
type blockingFirstListContactStore struct {
|
|
store.ContactStore
|
|
started chan struct{}
|
|
release chan struct{}
|
|
first domain.ContactList
|
|
|
|
mu sync.Mutex
|
|
firstUsed bool
|
|
listCalls int
|
|
}
|
|
|
|
type blockingFirstPersonalPhotoStore struct {
|
|
store.ContactStore
|
|
started chan struct{}
|
|
release chan struct{}
|
|
first map[int64]domain.ProfilePhotoRef
|
|
|
|
mu sync.Mutex
|
|
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()
|
|
close(s.started)
|
|
select {
|
|
case <-s.release:
|
|
case <-ctx.Done():
|
|
return domain.ContactList{}, ctx.Err()
|
|
}
|
|
return domain.ContactList{Contacts: cloneCachedContacts(s.first.Contacts), Hash: s.first.Hash}, nil
|
|
}
|
|
s.mu.Unlock()
|
|
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 {
|
|
s.firstUsed = true
|
|
first := cloneCachedProfilePhotoRefs(s.first)
|
|
s.mu.Unlock()
|
|
close(s.started)
|
|
select {
|
|
case <-s.release:
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
return first, nil
|
|
}
|
|
s.mu.Unlock()
|
|
return s.ContactStore.PersonalPhotos(ctx, userID, contactUserIDs)
|
|
}
|
|
|
|
func (s *blockingFirstPersonalPhotoStore) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) {
|
|
return s.ContactStore.SetPersonalPhoto(ctx, userID, contactUserID, photoID, date)
|
|
}
|
|
|
|
func waitForCacheTestSignal(t *testing.T, ch <-chan struct{}) {
|
|
t.Helper()
|
|
select {
|
|
case <-ch:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for cache test signal")
|
|
}
|
|
}
|
|
|
|
type countingContactStore struct {
|
|
store.ContactStore
|
|
listCalls int
|
|
getManyCalls int
|
|
reverseCalls int
|
|
projectionCalls int
|
|
personalPhotoCalls int
|
|
setPersonalPhotoHit int
|
|
}
|
|
|
|
func (s *countingContactStore) ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) {
|
|
s.listCalls++
|
|
return s.ContactStore.ListByUser(ctx, userID)
|
|
}
|
|
|
|
func (s *countingContactStore) GetMany(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.Contact, error) {
|
|
s.getManyCalls++
|
|
return s.ContactStore.GetMany(ctx, userID, contactUserIDs)
|
|
}
|
|
|
|
func (s *countingContactStore) GetReverseContacts(ctx context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) {
|
|
s.reverseCalls++
|
|
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)
|
|
}
|
|
|
|
func (s *countingContactStore) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) {
|
|
s.setPersonalPhotoHit++
|
|
return s.ContactStore.SetPersonalPhoto(ctx, userID, contactUserID, photoID, date)
|
|
}
|
|
|
|
func TestCachedContactStoreCachesProjectionReads(t *testing.T) {
|
|
ctx := context.Background()
|
|
base := memory.NewContactStore()
|
|
if _, err := base.Upsert(ctx, 1, domain.ContactInput{
|
|
ContactUserID: 2,
|
|
FirstName: "Alice",
|
|
Phone: "111",
|
|
Note: "private note",
|
|
NoteEntities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 7}},
|
|
}); err != nil {
|
|
t.Fatalf("upsert contact: %v", err)
|
|
}
|
|
counting := &countingContactStore{ContactStore: base}
|
|
cached := NewCachedContactStore(counting, 0)
|
|
|
|
first, err := cached.GetMany(ctx, 1, []int64{2, 3})
|
|
if err != nil {
|
|
t.Fatalf("get many first: %v", err)
|
|
}
|
|
if first[2].FirstName != "Alice" || first[2].Note != "private note" || len(first[2].NoteEntities) != 1 {
|
|
t.Fatalf("first contact = %+v, want Alice with private note", first[2])
|
|
}
|
|
first[2].NoteEntities[0].Length = 99
|
|
second, err := cached.GetMany(ctx, 1, []int64{2, 3})
|
|
if err != nil {
|
|
t.Fatalf("get many second: %v", err)
|
|
}
|
|
if second[2].FirstName != "Alice" || second[2].Note != "private note" || len(second[2].NoteEntities) != 1 || second[2].NoteEntities[0].Length != 7 {
|
|
t.Fatalf("second contact = %+v, want isolated cached Alice note", second[2])
|
|
}
|
|
if counting.listCalls != 1 {
|
|
t.Fatalf("ListByUser calls = %d, want 1 account snapshot load", counting.listCalls)
|
|
}
|
|
if counting.getManyCalls != 0 {
|
|
t.Fatalf("GetMany calls = %d, want 0 with account snapshot", counting.getManyCalls)
|
|
}
|
|
|
|
reverse, err := cached.GetReverseContacts(ctx, 2, []int64{1})
|
|
if err != nil {
|
|
t.Fatalf("get reverse: %v", err)
|
|
}
|
|
if reverse[1].FirstName != "Alice" {
|
|
t.Fatalf("reverse contact = %+v, want Alice", reverse[1])
|
|
}
|
|
if counting.reverseCalls != 0 {
|
|
t.Fatalf("GetReverseContacts calls = %d, want 0 from shared contact cache", counting.reverseCalls)
|
|
}
|
|
}
|
|
|
|
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()
|
|
owners := make([]int64, 32)
|
|
for i := range owners {
|
|
owners[i] = int64(i + 1)
|
|
if i%2 == 0 {
|
|
if _, err := base.Upsert(ctx, owners[i], domain.ContactInput{
|
|
ContactUserID: 9001,
|
|
FirstName: "Viewer",
|
|
}); err != nil {
|
|
t.Fatalf("seed owner %d: %v", owners[i], err)
|
|
}
|
|
}
|
|
}
|
|
counting := &countingContactStore{ContactStore: base}
|
|
cached := NewCachedContactStore(counting, 0)
|
|
|
|
first, err := cached.GetReverseContacts(ctx, 9001, owners)
|
|
if err != nil {
|
|
t.Fatalf("first reverse lookup: %v", err)
|
|
}
|
|
if len(first) != 16 || counting.reverseCalls != 1 || counting.listCalls != 0 {
|
|
t.Fatalf("first reverse hits=%d reverseCalls=%d listCalls=%d, want 16/1/0", len(first), counting.reverseCalls, counting.listCalls)
|
|
}
|
|
second, err := cached.GetReverseContacts(ctx, 9001, owners)
|
|
if err != nil {
|
|
t.Fatalf("second reverse lookup: %v", err)
|
|
}
|
|
if len(second) != 16 || counting.reverseCalls != 1 || counting.listCalls != 0 {
|
|
t.Fatalf("cached reverse hits=%d reverseCalls=%d listCalls=%d, want 16/1/0", len(second), counting.reverseCalls, counting.listCalls)
|
|
}
|
|
|
|
cached.InvalidateViewers(owners[0])
|
|
third, err := cached.GetReverseContacts(ctx, 9001, owners)
|
|
if err != nil {
|
|
t.Fatalf("reverse lookup after owner invalidation: %v", err)
|
|
}
|
|
if len(third) != 16 || counting.reverseCalls != 2 {
|
|
t.Fatalf("invalidated reverse hits=%d reverseCalls=%d, want 16/2", len(third), counting.reverseCalls)
|
|
}
|
|
}
|
|
|
|
func TestCachedContactStoreReversePairsUsePerEntryLRU(t *testing.T) {
|
|
ctx := context.Background()
|
|
base := memory.NewContactStore()
|
|
for ownerID := int64(1); ownerID <= 3; ownerID++ {
|
|
if _, err := base.Upsert(ctx, ownerID, domain.ContactInput{
|
|
ContactUserID: 9001,
|
|
FirstName: "Viewer",
|
|
}); err != nil {
|
|
t.Fatalf("seed owner %d: %v", ownerID, err)
|
|
}
|
|
}
|
|
counting := &countingContactStore{ContactStore: base}
|
|
cached := NewCachedContactStore(counting, 0)
|
|
cached.reverseCap = 2
|
|
|
|
for _, ownerID := range []int64{1, 2, 1, 3, 1, 2} {
|
|
got, err := cached.GetReverseContacts(ctx, 9001, []int64{ownerID})
|
|
if err != nil {
|
|
t.Fatalf("reverse owner %d: %v", ownerID, err)
|
|
}
|
|
if _, ok := got[ownerID]; !ok {
|
|
t.Fatalf("reverse owner %d missing", ownerID)
|
|
}
|
|
}
|
|
if counting.reverseCalls != 4 {
|
|
t.Fatalf("reverse calls = %d, want 4 (owner 1 touched, owner 2 evicted only)", counting.reverseCalls)
|
|
}
|
|
if len(cached.reverse) != 2 || cached.reverseLRU.Len() != 2 {
|
|
t.Fatalf("reverse cache map/list = %d/%d, want 2/2", len(cached.reverse), cached.reverseLRU.Len())
|
|
}
|
|
}
|
|
|
|
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 {
|
|
t.Fatalf("upsert contact: %v", err)
|
|
}
|
|
counting := &countingContactStore{ContactStore: base}
|
|
cached := NewCachedContactStore(counting, 0)
|
|
|
|
first, err := cached.GetMany(ctx, 1, []int64{2})
|
|
if err != nil {
|
|
t.Fatalf("get first: %v", err)
|
|
}
|
|
if first[2].FirstName != "Alice" {
|
|
t.Fatalf("first = %+v, want Alice", first[2])
|
|
}
|
|
if _, err := cached.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alicia"}); err != nil {
|
|
t.Fatalf("upsert through cache: %v", err)
|
|
}
|
|
second, err := cached.GetMany(ctx, 1, []int64{2})
|
|
if err != nil {
|
|
t.Fatalf("get second: %v", err)
|
|
}
|
|
if second[2].FirstName != "Alicia" {
|
|
t.Fatalf("second = %+v, want Alicia after invalidation", second[2])
|
|
}
|
|
if counting.listCalls != 2 {
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCachedContactStoreExternalInvalidationAndFlush(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("upsert contact: %v", err)
|
|
}
|
|
counting := &countingContactStore{ContactStore: base}
|
|
cached := NewCachedContactStore(counting, 0)
|
|
|
|
if _, err := cached.GetMany(ctx, 1, []int64{2}); err != nil {
|
|
t.Fatalf("prime get: %v", err)
|
|
}
|
|
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alicia"}); err != nil {
|
|
t.Fatalf("direct upsert: %v", err)
|
|
}
|
|
cached.InvalidateViewers(1)
|
|
got, err := cached.GetMany(ctx, 1, []int64{2})
|
|
if err != nil {
|
|
t.Fatalf("get after external invalidation: %v", err)
|
|
}
|
|
if got[2].FirstName != "Alicia" {
|
|
t.Fatalf("after invalidation = %+v, want Alicia", got[2])
|
|
}
|
|
|
|
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Ally"}); err != nil {
|
|
t.Fatalf("direct upsert 2: %v", err)
|
|
}
|
|
cached.FlushReadModelCache()
|
|
got, err = cached.GetMany(ctx, 1, []int64{2})
|
|
if err != nil {
|
|
t.Fatalf("get after flush: %v", err)
|
|
}
|
|
if got[2].FirstName != "Ally" {
|
|
t.Fatalf("after flush = %+v, want Ally", got[2])
|
|
}
|
|
if counting.listCalls != 3 {
|
|
t.Fatalf("ListByUser calls = %d, want 3 after prime+invalidate+flush", counting.listCalls)
|
|
}
|
|
}
|
|
|
|
func TestCachedContactStoreDoesNotRefillStaleSnapshotAfterInvalidation(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, 0)
|
|
|
|
type readResult struct {
|
|
contacts map[int64]domain.Contact
|
|
err error
|
|
}
|
|
resultCh := make(chan readResult, 1)
|
|
go func() {
|
|
got, err := cached.GetMany(ctx, 1, []int64{2})
|
|
resultCh <- readResult{contacts: got, 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.InvalidateViewers(1)
|
|
close(blocking.release)
|
|
|
|
var result readResult
|
|
select {
|
|
case result = <-resultCh:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for contact read")
|
|
}
|
|
if result.err != nil {
|
|
t.Fatalf("contact read: %v", result.err)
|
|
}
|
|
if result.contacts[2].FirstName != "Alicia" {
|
|
t.Fatalf("contact after concurrent invalidation = %+v, want Alicia", result.contacts[2])
|
|
}
|
|
|
|
cachedHit, err := cached.GetMany(ctx, 1, []int64{2})
|
|
if err != nil {
|
|
t.Fatalf("cached hit after stale load retry: %v", err)
|
|
}
|
|
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) {
|
|
ctx := context.Background()
|
|
base := memory.NewContactStore()
|
|
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil {
|
|
t.Fatalf("upsert contact: %v", err)
|
|
}
|
|
if _, found, err := base.SetPersonalPhoto(ctx, 1, 2, 9001, 100); err != nil || !found {
|
|
t.Fatalf("set personal photo: %v found=%v", err, found)
|
|
}
|
|
counting := &countingContactStore{ContactStore: base}
|
|
cached := NewCachedContactStore(counting, 0)
|
|
|
|
first, err := cached.PersonalPhotos(ctx, 1, []int64{2})
|
|
if err != nil {
|
|
t.Fatalf("personal photos first: %v", err)
|
|
}
|
|
if first[2].PhotoID != 9001 || !first[2].Personal {
|
|
t.Fatalf("first personal photo = %+v, want 9001", first[2])
|
|
}
|
|
second, err := cached.PersonalPhotos(ctx, 1, []int64{2})
|
|
if err != nil {
|
|
t.Fatalf("personal photos second: %v", err)
|
|
}
|
|
if second[2].PhotoID != 9001 {
|
|
t.Fatalf("second personal photo = %+v, want 9001", second[2])
|
|
}
|
|
if counting.listCalls != 1 {
|
|
t.Fatalf("ListByUser calls = %d, want 1 personal-photo account snapshot load", counting.listCalls)
|
|
}
|
|
if counting.personalPhotoCalls != 1 {
|
|
t.Fatalf("PersonalPhotos calls = %d, want 1", counting.personalPhotoCalls)
|
|
}
|
|
|
|
if _, found, err := cached.SetPersonalPhoto(ctx, 1, 2, 9002, 101); err != nil || !found {
|
|
t.Fatalf("cached set personal photo: %v found=%v", err, found)
|
|
}
|
|
third, err := cached.PersonalPhotos(ctx, 1, []int64{2})
|
|
if err != nil {
|
|
t.Fatalf("personal photos third: %v", err)
|
|
}
|
|
if third[2].PhotoID != 9002 {
|
|
t.Fatalf("third personal photo = %+v, want 9002 after invalidation", third[2])
|
|
}
|
|
if counting.personalPhotoCalls != 2 {
|
|
t.Fatalf("PersonalPhotos calls after invalidation = %d, want 2", counting.personalPhotoCalls)
|
|
}
|
|
if counting.listCalls != 2 {
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestCachedContactStoreDoesNotRefillStalePersonalPhotoAfterInvalidation(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("upsert 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)
|
|
}
|
|
first, err := base.PersonalPhotos(ctx, 1, []int64{2})
|
|
if err != nil {
|
|
t.Fatalf("snapshot first personal photo: %v", err)
|
|
}
|
|
blocking := &blockingFirstPersonalPhotoStore{
|
|
ContactStore: base,
|
|
started: make(chan struct{}),
|
|
release: make(chan struct{}),
|
|
first: first,
|
|
}
|
|
cached := NewCachedContactStore(blocking, 0)
|
|
|
|
type readResult struct {
|
|
refs map[int64]domain.ProfilePhotoRef
|
|
err error
|
|
}
|
|
resultCh := make(chan readResult, 1)
|
|
go func() {
|
|
refs, err := cached.PersonalPhotos(ctx, 1, []int64{2})
|
|
resultCh <- readResult{refs: refs, err: err}
|
|
}()
|
|
waitForCacheTestSignal(t, blocking.started)
|
|
|
|
if _, found, err := cached.SetPersonalPhoto(ctx, 1, 2, 9002, 101); err != nil || !found {
|
|
t.Fatalf("update personal photo while first load is blocked: %v found=%v", err, found)
|
|
}
|
|
close(blocking.release)
|
|
|
|
var result readResult
|
|
select {
|
|
case result = <-resultCh:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for personal photo read")
|
|
}
|
|
if result.err != nil {
|
|
t.Fatalf("personal photo read: %v", result.err)
|
|
}
|
|
if result.refs[2].PhotoID != 9002 {
|
|
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)
|
|
}
|
|
}
|