468 lines
15 KiB
Go
468 lines
15 KiB
Go
package userprojection
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"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
|
|
}
|
|
|
|
type blockingFirstPersonalPhotoStore struct {
|
|
store.ContactStore
|
|
started chan struct{}
|
|
release chan struct{}
|
|
first map[int64]domain.ProfilePhotoRef
|
|
|
|
mu sync.Mutex
|
|
firstUsed bool
|
|
}
|
|
|
|
func (s *blockingFirstListContactStore) ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) {
|
|
s.mu.Lock()
|
|
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 *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
|
|
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) 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 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 TestCachedContactStoreInvalidatesAccountSnapshot(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 write invalidation", counting.listCalls)
|
|
}
|
|
}
|
|
|
|
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])
|
|
}
|
|
}
|
|
|
|
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 invalidation = %d, want 2", counting.listCalls)
|
|
}
|
|
}
|
|
|
|
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])
|
|
}
|
|
}
|