package rpc import ( "context" "sync" "sync/atomic" "github.com/gotd/td/tg" "go.uber.org/zap" "telesrv/internal/domain" ) // channel fan-out 异步化(设计 docs/channel-fanout-async-design.md Phase 0 / §9 v1 最小实现)。 // // 目标:把频道 payload fan-out 移出发送者 RPC 同步路径——发送者只等事务 + 自己视角 echo // (rpc_result),其余在线成员的投递交给本 dispatcher 的后台 worker。 // // v1(单实例)关键取舍: // - durable 真值仍是 channel_update_events;本 dispatcher 只做 best-effort 在线加速, // 丢失由客户端 getChannelDifference 兜底(设计决策 1)。 // - 按 channelID 哈希到固定分片,使同一 channel 串行处理(FIFO → 单调 pts),满足 // DrKLO Android ~1.5s 乱序窗口与 TDesktop PtsWaiter 的连续性期望(设计 §10.2)。 // - 单实例 + 无 durable 重投队列 + 同 channel 串行 → 无乱序、无自重复,故 v1 不需要 // per-session at-most-once 双水位(那是 Phase 3 跨实例的事,设计 §9/§10.1)。 // - 有界队列满时丢弃当前 job 并告警:被丢 recipient 会在该 channel 下一条成功投递的 // pts 跳变时经 getChannelDifference 收敛(设计约束 B)。 const ( defaultChannelFanoutShards = 64 defaultChannelFanoutBuffer = 2048 ) // channelFanoutBuilder 按 viewer 构建该 viewer 视角的 channel updates。与同步 // channelUpdatesBuilder 不同,它接受 worker 提供的后台 ctx(请求 ctx 在 fan-out 异步 // 执行时已被取消),且实现不得从 ctx 读取 viewer/auth 派生数据(viewerUserID 显式传入)。 type channelFanoutBuilder func(ctx context.Context, viewerUserID int64) *tg.Updates // channelFanoutJob 是一条频道 payload fan-out 任务。Pts 仅用于日志/折叠语义;真值仍是 // channel_update_events,worker 只做在线投递。originAuthKeyID 是业务视角 auth key // (与 SessionManager.shouldExcludeSession 的比较侧一致),用于显式排除发起设备——异步 // 执行时请求 ctx 已失效,不能再靠 ctx 派生排除。 type channelFanoutJob struct { scope channelFanoutScope originUserID int64 channelID int64 pts int recipients []int64 originAuthKeyID [8]byte originSessionID int64 prefetch channelFanoutPrefetch build channelFanoutBuilder } // channelFanoutPrefetch 在 worker 解析出最终 recipient 集合后、逐 viewer build 之前调用一次, // 用于跨全部 recipient 一次性预热每 viewer 的用户投影(fan-out 模板化,O(owner))。可选:为 nil // 时 build 仍逐 viewer 解析(行为不变)。在 worker goroutine 内串行执行,与 build 共享同一 // viewerPeerCache,无跨 goroutine 竞态。 type channelFanoutPrefetch func(ctx context.Context, viewers []int64) // channelFanoutDispatcher 把频道 payload fan-out 移出发送者 RPC,按 channelID 分片串行处理。 type channelFanoutDispatcher struct { r *Router log *zap.Logger shards []chan channelFanoutJob started atomic.Bool dropped atomic.Int64 } // enqueueChannelFanout 把一条 channel-payload-pts 的 fan-out 投入异步 dispatcher。 // 从请求 ctx 抓取发起设备的业务 auth key + session_id 显式带入 job,使异步 worker 仍能 // 排除发起设备回显(请求 ctx 异步时已失效)。仅用于会推进客户端 channel PtsWaiter 的真实 // payload(新消息/编辑/删除/pin);reaction/poll(viewer-only 零 pts)、participant/TTL/ // channel state(无 channel pts)、typing(transient)不走此路径(设计 §2.1/§5 分类)。 func (r *Router) enqueueChannelFanout(ctx context.Context, scope channelFanoutScope, originUserID, channelID int64, pts int, recipients []int64, build channelFanoutBuilder) { r.enqueueChannelFanoutWithPrefetch(ctx, scope, originUserID, channelID, pts, recipients, nil, build) } // enqueueChannelFanoutWithPrefetch 同 enqueueChannelFanout,但额外带一个跨 viewer 用户投影预热钩子 // (fan-out 模板化把每 recipient 的逐 viewer 投影折叠成一次 O(owner) 投影;见 prefetchChannelFanoutUsers)。 func (r *Router) enqueueChannelFanoutWithPrefetch(ctx context.Context, scope channelFanoutScope, originUserID, channelID int64, pts int, recipients []int64, prefetch channelFanoutPrefetch, build channelFanoutBuilder) { if r.channelFanout == nil || build == nil { return } originAuthKeyID, _ := AuthKeyIDFrom(ctx) originSessionID, _ := SessionIDFrom(ctx) r.channelFanout.Enqueue(ctx, channelFanoutJob{ scope: scope, originUserID: originUserID, channelID: channelID, pts: pts, recipients: recipients, originAuthKeyID: originAuthKeyID, originSessionID: originSessionID, prefetch: prefetch, build: build, }) } // RunChannelFanout 启动频道 fan-out 后台 worker,由 main 与其它 dispatcher 一同 go 起。 // 阻塞到 ctx 取消;未调用前 fan-out 同步执行(行为同旧版)。 func (r *Router) RunChannelFanout(ctx context.Context) { r.channelFanout.Run(ctx) } func newChannelFanoutDispatcher(r *Router, shards, buffer int) *channelFanoutDispatcher { if shards <= 0 { shards = defaultChannelFanoutShards } if buffer <= 0 { buffer = defaultChannelFanoutBuffer } d := &channelFanoutDispatcher{r: r, log: r.log.Named("channel-fanout"), shards: make([]chan channelFanoutJob, shards)} for i := range d.shards { d.shards[i] = make(chan channelFanoutJob, buffer) } return d } // Run 启动 worker:每分片一个 goroutine,保证同 channel 串行。阻塞到 ctx 取消。 // 未调用 Run 前 Enqueue 回退为同步执行(保持测试/未装配场景行为不变)。 func (d *channelFanoutDispatcher) Run(ctx context.Context) { if d == nil || !d.started.CompareAndSwap(false, true) { return } var wg sync.WaitGroup for i := range d.shards { wg.Add(1) ch := d.shards[i] go func() { defer wg.Done() for { select { case <-ctx.Done(): return case job := <-ch: d.r.runChannelFanoutJob(ctx, job) } } }() } wg.Wait() } func (d *channelFanoutDispatcher) shardIndex(channelID int64) int { n := int64(len(d.shards)) idx := channelID % n if idx < 0 { idx += n } return int(idx) } // Enqueue 投递一条 fan-out 任务。dispatcher 未启动时同步执行(用请求 ctx,保持旧行为); // 已启动时投入对应分片,满则丢弃 + 告警(该 channel 下一条消息的 pts 跳变会经 // getChannelDifference 兜底)。 func (d *channelFanoutDispatcher) Enqueue(reqCtx context.Context, job channelFanoutJob) { if d == nil || job.build == nil { return } if !d.started.Load() { d.r.runChannelFanoutJob(reqCtx, job) return } shard := d.shards[d.shardIndex(job.channelID)] select { case shard <- job: default: d.dropped.Add(1) d.log.Warn("channel fanout queue full, dropped realtime push (recovered via next pts gap / getChannelDifference)", zap.Int64("channel_id", job.channelID), zap.Int("pts", job.pts)) } } // runChannelFanoutJob 执行一条 fan-out:与同步 pushChannelUpdatesWithScope 等价,区别是 // build 接受 ctx、且排除发起设备用 job 显式携带的 (originAuthKeyID, originSessionID) // 叠加到 ctx 后复用 pushUserUpdates,而非依赖已失效的请求 ctx。 func (r *Router) runChannelFanoutJob(ctx context.Context, job channelFanoutJob) { if r.deps.Sessions == nil || job.build == nil { return } pushCtx := WithSessionID(WithAuthKeyID(ctx, job.originAuthKeyID), job.originSessionID) recipients := r.channelFanoutRecipients(ctx, job.scope, job.channelID, job.recipients) // 预热跨 viewer 用户投影(fan-out 模板化):在逐 viewer build 之前一次性算好每 recipient 的 // 投影并预热共享 cache,使 build 只命中缓存、不再 O(viewer) 逐个 ForViewer。覆盖 recipients + // 兜底 origin(无在线 recipient 时 build 会回退给 origin)。失败/未实现时静默退化为逐 viewer。 if job.prefetch != nil { viewers := recipients if job.originUserID != 0 { viewers = append(append(make([]int64, 0, len(recipients)+1), recipients...), job.originUserID) } job.prefetch(ctx, viewers) } seen := make(map[int64]struct{}, len(recipients)) pushed := false for _, userID := range recipients { if userID == 0 { continue } if _, ok := seen[userID]; ok { continue } seen[userID] = struct{}{} updates := job.build(ctx, userID) if updates == nil { continue } r.pushUserUpdates(pushCtx, userID, updates) pushed = true } if !pushed && job.originUserID != 0 { seen[job.originUserID] = struct{}{} if updates := job.build(ctx, job.originUserID); updates != nil { r.pushUserUpdates(pushCtx, job.originUserID, updates) } } // P0-8:完整 payload 受 MaxChannelRealtimeFanout 封顶,超出 cap 的在线成员既收不到 // payload 也收不到任何东西(频道纯拉模型不会自发轮询)。给这些「已在线但未收完整 payload」 // 的成员发廉价 UpdateChannelTooLong{pts} nudge,促其 getChannelDifference 收敛。 // 仅对会推进客户端 channel PtsWaiter 的真实 payload(members scope + 带 channel pts)做。 if job.scope == channelFanoutMembers && job.pts > 0 { r.nudgeBeyondCapChannelMembers(pushCtx, job.channelID, job.pts, seen) } } // prefetchChannelFanoutUsers 跨全部 recipient 一次性投影 owner 用户(fan-out 模板化,O(owner)), // 把结果按 viewer 预热进共享 cache;之后每 viewer 的 build 只命中缓存,不再逐 viewer ForViewer。 // ownerIDs 由调用方从消息/事件 peer refs 收集。deps.Users 未实现 BatchViewerUsersResolver 或解析 // 失败时静默跳过——build 回退逐 viewer 解析,行为不变,仅退化为旧的 O(viewer) 成本。 func (r *Router) prefetchChannelFanoutUsers(ctx context.Context, cache *viewerPeerCache, viewers, ownerIDs []int64) { if cache == nil || len(viewers) == 0 || len(ownerIDs) == 0 || r.deps.Users == nil { return } resolver, ok := r.deps.Users.(BatchViewerUsersResolver) if !ok { return } byViewer, err := resolver.ByIDsForViewers(ctx, viewers, ownerIDs) if err != nil { r.log.Warn("channel fanout user prefetch failed; falling back to per-viewer projection", zap.Int("viewers", len(viewers)), zap.Int("owners", len(ownerIDs)), zap.Error(err)) return } for viewer, users := range byViewer { cache.primeUsers(viewer, users) } } // channelMessageFanoutOwnerIDs 收集一条频道消息 fan-out 会下发到 Users 数组里的全部 owner 用户 id // (sender/from/send_as/forward/via_bot/reply/contact/poll 等 peer refs,与 // channelMessagesUpdatesWithPeerCache 的收集口径一致),用于预热跨 viewer 投影。 func channelMessageFanoutOwnerIDs(res domain.SendChannelMessageResult, extraUserIDs []int64) []int64 { return channelMessagesFanoutOwnerIDs([]domain.SendChannelMessageResult{res}, extraUserIDs) } // channelMessagesFanoutOwnerIDs 同上,但取多条结果(批量转发汇成一个 job)的 owner id 并集。 func channelMessagesFanoutOwnerIDs(results []domain.SendChannelMessageResult, extraUserIDs []int64) []int64 { userIDs := make(map[int64]struct{}, len(results)+len(extraUserIDs)+4) channelIDs := make(map[int64]struct{}) for _, id := range extraUserIDs { if id != 0 { userIDs[id] = struct{}{} } } for _, res := range results { collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs) collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs) } return peerIDMapKeys(userIDs) } // enqueueChannelMessageFanout 异步 fan-out 单条频道消息并预热跨 viewer 投影(「频道里出现一条新消息」 // 类事件的常见形态:发送/转发单条/讨论组联动/forum topic 消息)。语义与 enqueueChannelFanout 一致, // 仅多了把每 viewer 投影一次性算好预热进共享 cache(O(owner)),不改变投递/排除/nudge 行为。 func (r *Router) enqueueChannelMessageFanout(ctx context.Context, originUserID int64, res domain.SendChannelMessageResult, extraUserIDs []int64) { r.enqueueBotAPIChannelMessageUpdate(ctx, originUserID, res) fanoutCache := newViewerPeerCache(r) ownerIDs := channelMessageFanoutOwnerIDs(res, extraUserIDs) skip := skipDeliverySet(res.SkipDeliveryUserIDs) r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, res.Channel.ID, res.Event.Pts, res.Recipients, func(bgCtx context.Context, viewers []int64) { r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) }, func(bgCtx context.Context, viewerUserID int64) *tg.Updates { // privacy bot 在 send 时被 SkipDeliveryUserIDs 排除(命令/@/回复以外的消息不可见)。 // channelFanoutRecipients 按「在线活跃成员」重算 recipients 会把它加回,故在线 fanout // 必须在此跳过它的直接推送,否则在线 bot 仍能实时收到群里全部消息(持久 history/ // difference 已正确隐藏,仅此直接推送泄漏内容)。nudge 安全:bot 落在 seen 里不被 nudge, // 即便 nudge 也会被 filterBotChannelDifference 过滤掉隐藏消息。 if _, skipped := skip[viewerUserID]; skipped { return nil } return r.channelMessageUpdatesWithPeerCache(bgCtx, viewerUserID, res, 0, fanoutCache) }) } // skipDeliverySet 把 SkipDeliveryUserIDs 切片转成查找集合(nil 表示无排除)。 func skipDeliverySet(ids []int64) map[int64]struct{} { if len(ids) == 0 { return nil } set := make(map[int64]struct{}, len(ids)) for _, id := range ids { if id != 0 { set[id] = struct{}{} } } return set } // channelEditMessageFanoutOwnerIDs 收集一条频道编辑 fan-out 会下发到 Users 数组里的全部 owner 用户 id。 // 严格镜像 channelEditMessageUpdates 的两容器 + pts 门控收集口径(Event/Message 仅 Event.Pts!=0 时收, // ServiceEvent/ServiceMessage 仅 ServiceEvent.Pts!=0 时收,对应 todo 编辑的服务消息第二容器),使预热 // owner 集与 build 实际下发的 Users 集恰好一致——多收只会无害多预热,但镜像门控让等价测试最紧。 func channelEditMessageFanoutOwnerIDs(res domain.EditChannelMessageResult) []int64 { userIDs := make(map[int64]struct{}, 4) channelIDs := make(map[int64]struct{}) if res.Event.Pts != 0 { collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs) collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs) } if res.ServiceEvent.Pts != 0 { collectChannelUpdatePeerRefs(res.ServiceEvent, res.Channel.ID, userIDs, channelIDs) collectChannelMessagePeerRefs(res.ServiceMessage, res.Channel.ID, userIDs, channelIDs) } return peerIDMapKeys(userIDs) } // enqueueChannelEditMessageFanout 异步 fan-out 一条频道编辑并预热跨 viewer 投影(editMessage/geolive/ // todo/bot-inline edit 的共同形态)。语义与原 enqueueChannelFanout(channelEditMessageUpdates) 一致, // 仅多了把每 viewer 投影一次性算好预热进共享 cache(O(owner))。注意 edit 不做 per-viewer mention // overlay(EditChannelMessageResult 不带 MentionUserIDs,编辑新增 @ 走 durable channel_unread_mentions // 经 getChannelDifference 自愈),故各 viewer 的 mentioned/media_unread 字节恒等,预热不影响等价。 // // nudge pts 取两容器较大值:edit 可产两条带 pts 事件(Event=编辑本身、ServiceEvent=如 todo 完成的 // "X completed Y" 服务消息),ServiceEvent.Pts 在后分配恒更大;某些编辑只产 ServiceEvent(Event.Pts==0)。 // nudge 须带 channel 当前最高 pts 才能让 >cap 在线成员的 getChannelDifference 拉齐到末尾——用 Event.Pts // 会在 Event.Pts==0 时漏发 nudge、或低于真实 pts。max() 在三种形态(仅 Event/仅 ServiceEvent/两者)都正确。 func (r *Router) enqueueChannelEditMessageFanout(ctx context.Context, originUserID int64, res domain.EditChannelMessageResult) { r.enqueueBotAPIChannelEditMessageUpdate(ctx, originUserID, res) fanoutCache := newViewerPeerCache(r) ownerIDs := channelEditMessageFanoutOwnerIDs(res) nudgePts := max(res.Event.Pts, res.ServiceEvent.Pts) r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, res.Channel.ID, nudgePts, res.Recipients, func(bgCtx context.Context, viewers []int64) { r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) }, func(bgCtx context.Context, viewerUserID int64) *tg.Updates { return r.channelEditMessageUpdatesWithPeerCache(bgCtx, viewerUserID, res, fanoutCache) }) } // enqueueChannelMessagesFanout 同 enqueueChannelMessageFanout,但把多条结果汇成一个 job(批量转发: // 一个 Updates 内含多条 UpdateNewChannelMessage),peer refs 取全部结果并集预热。channelID/pts/ // recipients 由调用方按批量语义给定(pts 取最后一条;recipients 受大群截断口径影响)。 func (r *Router) enqueueChannelMessagesFanout(ctx context.Context, originUserID, channelID int64, pts int, recipients []int64, results []domain.SendChannelMessageResult, extraUserIDs []int64) { r.enqueueBotAPIChannelMessagesUpdate(ctx, originUserID, results) fanoutCache := newViewerPeerCache(r) ownerIDs := channelMessagesFanoutOwnerIDs(results, extraUserIDs) r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, channelID, pts, recipients, func(bgCtx context.Context, viewers []int64) { r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) }, func(bgCtx context.Context, viewerUserID int64) *tg.Updates { return r.channelMessagesUpdatesWithPeerCache(bgCtx, viewerUserID, results, nil, false, extraUserIDs, fanoutCache) }) } // defaultChannelNudgeMaxTargets 限一次 fan-out 的 nudge 上限:nudge 是 O(1)/人廉价 push,但 nudge 被 // 消费后客户端会 getChannelDifference(DrKLO 对未加载频道还会先 getPeerDialogs,设计 §10.3), // 大群高频下可能放大。可经 Config.ChannelNudgeMaxTargets 覆盖;客户端侧由 difference/getPeerDialogs // 的 FLOOD_WAIT(Phase 2)兜底(见 checkCatchupRateLimit)。 const defaultChannelNudgeMaxTargets = 50000 // channelNudgeMaxTargets 返回生效的 nudge 上限(Config 覆盖,否则默认)。 func (r *Router) channelNudgeMaxTargets() int { if r.cfg.ChannelNudgeMaxTargets > 0 { return r.cfg.ChannelNudgeMaxTargets } return defaultChannelNudgeMaxTargets } // nudgeBeyondCapChannelMembers 给频道在线成员中未收到完整 payload(不在 delivered 内)的成员发 // UpdateChannelTooLong{pts}。nudge 必须带 pts(flags&1)——DrKLO 对不带 pts 的 tooLong 不触发 // getChannelDifference(设计 §10.3)。走 pushUserUpdates(best-effort、未就绪入 pending、非 // transient),符合设计 §决策4 的 nudge 投递可靠性要求。SessionManager 未实现 ChannelNudgeProvider // 时(测试/未装配)静默跳过,不影响完整 payload 投递。 func (r *Router) nudgeBeyondCapChannelMembers(ctx context.Context, channelID int64, pts int, delivered map[int64]struct{}) { provider, ok := r.deps.Sessions.(ChannelNudgeProvider) if !ok || channelID == 0 || pts <= 0 { return } targets := provider.OnlineChannelMemberUserIDsExcluding(channelID, delivered, r.channelNudgeMaxTargets()) if len(targets) == 0 { return } date := int(r.clock.Now().Unix()) for _, userID := range targets { if userID == 0 { continue } tooLong := &tg.UpdateChannelTooLong{ChannelID: channelID} tooLong.SetPts(pts) r.pushUserUpdates(ctx, userID, &tg.Updates{ Updates: []tg.UpdateClass{tooLong}, Users: []tg.UserClass{}, Chats: []tg.ChatClass{}, Date: date, Seq: 0, }) } }