单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
211 lines
7.8 KiB
Go
211 lines
7.8 KiB
Go
// routing.go:能力路由(标签制,design §4.4)+ read 降级链(plan-final §3)。
|
||
//
|
||
// 路由表(intent + region + render → adapter 名):
|
||
//
|
||
// search + domestic → searxng-cn
|
||
// search + overseas → searxng-global
|
||
// read + render=none → trafilatura
|
||
// read + render=light → lightpanda
|
||
// read + render=full → headless-shell
|
||
//
|
||
// 降级链(read):trafilatura 空正文(empty_extract 类)→ lightpanda(render=light)
|
||
// → headless-shell(仅特权/保真需求)→ 全败 blocked/upstream + warnings,不静默降质。
|
||
// 适配器 Health().ok=false → 从路由摘除走降级(design §4.5 条款 2)。
|
||
// overseas 任务:经 proxymanager 选出口;proxymanager 不可达 → fail-closed 排队
|
||
// 等待(O3:回队+告警,不降级直连)。
|
||
package scheduler
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"time"
|
||
|
||
"onesvm.com/onesvm/browser-server/internal/contract"
|
||
)
|
||
|
||
// 路由目标常量。
|
||
const (
|
||
adapterSearxCN = "searxng-cn"
|
||
adapterSearxGlobal = "searxng-global"
|
||
adapterTraf = "trafilatura"
|
||
adapterPanda = "lightpanda"
|
||
adapterShell = "headless-shell"
|
||
)
|
||
|
||
// routeTarget 单步路由决策。
|
||
type routeTarget struct {
|
||
adapter string
|
||
minRender string
|
||
}
|
||
|
||
// routeSearch 搜索路由(region 决定实例;无降级——引擎不可用即 upstream 重试)。
|
||
func routeSearch(region string) string {
|
||
if region == contract.RegionOverseas {
|
||
return adapterSearxGlobal
|
||
}
|
||
return adapterSearxCN
|
||
}
|
||
|
||
// routeRead 首选 read 适配器:render=none → trafilatura;light → panda;full → shell。
|
||
// 意图判定依据 ReadInput.Formats / Extract(首版 read 默认 markdown → none);
|
||
// formats 含 html/screenshot 或显式 render 需求走升级。
|
||
func routeRead(job contract.JobEnvelope) string {
|
||
if job.Read == nil {
|
||
return adapterTraf
|
||
}
|
||
for _, f := range job.Read.Formats {
|
||
switch f {
|
||
case "html", "screenshot":
|
||
return adapterShell // 特权 formats 需要完整 DOM
|
||
}
|
||
}
|
||
return adapterTraf
|
||
}
|
||
|
||
// routeResult 单次执行结论。
|
||
type routeResult struct {
|
||
raw *contract.RawResult
|
||
errBody *contract.ErrBody
|
||
adapter string // 最终成功的适配器名
|
||
proxy string // provenance.proxy_exit
|
||
warnings []string
|
||
tookMs int64
|
||
}
|
||
|
||
// executeWithRouting 能力路由 + 降级链执行(runJob 内核)。
|
||
func (c *Core) executeWithRouting(ctx context.Context, job contract.JobEnvelope) (*contract.RawResult, *contract.ErrBody, routeResult) {
|
||
t0 := time.Now()
|
||
switch job.Intent {
|
||
case "search":
|
||
return c.execSearch(ctx, job, t0)
|
||
case "read":
|
||
return c.execReadChain(ctx, job, t0)
|
||
default:
|
||
return nil, &contract.ErrBody{Code: contract.CodeUpstream, Message: "未知意图 " + job.Intent}, routeResult{tookMs: ms(t0)}
|
||
}
|
||
}
|
||
|
||
// execSearch 搜索执行(无降级链;overseas 需 proxymanager 可达)。
|
||
func (c *Core) execSearch(ctx context.Context, job contract.JobEnvelope, t0 time.Time) (*contract.RawResult, *contract.ErrBody, routeResult) {
|
||
region := contract.RegionDomestic
|
||
if job.Search != nil {
|
||
region = job.Search.Region
|
||
}
|
||
name := routeSearch(region)
|
||
// overseas:proxymanager 不可达 → fail-closed 排队等待(O3 回队+告警)。
|
||
if region == contract.RegionOverseas {
|
||
if !c.proxyOK.Load() {
|
||
return nil, &contract.ErrBody{Code: contract.CodeUnavailable, RetryAfterS: retryPtr(10),
|
||
Message: "proxymanager 不可达,overseas 任务排队等待(fail-closed,不降级直连)"},
|
||
routeResult{tookMs: ms(t0)}
|
||
}
|
||
}
|
||
a, ok := c.reg.Get(name)
|
||
if !ok || !a.Health().OK {
|
||
return nil, &contract.ErrBody{Code: contract.CodeUpstream, Message: "搜索适配器不可用: " + name}, routeResult{tookMs: ms(t0)}
|
||
}
|
||
proxy := "direct"
|
||
if region == contract.RegionOverseas {
|
||
pe, err := c.proxy.ExitForDomain(ctx, jobSearchURL(job))
|
||
if err != nil {
|
||
return nil, &contract.ErrBody{Code: contract.CodeUnavailable, RetryAfterS: retryPtr(10),
|
||
Message: "出口查询失败(fail-closed): " + err.Error()}, routeResult{tookMs: ms(t0)}
|
||
}
|
||
proxy = pe.Proxy
|
||
}
|
||
raw, eb := a.Execute(ctx, job)
|
||
return raw, eb, routeResult{adapter: name, proxy: proxyExitLabel(region, proxy), tookMs: ms(t0)}
|
||
}
|
||
|
||
// execReadChain read 降级链:trafilatura → lightpanda → headless-shell。
|
||
// 每步失败原因进 warnings;全败 blocked/upstream(不静默降质)。
|
||
func (c *Core) execReadChain(ctx context.Context, job contract.JobEnvelope, t0 time.Time) (*contract.RawResult, *contract.ErrBody, routeResult) {
|
||
primary := routeRead(job)
|
||
// 渲染档需求(formats html/screenshot)直接进 shell 单步(特权路径不走 A/B)。
|
||
if primary == adapterShell {
|
||
return c.execOne(ctx, job, adapterShell, "direct", t0)
|
||
}
|
||
// 链:A(trafilatura) → B(lightpanda) → C(headless-shell)。
|
||
chain := []string{adapterTraf, adapterPanda, adapterShell}
|
||
// 从需求档位起走(render=light 意图直接从 B 起)。
|
||
if primary == adapterPanda {
|
||
chain = []string{adapterPanda, adapterShell}
|
||
} else if primary == adapterShell {
|
||
chain = []string{adapterShell}
|
||
}
|
||
proxy := "direct"
|
||
if job.Read != nil && job.Read.Region == contract.RegionOverseas {
|
||
if !c.proxyOK.Load() {
|
||
return nil, &contract.ErrBody{Code: contract.CodeUnavailable, RetryAfterS: retryPtr(10),
|
||
Message: "proxymanager 不可达,overseas read 排队等待(fail-closed)"}, routeResult{tookMs: ms(t0)}
|
||
}
|
||
pe, err := c.proxy.ExitForDomain(ctx, job.Read.URL)
|
||
if err != nil {
|
||
return nil, &contract.ErrBody{Code: contract.CodeUnavailable, RetryAfterS: retryPtr(10),
|
||
Message: "出口查询失败(fail-closed): " + err.Error()}, routeResult{tookMs: ms(t0)}
|
||
}
|
||
proxy = pe.Proxy
|
||
}
|
||
var warnings []string
|
||
var lastErr *contract.ErrBody
|
||
for _, name := range chain {
|
||
a, ok := c.reg.Get(name)
|
||
if !ok || !a.Health().OK {
|
||
warnings = append(warnings, "adapter_unhealthy:"+name)
|
||
continue
|
||
}
|
||
raw, eb := a.Execute(ctx, job)
|
||
if eb == nil {
|
||
return raw, nil, routeResult{adapter: name, proxy: proxyExitLabel(job.Read.Region, proxy),
|
||
warnings: warnings, tookMs: ms(t0)}
|
||
}
|
||
lastErr = eb
|
||
warnings = append(warnings, name+" 失败("+eb.Code+"): "+eb.Message)
|
||
// denied(合规拦截)不降级——换引擎同样被拦,直接终态。
|
||
if eb.Code == contract.CodeDenied {
|
||
return nil, eb, routeResult{warnings: warnings, tookMs: ms(t0)}
|
||
}
|
||
}
|
||
if lastErr == nil {
|
||
lastErr = &contract.ErrBody{Code: contract.CodeUpstream, Message: "read 降级链全部不可用"}
|
||
}
|
||
return nil, lastErr, routeResult{warnings: warnings, tookMs: ms(t0)}
|
||
}
|
||
|
||
// execOne 单适配器执行(shell 直达路径用)。
|
||
func (c *Core) execOne(ctx context.Context, job contract.JobEnvelope, name, proxy string, t0 time.Time) (*contract.RawResult, *contract.ErrBody, routeResult) {
|
||
a, ok := c.reg.Get(name)
|
||
if !ok || !a.Health().OK {
|
||
return nil, &contract.ErrBody{Code: contract.CodeUpstream, Message: "适配器不可用: " + name}, routeResult{tookMs: ms(t0)}
|
||
}
|
||
raw, eb := a.Execute(ctx, job)
|
||
return raw, eb, routeResult{adapter: name, proxy: proxy, tookMs: ms(t0)}
|
||
}
|
||
|
||
// proxyExitLabel proxy_exit 字段归一("direct"|"pool:<name>"|"none")。
|
||
func proxyExitLabel(region, proxy string) string {
|
||
if region != contract.RegionOverseas {
|
||
return "direct"
|
||
}
|
||
if proxy == "" {
|
||
return "none"
|
||
}
|
||
return proxy
|
||
}
|
||
|
||
// jobSearchURL 搜索任务无目标 URL(域名路由键给空——proxymanager 按默认池)。
|
||
func jobSearchURL(job contract.JobEnvelope) string {
|
||
if job.Read != nil {
|
||
return job.Read.URL
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ms 起始到现在的毫秒。
|
||
func ms(t time.Time) int64 { return time.Since(t).Milliseconds() }
|
||
|
||
// isBlockedLikeUpstream 判定 upstream 消息里的 empty_extract 类(trafilatura 空正文)。
|
||
func isEmptyExtract(msg string) bool { return strings.Contains(msg, "empty_extract") }
|
||
|
||
// retryPtr int → *int(RetryAfterS 可选字段辅助)。
|
||
func retryPtr(n int) *int { return &n }
|