chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -0,0 +1,319 @@
package rpc
import (
"context"
"sync"
"testing"
"time"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/proto"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
func fanoutTestJob(recipients []int64, originUser, originSession int64, built map[int64]bool) channelFanoutJob {
return channelFanoutJob{
scope: channelFanoutMembers,
originUserID: originUser,
channelID: 1001,
pts: 5,
recipients: recipients,
originSessionID: originSession,
build: func(_ context.Context, viewerUserID int64) *tg.Updates {
if built != nil {
built[viewerUserID] = true
}
return &tg.Updates{Updates: []tg.UpdateClass{&tg.UpdateChannelTooLong{ChannelID: 1001}}, Date: 1}
},
}
}
func fanoutHasID(ids []int64, want int64) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
// TestChannelFanoutDispatcherSyncFallback:dispatcher 未启动时 Enqueue 同步执行——
// 保持测试/未装配场景行为不变,recipients 立即被推送、发起 session 作为 exclude 透传。
// deps.Channels=nil 时 channelFanoutRecipients 直接返回 explicit recipients。
func TestChannelFanoutDispatcherSyncFallback(t *testing.T) {
cs := &captureSessions{}
r := New(Config{}, Deps{Sessions: cs}, zaptest.NewLogger(t), clock.System)
built := map[int64]bool{}
r.channelFanout.Enqueue(context.Background(), fanoutTestJob([]int64{2001, 2002}, 0, 99, built))
pushed := cs.pushedUserIDs()
if len(pushed) != 2 || !fanoutHasID(pushed, 2001) || !fanoutHasID(pushed, 2002) {
t.Fatalf("sync fallback pushed = %v, want [2001 2002]", pushed)
}
if got := cs.snapshot().sessionID; got != 99 {
t.Fatalf("exclude session = %d, want 99 (origin session passed explicitly, not via request ctx)", got)
}
if !built[2001] || !built[2002] {
t.Fatalf("build not invoked per viewer: %v", built)
}
}
// TestChannelFanoutDispatcherDeliversAsync:dispatcher 启动后 Enqueue 异步投递,
// recipients 最终被 worker 推送(不阻塞 Enqueue 调用方)。
func TestChannelFanoutDispatcherDeliversAsync(t *testing.T) {
cs := &captureSessions{}
r := New(Config{}, Deps{Sessions: cs}, zaptest.NewLogger(t), clock.System)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go r.RunChannelFanout(ctx)
for i := 0; i < 200 && !r.channelFanout.started.Load(); i++ {
time.Sleep(time.Millisecond)
}
if !r.channelFanout.started.Load() {
t.Fatal("dispatcher did not start")
}
// built map 跨 goroutine,只断言 mutex 保护的 pushedUserIDs,不读 built。
r.channelFanout.Enqueue(context.Background(), fanoutTestJob([]int64{3001}, 0, 7, nil))
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if fanoutHasID(cs.pushedUserIDs(), 3001) {
break
}
time.Sleep(2 * time.Millisecond)
}
if !fanoutHasID(cs.pushedUserIDs(), 3001) {
t.Fatalf("async fan-out did not deliver to 3001: %v", cs.pushedUserIDs())
}
if got := cs.snapshot().sessionID; got != 7 {
t.Fatalf("exclude session = %d, want 7 (origin carried into job, not lost on bg ctx)", got)
}
}
// TestChannelFanoutDispatcherInvokesPrefetch:worker 在逐 viewer build 之前调用一次 prefetch,
// 且传入「解析后的 recipients + 兜底 origin」——这是 fan-out 跨 viewer 投影预热(O(owner))的入口。
func TestChannelFanoutDispatcherInvokesPrefetch(t *testing.T) {
cs := &captureSessions{}
r := New(Config{}, Deps{Sessions: cs}, zaptest.NewLogger(t), clock.System)
var gotViewers []int64
job := fanoutTestJob([]int64{2001, 2002}, 5, 99, nil)
job.prefetch = func(_ context.Context, viewers []int64) {
gotViewers = append([]int64(nil), viewers...)
}
// deps.Channels=nil → channelFanoutRecipients 返回 explicit recipients=[2001 2002];origin=5 兜底追加。
r.channelFanout.Enqueue(context.Background(), job)
want := map[int64]bool{2001: true, 2002: true, 5: true}
if len(gotViewers) != len(want) {
t.Fatalf("prefetch viewers = %v, want recipients+origin %v", gotViewers, want)
}
for _, v := range gotViewers {
if !want[v] {
t.Fatalf("prefetch viewers = %v, unexpected %d (want recipients+origin)", gotViewers, v)
}
}
}
// editFanoutTestResult 构造一条覆盖两容器的 EditChannelMessageResult:主容器(Event/Message)带
// sender A + reply B,服务消息容器(ServiceEvent/ServiceMessage)带 sender C + Action.UserIDs=[D]。
func editFanoutTestResult(eventPts, servicePts int) domain.EditChannelMessageResult {
res := domain.EditChannelMessageResult{
Channel: domain.Channel{ID: 1001},
Recipients: []int64{3001, 3002},
}
res.Event = domain.ChannelUpdateEvent{Pts: eventPts, SenderUserID: 2001, Message: domain.ChannelMessage{ChannelID: 1001, SenderUserID: 2001}}
res.Message = domain.ChannelMessage{ChannelID: 1001, SenderUserID: 2001, ReplyTo: &domain.MessageReply{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2002}}}
res.ServiceEvent = domain.ChannelUpdateEvent{Pts: servicePts, SenderUserID: 2003, Message: domain.ChannelMessage{ChannelID: 1001, SenderUserID: 2003}}
res.ServiceMessage = domain.ChannelMessage{ChannelID: 1001, SenderUserID: 2003, Action: &domain.ChannelMessageAction{Type: domain.ChannelActionTodoCompletions, UserIDs: []int64{2004}}}
return res
}
func ownerIDSet(ids []int64) map[int64]bool {
out := make(map[int64]bool, len(ids))
for _, id := range ids {
out[id] = true
}
return out
}
// TestChannelEditMessageFanoutOwnerIDsCoversBothContainers:编辑预热的 owner-id 收集必须并集两个
// 容器(主消息 + 服务消息),否则服务消息的 sender/Action.UserIDs 漏出缓存预热(edit 相对 send
// 路径唯一新增的等价面,见 editVerify)。
func TestChannelEditMessageFanoutOwnerIDsCoversBothContainers(t *testing.T) {
got := ownerIDSet(channelEditMessageFanoutOwnerIDs(editFanoutTestResult(5, 6)))
for _, want := range []int64{2001, 2002, 2003, 2004} {
if !got[want] {
t.Fatalf("owner ids %v missing %d (both containers must be unioned)", got, want)
}
}
}
// TestChannelEditMessageFanoutOwnerIDsGating:owner-id 收集必须严格镜像 builder 的 pts 门控——
// ServiceEvent.Pts==0 时不收服务消息容器;Event.Pts==0 时不收主容器。保证预热集与 build 下发的
// Users 集恰好一致(多收无害但破坏等价测试紧致性)。
func TestChannelEditMessageFanoutOwnerIDsGating(t *testing.T) {
// 仅主容器(服务消息 pts=0)。
noService := ownerIDSet(channelEditMessageFanoutOwnerIDs(editFanoutTestResult(5, 0)))
if !noService[2001] || !noService[2002] {
t.Fatalf("event-only owner ids %v should contain 2001/2002", noService)
}
if noService[2003] || noService[2004] {
t.Fatalf("event-only owner ids %v must not contain service-container ids 2003/2004", noService)
}
// 两容器都无 pts → 空。
if ids := channelEditMessageFanoutOwnerIDs(editFanoutTestResult(0, 0)); len(ids) != 0 {
t.Fatalf("no-pts owner ids = %v, want empty", ids)
}
}
// prefetchRecordingUsersService 在 mapUsersService 基础上实现 BatchViewerUsersResolver 并记录
// ByIDsForViewers 收到的 (viewers, ownerIDs),用于断言 edit fan-out 用正确 owner 集预热。
type prefetchRecordingUsersService struct {
mapUsersService
mu sync.Mutex
gotViewers []int64
gotOwnerIDs []int64
forViewerCall int
}
func (s *prefetchRecordingUsersService) ByIDsForViewers(_ context.Context, viewerUserIDs, userIDs []int64) (map[int64][]domain.User, error) {
s.mu.Lock()
s.forViewerCall++
s.gotViewers = append([]int64(nil), viewerUserIDs...)
s.gotOwnerIDs = append([]int64(nil), userIDs...)
s.mu.Unlock()
out := make(map[int64][]domain.User, len(viewerUserIDs))
for _, v := range viewerUserIDs {
out[v] = nil
}
return out, nil
}
// TestChannelEditMessageFanoutInvokesPrefetch:enqueueChannelEditMessageFanout 在逐 viewer build
// 前用「channelEditMessageFanoutOwnerIDs(res) + recipients+origin」预热(dispatcher 未启动→同步
// 回退,prefetch 同步执行)。锁定 edit 路径接入了 O(owner) 预热而非逐 viewer 投影。
func TestChannelEditMessageFanoutInvokesPrefetch(t *testing.T) {
users := &prefetchRecordingUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{}}}
cs := &captureSessions{}
r := New(Config{}, Deps{Sessions: cs, Users: users}, zaptest.NewLogger(t), clock.System)
res := editFanoutTestResult(5, 6)
r.enqueueChannelEditMessageFanout(context.Background(), 5, res)
if users.forViewerCall != 1 {
t.Fatalf("ByIDsForViewers called %d times, want 1 (prefetch must run once before per-viewer build)", users.forViewerCall)
}
gotViewers := ownerIDSet(users.gotViewers)
for _, want := range []int64{3001, 3002, 5} {
if !gotViewers[want] {
t.Fatalf("prefetch viewers %v missing %d (recipients+origin)", users.gotViewers, want)
}
}
gotOwners := ownerIDSet(users.gotOwnerIDs)
for _, want := range []int64{2001, 2002, 2003, 2004} {
if !gotOwners[want] {
t.Fatalf("prefetch owner ids %v missing %d (must equal channelEditMessageFanoutOwnerIDs)", users.gotOwnerIDs, want)
}
}
}
// nudgeSessions 在 captureSessions 基础上实现 ChannelNudgeProvider 并按 user 记录最近一次推送,
// 用于断言 >cap 在线成员收到带 pts 的 UpdateChannelTooLong nudge。
type nudgeSessions struct {
*captureSessions
online []int64
mu sync.Mutex
byUser map[int64]bin.Encoder
}
func newNudgeSessions(online []int64) *nudgeSessions {
return &nudgeSessions{captureSessions: &captureSessions{}, online: online, byUser: map[int64]bin.Encoder{}}
}
func (s *nudgeSessions) PushToUserExceptSession(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) {
s.mu.Lock()
s.byUser[userID] = msg
s.mu.Unlock()
return s.captureSessions.PushToUserExceptSession(ctx, userID, excludeSessionID, t, msg)
}
func (s *nudgeSessions) OnlineChannelMemberUserIDsExcluding(_ int64, exclude map[int64]struct{}, limit int) []int64 {
out := make([]int64, 0, len(s.online))
for _, id := range s.online {
if _, ok := exclude[id]; ok {
continue
}
out = append(out, id)
if limit > 0 && len(out) >= limit {
break
}
}
return out
}
func (s *nudgeSessions) msgFor(userID int64) bin.Encoder {
s.mu.Lock()
defer s.mu.Unlock()
return s.byUser[userID]
}
// TestChannelFanoutDispatcherNudgesBeyondCapMembers(P0-8):完整 payload 投递给 cap 内
// recipients 后,cap 外在线成员收到带 pts 的 UpdateChannelTooLong nudge;cap 内成员不重复 nudge。
func TestChannelFanoutDispatcherNudgesBeyondCapMembers(t *testing.T) {
cs := newNudgeSessions([]int64{2001, 2002, 2003})
r := New(Config{}, Deps{Sessions: cs}, zaptest.NewLogger(t), clock.System)
// deps.Channels=nil → channelFanoutRecipients 返回 explicit recipients=[2001](收完整 payload)。
// 2002/2003 是 cap 外在线成员(OnlineChannelMemberUserIDsExcluding 排除 2001 后返回)。
r.channelFanout.Enqueue(context.Background(), fanoutTestJob([]int64{2001}, 0, 99, nil))
pushed := cs.pushedUserIDs()
for _, want := range []int64{2001, 2002, 2003} {
if !fanoutHasID(pushed, want) {
t.Fatalf("user %d not pushed: %v", want, pushed)
}
}
// 2002/2003 必须是带 pts 的 UpdateChannelTooLong(DrKLO 对不带 pts 的 tooLong 不触发 difference)。
for _, uid := range []int64{2002, 2003} {
ups, ok := cs.msgFor(uid).(*tg.Updates)
if !ok || len(ups.Updates) != 1 {
t.Fatalf("nudge to %d not single-update *tg.Updates: %#v", uid, cs.msgFor(uid))
}
tl, ok := ups.Updates[0].(*tg.UpdateChannelTooLong)
if !ok {
t.Fatalf("nudge to %d not UpdateChannelTooLong: %#v", uid, ups.Updates[0])
}
if p, ok := tl.GetPts(); !ok || p != 5 {
t.Fatalf("nudge to %d pts=%d ok=%v, want 5 (must carry pts)", uid, p, ok)
}
}
}
// TestChannelEditMessageFanoutNudgePtsUsesMaxContainer:edit 可只产服务消息容器(Event.Pts==0、
// ServiceEvent.Pts!=0,如纯 todo 完成)。此时 >cap 在线成员的 nudge 必须带 ServiceEvent.Pts(两容器
// 较大值),否则用 Event.Pts==0 会被 job.pts>0 门控吞掉 nudge、beyond-cap 成员错过 getChannelDifference。
func TestChannelEditMessageFanoutNudgePtsUsesMaxContainer(t *testing.T) {
cs := newNudgeSessions([]int64{3001, 4001}) // 4001 是 cap 外在线成员(不在 recipients)
r := New(Config{}, Deps{Sessions: cs, Users: mapUsersService{users: map[int64]domain.User{}}}, zaptest.NewLogger(t), clock.System)
// 仅服务消息容器有 pts:Event.Pts=0, ServiceEvent.Pts=11。deps.Channels=nil → recipients=[3001 3002]。
res := editFanoutTestResult(0, 11)
r.enqueueChannelEditMessageFanout(context.Background(), 0, res)
ups, ok := cs.msgFor(4001).(*tg.Updates)
if !ok || len(ups.Updates) != 1 {
t.Fatalf("nudge to 4001 not single-update *tg.Updates: %#v", cs.msgFor(4001))
}
tl, ok := ups.Updates[0].(*tg.UpdateChannelTooLong)
if !ok {
t.Fatalf("nudge to 4001 not UpdateChannelTooLong: %#v", ups.Updates[0])
}
if p, ok := tl.GetPts(); !ok || p != 11 {
t.Fatalf("nudge pts=%d ok=%v, want 11 (max(Event=0, Service=11))", p, ok)
}
}