1341 lines
52 KiB
Go
1341 lines
52 KiB
Go
package rpc
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"github.com/iamxvbaba/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)。
|
||
// - 有界队列满时不静默丢弃恢复触发:真实 payload job 降级为按 channel 合并、只保留
|
||
// 最高 pts 的 UpdateChannelTooLong nudge。每个 shard 独立公平 drain;即使该频道随后
|
||
// 静默,也不依赖“下一条消息”才能触发 getChannelDifference(设计约束 B)。
|
||
|
||
const (
|
||
defaultChannelFanoutShards = 64
|
||
// The old 64x2048 channel buffers eagerly retained up to 131k closures (each may capture a
|
||
// message batch and ~2k recipients). Keep one small FIFO per ordering shard and enforce a
|
||
// process-wide retained-byte budget below.
|
||
defaultChannelFanoutBuffer = 64
|
||
defaultChannelFanoutMaxQueuedJobs = 4096
|
||
defaultChannelFanoutMaxQueuedBytes = 256 << 20
|
||
defaultChannelFanoutOverflowPerShard = 256
|
||
defaultChannelNudgeWorkers = 8
|
||
defaultChannelNudgeQueue = 4096
|
||
defaultChannelFanoutRecoverySweepPage = 256
|
||
channelFanoutMinRetainedBytes int64 = 64 << 10
|
||
channelFanoutNudgeRetryMin = time.Millisecond
|
||
channelFanoutNudgeRetryMax = 50 * time.Millisecond
|
||
channelFanoutRecoveryRetryMin = 10 * time.Millisecond
|
||
channelFanoutRecoveryRetryMax = time.Second
|
||
defaultChannelFanoutNudgeDeadline = 5 * time.Second
|
||
)
|
||
|
||
// 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 是物理 raw 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
|
||
// retainedBytes is a conservative reservation for the request-derived closure, result
|
||
// snapshots and explicit recipient slice. It is charged before the job enters any queue.
|
||
retainedBytes int64
|
||
// queueSeq 是 dispatcher shard 内部的 FIFO 序号。仅成功进入正常 payload queue 的
|
||
// job 占用序号;overflow watermark 记录入队失败时已经接受的最大序号,等这些更早
|
||
// payload 处理完后才发 nudge,避免 nudge 越过其之前的正常 FIFO payload。
|
||
queueSeq uint64
|
||
}
|
||
|
||
// channelFanoutOverflow 是 queue full 时的 nudge-only 恢复水位。同一 channel 只保留
|
||
// 最大 pts;barrier 是该次 overflow 之前已经进入正常 FIFO 的最后一个 shard 序号。
|
||
type channelFanoutOverflow struct {
|
||
pts int
|
||
barrier uint64
|
||
}
|
||
|
||
// channelFanoutShard 把正常 payload FIFO 与 overflow nudge mailbox 放在同一个 worker
|
||
// 下。overflowOrder 每个 channel 最多出现一次;热点 channel 只更新 map 水位,不会占满
|
||
// order,从而不能把其它 channel 的唯一恢复 nudge 永久饿死。
|
||
type channelFanoutShard struct {
|
||
jobs chan channelFanoutJob
|
||
overflowWake chan struct{}
|
||
// overflowSpace is a generation channel, not a one-token notification. A slot
|
||
// release closes the current generation and installs a fresh channel while mu is
|
||
// held, waking every waiter that observed the old full mailbox. Each waiter then
|
||
// competes under mu for the actually available slots; losers observe the new
|
||
// generation and sleep again. This avoids losing N-1 wakeups when several slots
|
||
// are released before any of N waiters gets scheduled.
|
||
overflowSpace chan struct{}
|
||
|
||
mu sync.Mutex
|
||
nextSeq uint64
|
||
processedSeq uint64
|
||
overflow map[int64]channelFanoutOverflow
|
||
overflowOrder []int64
|
||
overflowLimit int
|
||
// overflowWaiters is guarded by mu and only covers the distinct-channel
|
||
// saturation slow path. Besides making the wait lifecycle explicit, it avoids
|
||
// allocating a fresh generation channel when no goroutine is subscribed.
|
||
overflowWaiters int
|
||
}
|
||
|
||
func newChannelFanoutShard(buffer int) *channelFanoutShard {
|
||
return &channelFanoutShard{
|
||
jobs: make(chan channelFanoutJob, buffer),
|
||
overflowWake: make(chan struct{}, 1),
|
||
overflowSpace: make(chan struct{}),
|
||
overflow: make(map[int64]channelFanoutOverflow),
|
||
overflowLimit: defaultChannelFanoutOverflowPerShard,
|
||
}
|
||
}
|
||
|
||
// enqueue 尝试把真实 payload 放入正常 FIFO;满时按 channel 合并最高 pts 的 nudge-only
|
||
// watermark。返回 true 表示正常入队,false 表示已经安全降级为 overflow watermark。
|
||
func (s *channelFanoutShard) enqueue(job channelFanoutJob) bool {
|
||
s.mu.Lock()
|
||
job.queueSeq = s.nextSeq + 1
|
||
select {
|
||
case s.jobs <- job:
|
||
s.nextSeq = job.queueSeq
|
||
s.mu.Unlock()
|
||
return true
|
||
default:
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (s *channelFanoutShard) enqueueOverflow(channelID int64, pts int) bool {
|
||
s.mu.Lock()
|
||
accepted := s.addOverflowLocked(channelID, pts)
|
||
s.mu.Unlock()
|
||
if accepted {
|
||
s.signalOverflow()
|
||
}
|
||
return accepted
|
||
}
|
||
|
||
func (s *channelFanoutShard) enqueueOverflowWait(ctx context.Context, channelID int64, pts int, stop <-chan struct{}) bool {
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return false
|
||
case <-stop:
|
||
return false
|
||
default:
|
||
}
|
||
// Try and capture the current space generation under the same lock. Separating
|
||
// these operations creates a classic missed-wakeup window: a drain may free the
|
||
// mailbox after the failed try but before the waiter starts observing the signal.
|
||
s.mu.Lock()
|
||
if s.addOverflowLocked(channelID, pts) {
|
||
s.mu.Unlock()
|
||
s.signalOverflow()
|
||
return true
|
||
}
|
||
space := s.overflowSpace
|
||
s.overflowWaiters++
|
||
s.mu.Unlock()
|
||
defer func() {
|
||
s.mu.Lock()
|
||
s.overflowWaiters--
|
||
s.mu.Unlock()
|
||
}()
|
||
|
||
// Same-channel overflow is the hot saturation path and only updates an existing map item.
|
||
// Distinct-channel saturation may wait here only from the dispatcher's fixed recovery sweep
|
||
// actor. RPC producers never call this method: they publish an O(1) global recovery generation
|
||
// when the bounded mailbox is full, so request goroutines cannot be exhausted by fan-out
|
||
// admission pressure.
|
||
for {
|
||
// Cardinality is full with distinct channels. Apply bounded-memory backpressure instead of
|
||
// allocating an unbounded recovery map or dropping the recovery watermark.
|
||
select {
|
||
case <-space:
|
||
case <-ctx.Done():
|
||
return false
|
||
case <-stop:
|
||
return false
|
||
}
|
||
// Do not turn a release racing with cancellation into admission after the caller ended.
|
||
select {
|
||
case <-ctx.Done():
|
||
return false
|
||
case <-stop:
|
||
return false
|
||
default:
|
||
}
|
||
// Retrying and subscribing to the next generation must also be atomic with
|
||
// respect to a release. Broadcast wakeups can be spurious for a particular
|
||
// waiter (another waiter may win the sole slot), so loop until accepted or stopped.
|
||
s.mu.Lock()
|
||
if s.addOverflowLocked(channelID, pts) {
|
||
s.mu.Unlock()
|
||
s.signalOverflow()
|
||
return true
|
||
}
|
||
space = s.overflowSpace
|
||
s.mu.Unlock()
|
||
}
|
||
}
|
||
|
||
func (s *channelFanoutShard) addOverflowLocked(channelID int64, pts int) bool {
|
||
item, exists := s.overflow[channelID]
|
||
if !exists {
|
||
if len(s.overflow) >= s.overflowLimit {
|
||
return false
|
||
}
|
||
s.overflowOrder = append(s.overflowOrder, channelID)
|
||
// The first overflow fixes the FIFO barrier. Later same-channel losses only raise the
|
||
// durable pts watermark: moving the barrier on every merge lets a continuously full
|
||
// payload queue keep the recovery nudge one slot behind forever. UpdateChannelTooLong is
|
||
// an idempotent catch-up trigger, so it is safe for its newest pts to overtake payloads
|
||
// accepted after the first loss; those payloads become harmless duplicates after
|
||
// getChannelDifference converges the client.
|
||
item.barrier = s.nextSeq
|
||
}
|
||
if pts > item.pts {
|
||
item.pts = pts
|
||
}
|
||
s.overflow[channelID] = item
|
||
return true
|
||
}
|
||
|
||
func (s *channelFanoutShard) markProcessed(seq uint64) {
|
||
s.mu.Lock()
|
||
if seq > s.processedSeq {
|
||
s.processedSeq = seq
|
||
}
|
||
s.mu.Unlock()
|
||
}
|
||
|
||
// signalOverflowSpaceLocked announces a mailbox-cardinality decrease. Callers
|
||
// must hold s.mu. Close-and-replace provides broadcast generations without an
|
||
// unbounded waiter list, goroutine-per-waiter, or lossy fixed-capacity token queue.
|
||
func (s *channelFanoutShard) signalOverflowSpaceLocked() {
|
||
if s.overflowWaiters == 0 {
|
||
return
|
||
}
|
||
close(s.overflowSpace)
|
||
s.overflowSpace = make(chan struct{})
|
||
}
|
||
|
||
// popOverflow 仅供 mailbox/cardinality 单元测试直接释放一个 overflow;生产 drain 必须走
|
||
// tryQueueOverflow,确保 nudgeJobs 真正接收成功前不删除水位。
|
||
func (s *channelFanoutShard) popOverflow() (channelID int64, pts int, ok bool) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
for remaining := len(s.overflowOrder); remaining > 0; remaining-- {
|
||
channelID = s.overflowOrder[0]
|
||
s.overflowOrder = s.overflowOrder[1:]
|
||
item, exists := s.overflow[channelID]
|
||
if !exists {
|
||
continue
|
||
}
|
||
if item.barrier > s.processedSeq {
|
||
s.overflowOrder = append(s.overflowOrder, channelID)
|
||
continue
|
||
}
|
||
delete(s.overflow, channelID)
|
||
s.signalOverflowSpaceLocked()
|
||
return channelID, item.pts, true
|
||
}
|
||
return 0, 0, false
|
||
}
|
||
|
||
// tryQueueOverflow 尝试把一个 barrier 已满足的 overflow 水位非阻塞提交给共享 nudge queue。
|
||
// 只有 channel send 成功才从 mailbox 删除;queue 满时保留原 item(包含并发合并后的最高 pts)
|
||
// 和原 order 位置。整个操作在 shard.mu 下完成,因此不会出现“读到旧 pts 后删除新 pts”的竞态。
|
||
func (s *channelFanoutShard) tryQueueOverflow(offer func(channelFanoutNudge) bool) (queued, blocked bool) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
for i := 0; i < len(s.overflowOrder); {
|
||
channelID := s.overflowOrder[i]
|
||
item, exists := s.overflow[channelID]
|
||
if !exists {
|
||
copy(s.overflowOrder[i:], s.overflowOrder[i+1:])
|
||
s.overflowOrder = s.overflowOrder[:len(s.overflowOrder)-1]
|
||
continue
|
||
}
|
||
if item.barrier > s.processedSeq {
|
||
i++
|
||
continue
|
||
}
|
||
if offer(channelFanoutNudge{channelID: channelID, pts: item.pts}) {
|
||
delete(s.overflow, channelID)
|
||
copy(s.overflowOrder[i:], s.overflowOrder[i+1:])
|
||
s.overflowOrder = s.overflowOrder[:len(s.overflowOrder)-1]
|
||
s.signalOverflowSpaceLocked()
|
||
return true, false
|
||
} else {
|
||
// Shared nudge workers are saturated. Keep the exact watermark and retry from
|
||
// the shard's bounded timer; do not remove or advance the mailbox entry, and
|
||
// never park this payload worker on the nudge queue.
|
||
return false, true
|
||
}
|
||
}
|
||
return false, false
|
||
}
|
||
|
||
func (s *channelFanoutShard) signalOverflow() {
|
||
select {
|
||
case s.overflowWake <- struct{}{}:
|
||
default:
|
||
}
|
||
}
|
||
|
||
func (s *channelFanoutShard) signalEligibleOverflow() {
|
||
s.mu.Lock()
|
||
eligible := false
|
||
for _, channelID := range s.overflowOrder {
|
||
if item, ok := s.overflow[channelID]; ok && item.barrier <= s.processedSeq {
|
||
eligible = true
|
||
break
|
||
}
|
||
}
|
||
s.mu.Unlock()
|
||
if eligible {
|
||
s.signalOverflow()
|
||
}
|
||
}
|
||
|
||
// channelFanoutPrefetch 在 worker 解析出最终 recipient 集合后、逐 viewer build 之前调用一次,
|
||
// 用于跨全部 recipient 一次性预热每 viewer 的用户投影(fan-out 模板化,O(owner))。为 nil
|
||
// 表示该 payload 不含需要预热的 user envelope。在 worker goroutine 内串行执行,与 build 共享同一
|
||
// viewerPeerCache,无跨 goroutine 竞态。返回 false 表示批量预热失败;worker 必须 fail-closed,
|
||
// 不能静默退回逐 viewer 投影。
|
||
type channelFanoutPrefetch func(ctx context.Context, viewers []int64) bool
|
||
|
||
// channelFanoutDispatcher 把频道 payload fan-out 移出发送者 RPC,按 channelID 分片串行处理。
|
||
type channelFanoutDispatcher struct {
|
||
r *Router
|
||
log *zap.Logger
|
||
shards []*channelFanoutShard
|
||
started atomic.Bool
|
||
stopped atomic.Bool
|
||
stopCh chan struct{}
|
||
stopOnce sync.Once
|
||
enqueueMu sync.RWMutex
|
||
|
||
budgetMu sync.Mutex
|
||
queuedJobs int
|
||
queuedBytes int64
|
||
maxQueuedJobs int
|
||
maxQueuedBytes int64
|
||
|
||
nudgeJobs chan int64
|
||
nudgeWorkers int
|
||
nudgeTimeout time.Duration
|
||
nudgeMu sync.Mutex
|
||
// nudgePending and nudgeJobs form one bounded coalescing mailbox. nudgeJobs contains only
|
||
// channel ids; the mutable map value always holds the highest pts observed before a worker
|
||
// takes that id. A hot channel therefore occupies one slot rather than filling the queue.
|
||
nudgePending map[int64]int
|
||
nudgeLimit int
|
||
|
||
// recoveryGeneration is the terminal in-memory saturation fallback. It deliberately carries
|
||
// no channel id: the fixed recovery actor enumerates the online membership index and reloads
|
||
// each channel's durable max pts. Thus even when every key-bearing mailbox is full, publishing
|
||
// one constant-size generation cannot fail or block an RPC producer.
|
||
recoveryGeneration atomic.Uint64
|
||
recoveryCompleted atomic.Uint64
|
||
recoveryWake chan struct{}
|
||
// dropped 保留旧字段名供既有统计兼容;现在表示“真实 payload 因 queue full 被折叠为
|
||
// nudge-only overflow watermark”的次数,不再表示恢复触发也被静默丢弃。
|
||
dropped atomic.Int64
|
||
}
|
||
|
||
type channelFanoutNudge struct {
|
||
channelID int64
|
||
pts int
|
||
}
|
||
|
||
// enqueueChannelFanout 把一条 channel-payload-pts 的 fan-out 投入异步 dispatcher。
|
||
// 从请求 ctx 抓取发起设备的 raw 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, 0, 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, retainedFloor int64, prefetch channelFanoutPrefetch, build channelFanoutBuilder) {
|
||
if r.channelFanout == nil || build == nil {
|
||
return
|
||
}
|
||
originAuthKeyID := rawAuthKeyIDForOrigin(ctx)
|
||
originSessionID, _ := SessionIDFrom(ctx)
|
||
retainedBytes := int64(inboundRPCBytesFrom(ctx)) + int64(len(recipients))*8 + 4096
|
||
if retainedFloor < channelFanoutMinRetainedBytes {
|
||
retainedFloor = channelFanoutMinRetainedBytes
|
||
}
|
||
if retainedBytes < retainedFloor {
|
||
retainedBytes = retainedFloor
|
||
}
|
||
r.channelFanout.Enqueue(ctx, channelFanoutJob{
|
||
scope: scope,
|
||
originUserID: originUserID,
|
||
channelID: channelID,
|
||
pts: pts,
|
||
recipients: recipients,
|
||
originAuthKeyID: originAuthKeyID,
|
||
originSessionID: originSessionID,
|
||
prefetch: prefetch,
|
||
build: build,
|
||
retainedBytes: retainedBytes,
|
||
})
|
||
}
|
||
|
||
// 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([]*channelFanoutShard, shards),
|
||
stopCh: make(chan struct{}),
|
||
maxQueuedJobs: defaultChannelFanoutMaxQueuedJobs,
|
||
maxQueuedBytes: defaultChannelFanoutMaxQueuedBytes,
|
||
nudgeJobs: make(chan int64, defaultChannelNudgeQueue),
|
||
nudgeWorkers: defaultChannelNudgeWorkers,
|
||
nudgeTimeout: defaultChannelFanoutNudgeDeadline,
|
||
nudgePending: make(map[int64]int),
|
||
nudgeLimit: defaultChannelNudgeQueue,
|
||
recoveryWake: make(chan struct{}, 1),
|
||
}
|
||
for i := range d.shards {
|
||
d.shards[i] = newChannelFanoutShard(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
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
<-ctx.Done()
|
||
d.enqueueMu.Lock()
|
||
d.stopped.Store(true)
|
||
d.stopOnce.Do(func() { close(d.stopCh) })
|
||
d.enqueueMu.Unlock()
|
||
}()
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
d.runRecoverySweeps(ctx)
|
||
}()
|
||
for range d.nudgeWorkers {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case channelID := <-d.nudgeJobs:
|
||
nudge, ok := d.takeNudge(channelID)
|
||
if !ok {
|
||
continue
|
||
}
|
||
timeout := d.nudgeTimeout
|
||
if timeout <= 0 {
|
||
timeout = defaultChannelFanoutNudgeDeadline
|
||
}
|
||
nudgeCtx, cancel := context.WithTimeout(ctx, timeout)
|
||
complete := d.r.runChannelFanoutOverflowNudge(nudgeCtx, nudge.channelID, nudge.pts)
|
||
cancel()
|
||
if !complete && ctx.Err() == nil {
|
||
// A deadline may leave only a prefix of online members nudged. Do not try to
|
||
// remember that recipient subset: request a durable max-pts sweep instead.
|
||
d.requestRecoverySweep("nudge deadline")
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
for i := range d.shards {
|
||
wg.Add(1)
|
||
shard := d.shards[i]
|
||
go func() {
|
||
defer wg.Done()
|
||
var retryTimer *time.Timer
|
||
var retryC <-chan time.Time
|
||
retryDelay := channelFanoutNudgeRetryMin
|
||
stopRetryTimer := func() {
|
||
if retryTimer != nil {
|
||
retryTimer.Stop()
|
||
}
|
||
}
|
||
defer stopRetryTimer()
|
||
scheduleRetry := func() {
|
||
if retryC != nil {
|
||
return
|
||
}
|
||
if retryTimer == nil {
|
||
retryTimer = time.NewTimer(retryDelay)
|
||
} else {
|
||
retryTimer.Reset(retryDelay)
|
||
}
|
||
retryC = retryTimer.C
|
||
if retryDelay < channelFanoutNudgeRetryMax {
|
||
retryDelay *= 2
|
||
if retryDelay > channelFanoutNudgeRetryMax {
|
||
retryDelay = channelFanoutNudgeRetryMax
|
||
}
|
||
}
|
||
}
|
||
drain := func() {
|
||
// While a retry is armed, payload completions and coalescing wakeups must not
|
||
// defeat backoff and spin on a full shared queue.
|
||
if retryC != nil {
|
||
return
|
||
}
|
||
queued, blocked := d.drainOneOverflow(shard)
|
||
if queued {
|
||
retryDelay = channelFanoutNudgeRetryMin
|
||
shard.signalEligibleOverflow()
|
||
return
|
||
}
|
||
if blocked {
|
||
scheduleRetry()
|
||
}
|
||
}
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case job := <-shard.jobs:
|
||
d.r.runChannelFanoutJob(ctx, job)
|
||
d.releaseQueuedJob(job)
|
||
shard.markProcessed(job.queueSeq)
|
||
// 每处理一条正常 FIFO payload,主动尝试 drain 一条已经越过
|
||
// barrier 的 overflow。这样持续灌满正常队列的热点频道也不能
|
||
// 永久饿死其它频道的恢复 nudge。
|
||
drain()
|
||
case <-shard.overflowWake:
|
||
drain()
|
||
case <-retryC:
|
||
retryC = nil
|
||
drain()
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
// Workers may choose ctx.Done while jobs remain buffered. Release every reservation and
|
||
// drop closure references so tests/restarts do not retain the global budget after shutdown.
|
||
for _, shard := range d.shards {
|
||
for {
|
||
select {
|
||
case job := <-shard.jobs:
|
||
d.releaseQueuedJob(job)
|
||
default:
|
||
goto drained
|
||
}
|
||
}
|
||
drained:
|
||
shard.mu.Lock()
|
||
clear(shard.overflow)
|
||
shard.overflowOrder = nil
|
||
shard.mu.Unlock()
|
||
}
|
||
d.nudgeMu.Lock()
|
||
clear(d.nudgePending)
|
||
d.nudgeMu.Unlock()
|
||
}
|
||
|
||
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,保持旧行为);
|
||
// 已启动时投入对应分片。满时正常 payload 不阻塞请求路径,而是按 channel 合并为最高 pts
|
||
// 的 nudge-only overflow watermark,由同 shard worker 在更早的 FIFO payload 后公平 drain。
|
||
// 若 overflow cardinality 也已满,只发布一个常量大小的全局 recovery generation;固定后台
|
||
// actor 随后从 durable channel pts 重建全部在线 channel 的 nudge。RPC goroutine 永不等待 slot。
|
||
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
|
||
}
|
||
d.enqueueMu.RLock()
|
||
if d.stopped.Load() {
|
||
d.enqueueMu.RUnlock()
|
||
return
|
||
}
|
||
shard := d.shards[d.shardIndex(job.channelID)]
|
||
queued := false
|
||
if d.reserveQueuedJob(job) {
|
||
queued = shard.enqueue(job)
|
||
if queued {
|
||
d.enqueueMu.RUnlock()
|
||
return
|
||
}
|
||
d.releaseQueuedJob(job)
|
||
}
|
||
d.dropped.Add(1)
|
||
if job.scope != channelFanoutMembers && job.scope != channelFanoutMessageBox || job.pts <= 0 {
|
||
// 只有 durable member/message-box payload 可折叠为 channel nudge;viewer-only/no-pts
|
||
// 更新没有 difference 恢复契约,不能伪装成 channel PTS 水位。
|
||
d.log.Error("channel fanout queue full for non-coalescible job; overflow contract violated",
|
||
zap.Int64("channel_id", job.channelID), zap.Int("pts", job.pts), zap.Int("scope", int(job.scope)))
|
||
d.enqueueMu.RUnlock()
|
||
return
|
||
}
|
||
channelID, pts := job.channelID, job.pts
|
||
// The payload closure and recipient snapshot are no longer needed after normal queue
|
||
// admission failed. Make them unreachable before applying overflow-cardinality backpressure;
|
||
// otherwise blocked producers would retain unbudgeted request bodies while waiting for one of
|
||
// the fixed mailbox slots. Inbound RPC concurrency remains the producer-count bound.
|
||
job.recipients = nil
|
||
job.prefetch = nil
|
||
job.build = nil
|
||
if !shard.enqueueOverflow(channelID, pts) {
|
||
// Every key-bearing in-memory structure is bounded. Once the shard mailbox has no distinct
|
||
// channel slot, do not add another queue and do not park this RPC worker. A generation bit is
|
||
// enough because channels.pts/channel_update_events are already the durable truth: the fixed
|
||
// recovery actor can enumerate all online channel ids and reconstruct the highest watermark.
|
||
d.requestRecoverySweep("overflow cardinality full")
|
||
d.log.Warn("channel fanout overflow cardinality exhausted; scheduled durable max-pts recovery sweep",
|
||
zap.Int64("channel_id", channelID), zap.Int("pts", pts))
|
||
d.enqueueMu.RUnlock()
|
||
return
|
||
}
|
||
d.log.Warn("channel fanout capacity exhausted, coalesced realtime payload into highest-pts overflow nudge",
|
||
zap.Int64("channel_id", channelID), zap.Int("pts", pts))
|
||
d.enqueueMu.RUnlock()
|
||
}
|
||
|
||
func (d *channelFanoutDispatcher) reserveQueuedJob(job channelFanoutJob) bool {
|
||
size := job.retainedBytes
|
||
if size < channelFanoutMinRetainedBytes {
|
||
size = channelFanoutMinRetainedBytes
|
||
}
|
||
d.budgetMu.Lock()
|
||
defer d.budgetMu.Unlock()
|
||
if d.queuedJobs >= d.maxQueuedJobs || size > d.maxQueuedBytes-d.queuedBytes {
|
||
return false
|
||
}
|
||
d.queuedJobs++
|
||
d.queuedBytes += size
|
||
return true
|
||
}
|
||
|
||
func (d *channelFanoutDispatcher) releaseQueuedJob(job channelFanoutJob) {
|
||
size := job.retainedBytes
|
||
if size < channelFanoutMinRetainedBytes {
|
||
size = channelFanoutMinRetainedBytes
|
||
}
|
||
d.budgetMu.Lock()
|
||
d.queuedJobs--
|
||
d.queuedBytes -= size
|
||
if d.queuedJobs < 0 || d.queuedBytes < 0 {
|
||
panic("channel fanout queue budget underflow")
|
||
}
|
||
d.budgetMu.Unlock()
|
||
}
|
||
|
||
func (d *channelFanoutDispatcher) queuedBudgetSnapshot() (jobs int, bytes int64) {
|
||
d.budgetMu.Lock()
|
||
defer d.budgetMu.Unlock()
|
||
return d.queuedJobs, d.queuedBytes
|
||
}
|
||
|
||
func (d *channelFanoutDispatcher) drainOneOverflow(shard *channelFanoutShard) (queued, blocked bool) {
|
||
return shard.tryQueueOverflow(d.offerNudge)
|
||
}
|
||
|
||
// offerNudge inserts one channel id into the bounded shared queue and stores its mutable highest
|
||
// pts in nudgePending. It never blocks. A same-channel update succeeds even when cardinality is
|
||
// full because it consumes no additional queue slot.
|
||
func (d *channelFanoutDispatcher) offerNudge(nudge channelFanoutNudge) bool {
|
||
if nudge.channelID == 0 || nudge.pts <= 0 {
|
||
return true
|
||
}
|
||
d.nudgeMu.Lock()
|
||
if current, exists := d.nudgePending[nudge.channelID]; exists {
|
||
if nudge.pts > current {
|
||
d.nudgePending[nudge.channelID] = nudge.pts
|
||
}
|
||
d.nudgeMu.Unlock()
|
||
return true
|
||
}
|
||
if len(d.nudgePending) >= d.nudgeLimit {
|
||
d.nudgeMu.Unlock()
|
||
return false
|
||
}
|
||
d.nudgePending[nudge.channelID] = nudge.pts
|
||
select {
|
||
case d.nudgeJobs <- nudge.channelID:
|
||
d.nudgeMu.Unlock()
|
||
return true
|
||
default:
|
||
// nudgeJobs has the same cardinality bound as nudgePending. This branch is reachable only
|
||
// while a test overrides one without the other or an invariant regresses; roll back rather
|
||
// than retain an unreachable map entry.
|
||
delete(d.nudgePending, nudge.channelID)
|
||
d.nudgeMu.Unlock()
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (d *channelFanoutDispatcher) takeNudge(channelID int64) (channelFanoutNudge, bool) {
|
||
d.nudgeMu.Lock()
|
||
pts, ok := d.nudgePending[channelID]
|
||
if ok {
|
||
delete(d.nudgePending, channelID)
|
||
}
|
||
d.nudgeMu.Unlock()
|
||
return channelFanoutNudge{channelID: channelID, pts: pts}, ok
|
||
}
|
||
|
||
func (d *channelFanoutDispatcher) requestRecoverySweep(reason string) {
|
||
generation := d.recoveryGeneration.Add(1)
|
||
select {
|
||
case d.recoveryWake <- struct{}{}:
|
||
default:
|
||
}
|
||
d.log.Debug("channel fanout durable recovery sweep requested",
|
||
zap.Uint64("generation", generation), zap.String("reason", reason))
|
||
}
|
||
|
||
// runRecoverySweeps owns the only potentially waiting overflow admission path. Producers publish
|
||
// generations and return; this fixed actor reconstructs channel ids from the live membership index
|
||
// and watermarks from durable channels.pts. A generation is marked complete only after every page
|
||
// and every channel in that page has successfully entered its shard's barrier-preserving overflow
|
||
// mailbox. Errors retain the generation and retry with bounded backoff.
|
||
func (d *channelFanoutDispatcher) runRecoverySweeps(ctx context.Context) {
|
||
completed := d.recoveryCompleted.Load()
|
||
retryDelay := channelFanoutRecoveryRetryMin
|
||
var retryTimer *time.Timer
|
||
defer func() {
|
||
if retryTimer != nil {
|
||
retryTimer.Stop()
|
||
}
|
||
}()
|
||
for {
|
||
target := d.recoveryGeneration.Load()
|
||
if target <= completed {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-d.recoveryWake:
|
||
continue
|
||
}
|
||
}
|
||
if err := d.sweepOnlineChannelRecovery(ctx); err != nil {
|
||
if ctx.Err() != nil {
|
||
return
|
||
}
|
||
d.log.Warn("channel fanout durable recovery sweep failed; retaining generation",
|
||
zap.Uint64("generation", target), zap.Duration("retry_in", retryDelay), zap.Error(err))
|
||
if retryTimer == nil {
|
||
retryTimer = time.NewTimer(retryDelay)
|
||
} else {
|
||
retryTimer.Reset(retryDelay)
|
||
}
|
||
// recoveryGeneration already records every concurrent request. A wake may shorten the
|
||
// idle wait before a healthy sweep, but it must never bypass failure backoff: otherwise
|
||
// sustained saturation plus a persistent DB error retries at producer rate.
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-retryTimer.C:
|
||
}
|
||
if retryDelay < channelFanoutRecoveryRetryMax {
|
||
retryDelay *= 2
|
||
if retryDelay > channelFanoutRecoveryRetryMax {
|
||
retryDelay = channelFanoutRecoveryRetryMax
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
completed = target
|
||
d.recoveryCompleted.Store(completed)
|
||
retryDelay = channelFanoutRecoveryRetryMin
|
||
d.log.Info("channel fanout durable recovery sweep completed", zap.Uint64("generation", completed))
|
||
// If a producer saturated after its channel had already been visited, generation is now
|
||
// greater than completed and the next loop immediately performs a fresh full pass.
|
||
}
|
||
}
|
||
|
||
func (d *channelFanoutDispatcher) sweepOnlineChannelRecovery(ctx context.Context) error {
|
||
sessions, ok := d.r.deps.Sessions.(ChannelFanoutRecoverySessionProvider)
|
||
if !ok {
|
||
return fmt.Errorf("sessions dependency lacks online channel recovery enumeration")
|
||
}
|
||
channels, ok := d.r.deps.Channels.(ChannelFanoutRecoveryPtsProvider)
|
||
if !ok {
|
||
return fmt.Errorf("channels dependency lacks durable max pts lookup")
|
||
}
|
||
channelIDs := sessions.OnlineChannelIDsSnapshot()
|
||
for i, channelID := range channelIDs {
|
||
if channelID <= 0 || (i > 0 && channelID <= channelIDs[i-1]) {
|
||
return fmt.Errorf("online channel recovery snapshot is not strictly ascending: index=%d got=%d", i, channelID)
|
||
}
|
||
}
|
||
for start := 0; start < len(channelIDs); start += defaultChannelFanoutRecoverySweepPage {
|
||
end := start + defaultChannelFanoutRecoverySweepPage
|
||
if end > len(channelIDs) {
|
||
end = len(channelIDs)
|
||
}
|
||
page := channelIDs[start:end]
|
||
ptsByChannel, err := channels.MaxChannelPtsBatch(ctx, page)
|
||
if err != nil {
|
||
return fmt.Errorf("load durable max pts for online channel page [%d:%d]: %w", start, end, err)
|
||
}
|
||
for _, channelID := range page {
|
||
pts := ptsByChannel[channelID]
|
||
if pts > 0 {
|
||
shard := d.shards[d.shardIndex(channelID)]
|
||
if !shard.enqueueOverflowWait(ctx, channelID, pts, d.stopCh) {
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
return fmt.Errorf("dispatcher stopped while admitting recovery for channel %d", channelID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// runChannelFanoutOverflowNudge 是 queue-full 的 nudge-only 降级路径。不能复用原 job 的
|
||
// origin exclude:同一 channel 水位可能合并多个不同发起 session;向全部在线成员发最高 pts
|
||
// nudge 是保守且幂等的,已追上 pts 的 TDesktop 会直接忽略。
|
||
func (r *Router) runChannelFanoutOverflowNudge(ctx context.Context, channelID int64, pts int) bool {
|
||
if r.deps.Sessions == nil || channelID == 0 || pts <= 0 {
|
||
return true
|
||
}
|
||
return r.nudgeBeyondCapChannelMessageAudience(ctx, channelID, pts, nil)
|
||
}
|
||
|
||
// 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(WithRawAuthKeyID(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)。失败时禁止构造真实 payload,
|
||
// 改发 viewer-independent too-long nudge,让客户端从 durable difference 恢复。
|
||
if job.prefetch != nil {
|
||
viewers := recipients
|
||
if job.originUserID != 0 {
|
||
viewers = append(append(make([]int64, 0, len(recipients)+1), recipients...), job.originUserID)
|
||
}
|
||
if !job.prefetch(ctx, viewers) {
|
||
r.log.Warn("channel fanout prefetch failed; replacing online payload with recovery nudge",
|
||
zap.Int64("channel_id", job.channelID),
|
||
zap.Int("pts", job.pts),
|
||
zap.Int("viewers", len(viewers)),
|
||
)
|
||
r.recoverFailedChannelFanoutPrefetch(pushCtx, job, recipients)
|
||
return
|
||
}
|
||
}
|
||
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)
|
||
} else if job.scope == channelFanoutMessageBox && job.pts > 0 {
|
||
r.nudgeBeyondCapChannelMessageAudience(pushCtx, job.channelID, job.pts, seen)
|
||
}
|
||
}
|
||
|
||
func (r *Router) recoverFailedChannelFanoutPrefetch(ctx context.Context, job channelFanoutJob, recipients []int64) {
|
||
if job.channelID == 0 || job.pts <= 0 {
|
||
return
|
||
}
|
||
targets := append([]int64(nil), recipients...)
|
||
if job.originUserID != 0 {
|
||
targets = append(targets, job.originUserID)
|
||
}
|
||
targets = uniquePeerIDs(targets)
|
||
delivered := make(map[int64]struct{}, len(targets))
|
||
date := int(r.clock.Now().Unix())
|
||
tooLong := &tg.UpdateChannelTooLong{ChannelID: job.channelID}
|
||
tooLong.SetPts(job.pts)
|
||
updates := &tg.Updates{
|
||
Updates: []tg.UpdateClass{tooLong},
|
||
Users: []tg.UserClass{},
|
||
Chats: []tg.ChatClass{},
|
||
Date: date,
|
||
}
|
||
for _, userID := range targets {
|
||
if userID == 0 {
|
||
continue
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
default:
|
||
}
|
||
r.pushUserUpdates(ctx, userID, updates)
|
||
delivered[userID] = struct{}{}
|
||
}
|
||
// Explicit monoforum/suggested-post recipients are the full authorized
|
||
// audience. Member/message-box scopes may have additional online viewers
|
||
// beyond the full-payload cap, so nudge that recovery audience too.
|
||
switch job.scope {
|
||
case channelFanoutMembers:
|
||
r.nudgeBeyondCapChannelMembers(ctx, job.channelID, job.pts, delivered)
|
||
case channelFanoutMessageBox:
|
||
r.nudgeBeyondCapChannelMessageAudience(ctx, job.channelID, job.pts, delivered)
|
||
}
|
||
}
|
||
|
||
// prefetchChannelFanoutUsers 跨全部 recipient 一次性投影 owner 用户(fan-out 模板化,O(owner)),
|
||
// 把结果按 viewer 预热进共享 cache;之后每 viewer 的 build 只命中缓存,不再逐 viewer ForViewer。
|
||
// ownerIDs 由调用方从消息/事件 peer refs 收集。deps.Users 必须实现 BatchViewerUsersResolver;
|
||
// 缺能力、解析失败或 envelope 不完整时返回 false,由 worker fail-closed,禁止逐 viewer 回退。
|
||
func (r *Router) prefetchChannelFanoutUsers(ctx context.Context, cache *viewerPeerCache, viewers, ownerIDs []int64) bool {
|
||
viewers = uniquePeerIDs(viewers)
|
||
ownerIDs = uniquePeerIDs(ownerIDs)
|
||
if len(viewers) == 0 || len(ownerIDs) == 0 {
|
||
return true
|
||
}
|
||
if cache == nil || r.deps.Users == nil {
|
||
return false
|
||
}
|
||
resolver, ok := r.deps.Users.(BatchViewerUsersResolver)
|
||
if !ok {
|
||
return false
|
||
}
|
||
byViewer, err := resolver.ByIDsForViewers(ctx, viewers, ownerIDs)
|
||
if err != nil {
|
||
r.log.Warn("channel fanout user prefetch failed",
|
||
zap.Int("viewers", len(viewers)), zap.Int("owners", len(ownerIDs)), zap.Error(err))
|
||
return false
|
||
}
|
||
for _, viewer := range viewers {
|
||
if missingID, missing := missingProjectedUserID(ownerIDs, byViewer[viewer]); missing {
|
||
r.log.Warn("channel fanout user prefetch returned an incomplete envelope",
|
||
zap.Int64("viewer_user_id", viewer),
|
||
zap.Int64("missing_user_id", missingID),
|
||
zap.Int("owners", len(ownerIDs)))
|
||
return false
|
||
}
|
||
cache.primeExpectedUsers(viewer, ownerIDs, byViewer[viewer])
|
||
}
|
||
return true
|
||
}
|
||
|
||
// 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, _ := channelMessagesFanoutPeerRefs(results, extraUserIDs)
|
||
return peerIDMapKeys(userIDs)
|
||
}
|
||
|
||
func channelMessagesFanoutUsernamePeers(results []domain.SendChannelMessageResult, extraUserIDs []int64) []domain.Peer {
|
||
userIDs, channelIDs := channelMessagesFanoutPeerRefs(results, extraUserIDs)
|
||
peers := make([]domain.Peer, 0, len(userIDs)+len(channelIDs))
|
||
for userID := range userIDs {
|
||
peers = append(peers, domain.Peer{Type: domain.PeerTypeUser, ID: userID})
|
||
}
|
||
for channelID := range channelIDs {
|
||
peers = append(peers, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID})
|
||
}
|
||
return peers
|
||
}
|
||
|
||
func channelMessagesFanoutPeerRefs(results []domain.SendChannelMessageResult, extraUserIDs []int64) (map[int64]struct{}, map[int64]struct{}) {
|
||
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 userIDs, channelIDs
|
||
}
|
||
|
||
// 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)
|
||
usernamePeers := channelMessagesFanoutUsernamePeers([]domain.SendChannelMessageResult{res}, extraUserIDs)
|
||
var usernames map[domain.Peer][]domain.Username
|
||
skip := skipDeliverySet(res.SkipDeliveryUserIDs)
|
||
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMessageBox, originUserID, res.Channel.ID, res.Event.Pts, res.Recipients,
|
||
0,
|
||
func(bgCtx context.Context, viewers []int64) bool {
|
||
if !r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) {
|
||
return false
|
||
}
|
||
usernames = r.usernameRegistryMap(bgCtx, usernamePeers)
|
||
return true
|
||
},
|
||
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.channelMessageUpdatesWithPeerCacheAndUsernames(bgCtx, viewerUserID, res, 0, fanoutCache, usernames)
|
||
})
|
||
}
|
||
|
||
// enqueueMonoforumMessageFanout only targets the subscriber sub-dialog and active parent-channel
|
||
// admins. A monoforum has no ordinary members, so member recomputation would either drop the
|
||
// message or leak it to an invalid historical membership.
|
||
func (r *Router) enqueueMonoforumMessageFanout(ctx context.Context, originUserID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) {
|
||
fanoutCache := newViewerPeerCache(r)
|
||
ownerIDs := channelMessageFanoutOwnerIDs(res, []int64{savedPeer.ID})
|
||
projectionPeers := monoforumProjectionPeers(mono.ID, mono.LinkedMonoforumID, ownerIDs)
|
||
var overlays *monoforumPeerOverlays
|
||
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutExplicit, originUserID, mono.ID, res.Event.Pts, res.Recipients,
|
||
0,
|
||
func(bgCtx context.Context, viewers []int64) bool {
|
||
if !r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) {
|
||
return false
|
||
}
|
||
overlays = r.loadMonoforumPeerOverlays(bgCtx, projectionPeers)
|
||
return true
|
||
},
|
||
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
|
||
return r.monoforumDeliveryUpdatesWithPeerCacheAndOverlays(bgCtx, viewerUserID, mono, savedPeer, res, fanoutCache, overlays)
|
||
})
|
||
}
|
||
|
||
// 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, _ := channelEditMessageFanoutPeerRefs(res)
|
||
return peerIDMapKeys(userIDs)
|
||
}
|
||
|
||
func channelEditMessageFanoutUsernamePeers(res domain.EditChannelMessageResult) []domain.Peer {
|
||
userIDs, channelIDs := channelEditMessageFanoutPeerRefs(res)
|
||
peers := make([]domain.Peer, 0, len(userIDs)+len(channelIDs))
|
||
for userID := range userIDs {
|
||
peers = append(peers, domain.Peer{Type: domain.PeerTypeUser, ID: userID})
|
||
}
|
||
for channelID := range channelIDs {
|
||
peers = append(peers, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID})
|
||
}
|
||
return peers
|
||
}
|
||
|
||
func channelEditMessageFanoutPeerRefs(res domain.EditChannelMessageResult) (map[int64]struct{}, map[int64]struct{}) {
|
||
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 userIDs, channelIDs
|
||
}
|
||
|
||
// 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)
|
||
usernamePeers := channelEditMessageFanoutUsernamePeers(res)
|
||
var usernames map[domain.Peer][]domain.Username
|
||
nudgePts := max(res.Event.Pts, res.ServiceEvent.Pts)
|
||
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMessageBox, originUserID, res.Channel.ID, nudgePts, res.Recipients,
|
||
0,
|
||
func(bgCtx context.Context, viewers []int64) bool {
|
||
if !r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) {
|
||
return false
|
||
}
|
||
usernames = r.usernameRegistryMap(bgCtx, usernamePeers)
|
||
return true
|
||
},
|
||
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
|
||
return r.channelEditMessageUpdatesWithPeerCacheAndUsernames(bgCtx, viewerUserID, res, fanoutCache, usernames)
|
||
})
|
||
}
|
||
|
||
// 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)
|
||
usernamePeers := channelMessagesFanoutUsernamePeers(results, extraUserIDs)
|
||
var usernames map[domain.Peer][]domain.Username
|
||
r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMessageBox, originUserID, channelID, pts, recipients,
|
||
int64(len(results))*(64<<10),
|
||
func(bgCtx context.Context, viewers []int64) bool {
|
||
if !r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) {
|
||
return false
|
||
}
|
||
usernames = r.usernameRegistryMap(bgCtx, usernamePeers)
|
||
return true
|
||
},
|
||
func(bgCtx context.Context, viewerUserID int64) *tg.Updates {
|
||
return r.channelMessagesUpdatesWithPeerCacheAndUsernames(bgCtx, viewerUserID, results, nil, false, extraUserIDs, fanoutCache, usernames)
|
||
})
|
||
}
|
||
|
||
// 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{}) bool {
|
||
provider, ok := r.deps.Sessions.(ChannelNudgeProvider)
|
||
if !ok || channelID == 0 || pts <= 0 {
|
||
return true
|
||
}
|
||
targets := provider.OnlineChannelMemberUserIDsExcluding(channelID, delivered, r.channelNudgeMaxTargets())
|
||
if len(targets) == 0 {
|
||
return true
|
||
}
|
||
date := int(r.clock.Now().Unix())
|
||
tooLong := &tg.UpdateChannelTooLong{ChannelID: channelID}
|
||
tooLong.SetPts(pts)
|
||
updates := &tg.Updates{
|
||
Updates: []tg.UpdateClass{tooLong},
|
||
Users: []tg.UserClass{},
|
||
Chats: []tg.ChatClass{},
|
||
Date: date,
|
||
Seq: 0,
|
||
}
|
||
for _, userID := range targets {
|
||
select {
|
||
case <-ctx.Done():
|
||
return false
|
||
default:
|
||
}
|
||
if userID == 0 {
|
||
continue
|
||
}
|
||
// The nudge is viewer-independent and immutable. Reuse the TL object across
|
||
// recipients; SessionManager encodes before enqueue and never mutates it.
|
||
r.pushUserUpdates(ctx, userID, updates)
|
||
}
|
||
return ctx.Err() == nil
|
||
}
|
||
|
||
// nudgeBeyondCapChannelMessageAudience extends member recovery to users with an
|
||
// unexpired public-channel short-poll subscription. Subscribers are selected
|
||
// first (normally a tiny set), then the remaining bounded capacity is filled
|
||
// with joined members. One authoritative batched audience check prevents stale
|
||
// runtime indexes from leaking even the channel id/pts to revoked viewers.
|
||
func (r *Router) nudgeBeyondCapChannelMessageAudience(ctx context.Context, channelID int64, pts int, delivered map[int64]struct{}) bool {
|
||
if r.deps.Sessions == nil || channelID == 0 || pts <= 0 {
|
||
return true
|
||
}
|
||
if r.deps.Channels == nil {
|
||
return r.nudgeBeyondCapChannelMembers(ctx, channelID, pts, delivered)
|
||
}
|
||
audience, ok := r.deps.Channels.(ChannelMessageAudienceService)
|
||
if !ok {
|
||
return r.nudgeBeyondCapChannelMembers(ctx, channelID, pts, delivered)
|
||
}
|
||
limit := r.channelNudgeMaxTargets()
|
||
excluded := make(map[int64]struct{}, len(delivered)+16)
|
||
for userID := range delivered {
|
||
excluded[userID] = struct{}{}
|
||
}
|
||
candidates := make([]int64, 0, min(limit, 64))
|
||
if subscriptions, ok := r.deps.Sessions.(ChannelSubscriptionProvider); ok {
|
||
for _, userID := range subscriptions.OnlineChannelSubscriberUserIDsExcluding(channelID, excluded, limit) {
|
||
if userID == 0 {
|
||
continue
|
||
}
|
||
excluded[userID] = struct{}{}
|
||
candidates = append(candidates, userID)
|
||
if len(candidates) >= limit {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if len(candidates) < limit {
|
||
if members, ok := r.deps.Sessions.(ChannelNudgeProvider); ok {
|
||
for _, userID := range members.OnlineChannelMemberUserIDsExcluding(channelID, excluded, limit-len(candidates)) {
|
||
if userID == 0 {
|
||
continue
|
||
}
|
||
excluded[userID] = struct{}{}
|
||
candidates = append(candidates, userID)
|
||
}
|
||
}
|
||
}
|
||
if len(candidates) == 0 {
|
||
return true
|
||
}
|
||
targets, err := audience.FilterMessageAudienceIDs(ctx, channelID, candidates)
|
||
if err != nil {
|
||
r.log.Warn("channel message audience nudge authorization failed",
|
||
zap.Int64("channel_id", channelID), zap.Int("pts", pts), zap.Error(err))
|
||
return false
|
||
}
|
||
if len(targets) == 0 {
|
||
return true
|
||
}
|
||
date := int(r.clock.Now().Unix())
|
||
tooLong := &tg.UpdateChannelTooLong{ChannelID: channelID}
|
||
tooLong.SetPts(pts)
|
||
updates := &tg.Updates{
|
||
Updates: []tg.UpdateClass{tooLong},
|
||
Users: []tg.UserClass{},
|
||
Chats: []tg.ChatClass{},
|
||
Date: date,
|
||
Seq: 0,
|
||
}
|
||
for _, userID := range targets {
|
||
select {
|
||
case <-ctx.Done():
|
||
return false
|
||
default:
|
||
}
|
||
r.pushUserUpdates(ctx, userID, updates)
|
||
}
|
||
return true
|
||
}
|