fix: sync contact phone privacy disclosure

This commit is contained in:
iamxvbaba 2026-07-24 11:56:58 +08:00
parent 0e2fcdf9c8
commit e1a95c7318
19 changed files with 789 additions and 50 deletions

View file

@ -1,8 +1,10 @@
package userprojection
import (
"container/list"
"context"
"fmt"
"sort"
"sync"
"time"
@ -18,7 +20,7 @@ const (
DefaultContactProjectionCacheTTL = 24 * time.Hour
contactSnapshotMaxViewers = 4096
contactReverseSnapshotOwnerCap = 16
contactReversePairMaxEntries = 262144
contactPersonalPhotoSnapshotCap = 4096
)
@ -34,11 +36,32 @@ type personalPhotoSnapshot struct {
expireAt time.Time
}
type reverseContactKey struct {
ownerUserID int64
contactUserID int64
}
type reverseContactSnapshot struct {
contact domain.Contact
found bool
expireAt time.Time
}
type reverseContactEntry struct {
key reverseContactKey
snapshot reverseContactSnapshot
}
type contactSnapshotLoadResult struct {
snap contactAccountSnapshot
stored bool
}
type reverseContactLoadResult struct {
contacts map[int64]domain.Contact
stored bool
}
type personalPhotoSnapshotLoadResult struct {
snap personalPhotoSnapshot
stored bool
@ -59,6 +82,10 @@ type CachedContactStore struct {
mu sync.RWMutex
contacts map[int64]contactAccountSnapshot
personalPhotos map[int64]personalPhotoSnapshot
reverse map[reverseContactKey]*list.Element
reverseLRU *list.List
reverseByOwner map[int64]map[int64]struct{}
reverseCap int
epoch uint64
sf singleflight.Group
}
@ -76,6 +103,10 @@ func NewCachedContactStore(inner store.ContactStore, ttl time.Duration) *CachedC
now: time.Now,
contacts: make(map[int64]contactAccountSnapshot, 1024),
personalPhotos: make(map[int64]personalPhotoSnapshot, 1024),
reverse: make(map[reverseContactKey]*list.Element, 4096),
reverseLRU: list.New(),
reverseByOwner: make(map[int64]map[int64]struct{}, 1024),
reverseCap: contactReversePairMaxEntries,
}
}
@ -131,24 +162,90 @@ func (c *CachedContactStore) GetReverseContacts(ctx context.Context, userID int6
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)
}
missing := make([]int64, 0, len(owners))
now := c.now()
for _, ownerID := range owners {
snap, err := c.contactSnapshot(ctx, ownerID)
if err != nil {
return nil, err
// Reuse a full owner snapshot when another hot path already loaded it.
// Do not cold-load one full list per owner: a large projection would turn
// into N SQL queries.
if snap, ok := c.lookupContactSnapshot(ownerID, now); ok {
if contact, found := snap.contacts[userID]; found {
out[ownerID] = cloneCachedContact(contact)
}
continue
}
if contact, ok := snap.contacts[userID]; ok {
if contact, found, cached := c.lookupReverseContact(ownerID, userID, now); cached {
if found {
out[ownerID] = contact
}
continue
}
missing = append(missing, ownerID)
}
if len(missing) == 0 {
return out, nil
}
loaded, err := c.loadReverseContacts(ctx, userID, missing)
if err != nil {
return nil, err
}
for ownerID, contact := range loaded {
if contact.User.ID != 0 {
out[ownerID] = cloneCachedContact(contact)
}
}
return out, nil
}
// loadReverseContacts performs at most one batched cold-store read for all
// missing owner→viewer pairs, then caches both hits and misses. Privacy
// projection therefore stays memory-only after warm-up instead of repeating a
// reverse-contact SQL query on every large user vector.
func (c *CachedContactStore) loadReverseContacts(ctx context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) {
owners := append([]int64(nil), ownerUserIDs...)
sort.Slice(owners, func(i, j int) bool { return owners[i] < owners[j] })
sfKey := fmt.Sprintf("contact-reverse:%d:%v", userID, owners)
for {
v, err, _ := c.sf.Do(sfKey, func() (any, error) {
loadEpoch := c.cacheEpoch()
contacts, err := c.inner.GetReverseContacts(ctx, userID, owners)
if err != nil {
return reverseContactLoadResult{}, err
}
now := c.now()
expireAt := now.Add(c.ttl)
c.mu.Lock()
stored := c.epoch == loadEpoch
if stored {
for _, ownerID := range owners {
key := reverseContactKey{ownerUserID: ownerID, contactUserID: userID}
contact, found := contacts[ownerID]
c.storeReverseContactLocked(key, reverseContactSnapshot{
contact: cloneCachedContact(contact),
found: found,
expireAt: expireAt,
})
}
}
c.mu.Unlock()
return reverseContactLoadResult{
contacts: cloneCachedContactMap(contacts),
stored: stored,
}, nil
})
if err != nil {
return nil, err
}
result := v.(reverseContactLoadResult)
if result.stored {
return result.contacts, nil
}
if err := ctx.Err(); err != nil {
return nil, err
}
}
}
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 {
@ -367,6 +464,59 @@ func (c *CachedContactStore) lookupPersonalPhotoSnapshot(userID int64, now time.
return snap, true
}
func (c *CachedContactStore) lookupReverseContact(ownerUserID, contactUserID int64, now time.Time) (domain.Contact, bool, bool) {
key := reverseContactKey{ownerUserID: ownerUserID, contactUserID: contactUserID}
c.mu.Lock()
element, ok := c.reverse[key]
if !ok {
c.mu.Unlock()
return domain.Contact{}, false, false
}
entry := element.Value.(*reverseContactEntry)
snap := entry.snapshot
if !snap.expireAt.After(now) {
c.removeReverseElementLocked(element)
c.mu.Unlock()
return domain.Contact{}, false, false
}
c.reverseLRU.MoveToFront(element)
c.mu.Unlock()
return cloneCachedContact(snap.contact), snap.found, true
}
func (c *CachedContactStore) storeReverseContactLocked(key reverseContactKey, snapshot reverseContactSnapshot) {
if element, ok := c.reverse[key]; ok {
entry := element.Value.(*reverseContactEntry)
entry.snapshot = snapshot
c.reverseLRU.MoveToFront(element)
return
}
element := c.reverseLRU.PushFront(&reverseContactEntry{key: key, snapshot: snapshot})
c.reverse[key] = element
if c.reverseByOwner[key.ownerUserID] == nil {
c.reverseByOwner[key.ownerUserID] = make(map[int64]struct{})
}
c.reverseByOwner[key.ownerUserID][key.contactUserID] = struct{}{}
for c.reverseLRU.Len() > c.reverseCap {
c.removeReverseElementLocked(c.reverseLRU.Back())
}
}
func (c *CachedContactStore) removeReverseElementLocked(element *list.Element) {
if element == nil {
return
}
entry := element.Value.(*reverseContactEntry)
delete(c.reverse, entry.key)
if viewers := c.reverseByOwner[entry.key.ownerUserID]; viewers != nil {
delete(viewers, entry.key.contactUserID)
if len(viewers) == 0 {
delete(c.reverseByOwner, entry.key.ownerUserID)
}
}
c.reverseLRU.Remove(element)
}
func (c *CachedContactStore) InvalidateViewers(ids ...int64) {
if c == nil || len(ids) == 0 {
return
@ -379,6 +529,11 @@ func (c *CachedContactStore) InvalidateViewers(ids ...int64) {
}
delete(c.contacts, id)
delete(c.personalPhotos, id)
for contactUserID := range c.reverseByOwner[id] {
if element, ok := c.reverse[reverseContactKey{ownerUserID: id, contactUserID: contactUserID}]; ok {
c.removeReverseElementLocked(element)
}
}
}
c.mu.Unlock()
}
@ -391,6 +546,9 @@ func (c *CachedContactStore) FlushReadModelCache() {
c.epoch++
c.contacts = make(map[int64]contactAccountSnapshot, 1024)
c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024)
c.reverse = make(map[reverseContactKey]*list.Element, 4096)
c.reverseLRU.Init()
c.reverseByOwner = make(map[int64]map[int64]struct{}, 1024)
c.mu.Unlock()
}
@ -415,6 +573,14 @@ func buildContactAccountSnapshot(list domain.ContactList, expireAt time.Time) co
return contactAccountSnapshot{contacts: contacts, ordered: ordered, hash: list.Hash, expireAt: expireAt}
}
func cloneCachedContactMap(in map[int64]domain.Contact) map[int64]domain.Contact {
out := make(map[int64]domain.Contact, len(in))
for id, contact := range in {
out[id] = cloneCachedContact(contact)
}
return out
}
func dedupContactIDs(ids []int64) []int64 {
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))