merged from gramsrv upstream

This commit is contained in:
onysd 2026-09-01 12:06:31 +03:00
parent 79c64ee916
commit 21a0856587
651 changed files with 54774 additions and 4590 deletions

View file

@ -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) {

View file

@ -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 {

View 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)
}

View file

@ -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)
}
}