merged from gramsrv upstream
This commit is contained in:
parent
79c64ee916
commit
21a0856587
651 changed files with 54774 additions and 4590 deletions
|
|
@ -2,11 +2,13 @@ package privacy
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/readmodelcache"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -15,6 +17,10 @@ const (
|
|||
|
||||
privacyViewerFactsMaxEntries = 8192
|
||||
privacyMembershipMaxEntries = 65536
|
||||
// A single legal cold batch must not replace the complete long-lived pair
|
||||
// cache. Large projections still use one exact store batch, but bypass LRU
|
||||
// admission and return the loaded facts directly.
|
||||
privacyMembershipBatchAdmissionMaxPairs = privacyMembershipMaxEntries / 4
|
||||
)
|
||||
|
||||
// baseUserProvider returns viewer-independent user facts through the users read
|
||||
|
|
@ -24,10 +30,11 @@ type baseUserProvider interface {
|
|||
PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]domain.User, error)
|
||||
}
|
||||
|
||||
// channelMembershipProvider is the cold loader behind the bounded membership
|
||||
// read model. Privacy evaluation never calls it for a warm (chat,user) pair.
|
||||
// channelMembershipProvider is the exact-pair cold loader behind the bounded
|
||||
// membership read model. Cache-admitted batches load only misses; oversized
|
||||
// non-admitted batches reload once without polluting the long-lived LRU.
|
||||
type channelMembershipProvider interface {
|
||||
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
|
||||
FilterActiveChannelMemberPairs(ctx context.Context, userIDsByChannel map[int64][]int64) (map[int64][]int64, error)
|
||||
}
|
||||
|
||||
type viewerFacts struct {
|
||||
|
|
@ -153,40 +160,30 @@ func (s *Service) loadMembershipFacts(ctx context.Context, chatIDs, viewerUserID
|
|||
if len(chats) == 0 || len(viewers) == 0 {
|
||||
return map[membershipKey]bool{}, nil
|
||||
}
|
||||
if !activeChannelMembershipPairsAllowed(0, len(chats), len(viewers)) {
|
||||
return nil, activeChannelMembershipPairLimitError()
|
||||
}
|
||||
keys := make([]membershipKey, 0, len(chats)*len(viewers))
|
||||
for _, chatID := range chats {
|
||||
for _, viewerID := range viewers {
|
||||
keys = append(keys, membershipKey{ChatID: chatID, UserID: viewerID})
|
||||
}
|
||||
}
|
||||
loadMissing := func(ctx context.Context, missing []membershipKey) (map[membershipKey]bool, error) {
|
||||
out := make(map[membershipKey]bool, len(missing))
|
||||
byChat := make(map[int64][]int64)
|
||||
for _, key := range missing {
|
||||
out[key] = false // negative cache: not an active member.
|
||||
byChat[key.ChatID] = append(byChat[key.ChatID], key.UserID)
|
||||
}
|
||||
if s == nil || s.memberships == nil {
|
||||
return out, nil
|
||||
}
|
||||
for chatID, userIDs := range byChat {
|
||||
active, err := s.memberships.FilterActiveChannelMemberIDs(ctx, chatID, userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, userID := range active {
|
||||
out[membershipKey{ChatID: chatID, UserID: userID}] = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return s.loadMembershipFactsForKeys(ctx, keys)
|
||||
}
|
||||
|
||||
func activeChannelMembershipPairsAllowed(current, channelCount, userCount int) bool {
|
||||
if current < 0 || channelCount < 0 || userCount < 0 || current > store.MaxActiveChannelMemberPairs {
|
||||
return false
|
||||
}
|
||||
if s == nil || s.membershipFacts == nil {
|
||||
return loadMissing(ctx, keys)
|
||||
if channelCount == 0 || userCount == 0 {
|
||||
return true
|
||||
}
|
||||
return s.membershipFacts.GetOrLoadBatch(ctx, keys,
|
||||
func(membershipKey) (int64, bool) { return 0, true },
|
||||
loadMissing,
|
||||
)
|
||||
return channelCount <= (store.MaxActiveChannelMemberPairs-current)/userCount
|
||||
}
|
||||
|
||||
func activeChannelMembershipPairLimitError() error {
|
||||
return fmt.Errorf("%w: maximum %d", store.ErrActiveChannelMemberPairsLimit, store.MaxActiveChannelMemberPairs)
|
||||
}
|
||||
|
||||
func applyViewerFacts(ctx *domain.PrivacyContext, facts viewerFacts, now int64) {
|
||||
|
|
|
|||
|
|
@ -346,8 +346,8 @@ func (s *Service) ViewerIsPremium(ctx context.Context, viewerUserID int64) (bool
|
|||
}
|
||||
|
||||
// CanSeeMatrix 批量评估 owners × viewers × keys 的可见性矩阵,结果等价于逐 (owner,viewer,key)
|
||||
// 调 CanSee,但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate
|
||||
// (把 fan-out 投影从 O(viewer) 次 privacy 查询降到 O(owner))。返回 map[owner]map[viewer]map[key]bool。
|
||||
// 调 CanSee。生产 contact store 通过一次 exact owner->viewer pair batch 读取联系人关系;仅不支持
|
||||
// sparse projection 的测试/替代实现按 owner 回退 GetMany。返回 map[owner]map[viewer]map[key]bool。
|
||||
func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs []int64, keys []domain.PrivacyKey) (map[int64]map[int64]map[domain.PrivacyKey]bool, error) {
|
||||
out := make(map[int64]map[int64]map[domain.PrivacyKey]bool, len(ownerUserIDs))
|
||||
if len(ownerUserIDs) == 0 || len(viewerUserIDs) == 0 || len(keys) == 0 {
|
||||
|
|
@ -408,11 +408,29 @@ func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs
|
|||
return nil, err
|
||||
}
|
||||
}
|
||||
var contactsByOwner map[int64]map[int64]domain.Contact
|
||||
useSparseContacts := false
|
||||
if s != nil && s.contacts != nil {
|
||||
if loader, ok := s.contacts.(store.SparseContactProjectionStore); ok {
|
||||
requested := make(map[int64][]int64, len(owners))
|
||||
for _, owner := range owners {
|
||||
requested[owner] = viewers
|
||||
}
|
||||
batch, err := loader.ContactProjectionForViewerUserIDs(ctx, requested)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contactsByOwner = batch.Contacts
|
||||
useSparseContacts = true
|
||||
}
|
||||
}
|
||||
now := s.now().Unix()
|
||||
for _, owner := range owners {
|
||||
// owner 的联系人中哪些是本批 viewer(= privacy 的 ViewerIsContact,对应 contacts.Get(owner,viewer))。
|
||||
var ownerContacts map[int64]domain.Contact
|
||||
if s != nil && s.contacts != nil {
|
||||
if useSparseContacts {
|
||||
ownerContacts = contactsByOwner[owner]
|
||||
} else if s != nil && s.contacts != nil {
|
||||
var err error
|
||||
ownerContacts, err = s.contacts.GetMany(ctx, owner, viewers)
|
||||
if err != nil {
|
||||
|
|
|
|||
232
internal/app/privacy/service_sparse.go
Normal file
232
internal/app/privacy/service_sparse.go
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
package privacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// CanSeeForViewerUserIDs evaluates only the requested viewer->owner pairs.
|
||||
// contactsByOwner must contain the inverse owner->viewer contact rows prefetched
|
||||
// by the caller; accepting them here lets user projection share one sparse
|
||||
// contact read for contact overlays, personal photos, and privacy relations.
|
||||
func (s *Service) CanSeeForViewerUserIDs(
|
||||
ctx context.Context,
|
||||
ownerUserIDsByViewer map[int64][]int64,
|
||||
keys []domain.PrivacyKey,
|
||||
contactsByOwner map[int64]map[int64]domain.Contact,
|
||||
) (map[int64]map[int64]map[domain.PrivacyKey]bool, error) {
|
||||
out := make(map[int64]map[int64]map[domain.PrivacyKey]bool)
|
||||
if len(ownerUserIDsByViewer) == 0 || len(keys) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
for _, key := range keys {
|
||||
if !ValidKey(key) {
|
||||
return nil, domain.ErrPrivacyKeyInvalid
|
||||
}
|
||||
}
|
||||
viewersByOwner := make(map[int64][]int64)
|
||||
viewerSet := make(map[int64]struct{})
|
||||
for viewerID, ownerIDs := range ownerUserIDsByViewer {
|
||||
if viewerID == 0 {
|
||||
continue
|
||||
}
|
||||
seenOwners := make(map[int64]struct{}, len(ownerIDs))
|
||||
for _, ownerID := range ownerIDs {
|
||||
if ownerID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenOwners[ownerID]; ok {
|
||||
continue
|
||||
}
|
||||
seenOwners[ownerID] = struct{}{}
|
||||
viewersByOwner[ownerID] = append(viewersByOwner[ownerID], viewerID)
|
||||
viewerSet[viewerID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(viewersByOwner) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if contactsByOwner == nil && s != nil && s.contacts != nil {
|
||||
loader, ok := s.contacts.(store.SparseContactProjectionStore)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("privacy contact store does not support sparse projection")
|
||||
}
|
||||
batch, err := loader.ContactProjectionForViewerUserIDs(ctx, viewersByOwner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contactsByOwner = batch.Contacts
|
||||
}
|
||||
owners := make([]int64, 0, len(viewersByOwner))
|
||||
for ownerID := range viewersByOwner {
|
||||
owners = append(owners, ownerID)
|
||||
}
|
||||
rulesByOwner := make(map[int64]map[domain.PrivacyKey]domain.PrivacyRules, len(owners))
|
||||
if s != nil && s.rules != nil {
|
||||
list, err := s.rules.ListPrivacyRules(ctx, owners, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rules := range list {
|
||||
if !ValidKey(rules.Key) {
|
||||
continue
|
||||
}
|
||||
if len(rules.Rules) == 0 {
|
||||
rules.Rules = domain.DefaultPrivacyRules(rules.Key)
|
||||
}
|
||||
if rulesByOwner[rules.OwnerUserID] == nil {
|
||||
rulesByOwner[rules.OwnerUserID] = make(map[domain.PrivacyKey]domain.PrivacyRules, len(keys))
|
||||
}
|
||||
rulesByOwner[rules.OwnerUserID][rules.Key] = cloneRules(rules)
|
||||
}
|
||||
}
|
||||
|
||||
needsByOwner := make(map[int64]evaluationNeeds, len(owners))
|
||||
needsViewerFacts := false
|
||||
membershipPairCount := 0
|
||||
for _, ownerID := range owners {
|
||||
var needs evaluationNeeds
|
||||
for _, key := range keys {
|
||||
rules, ok := rulesByOwner[ownerID][key]
|
||||
if !ok {
|
||||
rules = defaultRules(ownerID, key)
|
||||
}
|
||||
mergeNeeds(&needs, needsForRules(rules))
|
||||
}
|
||||
needsByOwner[ownerID] = needs
|
||||
needsViewerFacts = needsViewerFacts || needs.viewerBase
|
||||
viewerCount := len(viewersByOwner[ownerID])
|
||||
if !activeChannelMembershipPairsAllowed(membershipPairCount, len(needs.chatIDs), viewerCount) {
|
||||
return nil, activeChannelMembershipPairLimitError()
|
||||
}
|
||||
membershipPairCount += len(needs.chatIDs) * viewerCount
|
||||
}
|
||||
membershipKeys := make([]membershipKey, 0, membershipPairCount)
|
||||
for _, ownerID := range owners {
|
||||
needs := needsByOwner[ownerID]
|
||||
for _, chatID := range needs.chatIDs {
|
||||
for _, viewerID := range viewersByOwner[ownerID] {
|
||||
membershipKeys = append(membershipKeys, membershipKey{ChatID: chatID, UserID: viewerID})
|
||||
}
|
||||
}
|
||||
}
|
||||
viewers := make([]int64, 0, len(viewerSet))
|
||||
for viewerID := range viewerSet {
|
||||
viewers = append(viewers, viewerID)
|
||||
}
|
||||
var baseFacts map[int64]viewerFacts
|
||||
if needsViewerFacts {
|
||||
var err error
|
||||
baseFacts, err = s.loadViewerFacts(ctx, viewers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
membershipFacts, err := s.loadMembershipFactsForKeys(ctx, membershipKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := s.now().Unix()
|
||||
for _, ownerID := range owners {
|
||||
perViewer := make(map[int64]map[domain.PrivacyKey]bool, len(viewersByOwner[ownerID]))
|
||||
for _, viewerID := range viewersByOwner[ownerID] {
|
||||
visibility := make(map[domain.PrivacyKey]bool, len(keys))
|
||||
if ownerID == viewerID {
|
||||
for _, key := range keys {
|
||||
visibility[key] = true
|
||||
}
|
||||
perViewer[viewerID] = visibility
|
||||
continue
|
||||
}
|
||||
contact, isContact := contactsByOwner[ownerID][viewerID]
|
||||
for _, key := range keys {
|
||||
rules, ok := rulesByOwner[ownerID][key]
|
||||
if !ok {
|
||||
rules = defaultRules(ownerID, key)
|
||||
}
|
||||
evalCtx := domain.PrivacyContext{
|
||||
OwnerUserID: ownerID, ViewerUserID: viewerID,
|
||||
ViewerIsContact: isContact, ViewerCloseFriend: isContact && contact.CloseFriend,
|
||||
}
|
||||
applyViewerFacts(&evalCtx, baseFacts[viewerID], now)
|
||||
applyMembershipFacts(&evalCtx, needsByOwner[ownerID].chatIDs, membershipFacts)
|
||||
visibility[key] = Evaluate(rules, evalCtx)
|
||||
}
|
||||
perViewer[viewerID] = visibility
|
||||
}
|
||||
out[ownerID] = perViewer
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadMembershipFactsForKeys(ctx context.Context, input []membershipKey) (map[membershipKey]bool, error) {
|
||||
capacity := len(input)
|
||||
if capacity > store.MaxActiveChannelMemberPairs {
|
||||
capacity = store.MaxActiveChannelMemberPairs
|
||||
}
|
||||
seen := make(map[membershipKey]struct{}, capacity)
|
||||
keys := make([]membershipKey, 0, capacity)
|
||||
for _, key := range input {
|
||||
if key.ChatID == 0 || key.UserID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
if len(keys) >= store.MaxActiveChannelMemberPairs {
|
||||
return nil, activeChannelMembershipPairLimitError()
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return map[membershipKey]bool{}, nil
|
||||
}
|
||||
loadMissing := func(ctx context.Context, missing []membershipKey) (map[membershipKey]bool, error) {
|
||||
out := make(map[membershipKey]bool, len(missing))
|
||||
byChat := make(map[int64][]int64)
|
||||
for _, key := range missing {
|
||||
out[key] = false
|
||||
byChat[key.ChatID] = append(byChat[key.ChatID], key.UserID)
|
||||
}
|
||||
if s == nil || s.memberships == nil {
|
||||
return out, nil
|
||||
}
|
||||
activeByChat, err := s.memberships.FilterActiveChannelMemberPairs(ctx, byChat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for chatID, userIDs := range activeByChat {
|
||||
for _, userID := range userIDs {
|
||||
key := membershipKey{ChatID: chatID, UserID: userID}
|
||||
if _, requested := out[key]; requested {
|
||||
out[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if s == nil || s.membershipFacts == nil {
|
||||
return loadMissing(ctx, keys)
|
||||
}
|
||||
if len(keys) > privacyMembershipBatchAdmissionMaxPairs {
|
||||
for {
|
||||
loadEpoch := s.membershipFacts.LoadEpoch()
|
||||
loaded, err := loadMissing(ctx, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.membershipFacts.LoadEpoch() == loadEpoch {
|
||||
return loaded, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.membershipFacts.GetOrLoadBatch(ctx, keys,
|
||||
func(membershipKey) (int64, bool) { return 0, true }, loadMissing)
|
||||
}
|
||||
|
|
@ -2,10 +2,12 @@ package privacy
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
|
|
@ -26,8 +28,47 @@ func (p *countingBaseUsers) PrivacyBaseUsers(_ context.Context, userIDs []int64)
|
|||
}
|
||||
|
||||
type countingMemberships struct {
|
||||
calls int
|
||||
active map[int64]map[int64]bool
|
||||
calls int
|
||||
batchCalls int
|
||||
batchRequests []map[int64][]int64
|
||||
active map[int64]map[int64]bool
|
||||
}
|
||||
|
||||
type countingSparseContacts struct {
|
||||
store.ContactStore
|
||||
sparseCalls int
|
||||
getMany int
|
||||
requested map[int64][]int64
|
||||
}
|
||||
|
||||
func (c *countingSparseContacts) GetMany(ctx context.Context, ownerUserID int64, viewerUserIDs []int64) (map[int64]domain.Contact, error) {
|
||||
c.getMany++
|
||||
return c.ContactStore.GetMany(ctx, ownerUserID, viewerUserIDs)
|
||||
}
|
||||
|
||||
func (c *countingSparseContacts) ContactProjectionForViewerUserIDs(ctx context.Context, requested map[int64][]int64) (domain.ContactProjectionBatch, error) {
|
||||
c.sparseCalls++
|
||||
c.requested = make(map[int64][]int64, len(requested))
|
||||
for viewerID, targetIDs := range requested {
|
||||
c.requested[viewerID] = append([]int64(nil), targetIDs...)
|
||||
}
|
||||
return c.ContactStore.(store.SparseContactProjectionStore).ContactProjectionForViewerUserIDs(ctx, requested)
|
||||
}
|
||||
|
||||
func (p *countingMemberships) FilterActiveChannelMemberPairs(_ context.Context, requested map[int64][]int64) (map[int64][]int64, error) {
|
||||
p.batchCalls++
|
||||
cloned := make(map[int64][]int64, len(requested))
|
||||
out := make(map[int64][]int64, len(requested))
|
||||
for channelID, userIDs := range requested {
|
||||
cloned[channelID] = append([]int64(nil), userIDs...)
|
||||
for _, userID := range userIDs {
|
||||
if p.active[channelID][userID] {
|
||||
out[channelID] = append(out[channelID], userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.batchRequests = append(p.batchRequests, cloned)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *countingMemberships) FilterActiveChannelMemberIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) {
|
||||
|
|
@ -226,6 +267,47 @@ func TestCanSeeMatrixEquivalentToCanSee(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCanSeeMatrixLoadsOwnerViewerContactsInOneSparseBatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
inner := memory.NewContactStore()
|
||||
contacts := &countingSparseContacts{ContactStore: inner}
|
||||
svc := NewService(memory.NewPrivacyStore(), contacts)
|
||||
owners := []int64{6101, 6102}
|
||||
viewers := []int64{7101, 7102}
|
||||
for _, owner := range owners {
|
||||
if _, err := svc.SetRules(ctx, owner, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{
|
||||
{Kind: domain.PrivacyRuleAllowContacts},
|
||||
{Kind: domain.PrivacyRuleDisallowAll},
|
||||
}); err != nil {
|
||||
t.Fatalf("set owner %d rules: %v", owner, err)
|
||||
}
|
||||
}
|
||||
if _, err := inner.Upsert(ctx, owners[0], domain.ContactInput{ContactUserID: viewers[0]}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
|
||||
matrix, err := svc.CanSeeMatrix(ctx, owners, viewers, []domain.PrivacyKey{domain.PrivacyKeyPhoneNumber})
|
||||
if err != nil {
|
||||
t.Fatalf("CanSeeMatrix: %v", err)
|
||||
}
|
||||
if contacts.sparseCalls != 1 || contacts.getMany != 0 {
|
||||
t.Fatalf("contact reads = sparse %d / GetMany %d, want 1 / 0", contacts.sparseCalls, contacts.getMany)
|
||||
}
|
||||
for _, owner := range owners {
|
||||
if got := len(contacts.requested[owner]); got != len(viewers) {
|
||||
t.Fatalf("requested owner %d viewers = %v, want %v", owner, contacts.requested[owner], viewers)
|
||||
}
|
||||
}
|
||||
if !matrix[owners[0]][viewers[0]][domain.PrivacyKeyPhoneNumber] {
|
||||
t.Fatal("owner contact relation was not applied")
|
||||
}
|
||||
if matrix[owners[0]][viewers[1]][domain.PrivacyKeyPhoneNumber] ||
|
||||
matrix[owners[1]][viewers[0]][domain.PrivacyKeyPhoneNumber] ||
|
||||
matrix[owners[1]][viewers[1]][domain.PrivacyKeyPhoneNumber] {
|
||||
t.Fatalf("unexpected non-contact visibility: %+v", matrix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerFactsReadModelBatchesCachesAndInvalidates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rules := memory.NewPrivacyStore()
|
||||
|
|
@ -313,15 +395,15 @@ func TestMembershipReadModelCachesNegativeFactsAndInvalidatesPair(t *testing.T)
|
|||
got[1001][2002][domain.PrivacyKeyChatInvite] {
|
||||
t.Fatalf("unexpected membership visibility matrix: %+v", got)
|
||||
}
|
||||
if memberships.calls != 2 {
|
||||
t.Fatalf("membership cold loads = %d, want one batch per referenced chat", memberships.calls)
|
||||
if memberships.batchCalls != 1 || memberships.calls != 0 {
|
||||
t.Fatalf("membership cold loads = batch %d scalar %d, want batch=1 scalar=0", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
|
||||
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || allowed {
|
||||
t.Fatalf("warm negative membership = %v, err=%v; want false", allowed, err)
|
||||
}
|
||||
if memberships.calls != 2 {
|
||||
t.Fatalf("negative cache missed: calls=%d", memberships.calls)
|
||||
if memberships.batchCalls != 1 || memberships.calls != 0 {
|
||||
t.Fatalf("negative cache missed: batch=%d scalar=%d", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
|
||||
memberships.active[9002][2002] = true
|
||||
|
|
@ -329,7 +411,127 @@ func TestMembershipReadModelCachesNegativeFactsAndInvalidatesPair(t *testing.T)
|
|||
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || !allowed {
|
||||
t.Fatalf("invalidated membership = %v, err=%v; want true", allowed, err)
|
||||
}
|
||||
if memberships.calls != 3 {
|
||||
t.Fatalf("pair invalidation reloads = %d, want 3", memberships.calls)
|
||||
if memberships.batchCalls != 2 || memberships.calls != 0 {
|
||||
t.Fatalf("pair invalidation reloads = batch %d scalar %d, want batch=2 scalar=0", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparsePrivacyMembershipUsesOneExactPairBatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
ownerA = int64(1001)
|
||||
ownerB = int64(1002)
|
||||
viewerA = int64(2001)
|
||||
viewerB = int64(2002)
|
||||
chatA = int64(9001)
|
||||
chatB = int64(9002)
|
||||
)
|
||||
rules := memory.NewPrivacyStore()
|
||||
memberships := &countingMemberships{active: map[int64]map[int64]bool{
|
||||
chatA: {viewerA: true, viewerB: true},
|
||||
chatB: {viewerA: true, viewerB: true},
|
||||
}}
|
||||
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(nil, memberships)
|
||||
for ownerID, chatID := range map[int64]int64{ownerA: chatA, ownerB: chatB} {
|
||||
if _, err := svc.SetRules(ctx, ownerID, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{
|
||||
{Kind: domain.PrivacyRuleAllowChatParticipants, ChatIDs: []int64{chatID}},
|
||||
{Kind: domain.PrivacyRuleDisallowAll},
|
||||
}); err != nil {
|
||||
t.Fatalf("SetRules(%d): %v", ownerID, err)
|
||||
}
|
||||
}
|
||||
got, err := svc.CanSeeForViewerUserIDs(ctx, map[int64][]int64{
|
||||
viewerA: {ownerA},
|
||||
viewerB: {ownerB},
|
||||
}, []domain.PrivacyKey{domain.PrivacyKeyProfilePhoto}, map[int64]map[int64]domain.Contact{})
|
||||
if err != nil {
|
||||
t.Fatalf("CanSeeForViewerUserIDs: %v", err)
|
||||
}
|
||||
if !got[ownerA][viewerA][domain.PrivacyKeyProfilePhoto] || !got[ownerB][viewerB][domain.PrivacyKeyProfilePhoto] {
|
||||
t.Fatalf("visibility = %+v, want both exact pairs visible", got)
|
||||
}
|
||||
if memberships.batchCalls != 1 || memberships.calls != 0 {
|
||||
t.Fatalf("membership loads = batch %d scalar %d, want batch=1 scalar=0", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
requested := memberships.batchRequests[0]
|
||||
if len(requested) != 2 || len(requested[chatA]) != 1 || requested[chatA][0] != viewerA || len(requested[chatB]) != 1 || requested[chatB][0] != viewerB {
|
||||
t.Fatalf("membership request = %+v, want only (%d,%d) and (%d,%d)", requested, chatA, viewerA, chatB, viewerB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparsePrivacyMembershipRejectsDerivedPairOverflowBeforeLoad(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rules := memory.NewPrivacyStore()
|
||||
memberships := &countingMemberships{active: map[int64]map[int64]bool{}}
|
||||
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(nil, memberships)
|
||||
owners := []int64{1001, 1002, 1003, 1004, 1005}
|
||||
for ownerIndex, ownerID := range owners {
|
||||
chatIDs := make([]int64, 5000)
|
||||
for i := range chatIDs {
|
||||
chatIDs[i] = int64(100000 + ownerIndex*10000 + i)
|
||||
}
|
||||
if _, err := svc.SetRules(ctx, ownerID, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{
|
||||
{Kind: domain.PrivacyRuleAllowChatParticipants, ChatIDs: chatIDs},
|
||||
{Kind: domain.PrivacyRuleDisallowAll},
|
||||
}); err != nil {
|
||||
t.Fatalf("SetRules(%d): %v", ownerID, err)
|
||||
}
|
||||
}
|
||||
_, err := svc.CanSeeForViewerUserIDs(ctx, map[int64][]int64{
|
||||
2001: owners,
|
||||
2002: owners,
|
||||
2003: owners,
|
||||
}, []domain.PrivacyKey{domain.PrivacyKeyProfilePhoto}, map[int64]map[int64]domain.Contact{})
|
||||
if !errors.Is(err, store.ErrActiveChannelMemberPairsLimit) {
|
||||
t.Fatalf("CanSeeForViewerUserIDs error = %v, want ErrActiveChannelMemberPairsLimit", err)
|
||||
}
|
||||
if memberships.batchCalls != 0 || memberships.calls != 0 {
|
||||
t.Fatalf("membership loads = batch %d scalar %d, want fail before load", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDensePrivacyMembershipRejectsDerivedPairOverflowBeforeLoad(t *testing.T) {
|
||||
memberships := &countingMemberships{active: map[int64]map[int64]bool{}}
|
||||
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore()).ConfigureReadModels(nil, memberships)
|
||||
chatIDs := make([]int64, 257)
|
||||
viewerIDs := make([]int64, 256)
|
||||
for i := range chatIDs {
|
||||
chatIDs[i] = int64(i + 1)
|
||||
}
|
||||
for i := range viewerIDs {
|
||||
viewerIDs[i] = int64(1000 + i)
|
||||
}
|
||||
_, err := svc.loadMembershipFacts(context.Background(), chatIDs, viewerIDs)
|
||||
if !errors.Is(err, store.ErrActiveChannelMemberPairsLimit) {
|
||||
t.Fatalf("loadMembershipFacts error = %v, want ErrActiveChannelMemberPairsLimit", err)
|
||||
}
|
||||
if memberships.batchCalls != 0 || memberships.calls != 0 {
|
||||
t.Fatalf("membership loads = batch %d scalar %d, want fail before load", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLargeMembershipBatchBypassesLRUAdmissionWithoutEvictingHotPair(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
memberships := &countingMemberships{active: map[int64]map[int64]bool{}}
|
||||
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore()).ConfigureReadModels(nil, memberships)
|
||||
hot := membershipKey{ChatID: 9001, UserID: 2001}
|
||||
if _, err := svc.loadMembershipFactsForKeys(ctx, []membershipKey{hot}); err != nil {
|
||||
t.Fatalf("warm hot membership pair: %v", err)
|
||||
}
|
||||
large := make([]membershipKey, store.MaxActiveChannelMemberPairs)
|
||||
for i := range large {
|
||||
large[i] = membershipKey{ChatID: 9002, UserID: int64(100000 + i)}
|
||||
}
|
||||
if _, err := svc.loadMembershipFactsForKeys(ctx, large); err != nil {
|
||||
t.Fatalf("load large membership batch: %v", err)
|
||||
}
|
||||
if memberships.batchCalls != 2 || memberships.calls != 0 {
|
||||
t.Fatalf("loads after large batch = batch %d scalar %d, want batch=2 scalar=0", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
if _, err := svc.loadMembershipFactsForKeys(ctx, []membershipKey{hot}); err != nil {
|
||||
t.Fatalf("reload hot membership pair: %v", err)
|
||||
}
|
||||
if memberships.batchCalls != 2 || memberships.calls != 0 {
|
||||
t.Fatalf("hot pair was evicted by non-admitted batch: batch=%d scalar=%d", memberships.batchCalls, memberships.calls)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue