Files

207 lines
8.1 KiB
Go
Raw Permalink 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/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()
}
}