fix: sync moderation refresh cache bypass
This commit is contained in:
parent
3dd9c345d7
commit
6cafa40c7b
6 changed files with 187 additions and 0 deletions
|
|
@ -216,6 +216,21 @@ func (s *Service) GetChannels(ctx context.Context, userID int64, channelIDs []in
|
||||||
return s.channels.GetChannels(ctx, userID, ids)
|
return s.channels.GetChannels(ctx, userID, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetChannelsAuthoritative bypasses the app-level versioned read model for a
|
||||||
|
// durable channel_state refresh. PostgreSQL GetChannels is a bounded direct
|
||||||
|
// projection query, so the returned flag snapshot cannot be the pre-commit
|
||||||
|
// value that the event is intended to invalidate.
|
||||||
|
func (s *Service) GetChannelsAuthoritative(ctx context.Context, userID int64, channelIDs []int64) ([]domain.ChannelView, error) {
|
||||||
|
if s == nil || s.channels == nil || userID == 0 {
|
||||||
|
return nil, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
ids := uniqueNonZero(channelIDs)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return s.channels.GetChannels(ctx, userID, ids)
|
||||||
|
}
|
||||||
|
|
||||||
// GetJoinableChannel returns a channel shell so RPC can verify access hash before join.
|
// GetJoinableChannel returns a channel shell so RPC can verify access hash before join.
|
||||||
func (s *Service) GetJoinableChannel(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
|
func (s *Service) GetJoinableChannel(ctx context.Context, userID, channelID int64) (domain.Channel, error) {
|
||||||
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
if s == nil || s.channels == nil || userID == 0 || channelID == 0 {
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,27 @@ func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int6
|
||||||
return s.projectUsers(ctx, currentUserID, users)
|
return s.projectUsers(ctx, currentUserID, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ByIDsAuthoritative reloads an explicit profile-refresh target from the
|
||||||
|
// durable store, then replaces the shared base cache before applying the
|
||||||
|
// viewer projection. It is intentionally reserved for durable update
|
||||||
|
// delivery; ordinary reads continue to use ByIDs.
|
||||||
|
func (s *Service) ByIDsAuthoritative(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
|
||||||
|
if currentUserID == 0 {
|
||||||
|
return nil, ErrNotAuthorized
|
||||||
|
}
|
||||||
|
ids := uniqueUserIDs(userIDs, maxBatchUsers)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
s.dropCachedUsers(ctx, ids...)
|
||||||
|
users, err := s.users.ByIDs(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.putCachedUsers(ctx, users...)
|
||||||
|
return s.projectUsers(ctx, currentUserID, users)
|
||||||
|
}
|
||||||
|
|
||||||
// ByIDsForViewers 跨多个 viewer 批量投影同一组 user(fan-out 模板化):base user 只加载一次,
|
// ByIDsForViewers 跨多个 viewer 批量投影同一组 user(fan-out 模板化):base user 只加载一次,
|
||||||
// 隐私/改名/头像投影经 userprojection.ForViewers 压成 O(owner) 查询。返回 map[viewerID][]User,
|
// 隐私/改名/头像投影经 userprojection.ForViewers 压成 O(owner) 查询。返回 map[viewerID][]User,
|
||||||
// 每个切片与 ByIDs(viewer, ids) 字节等价——**唯一例外是 personal photo overlay**(ForViewers v1
|
// 每个切片与 ByIDs(viewer, ids) 字节等价——**唯一例外是 personal photo overlay**(ForViewers v1
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,49 @@ func TestServiceUsesBaseCacheWithoutCachingViewerOverlay(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceAuthoritativeUserReloadReplacesStaleBaseCache(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
base := memory.NewUserStore()
|
||||||
|
viewer, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000011", FirstName: "Viewer"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create viewer: %v", err)
|
||||||
|
}
|
||||||
|
target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000012", FirstName: "Target"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create target: %v", err)
|
||||||
|
}
|
||||||
|
store := &countingUserStore{UserStore: base}
|
||||||
|
cache := newMemoryBaseUserCache()
|
||||||
|
svc := NewService(store, WithBaseUserCache(cache))
|
||||||
|
|
||||||
|
primed, err := svc.ByIDs(ctx, viewer.ID, []int64{target.ID})
|
||||||
|
if err != nil || len(primed) != 1 || primed[0].Scam {
|
||||||
|
t.Fatalf("prime ByIDs users=%+v err=%v", primed, err)
|
||||||
|
}
|
||||||
|
if _, err := base.SetScamFake(ctx, target.ID, true, false); err != nil {
|
||||||
|
t.Fatalf("commit moderation flags behind cache: %v", err)
|
||||||
|
}
|
||||||
|
stale, err := svc.ByIDs(ctx, viewer.ID, []int64{target.ID})
|
||||||
|
if err != nil || len(stale) != 1 || stale[0].Scam {
|
||||||
|
t.Fatalf("ordinary cached ByIDs users=%+v err=%v, want stale scam=false", stale, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fresh, err := svc.ByIDsAuthoritative(ctx, viewer.ID, []int64{target.ID})
|
||||||
|
if err != nil || len(fresh) != 1 || !fresh[0].Scam || fresh[0].Fake {
|
||||||
|
t.Fatalf("authoritative ByIDs users=%+v err=%v, want scam=true fake=false", fresh, err)
|
||||||
|
}
|
||||||
|
if store.byIDsCalls != 2 {
|
||||||
|
t.Fatalf("store ByIDs calls=%d, want prime + authoritative reload", store.byIDsCalls)
|
||||||
|
}
|
||||||
|
cached, err := svc.ByIDs(ctx, viewer.ID, []int64{target.ID})
|
||||||
|
if err != nil || len(cached) != 1 || !cached[0].Scam || cached[0].Fake {
|
||||||
|
t.Fatalf("replaced cache ByIDs users=%+v err=%v, want scam=true fake=false", cached, err)
|
||||||
|
}
|
||||||
|
if store.byIDsCalls != 2 {
|
||||||
|
t.Fatalf("store ByIDs calls after cached read=%d, want unchanged", store.byIDsCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceRefreshesBaseCacheAfterProfileUpdate(t *testing.T) {
|
func TestServiceRefreshesBaseCacheAfterProfileUpdate(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
base := memory.NewUserStore()
|
base := memory.NewUserStore()
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,15 @@ type UsersService interface {
|
||||||
ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error)
|
ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserAuthoritativeProjectionService bypasses viewer-independent base caches
|
||||||
|
// for an explicit durable profile-refresh event. The event exists precisely
|
||||||
|
// because a just-committed absolute user fact must replace client and server
|
||||||
|
// caches; re-reading a stale Redis value would acknowledge the outbox row
|
||||||
|
// without ever exposing the committed state.
|
||||||
|
type UserAuthoritativeProjectionService interface {
|
||||||
|
ByIDsAuthoritative(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
// TelegramLoginService is the domain-only boundary shared by the MTProto RPC
|
// TelegramLoginService is the domain-only boundary shared by the MTProto RPC
|
||||||
// edge and the public OIDC provider. PostgreSQL remains authoritative for all
|
// edge and the public OIDC provider. PostgreSQL remains authoritative for all
|
||||||
// consent transitions; the RPC layer only projects domain state to TL.
|
// consent transitions; the RPC layer only projects domain state to TL.
|
||||||
|
|
@ -781,6 +790,12 @@ type ChannelsService interface {
|
||||||
FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
|
FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChannelAuthoritativeProjectionService bypasses long-lived channel read
|
||||||
|
// models for a durable channel_state refresh emitted by an admin mutation.
|
||||||
|
type ChannelAuthoritativeProjectionService interface {
|
||||||
|
GetChannelsAuthoritative(ctx context.Context, userID int64, channelIDs []int64) ([]domain.ChannelView, error)
|
||||||
|
}
|
||||||
|
|
||||||
// CommunitiesService abstracts the Layer 228 Community aggregation domain.
|
// CommunitiesService abstracts the Layer 228 Community aggregation domain.
|
||||||
// Community containers never expose tg types and never own message/read/pts state.
|
// Community containers never expose tg types and never own message/read/pts state.
|
||||||
type CommunitiesService interface {
|
type CommunitiesService interface {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,47 @@
|
||||||
package rpc
|
package rpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/iamxvbaba/td/clock"
|
||||||
"github.com/iamxvbaba/td/tg"
|
"github.com/iamxvbaba/td/tg"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type moderationProjectionUsers struct {
|
||||||
|
UsersService
|
||||||
|
freshCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *moderationProjectionUsers) ByIDs(_ context.Context, _ int64, ids []int64) ([]domain.User, error) {
|
||||||
|
return []domain.User{{ID: ids[0], FirstName: "stale"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *moderationProjectionUsers) ByIDsAuthoritative(_ context.Context, _ int64, ids []int64) ([]domain.User, error) {
|
||||||
|
s.freshCalls++
|
||||||
|
return []domain.User{{ID: ids[0], FirstName: "fresh", Scam: true}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type moderationProjectionChannels struct {
|
||||||
|
ChannelsService
|
||||||
|
freshCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *moderationProjectionChannels) GetChannels(_ context.Context, _ int64, ids []int64) ([]domain.ChannelView, error) {
|
||||||
|
return []domain.ChannelView{{Channel: domain.Channel{ID: ids[0], Title: "stale", Megagroup: true}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *moderationProjectionChannels) GetChannelsAuthoritative(_ context.Context, viewerUserID int64, ids []int64) ([]domain.ChannelView, error) {
|
||||||
|
s.freshCalls++
|
||||||
|
return []domain.ChannelView{{
|
||||||
|
Channel: domain.Channel{ID: ids[0], Title: "fresh", Megagroup: true, Scam: true},
|
||||||
|
Self: domain.ChannelMember{ChannelID: ids[0], UserID: viewerUserID, Status: domain.ChannelMemberActive},
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestModerationProfileUpdateCarriesStandardFlagsAndPts(t *testing.T) {
|
func TestModerationProfileUpdateCarriesStandardFlagsAndPts(t *testing.T) {
|
||||||
const (
|
const (
|
||||||
viewerID = int64(1001)
|
viewerID = int64(1001)
|
||||||
|
|
@ -95,3 +129,28 @@ func TestChannelModerationUpdateCarriesStandardFlagsAndPts(t *testing.T) {
|
||||||
t.Fatalf("channel = %T %+v", updates.Chats[0], updates.Chats[0])
|
t.Fatalf("channel = %T %+v", updates.Chats[0], updates.Chats[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModerationRefreshEventsBypassServerProjectionCaches(t *testing.T) {
|
||||||
|
users := &moderationProjectionUsers{}
|
||||||
|
channels := &moderationProjectionChannels{}
|
||||||
|
r := New(Config{}, Deps{Users: users, Channels: channels}, zap.NewNop(), clock.System)
|
||||||
|
const viewerID = int64(5005)
|
||||||
|
events := r.enrichUpdateEvents(context.Background(), viewerID, []domain.UpdateEvent{
|
||||||
|
{
|
||||||
|
UserID: viewerID, Type: domain.UpdateEventUserProfile,
|
||||||
|
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 6006},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
UserID: viewerID, Type: domain.UpdateEventChannelState,
|
||||||
|
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 7007},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if users.freshCalls != 1 || len(events[0].Users) != 1 ||
|
||||||
|
events[0].Users[0].FirstName != "fresh" || !events[0].Users[0].Scam {
|
||||||
|
t.Fatalf("authoritative user refresh = calls:%d users:%+v", users.freshCalls, events[0].Users)
|
||||||
|
}
|
||||||
|
if channels.freshCalls != 1 || len(events[1].Channels) != 1 ||
|
||||||
|
events[1].Channels[0].Title != "fresh" || !events[1].Channels[0].Scam {
|
||||||
|
t.Fatalf("authoritative channel refresh = calls:%d channels:%+v", channels.freshCalls, events[1].Channels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package rpc
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -25,6 +27,38 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser
|
||||||
allUserIDs := make(map[int64]struct{})
|
allUserIDs := make(map[int64]struct{})
|
||||||
allChannelIDs := make(map[int64]struct{})
|
allChannelIDs := make(map[int64]struct{})
|
||||||
for i := range out {
|
for i := range out {
|
||||||
|
if out[i].Type == domain.UpdateEventUserProfile {
|
||||||
|
if service, ok := r.deps.Users.(UserAuthoritativeProjectionService); ok {
|
||||||
|
users, err := service.ByIDsAuthoritative(ctx, viewerUserID, []int64{out[i].Peer.ID})
|
||||||
|
if err != nil {
|
||||||
|
r.log.Warn("reload authoritative user profile event",
|
||||||
|
zap.Int64("viewer_user_id", viewerUserID),
|
||||||
|
zap.Int64("target_user_id", out[i].Peer.ID),
|
||||||
|
zap.Error(err))
|
||||||
|
} else {
|
||||||
|
out[i].Users = users
|
||||||
|
cache.primeUsers(viewerUserID, users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out[i].Type == domain.UpdateEventChannelState {
|
||||||
|
if service, ok := r.deps.Channels.(ChannelAuthoritativeProjectionService); ok {
|
||||||
|
views, err := service.GetChannelsAuthoritative(ctx, viewerUserID, []int64{out[i].Peer.ID})
|
||||||
|
if err != nil {
|
||||||
|
r.log.Warn("reload authoritative channel state event",
|
||||||
|
zap.Int64("viewer_user_id", viewerUserID),
|
||||||
|
zap.Int64("channel_id", out[i].Peer.ID),
|
||||||
|
zap.Error(err))
|
||||||
|
} else {
|
||||||
|
out[i].Channels = out[i].Channels[:0]
|
||||||
|
for _, view := range views {
|
||||||
|
if view.Channel.ID != 0 {
|
||||||
|
out[i].Channels = append(out[i].Channels, view.Channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if out[i].Type == domain.UpdateEventMessageReactions {
|
if out[i].Type == domain.UpdateEventMessageReactions {
|
||||||
out[i] = r.enrichMessageReactionEvent(ctx, viewerUserID, out[i])
|
out[i] = r.enrichMessageReactionEvent(ctx, viewerUserID, out[i])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue