Files
arcrun-collector/routebackoff.go
T

332 lines
14 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.
// routebackoff.go — 雲端某條路持續失敗時,跨輪退避(`inkstone/arcrun-rag#121` comment 6923)。
//
// 病(2026-09-13 leo Mac 實測):雲端 rag_ingest_card 每一發都回 HTTP 500
// `list_old_blocks` 缺 credential),而那個節點是全表掃。daemon 每 5 秒一輪、
// 每一輪都把同一面牆重撞一次:出處修正(Arcrun#167)一輪最多 20 發、資料夾索引一發、
// 每個新檔再各一發——youlin 的免費 D1 一小時被讀掉 600 萬列、當天知識庫停擺。
//
// 既有的閘為什麼都沒擋住:
// - 逐檔退避(manifest FailCount)管的是「這個檔壞了」,一批沒試過的新檔每個都會先撞一次;
// - roundGuardstallguard.go)只管「等到超時」,而且每輪歸零;
// - 額度冷卻只認得 Workers AI 的額度訊息。
//
// ⇒ 缺的是「**這條路**壞了」這一層:同一台知識庫的同一個端點連續失敗,
//
// 就整條路停一段時間,時間一次比一次長;有一發成功就立刻恢復。
//
// 判準(什麼算「路壞了」):
// - HTTP 5xx、429、等不到回應(逾時)、連上之後被切斷 ⇒ 算。
// - 🔴 **這台電腦根本沒連出去**(DNS 查不到、網路不通、連線被本機拒絕)⇒ **不算**
// `inkstone/arcrun-rag#201`):請求沒離開這台電腦,雲端一分額度都沒花,
// 斷路器存在的理由(別把雲端額度燒光)在這裡不成立。2026-09-16 leo 的 log 裡
// 這一類錯誤有 1849 筆(Mac 睡醒、網路還沒好的那幾秒),把它們算成「雲端壞了」
// 只會讓網路一恢復,小幫手反而還停在「先停 26 分鐘」。
// - HTTP 2xx 但 body 裡寫著工作流失敗 ⇒ **不算**:路是通的,失敗可能只屬於那一份內容,
// 把它算成整條路壞掉會讓一份怪檔拖住所有人(逐檔退避會照顧它)。
// - 其他 4xx ⇒ 不算(多半是那一發本身的問題,不是路的問題)。
//
// 🔴 什麼時候判「路壞了」(而不是「那一份內容壞了」):
// - 單看狀態碼分不出來——工作流任何節點失敗都回 500,不管是路壞還是那張卡怪。
// - 所以要**連續 routeStrikesBeforeBackoff 發「第一次送」的請求都失敗**才停。
// 「第一次送」=逐檔病歷上沒有失敗紀錄的檔,以及出處修正/總覽/目錄索引/下架這類系統件。
// - 已經失敗過的檔再重試又失敗 ⇒ **不算數也不歸零**:那是它自己的病(逐檔退避在管),
// 不准拿它把整條路關掉——否則一池壞檔會輪流把健康的檔擋在外面(t217 餓死病換個形狀回來)。
//
// 🔴 「連續」要是真的連續(`inkstone/arcrun-rag#201`):
// 舊版只有「2xx 才歸零」,而空 text 的探測通了刻意不記、有前科的檔成功之前都不會回 2xx
// ⇒ 一個所有檔都卡住的帳號,計數器**從行程啟動那一刻起只增不減**。
// 2026-09-16 geek6688 那句「連續失敗 11 次」其實是 09-14 17:22 開 App 之後兩天內零星的
// 11 發(最後一發=17:48:42 探測等了 20 秒),不是剛剛連撞 11 次。
// ⇒ 距離上一次失敗超過 routeFailMemory 還沒再失敗,就當作路早就好了,重新算起。
//
// 每一次「算進去」的失敗都會印一行帶時間的紀錄(phase:"route_failure",見 routeFailureLog),
// 事後看得出「這 N 次是哪一種、依序發生在什麼時候」——以前 log 只有整輪結果重印,沒有時間。
//
// 使用者按「立刻同步」(ForceSync)=這一輪不看退避,照打(與逐檔退避同一個語意)。
//
// 狀態只活在這個行程裡:行程重開=從零開始,最壞代價是重開後第一輪多撞一發就重新停下。
package collector
import (
"errors"
"fmt"
"net"
"net/url"
"strings"
"sync"
"time"
)
// routeBackoffLadder=同一條路連續失敗第 N 次之後要停多久。
// 第一格 1 分鐘:一次偶發的 500 不該讓使用者等太久;
// 上限 30 分鐘:雲端修好之後,最慢半小時內自己接上,不必重開小幫手。
var routeBackoffLadder = []time.Duration{
1 * time.Minute, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute, 30 * time.Minute,
}
// routeStrikesBeforeBackoff=同一條路連續幾發「第一次送」都失敗,才判定是路壞了。
//
// 為什麼是 4 而不是 1:一次 500 可能只是那一份內容的問題,下一輪就該照常重試
// (既有測試守著:下架失敗下一輪補、收回失敗下一輪補、總覽退避到期就補、
// 單輪上限 3 份全是壞檔時健康檔下一輪要遞補得上來)。
// 為什麼不是更多:2026-09-13 的實況,一輪開頭的出處修正+總覽+目錄索引+第一個新檔
// 就是 4 發——第一輪之內就停得下來。之後每次窗口到期只會再撞 1 發。
const routeStrikesBeforeBackoff = 4
// routeFailMemory=上一次失敗之後多久沒再失敗,就不再把它算進「連續」。
// 取階梯上限(30 分鐘)的兩倍:退避中的路窗口到期會再試一發,所以真的一直壞的路
// 最慢 30 分鐘就會再記一次失敗,不會被這條規則洗掉;只有「好幾個小時才偶發一次」
// 的零星失敗才會重新算起。
const routeFailMemory = time.Hour
type routeState struct {
fails int
until time.Time
lastFail time.Time
lastCause string // 給人看的「最近一次是怎麼失敗的」,不含狀態碼與網址
}
// RouteFailure=一次被算進斷路器的失敗(印進 collector.log 的那一行)。
type RouteFailure struct {
Route string `json:"route"` // 給人看的路名(例:雲端 AI 整理文件)
Account string `json:"account"` // 知識庫主機
Fails int `json:"fails"` // 算進這一發之後的連續失敗數
Cause string `json:"cause"` // 白話的失敗種類
HTTPStatus int `json:"http_status,omitempty"` // 0=沒拿到回應
Detail string `json:"detail,omitempty"` // 連線層錯誤原文(有才帶)
BackoffUntil string `json:"backoff_until,omitempty"` // 這一發讓整條路停到什麼時候(沒停=空)
}
// routeFailureLog=每算進一次失敗就呼叫一次。預設不做事(測試不想被印一堆);
// `collector direct` 主程式會把它接到 stdout(→ supervisor tee 進 collector.log)。
var routeFailureLog = func(at time.Time, f RouteFailure) {}
// announceRouteFailure 把一次失敗印成 stdout 上一個完整的 JSON 值(與其他播報共用鎖)。
func announceRouteFailure(at time.Time, f RouteFailure) {
printJSONLine(struct {
At string `json:"at"`
Phase string `json:"phase"`
RouteFailure
}{at.Format(time.RFC3339), "route_failure", f})
}
type routeBreaker struct {
mu sync.Mutex
routes map[string]*routeState
}
// cloudRoutes=整個行程共用的一份(跨輪、跨帳號;key 自帶主機名,帳號之間不會互相牽連)。
var cloudRoutes = &routeBreaker{routes: map[string]*routeState{}}
// routeBackoffError=「這一發根本沒打出去,因為這條路正在退避」。
// 呼叫端用 isRouteBackoff 認它:這不是那個檔的錯,不准記進逐檔病歷。
type routeBackoffError struct{ note string }
func (e *routeBackoffError) Error() string { return e.note }
func isRouteBackoff(err error) bool {
_, ok := err.(*routeBackoffError)
return ok
}
// isLocalNetworkErr=「這一發根本沒離開這台電腦」:DNS 查不到、網路不通、連線被拒。
//
// 🔴 刻意不含逾時(包括連線階段的逾時):等到超時的那一發,雲端可能已經在處理了,
// 那是 stallguard.go 的事,也照樣算進斷路器。
// 字串比對是給**舊病歷**用的:manifest 裡存的 LastError 是字串,不是 error 物件。
func isLocalNetworkErr(err error) bool {
if err == nil {
return false
}
if isStallError(err) {
return false
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return true
}
var opErr *net.OpError
if errors.As(err, &opErr) && opErr.Op == "dial" {
return true
}
return isLocalNetworkText(err.Error())
}
// isLocalNetworkTextisLocalNetworkErr 的字串版(manifest 的 LastError 只剩字串)。
func isLocalNetworkText(msg string) bool {
if msg == "" {
return false
}
for _, mark := range []string{
"no such host",
"network is unreachable",
"no route to host",
"connection refused",
"server misbehaving", // 本機 DNS 伺服器回壞答案
} {
if strings.Contains(msg, mark) {
return true
}
}
return false
}
// routeFailCause=給人看的「這一發是怎麼失敗的」。不帶狀態碼(畫面上的話不裸露上游代碼)。
func routeFailCause(status int, transportErr error) string {
switch {
case transportErr != nil && isStallError(transportErr):
return "等不到回應"
case transportErr != nil:
return "連線中途斷掉"
case status == 429:
return "雲端說請求太多"
default:
return "雲端回報內部錯誤"
}
}
// routeKey=主機+路徑(不含查詢字串)。同一台知識庫的不同工作流是不同的路。
func routeKey(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return raw
}
return u.Host + u.Path
}
// routeLabel=給人看的「哪一條路」。只取工作流名,不出現網址與狀態碼。
func routeLabel(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return "雲端"
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
// /webhooks/named/<ns>/<workflow>/trigger
if len(parts) >= 5 && parts[0] == "webhooks" && parts[len(parts)-1] == "trigger" {
return parts[len(parts)-2]
}
switch u.Path {
case "/portal/daemon/extract":
return "雲端 AI 整理文件"
case "/portal/daemon/folder-tree":
return "回報資料夾結構"
}
return u.Path
}
// note 回「這條路現在正在退避」的白話;空=可以打。知識庫以主機名稱呼(見 noteFor)。
func (b *routeBreaker) note(raw string, now time.Time) string {
return b.noteFor(raw, "", now)
}
// noteFor=note,另帶「哪一台知識庫」的稱呼(帳號名)。
//
// 🔴 `inkstone/arcrun-rag#201`:舊句子只講「雲端『雲端 AI 整理文件』這條路」——
// 小幫手連著三個知識庫,而這句會被浮到首頁頂端。2026-09-16 那句其實是 geek6688 的,
// leo 看的是 leo21c,於是查錯了帳號。⇒ 句子裡一定要點名是哪一台,
// 也要講最近一次是哪一種失敗、什麼時候(「連續失敗 11 次」本身說不出原因)。
func (b *routeBreaker) noteFor(raw, who string, now time.Time) string {
b.mu.Lock()
defer b.mu.Unlock()
st := b.routes[routeKey(raw)]
if st == nil || !now.Before(st.until) {
return ""
}
if strings.TrimSpace(who) == "" {
who = instanceHostOf(raw)
}
wait := st.until.Sub(now).Round(time.Second)
last := ""
if st.lastCause != "" && !st.lastFail.IsZero() {
last = fmt.Sprintf("(最近一次 %s%s", st.lastFail.Local().Format("15:04"), st.lastCause)
}
// 🔴 結尾「稍後會自動恢復」是 explainsWhySkipped 的識別字(sync_status.go),不要改掉。
// 不帶上游原文(HTTP 碼/JSON):這句會出現在畫面上;原文在 collector.log 的 route_failure 那幾行。
return fmt.Sprintf("知識庫「%s」的「%s」這條路連續失敗 %d 次%s,先停 %s 再試,避免一直重撞把雲端額度用光;稍後會自動恢復。",
who, routeLabel(raw), st.fails, last, humanWait(wait))
}
// record 記一發的結果。status=HTTP 狀態碼(0=沒拿到回應);transportErr=連線層錯誤;
// retry=這份內容先前就失敗過(逐檔病歷上有紀錄)——它失敗不算路壞,見檔頭。
func (b *routeBreaker) record(raw string, now time.Time, status int, transportErr error, retry bool) {
// #201:請求根本沒離開這台電腦 ⇒ 雲端沒花任何額度,不算數也不歸零。
if transportErr != nil && isLocalNetworkErr(transportErr) {
return
}
failed := transportErr != nil || status >= 500 || status == 429
b.mu.Lock()
key := routeKey(raw)
if !failed {
if status >= 200 && status < 300 {
delete(b.routes, key) // 通了就全部歸零
}
b.mu.Unlock()
return
}
if retry {
b.mu.Unlock()
return // 有前科的內容再失敗:它自己的病,不算數也不歸零
}
st := b.routes[key]
if st == nil {
st = &routeState{}
b.routes[key] = st
}
// #201:上一次失敗太久以前 ⇒ 那不是「連續」,重新算起。
if st.fails > 0 && !st.lastFail.IsZero() && now.Sub(st.lastFail) > routeFailMemory {
st.fails = 0
}
st.fails++
st.lastFail = now
st.lastCause = routeFailCause(status, transportErr)
entry := RouteFailure{
Route: routeLabel(raw),
Account: instanceHostOf(raw),
Fails: st.fails,
Cause: st.lastCause,
HTTPStatus: status,
}
if transportErr != nil {
entry.Detail = transportErr.Error()
}
if st.fails >= routeStrikesBeforeBackoff {
idx := st.fails - routeStrikesBeforeBackoff
if idx >= len(routeBackoffLadder) {
idx = len(routeBackoffLadder) - 1
}
st.until = now.Add(routeBackoffLadder[idx])
entry.BackoffUntil = st.until.Format(time.RFC3339)
}
b.mu.Unlock()
routeFailureLog(now, entry) // 鎖外呼叫:印 stdout 要拿另一把鎖,別疊在一起
}
// reset 清空全部狀態(測試用)。
func (b *routeBreaker) reset() {
b.mu.Lock()
defer b.mu.Unlock()
b.routes = map[string]*routeState{}
}
func humanWait(d time.Duration) string {
if d >= time.Minute {
return fmt.Sprintf("%d 分鐘", int((d+time.Minute-1)/time.Minute))
}
return fmt.Sprintf("%d 秒", int(d/time.Second))
}
// routeNote=打某條路之前先問「它現在在退避嗎」。按「立刻同步」的這一輪一律照打。
//
// arcrun-rag#197:雲端資料庫今天的免費額度用完 ⇒ 這台知識庫的每一條路都不打,
// **連「立刻同步」也不打**——恢復前每一發只會拿到同一個錯;而「立刻同步」那一輪
// cloudVersionThrottled 會強制重問 /health,額度真的恢復了這裡自然就放行。
func (c *DirectConfig) routeNote(raw string) string {
if note := d1QuotaNote(c.CypherURL, directNow()); note != "" {
return note
}
if c.ForceSync {
return ""
}
return cloudRoutes.noteFor(raw, c.InstanceName, directNow())
}