onesvm-browser-server/server/internal/scheduler/routing.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

211 lines
7.8 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.

// 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 }