Files
team-api/internal/dispatchadapter/wire.go
T
chengqian a5e2ecf527 fix(dispatch): 渠道健康聚合只统计有真实上报的模型,降级时保留库中旧值
渠道展示分是目录内全部模型 succ_ewma 的算术平均,而无流量模型读到的是乐观默认满分。
20 个模型的渠道死掉 1 个(succ→0.05)仍能算出 avgSucc≈0.95、91 分绿灯,恰好掩盖了
模型级熔断要隔离的典型场景。改为只统计 HasHealth 的模型;全渠道无真实上报时
Counted=0,保留库中上一次的分数而不是用默认满分覆盖历史结论。

Redis 读失败(Degraded)时跳过落盘并打 Warning:照常落盘会把全渠道健康分刷成 100,
连带 chn_health_snapshots 把假象写进历史趋势,事故期间的曲线反而全绿。自动禁用判定
一并跳过——该操作不可逆,不能基于不可信读值触发。

聚合逻辑抽成纯函数 aggregateChannelHealth 以便单测;另新增 RequestHealthSnapshot
按需重算入口(维护循环消费,满则丢弃由下一轮兜底),供渠道测试/重置健康度接入。
健康上报是 fire-and-forget,重算前等 500ms 让 worker 落完 Redis。
2026-08-27 21:59:43 +08:00

168 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dispatchadapter
import (
"context"
"math/rand"
"slices"
"sync"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/qianfree/team-api/relaykit/dispatch"
)
// 全局单例(handler / worker / 任务提交端点经 Coordinator() 使用)。
var (
wireOnce sync.Once
coordinator *dispatch.Coordinator
catalog *Catalog
redisState *RedisState
wireStop chan struct{}
)
// Coordinator 返回调度协调器单例,首次调用完成组装并启动后台任务:
// 目录刷新循环、pub/sub 失效订阅、健康上报 worker、策略热更新、维护任务。
func Coordinator(ctx context.Context) *dispatch.Coordinator {
wireOnce.Do(func() {
wireStop = make(chan struct{})
// 后台循环(目录刷新 / pub-sub 失效 / 健康上报 / 策略热更新 / 维护)必须用
// 独立的长生命周期 context:Coordinator 首次由某个请求惰性触发组装,若把该
// 请求 ctx 传给后台 goroutine,请求一结束 ctx 即被 cancel,之后所有后台 DB/
// Redis 读取都会 context canceled(例如策略刷新器每 30s 读 channel_routing_policy
// 失败,导致路由策略热更新形同虚设)。
bgCtx := gctx.New()
pol, err := LoadRoutingPolicy(ctx)
if err != nil {
g.Log().Warningf(ctx, "[Dispatch] 启动时路由策略非法,使用默认策略: %v", err)
pol = dispatch.DefaultRoutingPolicy()
}
// 组装依赖环:coordinator 持策略指针 → state/catalog 通过闭包读同一策略
policyFn := func() *dispatch.RoutingPolicy { return coordinator.Policy() }
redisState = NewRedisState(policyFn, func(channelID int64) (bool, int) {
return catalog.StrictLookup(channelID)
})
catalog = NewCatalog(policyFn, nil, func(ctx context.Context, channelID int64, model string) RuntimeReadout {
return redisState.ReadRuntime(ctx, channelID, model)
})
coordinator = dispatch.NewCoordinator(catalog, redisState, pol, dispatch.SystemClock{}, rand.Float64)
redisState.Start(bgCtx)
catalog.Start(bgCtx)
StartPolicyRefresher(bgCtx, wireStop, coordinator)
startMaintenance(bgCtx, wireStop)
})
return coordinator
}
// CatalogInstance 返回目录单例(handler 取 ForwardMeta 用,需先调用过 Coordinator)。
func CatalogInstance() *Catalog { return catalog }
// HealthAlpha 返回当前路由策略的健康指数 α(健康分 = succ_ewma^α × 100)。
//
// 管理后台展示健康分必须与调度 healthFactor、维护快照落盘同源:三处都用 succ^α,
// 唯独模型级能力列表曾用线性 succ×100,而前端两处共用同一套 80/50 阈值,导致
// succ=0.89 时模型行显示 89「健康」、渠道行显示 79「降级」,看起来像 bug。
//
// 调度引擎未组装(首次 relay 请求前)时返回默认策略的 α,不触发组装
// ——管理后台读接口不应有装配整个调度引擎的副作用(与 CatalogInstance 同策略)。
func HealthAlpha() float64 {
if coordinator == nil {
return dispatch.DefaultRoutingPolicy().Health.Alpha
}
return coordinator.Policy().Health.Alpha
}
// RefreshDispatchLease 长请求(流式/websocket)续期调度租约。
// 供 handler 的租约续期器直接调用(RouteSession 非并发安全,不经会话)。
func RefreshDispatchLease(ctx context.Context, channelID int64, requestID string) {
if redisState != nil {
redisState.RefreshLease(ctx, channelID, requestID)
}
}
// ReportProbeOutcome 渠道测试(管理后台手动测试 / 自动探测 cron)结果喂给调度健康体系。
// model 必须传平台模型名(chn_abilities.model_name),与调度健康键一致,不要传上游名。
// 探测失败只喂熔断窗口不衰减健康分(防周期性探测把无流量渠道健康分拖垮);
// 探测成功正常回升健康并可关闭 HALF_OPEN 熔断。
func ReportProbeOutcome(ctx context.Context, channelID int64, model string, success bool, latencyMs float64) {
Coordinator(ctx) // 确保已组装(含健康上报 worker)
if redisState == nil {
return
}
o := dispatch.Outcome{ChannelID: channelID, Model: model, Success: success, LatencyMs: latencyMs, Probe: true}
if !success {
o.Class = dispatch.ErrClassTransient
}
redisState.ReportOutcome(o)
}
// CatalogHasModel 判断渠道×模型当前是否在调度目录中(探测可观测性检查用)。
// 不在目录的探测目标(渠道无启用的能力行、或模型未配置能力)健康 EWMA 写入后无人消费:
// 调度不会选它、维护快照也不会落盘该渠道的健康分——管理后台将看不到探测效果。
// 需已调用过 Coordinator 完成组装;未组装时返回 false。
func CatalogHasModel(channelID int64, model string) bool {
if catalog == nil {
return false
}
return slices.Contains(catalog.ChannelModels()[channelID], model)
}
// MarkChannelRecovered 渠道被手动启用/恢复时复位熔断并开启爬坡窗口(管理后台调用)。
func MarkChannelRecovered(ctx context.Context, channelID int64) {
if redisState != nil {
redisState.MarkChannelRecovered(ctx, channelID)
}
}
// ResetChannelHealth 重置渠道健康度(管理后台"重置健康度"按钮):熔断复位 + 成功率恢复,
// 渠道立即恢复被调度选择的能力。models 从目录快照取(该渠道服务的模型列表)。
func ResetChannelHealth(ctx context.Context, channelID int64) {
if redisState == nil {
return
}
var models []string
if catalog != nil {
models = catalog.ChannelModels()[channelID]
}
redisState.ResetHealth(ctx, channelID, models)
}
// ResetModelHealth 重置指定模型的健康度(只重置该模型,不影响其他模型)
func ResetModelHealth(ctx context.Context, channelID int64, modelName string) {
if redisState == nil {
return
}
redisState.ResetHealth(ctx, channelID, []string{modelName})
}
// InvalidateChannel 渠道禁用/删除时的联动清理:清绑定 + 跨实例目录失效。
// 管理后台渠道写操作(更新/删除/Key 变更/能力变更)后调用。
func InvalidateChannel(ctx context.Context, channelID int64) {
if redisState != nil {
redisState.InvalidateChannelBindings(ctx, channelID)
}
PublishInvalidate(ctx)
if catalog != nil {
catalog.Invalidate()
}
}
// Shutdown 停止全部后台任务(进程退出时调用)。
func Shutdown() {
if wireStop != nil {
close(wireStop)
}
if catalog != nil {
catalog.Stop()
}
if redisState != nil {
redisState.Stop()
}
}