Files

148 lines
6.5 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.
// direct_pacing.go — 上傳節奏(2026-08-07):積壓不要一次湧上去,新改的檔優先。
//
// 背景(封測事故):Evan 在自己的免費 Cloudflare 帳號上跑,監看資料夾裡約 690 個檔,
// daemon 逐檔萃取上傳、每個檔在雲端產生一次工作流執行 ⇒ 1,070 次寫入撞上免費上限
// 1,000,額度爆掉,只有 8 個檔成功。雲端那一半(紀錄改走資料層 API)已修好,
// 但只要積壓一次全湧上去,額度一恢復就會立刻再爆一次——daemon 這一半必須自己有節奏。
//
// 兩個機制,缺一不可:
// 1. 排序:一輪掃到的多個 added/modified/renamed 事件,依檔案 mtime 由新到舊處理
// leo 2026-08-07 定的排序判準:「日期最近的最優先,今天寫的一定最近」——
// 一條佇列就夠,不必維護「新檔」「積壓」兩條)。
// 2. 節流:每次觸發雲端(萃取或 POST ingest/removed)之間留最小間隔,
// 且單輪最多處理有限筆——**存量慢慢消化不影響日常使用**,但下一輪 Scan()
// 會立刻看到使用者剛寫的新檔並插到最前面,不會被積壓卡住幾小時才輪到。
package collector
import (
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
// directPaceInterval:兩次觸發雲端 workflow 之間的最小間隔。
// 正式執行維持節流;測試會在 init 時歸零(見 direct_pacing_test.go),
// 需要驗證節流本身的測試再自行 save/restore 成一個小的非零值。
var directPaceInterval = 700 * time.Millisecond
// pace 節流:在每次要觸發雲端(萃取 API/postJSON)之前呼叫。
// directPaceInterval<=0 時(測試環境預設)為 no-op。
func pace() {
if directPaceInterval > 0 {
time.Sleep(directPaceInterval)
}
}
// DefaultMaxEventsPerRun:單輪最多處理幾個 added/modified/renamed/removed 事件。
// 選這個數字的理由:搭配預設 700ms 節流,一輪約 17.5 秒完成,遠短於使用者能感知的
// 「卡住」門檻,也讓下一輪 Scan()(PollSec 預設 5s 後)很快就能重新排序、
// 把使用者剛寫的新檔插到隊伍最前面——不會被巨量積壓鎖住好幾小時才輪得到。
const DefaultMaxEventsPerRun = 25
// effectiveMaxEventsPerRun 回傳這份設定實際生效的單輪事件上限(<=0 時用預設)。
func (c *DirectConfig) effectiveMaxEventsPerRun() int {
if c.MaxEventsPerRun > 0 {
return c.MaxEventsPerRun
}
return DefaultMaxEventsPerRun
}
// mtimeOf 讀檔案的修改時間;讀不到(已被刪除等)回零值,排序時會被當最舊、排到最後。
func mtimeOf(absPath string) time.Time {
info, err := os.Stat(absPath)
if err != nil {
return time.Time{}
}
return info.ModTime()
}
// sortEventsNewestFirst 把一輪的事件依「新改的檔優先」重排。
//
// ⚠️ 刻意不改 Event struct(不加 Mtime 欄位):collector-trigger.v1.schema.json
// 是凍結版本、additionalProperties:false,欄位變更=開 v2 新檔(見 schema 檔頭註解)。
// 這裡只重排 direct.go 本機處理佇列的順序,不動送上雲的 payload 形狀。
//
// - added/modified/renamed:依檔案現在的 mtime 由新到舊;mtime 相同或讀不到時
// 以路徑排序,保持每次呼叫結果一致(好測試、好除錯)。
// - removed:檔案已經不存在,沒有 mtime 可排,一律排在內容事件之後
// (下架不急,不該搶在使用者剛寫的新檔前面)。
func sortEventsNewestFirst(absRoot string, events []Event) []Event {
out := make([]Event, len(events))
copy(out, events)
kind := func(ev Event) int {
if ev.Type == "removed" {
return 1 // 排在後段
}
return 0 // added/modified/renamed
}
mt := make(map[string]time.Time, len(out))
for _, ev := range out {
if ev.Type != "removed" {
mt[ev.Path] = mtimeOf(filepath.Join(absRoot, filepath.FromSlash(ev.Path)))
}
}
sort.SliceStable(out, func(i, j int) bool {
ki, kj := kind(out[i]), kind(out[j])
if ki != kj {
return ki < kj
}
if ki == 1 { // 兩者皆 removed:路徑序,穩定即可
return out[i].Path < out[j].Path
}
ti, tj := mt[out[i].Path], mt[out[j].Path]
if !ti.Equal(tj) {
return ti.After(tj) // 新到舊
}
return out[i].Path < out[j].Path
})
return out
}
// resumeAfterCapMessage 給「這輪因為單輪上限被延後」的事件用的說明——
// 不是失敗,是刻意排隊;不該讓使用者以為程式壞了或漏了這個檔。
func resumeAfterCapMessage(remaining int) string {
return fmt.Sprintf("已排入佇列,下一輪會繼續處理(本輪上限已到,還有 %d 筆等待)", remaining)
}
// partitionRetryEligible 把「已依 mtime 新到舊排序」的事件分成兩組:
// - ready:這輪真的會被嘗試(會呼叫 pace()/打雲端)——沒在退避中、沒達重試上限、
// 帳號沒在額度冷卻中。
// - waiting:這輪不會被嘗試,只是單純交代原因——退避窗口未到、已達
// MaxFailBeforeSkip、或整個帳號正在額度冷卻。
//
// removed 事件不受退避/額度冷卻管制(下架本來就不看 ShouldRetry,見 direct.go 的
// case "removed"),一律歸 ready,維持既有行為不變。
//
// 🔴 為什麼要在 cap 之前先分這一刀(arcrun-rag#104 comment 4480t217):
// 舊版直接對排序後的原始清單套用 perRunCap`orderedEvents[:perRunCap]`)。
// mtime 不會因為一個檔正在退避就變新或變舊,排序因此是穩定的——只要前 perRunCap
// 名裡有幾個持續失敗的檔案,它們會**每一輪都繼續佔著那幾個名額**(即使這一輪
// 根本不會被嘗試,只是被跳過),排在它們後面、從沒被嘗試過的事件因此永遠排不到,
// 不管跑幾百輪都一樣。這正是 leo 實測「1691→1880→1936 筆從不減少」的真因:
// 不是處理得慢,是那些筆數的候補名單裡,有一大段從頭到尾沒拿到出場機會。
//
// 呼叫端該把 cap 套在這裡回傳的 ready 上,讓「退避中」的事件不佔嘗試名額,
// 把機會讓給排在它們後面、真正還沒被嘗試過的事件。
func partitionRetryEligible(m *Manifest, events []Event, now int64, force bool, coolingDown bool) (ready, waiting []Event) {
for _, ev := range events {
if ev.Type == "removed" {
ready = append(ready, ev)
continue
}
if coolingDown {
waiting = append(waiting, ev)
continue
}
if m.ShouldRetry(ev.Path, now, force) {
ready = append(ready, ev)
} else {
waiting = append(waiting, ev)
}
}
return ready, waiting
}