chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
470
internal/app/userprojection/contact_cache.go
Normal file
470
internal/app/userprojection/contact_cache.go
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
package userprojection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultContactProjectionCacheTTL is a safety bound for out-of-band writes.
|
||||
// Normal correctness relies on write-path invalidation, not natural expiry.
|
||||
DefaultContactProjectionCacheTTL = 24 * time.Hour
|
||||
|
||||
contactSnapshotMaxViewers = 4096
|
||||
contactReverseSnapshotOwnerCap = 16
|
||||
contactPersonalPhotoSnapshotCap = 4096
|
||||
)
|
||||
|
||||
type contactAccountSnapshot struct {
|
||||
contacts map[int64]domain.Contact
|
||||
ordered []domain.Contact
|
||||
hash int64
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
type personalPhotoSnapshot struct {
|
||||
refs map[int64]domain.ProfilePhotoRef
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
type contactSnapshotLoadResult struct {
|
||||
snap contactAccountSnapshot
|
||||
stored bool
|
||||
}
|
||||
|
||||
type personalPhotoSnapshotLoadResult struct {
|
||||
snap personalPhotoSnapshot
|
||||
stored bool
|
||||
}
|
||||
|
||||
// CachedContactStore wraps ContactStore with account-level read model snapshots.
|
||||
//
|
||||
// Contact data is low-churn and high-read: TDesktop repeatedly asks for the same
|
||||
// viewer-scoped user projection while switching dialogs. Pair-level short TTL
|
||||
// caching still lets every RPC plan new SQL for another pair; this cache loads a
|
||||
// viewer's whole contact projection once, filters it in memory, and relies on
|
||||
// contact write methods to invalidate the affected account snapshots.
|
||||
type CachedContactStore struct {
|
||||
inner store.ContactStore
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.RWMutex
|
||||
contacts map[int64]contactAccountSnapshot
|
||||
personalPhotos map[int64]personalPhotoSnapshot
|
||||
epoch uint64
|
||||
sf singleflight.Group
|
||||
}
|
||||
|
||||
func NewCachedContactStore(inner store.ContactStore, ttl time.Duration) *CachedContactStore {
|
||||
if inner == nil {
|
||||
return nil
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultContactProjectionCacheTTL
|
||||
}
|
||||
return &CachedContactStore{
|
||||
inner: inner,
|
||||
ttl: ttl,
|
||||
now: time.Now,
|
||||
contacts: make(map[int64]contactAccountSnapshot, 1024),
|
||||
personalPhotos: make(map[int64]personalPhotoSnapshot, 1024),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) {
|
||||
if userID == 0 {
|
||||
return domain.ContactList{}, nil
|
||||
}
|
||||
snap, err := c.contactSnapshot(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.ContactList{}, err
|
||||
}
|
||||
return domain.ContactList{Contacts: cloneCachedContacts(snap.ordered), Hash: snap.hash}, nil
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) Get(ctx context.Context, userID, contactUserID int64) (domain.Contact, bool, error) {
|
||||
if userID == 0 || contactUserID == 0 {
|
||||
return domain.Contact{}, false, nil
|
||||
}
|
||||
got, err := c.GetMany(ctx, userID, []int64{contactUserID})
|
||||
if err != nil {
|
||||
return domain.Contact{}, false, err
|
||||
}
|
||||
contact, ok := got[contactUserID]
|
||||
return contact, ok, nil
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) GetMany(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.Contact, error) {
|
||||
out := make(map[int64]domain.Contact, len(contactUserIDs))
|
||||
if userID == 0 || len(contactUserIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
snap, err := c.contactSnapshot(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ownerID := range contactUserIDs {
|
||||
if ownerID == 0 {
|
||||
continue
|
||||
}
|
||||
if contact, ok := snap.contacts[ownerID]; ok {
|
||||
out[ownerID] = cloneCachedContact(contact)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) GetReverseContacts(ctx context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) {
|
||||
out := make(map[int64]domain.Contact, len(ownerUserIDs))
|
||||
if userID == 0 || len(ownerUserIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
owners := dedupContactIDs(ownerUserIDs)
|
||||
if len(owners) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if len(owners) > contactReverseSnapshotOwnerCap {
|
||||
// Large fan-out should keep using the store's set query until a dedicated
|
||||
// reverse-contact read model exists; loading hundreds of full contact
|
||||
// lists would be worse than one batched SQL.
|
||||
return c.inner.GetReverseContacts(ctx, userID, owners)
|
||||
}
|
||||
for _, ownerID := range owners {
|
||||
snap, err := c.contactSnapshot(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if contact, ok := snap.contacts[userID]; ok {
|
||||
out[ownerID] = cloneCachedContact(contact)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
c.InvalidateViewers(userID, input.ContactUserID)
|
||||
}
|
||||
return contact, err
|
||||
}
|
||||
|
||||
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)
|
||||
for _, input := range inputs {
|
||||
ids = append(ids, input.ContactUserID)
|
||||
}
|
||||
c.InvalidateViewers(ids...)
|
||||
}
|
||||
return contacts, err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return contact, found, err
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) SetCloseFriends(ctx context.Context, userID int64, contactUserIDs []int64) (domain.CloseFriendsEditResult, error) {
|
||||
res, err := c.inner.SetCloseFriends(ctx, userID, contactUserIDs)
|
||||
if err == nil {
|
||||
c.InvalidateViewers(userID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
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 {
|
||||
c.InvalidateViewers(userID)
|
||||
}
|
||||
return contact, found, err
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
out := make(map[int64]domain.ProfilePhotoRef, len(contactUserIDs))
|
||||
if userID == 0 || len(contactUserIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
snap, err := c.personalPhotoSnapshot(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ownerID := range contactUserIDs {
|
||||
if ownerID == 0 {
|
||||
continue
|
||||
}
|
||||
if ref, ok := snap.refs[ownerID]; ok {
|
||||
out[ownerID] = cloneCachedProfilePhotoRef(ref)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
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...)
|
||||
}
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) Block(ctx context.Context, userID, blockedUserID int64, date int) (bool, error) {
|
||||
changed, err := c.inner.Block(ctx, userID, blockedUserID, date)
|
||||
if err == nil {
|
||||
c.InvalidateViewers(userID, blockedUserID)
|
||||
}
|
||||
return changed, err
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) Unblock(ctx context.Context, userID, blockedUserID int64) (bool, error) {
|
||||
changed, err := c.inner.Unblock(ctx, userID, blockedUserID)
|
||||
if err == nil {
|
||||
c.InvalidateViewers(userID, blockedUserID)
|
||||
}
|
||||
return changed, err
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) IsBlocked(ctx context.Context, userID, blockedUserID int64) (bool, error) {
|
||||
return c.inner.IsBlocked(ctx, userID, blockedUserID)
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) ListBlocked(ctx context.Context, userID int64, offset, limit int) (domain.BlockedContactList, error) {
|
||||
return c.inner.ListBlocked(ctx, userID, offset, limit)
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) contactSnapshot(ctx context.Context, userID int64) (contactAccountSnapshot, error) {
|
||||
for {
|
||||
if snap, ok := c.lookupContactSnapshot(userID, c.now()); ok {
|
||||
return snap, nil
|
||||
}
|
||||
v, err, _ := c.sf.Do(fmt.Sprintf("contact:%d", userID), func() (any, error) {
|
||||
now := c.now()
|
||||
if snap, ok := c.lookupContactSnapshot(userID, now); ok {
|
||||
return contactSnapshotLoadResult{snap: snap, stored: true}, nil
|
||||
}
|
||||
loadEpoch := c.cacheEpoch()
|
||||
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
|
||||
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.mu.Unlock()
|
||||
return contactSnapshotLoadResult{snap: snap, stored: stored}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return contactAccountSnapshot{}, err
|
||||
}
|
||||
result := v.(contactSnapshotLoadResult)
|
||||
if result.stored {
|
||||
return result.snap, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contactAccountSnapshot{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) lookupContactSnapshot(userID int64, now time.Time) (contactAccountSnapshot, bool) {
|
||||
c.mu.RLock()
|
||||
snap, ok := c.contacts[userID]
|
||||
c.mu.RUnlock()
|
||||
if !ok || !snap.expireAt.After(now) {
|
||||
if ok {
|
||||
c.InvalidateViewers(userID)
|
||||
}
|
||||
return contactAccountSnapshot{}, false
|
||||
}
|
||||
return snap, true
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) personalPhotoSnapshot(ctx context.Context, userID int64) (personalPhotoSnapshot, error) {
|
||||
for {
|
||||
if snap, ok := c.lookupPersonalPhotoSnapshot(userID, c.now()); ok {
|
||||
return snap, nil
|
||||
}
|
||||
v, err, _ := c.sf.Do(fmt.Sprintf("contact-photo:%d", userID), func() (any, error) {
|
||||
now := c.now()
|
||||
if snap, ok := c.lookupPersonalPhotoSnapshot(userID, now); ok {
|
||||
return personalPhotoSnapshotLoadResult{snap: snap, stored: true}, nil
|
||||
}
|
||||
loadEpoch := c.cacheEpoch()
|
||||
contacts, err := c.contactSnapshot(ctx, userID)
|
||||
if err != nil {
|
||||
return personalPhotoSnapshotLoadResult{}, err
|
||||
}
|
||||
ids := make([]int64, 0, len(contacts.contacts))
|
||||
for id := range contacts.contacts {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
refs := map[int64]domain.ProfilePhotoRef{}
|
||||
if len(ids) > 0 {
|
||||
refs, err = c.inner.PersonalPhotos(ctx, userID, ids)
|
||||
if err != nil {
|
||||
return personalPhotoSnapshotLoadResult{}, err
|
||||
}
|
||||
}
|
||||
snap := personalPhotoSnapshot{refs: cloneCachedProfilePhotoRefs(refs), expireAt: now.Add(c.ttl)}
|
||||
c.mu.Lock()
|
||||
stored := c.epoch == loadEpoch
|
||||
if stored {
|
||||
if len(c.personalPhotos) >= contactPersonalPhotoSnapshotCap {
|
||||
c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024)
|
||||
}
|
||||
c.personalPhotos[userID] = snap
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return personalPhotoSnapshotLoadResult{snap: snap, stored: stored}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return personalPhotoSnapshot{}, err
|
||||
}
|
||||
result := v.(personalPhotoSnapshotLoadResult)
|
||||
if result.stored {
|
||||
return result.snap, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return personalPhotoSnapshot{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) lookupPersonalPhotoSnapshot(userID int64, now time.Time) (personalPhotoSnapshot, bool) {
|
||||
c.mu.RLock()
|
||||
snap, ok := c.personalPhotos[userID]
|
||||
c.mu.RUnlock()
|
||||
if !ok || !snap.expireAt.After(now) {
|
||||
if ok {
|
||||
c.InvalidateViewers(userID)
|
||||
}
|
||||
return personalPhotoSnapshot{}, false
|
||||
}
|
||||
return snap, true
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) InvalidateViewers(ids ...int64) {
|
||||
if c == nil || len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.epoch++
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
delete(c.contacts, id)
|
||||
delete(c.personalPhotos, id)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) FlushReadModelCache() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.epoch++
|
||||
c.contacts = make(map[int64]contactAccountSnapshot, 1024)
|
||||
c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *CachedContactStore) cacheEpoch() uint64 {
|
||||
c.mu.RLock()
|
||||
epoch := c.epoch
|
||||
c.mu.RUnlock()
|
||||
return epoch
|
||||
}
|
||||
|
||||
func buildContactAccountSnapshot(list domain.ContactList, expireAt time.Time) contactAccountSnapshot {
|
||||
contacts := make(map[int64]domain.Contact, len(list.Contacts))
|
||||
ordered := make([]domain.Contact, 0, len(list.Contacts))
|
||||
for _, contact := range list.Contacts {
|
||||
if contact.User.ID == 0 {
|
||||
continue
|
||||
}
|
||||
clone := cloneCachedContact(contact)
|
||||
contacts[clone.User.ID] = clone
|
||||
ordered = append(ordered, clone)
|
||||
}
|
||||
return contactAccountSnapshot{contacts: contacts, ordered: ordered, hash: list.Hash, expireAt: expireAt}
|
||||
}
|
||||
|
||||
func dedupContactIDs(ids []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
out := make([]int64, 0, 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
|
||||
}
|
||||
|
||||
func cloneCachedContacts(in []domain.Contact) []domain.Contact {
|
||||
out := make([]domain.Contact, len(in))
|
||||
for i := range in {
|
||||
out[i] = cloneCachedContact(in[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneCachedContact(in domain.Contact) domain.Contact {
|
||||
in.User = cloneCachedUser(in.User)
|
||||
if in.NoteEntities != nil {
|
||||
in.NoteEntities = append([]domain.MessageEntity(nil), in.NoteEntities...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func cloneCachedUser(in domain.User) domain.User {
|
||||
if in.PhotoStripped != nil {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func cloneCachedProfilePhotoRefs(in map[int64]domain.ProfilePhotoRef) map[int64]domain.ProfilePhotoRef {
|
||||
out := make(map[int64]domain.ProfilePhotoRef, len(in))
|
||||
for id, ref := range in {
|
||||
out[id] = cloneCachedProfilePhotoRef(ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneCachedProfilePhotoRef(in domain.ProfilePhotoRef) domain.ProfilePhotoRef {
|
||||
if in.Stripped != nil {
|
||||
in.Stripped = append([]byte(nil), in.Stripped...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
386
internal/app/userprojection/contact_cache_test.go
Normal file
386
internal/app/userprojection/contact_cache_test.go
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
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"}); 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" {
|
||||
t.Fatalf("first contact = %+v, want Alice", first[2])
|
||||
}
|
||||
second, err := cached.GetMany(ctx, 1, []int64{2, 3})
|
||||
if err != nil {
|
||||
t.Fatalf("get many second: %v", err)
|
||||
}
|
||||
if second[2].FirstName != "Alice" {
|
||||
t.Fatalf("second contact = %+v, want Alice", 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 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])
|
||||
}
|
||||
}
|
||||
143
internal/app/userprojection/photo_cache.go
Normal file
143
internal/app/userprojection/photo_cache.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package userprojection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
)
|
||||
|
||||
// DefaultPhotoCacheTTL 是头像投影缓存的兜底有效期;正常正确性依赖写入侧触发
|
||||
// read_model_versions/NOTIFY 后显式失效,TTL 只负责覆盖进程外漏通知或手工改库。
|
||||
const DefaultPhotoCacheTTL = 10 * time.Second
|
||||
|
||||
const photoCacheMaxEntries = 200000
|
||||
|
||||
// combinedPhotoProvider 是同时具备 batch 与 kind 两种头像查询能力的底层 provider(postgres
|
||||
// MediaStore 即满足)。
|
||||
type combinedPhotoProvider interface {
|
||||
ProfilePhotoProvider
|
||||
ProfilePhotoKindProvider
|
||||
}
|
||||
|
||||
type photoCacheKey struct {
|
||||
ownerType domain.PeerType
|
||||
ownerID int64
|
||||
kind domain.ProfilePhotoKind
|
||||
}
|
||||
|
||||
// photoCacheValue 含负缓存:has=false 表示「已查过、该 owner 无此 kind 头像」,
|
||||
// 避免无头像 owner 反复打 PG。
|
||||
type photoCacheValue struct {
|
||||
ref domain.ProfilePhotoRef
|
||||
has bool
|
||||
}
|
||||
|
||||
// CachedPhotoProvider 给 owner 的当前 profile/fallback 头像查询加一层短 TTL 进程内缓存,
|
||||
// 由统一缓存原语承载(LRU 单条驱逐 / epoch 守卫 / clone;批量经 GetOrLoadBatch)。
|
||||
// projectBatch / ForViewers 对每批 owner 固定打 2 次 CurrentProfilePhotosKind(profile+fallback),
|
||||
// 且 base user 命中 redis 也不短路——高频「返回用户」的 RPC(getUsers 等)会把这两条头像查询
|
||||
// 刷到与 RPC 同频。这里按 (ownerType, ownerID, kind) 缓存结果(含负结果),命中 owner 不进 PG。
|
||||
//
|
||||
// 注意:只缓存 owner-only 的 profile/fallback;personal photo 是 per-viewer 的(contacts.PersonalPhotos),
|
||||
// 不经此 provider,故无跨 viewer 串号风险。隐私裁剪发生在投影之后、缓存之外,缓存的是 owner 原始
|
||||
// 头像 ref 而非投影结果,故不会把某 viewer 的可见性固化给其他 viewer。
|
||||
type CachedPhotoProvider struct {
|
||||
inner combinedPhotoProvider
|
||||
cache *readmodelcache.Cache[photoCacheKey, photoCacheValue]
|
||||
}
|
||||
|
||||
// NewCachedPhotoProvider 包装底层 provider;ttl<=0 用 DefaultPhotoCacheTTL。
|
||||
func NewCachedPhotoProvider(inner combinedPhotoProvider, ttl time.Duration) *CachedPhotoProvider {
|
||||
return newCachedPhotoProviderWithClock(inner, ttl, nil)
|
||||
}
|
||||
|
||||
// newCachedPhotoProviderWithClock 允许注入时钟,仅供测试确定地推进 TTL;now=nil 用真实时钟。
|
||||
func newCachedPhotoProviderWithClock(inner combinedPhotoProvider, ttl time.Duration, now func() time.Time) *CachedPhotoProvider {
|
||||
if inner == nil {
|
||||
return nil
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultPhotoCacheTTL
|
||||
}
|
||||
return &CachedPhotoProvider{
|
||||
inner: inner,
|
||||
cache: readmodelcache.New[photoCacheKey, photoCacheValue](readmodelcache.Config[photoCacheKey, photoCacheValue]{
|
||||
MaxEntries: photoCacheMaxEntries,
|
||||
TTL: ttl,
|
||||
Now: now,
|
||||
Clone: clonePhotoCacheValue,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentProfilePhotosKind 是被缓存的热路径:先按 owner 取缓存,仅未命中的 owner 批量查底层。
|
||||
func (c *CachedPhotoProvider) CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
if c == nil {
|
||||
return nil, nil
|
||||
}
|
||||
keys := make([]photoCacheKey, 0, len(ownerIDs))
|
||||
for _, id := range ownerIDs {
|
||||
keys = append(keys, photoCacheKey{ownerType: ownerType, ownerID: id, kind: kind})
|
||||
}
|
||||
values, err := c.cache.GetOrLoadBatch(ctx, keys,
|
||||
func(photoCacheKey) (int64, bool) { return 0, true }, // 纯 TTL,无版本闸门
|
||||
func(ctx context.Context, missing []photoCacheKey) (map[photoCacheKey]photoCacheValue, error) {
|
||||
missingIDs := make([]int64, len(missing))
|
||||
for i, k := range missing {
|
||||
missingIDs[i] = k.ownerID
|
||||
}
|
||||
refs, err := c.inner.CurrentProfilePhotosKind(ctx, ownerType, missingIDs, kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[photoCacheKey]photoCacheValue, len(missing))
|
||||
for _, k := range missing {
|
||||
ref, has := refs[k.ownerID]
|
||||
out[k] = photoCacheValue{ref: ref, has: has}
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[int64]domain.ProfilePhotoRef, len(values))
|
||||
for key, v := range values {
|
||||
if v.has {
|
||||
out[key.ownerID] = v.ref
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CurrentProfilePhotos 是非 kind 的回退路径——projectBatch 仅在 provider 不实现
|
||||
// ProfilePhotoKindProvider 时才走它,而本装饰器实现了 kind 接口,故生产不会命中此方法;
|
||||
// 直接透传,不缓存,保持行为不变。
|
||||
func (c *CachedPhotoProvider) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return c.inner.CurrentProfilePhotos(ctx, ownerType, ownerIDs)
|
||||
}
|
||||
|
||||
func (c *CachedPhotoProvider) InvalidateOwner(ownerType domain.PeerType, ownerID int64) {
|
||||
if c == nil || ownerID == 0 {
|
||||
return
|
||||
}
|
||||
c.cache.Invalidate(
|
||||
photoCacheKey{ownerType: ownerType, ownerID: ownerID, kind: domain.ProfilePhotoKindProfile},
|
||||
photoCacheKey{ownerType: ownerType, ownerID: ownerID, kind: domain.ProfilePhotoKindFallback},
|
||||
)
|
||||
}
|
||||
|
||||
func (c *CachedPhotoProvider) FlushReadModelCache() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.cache.Flush()
|
||||
}
|
||||
|
||||
func clonePhotoCacheValue(v photoCacheValue) photoCacheValue {
|
||||
if v.ref.Stripped != nil {
|
||||
v.ref.Stripped = append([]byte(nil), v.ref.Stripped...)
|
||||
}
|
||||
return v
|
||||
}
|
||||
221
internal/app/userprojection/photo_cache_test.go
Normal file
221
internal/app/userprojection/photo_cache_test.go
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
package userprojection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type countingPhotoProvider struct {
|
||||
kindCalls int
|
||||
queriedIDs []int64
|
||||
refs map[int64]domain.ProfilePhotoRef
|
||||
}
|
||||
|
||||
type blockingFirstPhotoProvider struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
firstUsed bool
|
||||
first map[int64]domain.ProfilePhotoRef
|
||||
refs map[int64]domain.ProfilePhotoRef
|
||||
}
|
||||
|
||||
func (p *blockingFirstPhotoProvider) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return p.CurrentProfilePhotosKind(ctx, ownerType, ownerIDs, domain.ProfilePhotoKindProfile)
|
||||
}
|
||||
|
||||
func (p *blockingFirstPhotoProvider) CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
p.mu.Lock()
|
||||
if !p.firstUsed {
|
||||
p.firstUsed = true
|
||||
first := clonePhotoRefs(p.first, ownerIDs)
|
||||
p.mu.Unlock()
|
||||
close(p.started)
|
||||
select {
|
||||
case <-p.release:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return first, nil
|
||||
}
|
||||
out := clonePhotoRefs(p.refs, ownerIDs)
|
||||
p.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *blockingFirstPhotoProvider) setRef(ownerID int64, ref domain.ProfilePhotoRef) {
|
||||
p.mu.Lock()
|
||||
p.refs[ownerID] = ref
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func clonePhotoRefs(in map[int64]domain.ProfilePhotoRef, ownerIDs []int64) map[int64]domain.ProfilePhotoRef {
|
||||
out := make(map[int64]domain.ProfilePhotoRef, len(ownerIDs))
|
||||
for _, id := range ownerIDs {
|
||||
ref, ok := in[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[id] = cloneCachedProfilePhotoRef(ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *countingPhotoProvider) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return p.CurrentProfilePhotosKind(ctx, ownerType, ownerIDs, domain.ProfilePhotoKindProfile)
|
||||
}
|
||||
|
||||
func (p *countingPhotoProvider) CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
p.kindCalls++
|
||||
p.queriedIDs = append(p.queriedIDs, ownerIDs...)
|
||||
out := map[int64]domain.ProfilePhotoRef{}
|
||||
for _, id := range ownerIDs {
|
||||
if ref, ok := p.refs[id]; ok {
|
||||
out[id] = ref
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestCachedPhotoProviderCachesHitsAndMisses(t *testing.T) {
|
||||
inner := &countingPhotoProvider{refs: map[int64]domain.ProfilePhotoRef{1: {PhotoID: 111}}}
|
||||
now := time.Unix(1000, 0)
|
||||
c := newCachedPhotoProviderWithClock(inner, time.Minute, func() time.Time { return now })
|
||||
ctx := context.Background()
|
||||
|
||||
// 首次:两 owner 都未命中,查底层一次。
|
||||
got, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1, 2}, domain.ProfilePhotoKindProfile)
|
||||
if err != nil {
|
||||
t.Fatalf("first: %v", err)
|
||||
}
|
||||
if got[1].PhotoID != 111 {
|
||||
t.Fatalf("owner 1 ref = %+v, want PhotoID 111", got[1])
|
||||
}
|
||||
if _, ok := got[2]; ok {
|
||||
t.Fatalf("owner 2 should have no photo")
|
||||
}
|
||||
if inner.kindCalls != 1 || len(inner.queriedIDs) != 2 {
|
||||
t.Fatalf("first call: kindCalls=%d queried=%v, want 1 call of 2 ids", inner.kindCalls, inner.queriedIDs)
|
||||
}
|
||||
|
||||
// 二次(TTL 内):全部命中缓存(含 owner 2 的负结果),不再查底层。
|
||||
got, err = c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1, 2}, domain.ProfilePhotoKindProfile)
|
||||
if err != nil {
|
||||
t.Fatalf("second: %v", err)
|
||||
}
|
||||
if got[1].PhotoID != 111 {
|
||||
t.Fatalf("cached owner 1 ref = %+v, want PhotoID 111", got[1])
|
||||
}
|
||||
if inner.kindCalls != 1 {
|
||||
t.Fatalf("second call hit DB: kindCalls=%d, want still 1", inner.kindCalls)
|
||||
}
|
||||
|
||||
// 不同 kind 是独立缓存键:fallback 应再查一次。
|
||||
if _, err = c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindFallback); err != nil {
|
||||
t.Fatalf("fallback: %v", err)
|
||||
}
|
||||
if inner.kindCalls != 2 {
|
||||
t.Fatalf("fallback kind should query: kindCalls=%d, want 2", inner.kindCalls)
|
||||
}
|
||||
|
||||
// TTL 过期后重新查底层。
|
||||
now = now.Add(2 * time.Minute)
|
||||
if _, err = c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1, 2}, domain.ProfilePhotoKindProfile); err != nil {
|
||||
t.Fatalf("after ttl: %v", err)
|
||||
}
|
||||
if inner.kindCalls != 3 {
|
||||
t.Fatalf("after ttl should re-query: kindCalls=%d, want 3", inner.kindCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedPhotoProviderInvalidatesOwnerAndFlushes(t *testing.T) {
|
||||
inner := &countingPhotoProvider{refs: map[int64]domain.ProfilePhotoRef{1: {PhotoID: 111}}}
|
||||
now := time.Unix(1000, 0)
|
||||
c := newCachedPhotoProviderWithClock(inner, time.Minute, func() time.Time { return now })
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile); err != nil {
|
||||
t.Fatalf("prime profile: %v", err)
|
||||
}
|
||||
if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindFallback); err != nil {
|
||||
t.Fatalf("prime fallback: %v", err)
|
||||
}
|
||||
inner.refs[1] = domain.ProfilePhotoRef{PhotoID: 222}
|
||||
c.InvalidateOwner(domain.PeerTypeUser, 1)
|
||||
|
||||
profile, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile)
|
||||
if err != nil {
|
||||
t.Fatalf("profile after invalidation: %v", err)
|
||||
}
|
||||
if profile[1].PhotoID != 222 {
|
||||
t.Fatalf("profile after invalidation = %+v, want 222", profile[1])
|
||||
}
|
||||
if _, err = c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindFallback); err != nil {
|
||||
t.Fatalf("fallback after invalidation: %v", err)
|
||||
}
|
||||
if inner.kindCalls != 4 {
|
||||
t.Fatalf("InvalidateOwner should drop both kinds: kindCalls=%d, want 4", inner.kindCalls)
|
||||
}
|
||||
|
||||
inner.refs[1] = domain.ProfilePhotoRef{PhotoID: 333}
|
||||
c.FlushReadModelCache()
|
||||
profile, err = c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile)
|
||||
if err != nil {
|
||||
t.Fatalf("profile after flush: %v", err)
|
||||
}
|
||||
if profile[1].PhotoID != 333 {
|
||||
t.Fatalf("profile after flush = %+v, want 333", profile[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedPhotoProviderDoesNotRefillStaleSnapshotAfterInvalidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
inner := &blockingFirstPhotoProvider{
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
first: map[int64]domain.ProfilePhotoRef{1: {PhotoID: 111}},
|
||||
refs: map[int64]domain.ProfilePhotoRef{1: {PhotoID: 111}},
|
||||
}
|
||||
c := NewCachedPhotoProvider(inner, time.Minute)
|
||||
|
||||
type readResult struct {
|
||||
refs map[int64]domain.ProfilePhotoRef
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan readResult, 1)
|
||||
go func() {
|
||||
got, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile)
|
||||
resultCh <- readResult{refs: got, err: err}
|
||||
}()
|
||||
waitForCacheTestSignal(t, inner.started)
|
||||
|
||||
inner.setRef(1, domain.ProfilePhotoRef{PhotoID: 222})
|
||||
c.InvalidateOwner(domain.PeerTypeUser, 1)
|
||||
close(inner.release)
|
||||
|
||||
var result readResult
|
||||
select {
|
||||
case result = <-resultCh:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for photo read")
|
||||
}
|
||||
if result.err != nil {
|
||||
t.Fatalf("photo read: %v", result.err)
|
||||
}
|
||||
if result.refs[1].PhotoID != 222 {
|
||||
t.Fatalf("photo after concurrent invalidation = %+v, want 222", result.refs[1])
|
||||
}
|
||||
|
||||
cachedHit, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile)
|
||||
if err != nil {
|
||||
t.Fatalf("cached hit after stale load retry: %v", err)
|
||||
}
|
||||
if cachedHit[1].PhotoID != 222 {
|
||||
t.Fatalf("cached photo after stale load retry = %+v, want 222", cachedHit[1])
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ package userprojection
|
|||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
|
@ -22,6 +24,28 @@ type PrivacyEvaluator interface {
|
|||
CanSee(ctx context.Context, ownerUserID, viewerUserID int64, key domain.PrivacyKey) (bool, error)
|
||||
}
|
||||
|
||||
// BatchPrivacyEvaluator 批量评估多 owner 对单 viewer 的可见性,消除 projectBatch / fan-out
|
||||
// 投影里 per-user 3×CanSee 的 N+1。可选:实现了它的 evaluator(privacy.Service)会被
|
||||
// projectBatch 优先用批量预取,否则回退逐 CanSee。结果必须与逐 CanSee 字节等价。
|
||||
type BatchPrivacyEvaluator interface {
|
||||
CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64, keys []domain.PrivacyKey) (map[int64]map[domain.PrivacyKey]bool, error)
|
||||
}
|
||||
|
||||
// MatrixPrivacyEvaluator 批量评估 owners×viewers×keys 的可见性矩阵(一次 ListPrivacyRules +
|
||||
// 每 owner 一次 GetMany + 内存 Evaluate),供 ForViewers 把 fan-out 跨 viewer 投影的 privacy
|
||||
// 查询从 O(viewer) 降到 O(owner)。可选:privacy.Service 实现了它,否则 ForViewers 回退逐 CanSee。
|
||||
// 结果必须与逐 CanSee 字节等价。
|
||||
type MatrixPrivacyEvaluator interface {
|
||||
CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs []int64, keys []domain.PrivacyKey) (map[int64]map[int64]map[domain.PrivacyKey]bool, error)
|
||||
}
|
||||
|
||||
// privacyProjectionKeys 是 projectBatch 投影会用到的 privacy key(phone/status/photo)。
|
||||
var privacyProjectionKeys = []domain.PrivacyKey{
|
||||
domain.PrivacyKeyPhoneNumber,
|
||||
domain.PrivacyKeyStatusTimestamp,
|
||||
domain.PrivacyKeyProfilePhoto,
|
||||
}
|
||||
|
||||
// Projector builds the current viewer's user view for RPC response payloads.
|
||||
// It intentionally stays in app/domain types; tg.* conversion remains in rpc.
|
||||
type Projector struct {
|
||||
|
|
@ -77,6 +101,178 @@ func (p *Projector) One(ctx context.Context, viewerUserID int64, user domain.Use
|
|||
return projected[0], nil
|
||||
}
|
||||
|
||||
// ForViewers 跨多个 viewer 批量投影同一组 owner 用户(fan-out 模板化)。它把 per-viewer 各跑
|
||||
// 一遍 ForViewer(=projectBatch) 的成本(O(viewer)×(photos+contacts+privacy) 查询)压成:
|
||||
// - 一次 profile/fallback 头像批量(跨 viewer 复用)
|
||||
// - O(owner) 次 GetReverseContacts(改名/电话覆盖,按 owner 反查 viewer)
|
||||
// - 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 完整投影自愈。
|
||||
// 调用方传入的 users 不被修改(内部复制)。
|
||||
func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users []domain.User) (map[int64][]domain.User, error) {
|
||||
out := make(map[int64][]domain.User, len(viewerUserIDs))
|
||||
if p == nil || len(users) == 0 {
|
||||
for _, v := range viewerUserIDs {
|
||||
out[v] = cloneUsers(users)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
viewers := dedupNonZeroInt64(viewerUserIDs)
|
||||
if len(viewers) == 0 {
|
||||
for _, v := range viewerUserIDs {
|
||||
out[v] = cloneUsers(users)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
ids := uniqueUserIDs(users)
|
||||
|
||||
// 三组预取互不依赖(共享头像、反向联系人覆盖、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
|
||||
)
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
// 1) 共享头像:profile/fallback 一次批量,跨全部 viewer 复用;personal photo v1 跳过(见 doc)。
|
||||
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) 命中同一条联系人记录(方向对称)。
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
contactsByViewer, err = p.reverseContactsByViewer(gctx, ids, viewers)
|
||||
return err
|
||||
})
|
||||
// 3) privacy 可见性矩阵:O(owner) 查询;nil(无 MatrixPrivacyEvaluator)时 applyPrivacy 回退逐 CanSee。
|
||||
if me, ok := p.privacy.(MatrixPrivacyEvaluator); ok && p.privacy != nil {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
matrix, err = me.CanSeeMatrix(gctx, ids, viewers, privacyProjectionKeys)
|
||||
return err
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 4) 逐 viewer 组装,复用与 projectBatch 完全相同的 apply* 链(personalRefs 传 nil)。
|
||||
for _, viewer := range viewers {
|
||||
projected := make([]domain.User, len(users))
|
||||
copy(projected, users)
|
||||
cache := make(map[int64]domain.User, len(projected))
|
||||
for i := range projected {
|
||||
u := projected[i]
|
||||
if u.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if pj, ok := cache[u.ID]; ok {
|
||||
projected[i] = pj
|
||||
continue
|
||||
}
|
||||
pj := applyBasePhotos(u, profileRefs, fallbackRefs, nil, viewer)
|
||||
if viewer != 0 && u.ID != viewer && u.ID != domain.OfficialSystemUserID && !u.Bot {
|
||||
contact, found := contactsByViewer[viewer][u.ID]
|
||||
pj = applyContactProjection(pj, contact, found)
|
||||
var vis map[domain.PrivacyKey]bool
|
||||
if matrix != nil {
|
||||
vis = matrix[u.ID][viewer]
|
||||
}
|
||||
var perr error
|
||||
pj, perr = applyPrivacy(ctx, p.privacy, viewer, pj, found, vis, profileRefs, fallbackRefs, nil)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
}
|
||||
cache[u.ID] = pj
|
||||
projected[i] = pj
|
||||
}
|
||||
out[viewer] = projected
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// batchProfileFallbackPhotos 取 owner 的 profile/fallback 头像(与 projectBatch 同逻辑),personal
|
||||
// 头像不取(ForViewers v1 跳过)。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{}
|
||||
if p.photos == nil || len(ids) == 0 {
|
||||
return profileRefs, fallbackRefs, nil
|
||||
}
|
||||
if kindPhotos, ok := p.photos.(ProfilePhotoKindProvider); ok {
|
||||
refs, err := kindPhotos.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindProfile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
profileRefs = refs
|
||||
refs, err = kindPhotos.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindFallback)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
fallbackRefs = refs
|
||||
return profileRefs, fallbackRefs, nil
|
||||
}
|
||||
refs, err := p.photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
out := make([]domain.User, len(users))
|
||||
copy(out, users)
|
||||
return out
|
||||
}
|
||||
|
||||
func dedupNonZeroInt64(ids []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
out := make([]int64, 0, 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
|
||||
}
|
||||
|
||||
// WithProfilePhotos enriches users with their current avatar from profile photo storage.
|
||||
// The lookup is best-effort: a storage error keeps the original user list.
|
||||
func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users []domain.User) []domain.User {
|
||||
|
|
@ -124,7 +320,7 @@ func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID in
|
|||
cache := make(map[int64]domain.User, len(users))
|
||||
for i := range out {
|
||||
u := out[i]
|
||||
if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID {
|
||||
if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
|
||||
continue
|
||||
}
|
||||
if projected, ok := cache[u.ID]; ok {
|
||||
|
|
@ -157,40 +353,76 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
out := make([]domain.User, len(users))
|
||||
copy(out, users)
|
||||
ids := uniqueUserIDs(out)
|
||||
profileRefs := map[int64]domain.ProfilePhotoRef{}
|
||||
fallbackRefs := map[int64]domain.ProfilePhotoRef{}
|
||||
personalRefs := map[int64]domain.ProfilePhotoRef{}
|
||||
var (
|
||||
profileRefs = map[int64]domain.ProfilePhotoRef{}
|
||||
fallbackRefs = map[int64]domain.ProfilePhotoRef{}
|
||||
personalRefs = map[int64]domain.ProfilePhotoRef{}
|
||||
contactsByID map[int64]domain.Contact
|
||||
visibility map[int64]map[domain.PrivacyKey]bool
|
||||
)
|
||||
// 这些预取查询互不依赖(头像 profile/fallback、联系人 GetMany/PersonalPhotos、privacy 可见性),
|
||||
// 并发执行把 ~6 次串行 round-trip 收敛成一波;每个 goroutine 只写自己那一个变量,组装循环在
|
||||
// Wait 之后串行进行(纯内存、无查询),无数据竞争。
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
if photos != nil && len(ids) > 0 {
|
||||
if kindPhotos, ok := photos.(ProfilePhotoKindProvider); ok {
|
||||
refs, err := kindPhotos.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindProfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profileRefs = refs
|
||||
refs, err = kindPhotos.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindFallback)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fallbackRefs = refs
|
||||
g.Go(func() error {
|
||||
refs, err := kindPhotos.CurrentProfilePhotosKind(gctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindProfile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
profileRefs = refs
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
refs, err := kindPhotos.CurrentProfilePhotosKind(gctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindFallback)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fallbackRefs = refs
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
refs, err := photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profileRefs = refs
|
||||
g.Go(func() error {
|
||||
refs, err := photos.CurrentProfilePhotos(gctx, domain.PeerTypeUser, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
profileRefs = refs
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
var contactsByID map[int64]domain.Contact
|
||||
if contacts != nil && viewerUserID != 0 && len(ids) > 0 {
|
||||
var err error
|
||||
contactsByID, err = contacts.GetMany(ctx, viewerUserID, ids)
|
||||
g.Go(func() error {
|
||||
m, err := contacts.GetMany(gctx, viewerUserID, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contactsByID = m
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
refs, err := contacts.PersonalPhotos(gctx, viewerUserID, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
personalRefs = refs
|
||||
return nil
|
||||
})
|
||||
}
|
||||
// 批量预取 privacy 可见性(若 evaluator 支持):把 per-user 3×CanSee×2行 的 N+1 降到
|
||||
// 一次 ListPrivacyRules + 一次 GetReverseContacts + 内存 Evaluate;nil 时 applyPrivacy 回退逐 CanSee。
|
||||
g.Go(func() error {
|
||||
v, err := prefetchPrivacyVisibility(gctx, privacy, viewerUserID, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
personalRefs, err = contacts.PersonalPhotos(ctx, viewerUserID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
visibility = v
|
||||
return nil
|
||||
})
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cache := make(map[int64]domain.User, len(out))
|
||||
for i := range out {
|
||||
|
|
@ -203,11 +435,13 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
continue
|
||||
}
|
||||
projected := applyBasePhotos(u, profileRefs, fallbackRefs, personalRefs, viewerUserID)
|
||||
if viewerUserID != 0 && u.ID != viewerUserID && u.ID != domain.OfficialSystemUserID {
|
||||
// bot 与系统账号豁免联系人/privacy 投影:官方 bot 无 phone/last seen,
|
||||
// 不参与联系人改名与隐私裁剪。
|
||||
if viewerUserID != 0 && u.ID != viewerUserID && u.ID != domain.OfficialSystemUserID && !u.Bot {
|
||||
contact, found := contactsByID[u.ID]
|
||||
projected = applyContactProjection(projected, contact, found)
|
||||
var err error
|
||||
projected, err = applyPrivacy(ctx, privacy, viewerUserID, projected, found, profileRefs, fallbackRefs, personalRefs)
|
||||
projected, err = applyPrivacy(ctx, privacy, viewerUserID, projected, found, visibility[u.ID], profileRefs, fallbackRefs, personalRefs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -218,6 +452,32 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, users []domain.User) (map[int64]map[domain.PrivacyKey]bool, error) {
|
||||
if privacy == nil || viewerUserID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
batch, ok := privacy.(BatchPrivacyEvaluator)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
ids := make([]int64, 0, len(users))
|
||||
seen := make(map[int64]struct{}, len(users))
|
||||
for _, u := range users {
|
||||
if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[u.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[u.ID] = struct{}{}
|
||||
ids = append(ids, u.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return batch.CanSeeBatch(ctx, ids, viewerUserID, privacyProjectionKeys)
|
||||
}
|
||||
|
||||
func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID int64, user domain.User) (domain.User, error) {
|
||||
contact, found, err := contacts.Get(ctx, viewerUserID, user.ID)
|
||||
if err != nil {
|
||||
|
|
@ -227,11 +487,13 @@ func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID i
|
|||
user.Phone = ""
|
||||
user.Contact = false
|
||||
user.Mutual = false
|
||||
user.CloseFriend = false
|
||||
return user, nil
|
||||
}
|
||||
projected := user
|
||||
projected.Contact = true
|
||||
projected.Mutual = contact.Mutual || contact.User.Mutual
|
||||
projected.CloseFriend = contact.CloseFriend || contact.User.CloseFriend
|
||||
if contact.User.Phone != "" {
|
||||
projected.Phone = contact.User.Phone
|
||||
} else {
|
||||
|
|
@ -290,10 +552,12 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
|
|||
user.Phone = ""
|
||||
user.Contact = false
|
||||
user.Mutual = false
|
||||
user.CloseFriend = false
|
||||
return user
|
||||
}
|
||||
user.Contact = true
|
||||
user.Mutual = contact.Mutual || contact.User.Mutual
|
||||
user.CloseFriend = contact.CloseFriend || contact.User.CloseFriend
|
||||
if contact.User.Phone != "" {
|
||||
user.Phone = contact.User.Phone
|
||||
} else {
|
||||
|
|
@ -309,18 +573,26 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
|
|||
return user
|
||||
}
|
||||
|
||||
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
|
||||
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
|
||||
if privacy == nil {
|
||||
return user, nil
|
||||
}
|
||||
phoneAllowed, err := privacy.CanSee(ctx, user.ID, viewerUserID, domain.PrivacyKeyPhoneNumber)
|
||||
// vis 为批量预取结果(projectBatch 一次 ListPrivacyRules+GetReverseContacts 算得);
|
||||
// 为 nil 时回退逐 CanSee,二者结果等价。
|
||||
canSee := func(key domain.PrivacyKey) (bool, error) {
|
||||
if vis != nil {
|
||||
return vis[key], nil
|
||||
}
|
||||
return privacy.CanSee(ctx, user.ID, viewerUserID, key)
|
||||
}
|
||||
phoneAllowed, err := canSee(domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !phoneAllowed && !isContact {
|
||||
user.Phone = ""
|
||||
}
|
||||
statusAllowed, err := privacy.CanSee(ctx, user.ID, viewerUserID, domain.PrivacyKeyStatusTimestamp)
|
||||
statusAllowed, err := canSee(domain.PrivacyKeyStatusTimestamp)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
|
|
@ -338,7 +610,7 @@ func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID in
|
|||
if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) && user.PhotoID == 0 {
|
||||
return user, nil
|
||||
}
|
||||
profileAllowed, err := privacy.CanSee(ctx, user.ID, viewerUserID, domain.PrivacyKeyProfilePhoto)
|
||||
profileAllowed, err := canSee(domain.PrivacyKeyProfilePhoto)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
|
|
@ -365,6 +637,7 @@ func applyPhotoRef(user *domain.User, ref domain.ProfilePhotoRef) {
|
|||
user.PhotoDCID = ref.DCID
|
||||
user.PhotoStripped = append([]byte(nil), ref.Stripped...)
|
||||
user.PhotoPersonal = ref.Personal
|
||||
user.PhotoHasVideo = ref.HasVideo
|
||||
}
|
||||
|
||||
func clearPhoto(user *domain.User) {
|
||||
|
|
@ -372,4 +645,5 @@ func clearPhoto(user *domain.User) {
|
|||
user.PhotoDCID = 0
|
||||
user.PhotoStripped = nil
|
||||
user.PhotoPersonal = false
|
||||
user.PhotoHasVideo = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package userprojection
|
|||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
privacyapp "telesrv/internal/app/privacy"
|
||||
|
|
@ -113,6 +114,114 @@ func TestProjectorUsesFallbackWhenProfilePhotoHidden(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 出现。
|
||||
func TestForViewersEquivalentToForViewer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
v1 = int64(5001)
|
||||
v2 = int64(5002)
|
||||
o1 = int64(5101) // 陌生人,默认规则
|
||||
o2 = int64(5102) // v1 的联系人(改名+电话),且 v1 给 o2 设了 personal photo
|
||||
o3 = int64(5103) // status 隐藏
|
||||
o4 = int64(5104) // profile photo 隐藏 → 走 fallback
|
||||
bot = int64(5105)
|
||||
)
|
||||
viewers := []int64{v1, v2}
|
||||
|
||||
contacts := memory.NewContactStore()
|
||||
rules := memory.NewPrivacyStore()
|
||||
privacy := privacyapp.NewService(rules, contacts)
|
||||
|
||||
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)。
|
||||
if _, _, err := contacts.SetPersonalPhoto(ctx, v1, o2, 9300, 300); err != nil {
|
||||
t.Fatalf("set personal photo: %v", err)
|
||||
}
|
||||
if _, err := privacy.SetRules(ctx, o3, domain.PrivacyKeyStatusTimestamp, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
|
||||
t.Fatalf("set o3 status: %v", err)
|
||||
}
|
||||
if _, err := privacy.SetRules(ctx, o4, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
|
||||
t.Fatalf("set o4 photo: %v", err)
|
||||
}
|
||||
|
||||
projector := New(
|
||||
WithContactStore(contacts),
|
||||
WithPrivacyEvaluator(privacy),
|
||||
WithPhotoProvider(fakeProfilePhotos{
|
||||
profile: map[int64]domain.ProfilePhotoRef{
|
||||
o1: {PhotoID: 9001, DCID: 1, Stripped: []byte{1}},
|
||||
o2: {PhotoID: 9002, DCID: 2, Stripped: []byte{2}},
|
||||
o3: {PhotoID: 9003, DCID: 3, Stripped: []byte{3}},
|
||||
o4: {PhotoID: 9004, DCID: 4, Stripped: []byte{4}},
|
||||
v1: {PhotoID: 9005, DCID: 5, Stripped: []byte{5}},
|
||||
v2: {PhotoID: 9006, DCID: 6, Stripped: []byte{6}},
|
||||
},
|
||||
fallback: map[int64]domain.ProfilePhotoRef{
|
||||
o4: {PhotoID: 9404, DCID: 4, Stripped: []byte{44}},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
users := []domain.User{
|
||||
{ID: o1, AccessHash: 11, Phone: "15550000001", FirstName: "Stranger", Status: domain.UserStatus{Kind: domain.UserStatusOnline}},
|
||||
{ID: o2, AccessHash: 12, Phone: "15550000002", FirstName: "PublicO2", Status: domain.UserStatus{Kind: domain.UserStatusOnline}},
|
||||
{ID: o3, AccessHash: 13, Phone: "15550000003", FirstName: "O3", Status: domain.UserStatus{Kind: domain.UserStatusOnline}, LastSeenAt: 123},
|
||||
{ID: o4, AccessHash: 14, Phone: "15550000004", FirstName: "O4"},
|
||||
{ID: bot, AccessHash: 15, FirstName: "Bot", Bot: true},
|
||||
{ID: domain.OfficialSystemUserID, FirstName: "System"},
|
||||
{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)
|
||||
}
|
||||
for _, viewer := range viewers {
|
||||
want, err := projector.ForViewer(ctx, viewer, users)
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewer(%d): %v", viewer, err)
|
||||
}
|
||||
got, ok := batch[viewer]
|
||||
if !ok {
|
||||
t.Fatalf("ForViewers missing viewer %d", viewer)
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("viewer %d len(got)=%d len(want)=%d", viewer, len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
w, g := want[i], got[i]
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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