跳转至

OutboundDispatcher 实现方案

最后更新: 2026-08-04

状态:已实现core/engine/dispatcher.go + infra/future)。 本文是当时的设计方案存档;最终实现与演进(含 worker 自删除竞态的修复) 见架构笔记 ./21-outbound-dispatcher.md

概述

新增 OutboundDispatcher 作为独立的发送调度层,将发送 I/O 与 Handler 执行解耦。 调度单位是 Message Stream(Chat),而不是单个 Task。


第一步:Future

infra/future/future.go

package future

import (
    "context"
    "errors"
    "sync"
)

var ErrNotReady = errors.New("future: not ready")

type Future[T any] struct {
    once sync.Once
    done chan struct{}
    val  T
    err  error
}

func New[T any]() *Future[T] {
    return &Future[T]{done: make(chan struct{})}
}

func (f *Future[T]) Resolve(val T, err error) bool {
    var resolved bool
    f.once.Do(func() {
        f.val, f.err = val, err
        close(f.done)
        resolved = true
    })
    return resolved
}

func (f *Future[T]) Wait(ctx context.Context) (T, error) {
    select {
    case <-f.done:
        return f.val, f.err
    case <-ctx.Done():
        var zero T
        return zero, ctx.Err()
    }
}

func (f *Future[T]) Result() (T, error) {
    select {
    case <-f.done:
        return f.val, f.err
    default:
        var zero T
        return zero, ErrNotReady
    }
}

func (f *Future[T]) IsDone() bool {
    select {
    case <-f.done:
        return true
    default:
        return false
    }
}

func (f *Future[T]) MustWait(ctx context.Context) T {
    val, err := f.Wait(ctx)
    if err != nil {
        panic(err)
    }
    return val
}

func (f *Future[T]) Done() <-chan struct{} { return f.done }

第二步:OutboundDispatcher

core/engine/dispatcher.go

核心语义:按 ChatID 严格 FIFO(Message Stream),跨 Chat 并发。

package engine

import (
    "context"
    "errors"
    "fmt"
    "runtime/debug"
    "sync"
    "sync/atomic"
    "time"
)

var ErrDispatcherClosed = errors.New("dispatcher: closed")
var ErrQueueFull = errors.New("dispatcher: queue full")

type DispatcherConfig struct {
    MaxInflight int           // 最大并发 Worker 数(默认 512)
    QueueSize   int           // 单个 Chat 队列最大长度(默认 64)
    SendTimeout time.Duration // 发送超时(默认 30s)
    Recover     func(any)     // panic 回调,nil 则仅记录
    Hooks       DispatcherHooks
}

type DispatchInfo struct {
    ChatID       string
    QueueLen     int
    QueueLatency time.Duration
    RunLatency   time.Duration
    Err          error
}

type DispatcherHooks struct {
    OnQueued   func(chatID string, queueLen int)
    OnStart    func(chatID string)
    OnDone     func(info DispatchInfo)
    OnRejected func(chatID string, err error)
}

// 经典 RingBuffer:固定容量,head/tail 指针
type ringBuffer struct {
    buf  []queuedTask
    head int // 下一个可读位置
    tail int // 下一个可写位置
    len  int
    cap  int
}

func newRingBuffer(cap int) *ringBuffer {
    return &ringBuffer{buf: make([]queuedTask, cap), cap: cap}
}

func (r *ringBuffer) push(t queuedTask) bool {
    if r.len >= r.cap {
        return false
    }
    r.buf[r.tail] = t
    r.tail = (r.tail + 1) % r.cap
    r.len++
    return true
}

func (r *ringBuffer) pop() (queuedTask, bool) {
    if r.len == 0 {
        return queuedTask{}, false
    }
    t := r.buf[r.head]
    r.head = (r.head + 1) % r.cap
    r.len--
    return t, true
}

type chatQueue struct {
    mu      sync.Mutex
    q       ringBuffer
    running bool
    chatID  string
}

type OutboundDispatcher struct {
    baseCtx  context.Context
    cancel   context.CancelFunc
    config   DispatcherConfig
    sem      chan struct{} // 全局并发令牌
    queues   sync.Map     // map[string]*chatQueue
    stopped  atomic.Bool
    wg       sync.WaitGroup
}

func NewOutboundDispatcher(ctx context.Context, config DispatcherConfig) *OutboundDispatcher {
    if config.MaxInflight <= 0 { config.MaxInflight = 512 }
    if config.QueueSize <= 0   { config.QueueSize = 64 }
    if config.SendTimeout <= 0 { config.SendTimeout = 30 * time.Second }
    bCtx, cancel := context.WithCancel(ctx)
    return &OutboundDispatcher{
        baseCtx: bCtx,
        cancel:  cancel,
        config:  config,
        sem:     make(chan struct{}, config.MaxInflight),
    }
}

Submit

func (d *OutboundDispatcher) Submit(chatID string, task func(context.Context) error) error {
    if d.stopped.Load() {
        d.reject(chatID, ErrDispatcherClosed)
        return ErrDispatcherClosed
    }

    actual, _ := d.queues.LoadOrStore(chatID, &chatQueue{
        chatID: chatID,
        q:      *newRingBuffer(d.config.QueueSize),
    })
    q := actual.(*chatQueue)

    q.mu.Lock()
    if !q.q.push(queuedTask{enqueueAt: time.Now(), run: task}) {
        q.mu.Unlock()
        d.reject(chatID, ErrQueueFull)
        return ErrQueueFull
    }
    if h := d.config.Hooks.OnQueued; h != nil {
        h(chatID, q.q.len)
    }
    if !q.running {
        q.running = true
        q.mu.Unlock()
        d.wg.Add(1)
        go d.worker(q)
    } else {
        q.mu.Unlock()
    }
    return nil
}

func (d *OutboundDispatcher) reject(chatID string, err error) {
    if h := d.config.Hooks.OnRejected; h != nil {
        h(chatID, err)
    }
}

Worker(recover 只清理 queue,不处理 Future):

func (d *OutboundDispatcher) worker(q *chatQueue) {
    defer d.wg.Done()

    select {
    case d.sem <- struct{}{}:
    case <-d.baseCtx.Done():
        q.mu.Lock()
        q.running = false
        q.mu.Unlock()
        return
    }
    defer func() { <-d.sem }()

    defer func() {
        if r := recover(); r != nil {
            stack := string(debug.Stack())
            err := fmt.Errorf("panic in dispatcher worker [%s]: %v\n%s", q.chatID, r, stack)
            // 丢弃队列中剩余任务(Future 由 Submit 方的 recover 保证)
            q.mu.Lock()
            q.q = *newRingBuffer(d.config.QueueSize)
            q.running = false
            if actual, ok := d.queues.Load(q.chatID); ok && actual == q {
                d.queues.Delete(q.chatID)
            }
            q.mu.Unlock()
            if fn := d.config.Recover; fn != nil {
                fn(r)
            }
            if h := d.config.Hooks.OnDone; h != nil {
                h(DispatchInfo{ChatID: q.chatID, Err: err})
            }
        }
    }()

    for {
        q.mu.Lock()
        if q.q.len == 0 {
            q.running = false
            if actual, ok := d.queues.Load(q.chatID); ok && actual == q {
                d.queues.Delete(q.chatID)
            }
            q.mu.Unlock()
            return
        }
        t, _ := q.q.pop()
        queueLen := q.q.len // 在锁内捕获,避免 data race
        q.mu.Unlock()

        start := time.Now()
        if h := d.config.Hooks.OnStart; h != nil {
            h(q.chatID)
        }

        sendCtx, cancel := context.WithTimeout(d.baseCtx, d.config.SendTimeout)
        err := t.run(sendCtx)
        cancel()

        if h := d.config.Hooks.OnDone; h != nil {
            h(DispatchInfo{
                ChatID:       q.chatID,
                QueueLen:     queueLen, // 无锁读取,无 race
                QueueLatency: start.Sub(t.enqueueAt),
                RunLatency:   time.Since(start),
                Err:          err,
            })
        }
    }
}

Shutdown

func (d *OutboundDispatcher) Close()              { d.stopped.Store(true) }
func (d *OutboundDispatcher) Drain(ctx context.Context) error { /* wg.Wait */ }
func (d *OutboundDispatcher) Shutdown(ctx context.Context) error { d.Close(); return d.Drain(ctx) }
func (d *OutboundDispatcher) ForceClose()          { d.stopped.Store(true); d.cancel() }

第三步:Context.Reply() 返回 Future

core/context/platform_event.go

Future 的 recover 在闭包内完成,Dispatcher 不感知:

func (ctx *Context) Reply(msg platform.OutboundMessage) *future.Future[platform.SendResult] {
    f := future.New[platform.SendResult]()
    req := platform.SendRequest{
        Target:  ctx.platformEvent.Chat(),
        Message: msg,
    }
    sender := ctx.platformSender
    chatID := ctx.platformEvent.Chat().ID
    err := ctx.dispatcher.Submit(chatID, func(sendCtx context.Context) error {
        // Reply 层保证 Future 被 Resolve(即使是 panic)
        defer func() {
            if r := recover(); r != nil {
                f.Resolve(platform.SendResult{}, fmt.Errorf("panic in send: %v", r))
                panic(r) // 让 Dispatcher 的 recovery 继续处理
            }
        }()
        res, err := sender.Send(sendCtx, req)
        f.Resolve(res, err)
        return err
    })
    if err != nil {
        f.Resolve(platform.SendResult{}, err)
    }
    return f
}

第四步:ReplyText/ReplyError/ReplySuccess

core/context/reply.go

func (ctx *Context) ReplyText(text string) *future.Future[platform.SendResult] {
    ctx.Reply(platform.TextMessage(text))
        return nil
}

第五步:SenderDecorator

middleware/sender/decorator.go

package sender

type SenderDecorator func(platform.Sender) platform.Sender

func Chain(decorators ...SenderDecorator) SenderDecorator {
    return func(s platform.Sender) platform.Sender {
        for i := len(decorators) - 1; i >= 0; i-- {
            s = decorators[i](s)
        }
        return s
    }
}

装饰器顺序(文档规定):

Metrics → Logging → Retry → RateLimit → Timeout → PlatformSender

第六步:Engine 集成

type Engine struct {
    dispatcher *OutboundDispatcher
}

func WithDispatcherConfig(cfg DispatcherConfig) Option

func (e *Engine) Shutdown(ctx context.Context) error {
    e.shutdown.Store(true)
    if e.internals.execPool != nil { _ = e.internals.execPool.Drain(ctx) }
    if e.dispatcher != nil { _ = e.dispatcher.Shutdown(ctx) }
    return nil
}

第七步:Context 注入 Dispatcher

type Context struct {
    dispatcher *engine.OutboundDispatcher
}

func (e *Engine) processEventContextWithPool(ctx *context.Context) {
    ctx.SetDispatcher(e.dispatcher)
    e.processEventMatchers(ctx, true)
}

Reply 语义(框架契约)

ctx.Reply(msg)
  → 保证:任务已进入 Dispatcher(队列或 Worker)
  → 不保证:已发送成功
  → 不保证:平台已收到
  → 不保证:已获得 MessageID
  → 保证:即使 Handler 已返回,Dispatcher 中的发送仍会继续执行

需要以上保证时,必须:
  future := ctx.Reply(msg)
  res, err := future.Wait(ctx)

实现顺序

步骤 文件 破坏性
1 infra/future/future.go
2 core/engine/dispatcher.go
3 core/context/platform_event.go (返回值可忽略)
4 core/context/reply.go (同上)
5 middleware/sender/*.go
6 core/engine/{engine,config}.go

设计原则速查

问题 答案
Dispatcher 知道 Future 吗? 不知道。只调度 func(ctx) error
同一 Chat 消息顺序? RingBuffer + 单 Worker,严格 FIFO
不同 Chat 并发? 全局 semaphore 限制
semaphore 在谁那边获取? Worker goroutine 内部,不阻塞 Submit
Future 不会 Resolve 的情况? panic 时闭包内的 defer 保证 Resolve
队列实现? 固定容量 RingBuffer,head/tail 指针
Sender 接口改不改? 不改
Shutdown 语义分几种? Close/Drain/Shutdown/ForceClose