Skip to content

fix: decouple result writeback from task dispatch to stop cross-region edge deadlock - #32

Open
710leo wants to merge 1 commit into
masterfrom
fix/decouple-result-writeback-from-task-dispatch
Open

fix: decouple result writeback from task dispatch to stop cross-region edge deadlock#32
710leo wants to merge 1 commit into
masterfrom
fix/decouple-result-writeback-from-task-dispatch

Conversation

@710leo

@710leo 710leo commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

On a cross-region deployment (edge in Tokyo, center in Shanghai), a categraf agent stopped receiving any tasks at all. Its log showed one line every 6 seconds, for hours, with no self-recovery:

E! AI ibex rpc call Server.Report fail: timeout

The RPC service itself was healthy. A minimal msgpack-rpc probe against the same port showed:

Server.Ping                        ->  0.063s
Server.Report, ReportTasks = []    ->  0.000s   (returns AssignTasks normally)
Server.Report, ReportTasks = 1356  ->  never returns within the client's 5s budget

That isolates the blocking to handleDoneTask.

Root cause

Report does two unrelated things in one synchronous call: it writes back execution results, and it hands out pending tasks. The writeback is per-result, and on an edge node each result costs one HTTP round trip to center — about 200ms across regions.

Once an agent has accumulated a batch of results (1356 in this incident), a single Report needs ~141s while the agent's RPC timeout is 5s. That closes a loop:

agent times out and reconnects
  -> Locals.Clean never runs
  -> local results are never dropped
  -> next round resends the identical batch
  -> server starts over
  -> times out again

The agent's own cleanup logic is locked out by the very thing it is supposed to clean up. Meanwhile AssignTasks is never delivered, so the host stops receiving tasks permanently. In this incident four legitimate tasks queued after the deadlock formed were never picked up.

The ceiling here is low for a component whose whole purpose is cross-region deployment: 5s / 200ms is roughly 25 results before the channel wedges for good.

Changes

src/server/rpc/method.go

  • Move the writeback to a background goroutine so Report returns AssignTasks immediately. A sync.Map keyed by ident keeps at most one writeback in flight per agent, so a resending agent cannot pile up goroutines — during the incident, edge-to-center connections grew from 126 to 160 for exactly this reason. A skipped batch is logged at warning level, because the agent drops its copy after Clean and the results are not recoverable.
  • handleDoneTask now logs and continues on a per-result failure instead of returning. Previously a single unwritable result prevented every result after it in the same batch from ever being persisted.

src/server/timer/timer_host_doing.go

Return early when either fetch fails, keeping the previous cache. The error was previously logged but not returned, and the partial result was then written over the cache. When center was unreachable this made agents silently stop receiving tasks, with models.TableRecordGets fail as the only trace.

src/storage/redis.go

IdInit uses SetNX. This id is the fallback allocator used when an edge cannot reach center, so it must not repeat across restarts. An unconditional Set restarts allocation from IDINITIAL on every boot and hands out ids that collide with historical tasks. The affected edge's id key was sitting at exactly 2^32.

Trade-off worth reviewing

The sync.Map guard trades "never pile up goroutines" against "a batch may be skipped while an earlier one is still draining". Skipped batches are logged, and the window only opens while a backlog is being flushed — normal writeback is sub-millisecond. If losing results is the greater concern, the guard can be dropped in favour of a plain go handleDoneTask(req): once the agent gets a successful response it clears Locals.M, so the next round carries nothing and pile-up is self-limiting. The conservative side was chosen here because this incident was driven by an agent resending the same 200KB batch every second.

Verification

  • gofmt -l src/ clean
  • go build and go vet pass on the three changed packages
  • Full go build ./... of n9e-plus passes with a replace pointing at this branch

Note that src/server/router fails to compile standalone on this repo; that is pre-existing and reproduces on an unmodified tree. It only builds inside n9e-plus, which carries the nightingale replace. The repository has no test files.

Release note

Next tag should be v1.3.12. n9e-plus currently pins v1.3.9, so bumping straight to v1.3.12 also pulls in v1.3.10 (DaMeng TaskHost.Upsert) and v1.3.11 (task table ids no longer treated as auto-increment) — worth a regression pass, since the latter touches the same area as the edge fallback ids described above.

线上现象:edge 上的 categraf 收不到任何新任务,日志里每 6 秒一条
`AI ibex rpc call Server.Report fail: timeout`,持续数小时不自愈。

成因是 Report 里把「回写执行结果」和「下发待办任务」放在了同一次同步调用里。
回写是逐条落库的,edge 上每条还要发一次 HTTP 到 center,跨机房往返约 200ms。
一旦 agent 手里攒了一批结果(线上是 1356 条),单次 Report 需要约 141 秒,
而 agent 的 RPC 超时是 5 秒。于是:

  agent 超时断开 -> Locals.Clean 不执行 -> 本地结果永远清不掉
    -> 下一轮原样重发 -> 服务端再跑一遍 -> 永远超时

清理逻辑被它自己要清理的东西挡在门外,而且 AssignTasks 一直发不出去,
这台机器的任务通道就被永久堵死了。跨机房部署下能承受的结果数上限只有
5s / 200ms ≈ 25 条,对一个专为跨地域设计的组件来说过低。

本次改动:

1. src/server/rpc/method.go
   回写改为后台 goroutine,Report 立即返回 AssignTasks,两件事解耦。
   用 sync.Map 保证每个 ident 同时只有一个回写在跑,防止 agent 重发时
   goroutine 堆积(线上观察到 edge 到 center 的连接数因此从 126 涨到 160)。
   跳过的批次打 Warning 留痕,因为 agent 侧 Clean 之后就取不回来了。

2. src/server/rpc/method.go
   handleDoneTask 单条回写失败改为 continue,不再 return 中断整批。
   原来一条写不进去,它后面的所有结果都没有机会落库。

3. src/server/timer/timer_host_doing.go
   取数失败时直接返回,保留上一轮缓存。原来错误只记日志不返回,
   随后照常用残缺数据覆盖,导致 center 不通时 agent 静默收不到任务,
   排查时唯一线索只有一行 models.TableRecordGets fail。

4. src/storage/redis.go
   IdInit 改用 SetNX。这个 id 是 edge 与 center 不通时给任务兜底发号用的,
   要求跨重启不重复;无条件 Set 会让每次重启都从 IDINITIAL 重新发号,
   发出与历史任务相同的 id。线上 edge 的 id 键正是精确的 2^32。

验证:gofmt 无差异;改动的三个包 go build / go vet 通过;
在 n9e-plus 里用 replace 指向本仓库全量 go build 通过。
(src/server/router 的编译错误是既有问题,未修改的树上同样存在。)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant