207 lines
8.1 KiB
Go
207 lines
8.1 KiB
Go
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/internal/dao"
|
||
"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})
|
||
}
|
||
|
||
// ClearChannelCredentialCooldown 解除渠道下所有 Key 的凭证冷却,返回被解除的 Key 数量。
|
||
//
|
||
// 更换 Key / 重置健康度后必须调用。凭证冷却按 keyID 打标(dispatch:v1:credcd:<keyID>,
|
||
// 401/403 时写入、默认 300s TTL),而管理后台更换 Key 是原地改同一行、keyID 不变:
|
||
// 不清标记的话调度器仍认为该 Key 在冷却中,渠道全部 Key 冷却即整体不可用
|
||
// (日志表现为「无可用渠道 … 凭证全部冷却×N」),且期间从不发起上游请求,
|
||
// 新 Key 一次都得不到验证,只能干等 TTL 自愈。
|
||
//
|
||
// 不按 status 过滤:被禁用后重新启用的 Key 同样需要解除。
|
||
func ClearChannelCredentialCooldown(ctx context.Context, channelID int64) int {
|
||
var keys []struct {
|
||
ID int64 `json:"id"`
|
||
}
|
||
if err := dao.ChnChannelKeys.Ctx(ctx).
|
||
Where("channel_id", channelID).
|
||
Fields("id").
|
||
Scan(&keys); err != nil {
|
||
g.Log().Warningf(ctx, "[Dispatch] 解除凭证冷却时查询渠道 Key 失败: channel=%d err=%v", channelID, err)
|
||
return 0
|
||
}
|
||
for _, k := range keys {
|
||
ClearCredentialCooldownByKey(ctx, k.ID)
|
||
}
|
||
return len(keys)
|
||
}
|
||
|
||
// ClearCredentialCooldownByKey 解除单个 Key 的凭证冷却。
|
||
// 凭证被就地更新(换 Key / OAuth 令牌刷新)后必须调用:keyID 不变,
|
||
// 旧凭证留下的冷却标记会继续把新凭证挡在调度之外,直到 TTL 自然到期。
|
||
func ClearCredentialCooldownByKey(ctx context.Context, keyID int64) {
|
||
if redisState != nil {
|
||
redisState.ClearCredentialCooldown(ctx, keyID) // 含本地镜像
|
||
return
|
||
}
|
||
// 调度引擎尚未组装:没有本地镜像可清,只需删 Redis 标记
|
||
clearCredCooldownKey(ctx, keyID)
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
}
|