Skip to content

fix(qoder-cn): bound unbounded session anchor map and SQLite backlog reads - #227

Open
Snssn wants to merge 1 commit into
alibaba:mainfrom
Snssn:fix/qoder-cn-collector-memory-growth
Open

fix(qoder-cn): bound unbounded session anchor map and SQLite backlog reads#227
Snssn wants to merge 1 commit into
alibaba:mainfrom
Snssn:fix/qoder-cn-collector-memory-growth

Conversation

@Snssn

@Snssn Snssn commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two structures in the qoder / qoder-cn collection path have no upper bound, so the
long-lived daemon's RSS grows with usage instead of staying flat. Both are fixed here.

1. QoderCnTraceInput.sessionAnchor grew forever. It is a plain Map that is only
ever written and read, never deleted — every new IDE session adds a permanent entry for
the lifetime of the process.

It now uses a capacity-bounded LRU map (500 entries, matching the existing
WINDOW_STATE_LIMIT convention in qoder-work-log/qoder-work-trace-input.ts). A read
counts as use, because the call site mutates the stored anchor in place rather than
re-inserting it, so an actively merged session is never dropped.

There is deliberately no idle TTL. A capacity cap already fully solves the unbounded
growth, whereas expiring on a timer would add a data-quality regression: a user can leave
a session open over lunch and resume it, and a dropped anchor would re-parent the resumed
turns onto a new turn id — splitting one conversation into two traces.

2. readNewRows read the entire backlog in one shot. BaseSqliteInput.collect()
called readNewRows(lastRowId), and both subclasses (qoder-cn-sqlite, qoder-sqlite)
issued WHERE rowid > ? ORDER BY rowid ASC with no LIMIT. A collector that fell
behind — daemon stopped or paused while the IDE kept writing — would materialise every
backlogged row, transform all of them, and hand the whole array to the flushers at once.
token_info values are JSON blobs, so this is the path that can actually reach gigabytes.

The row limit now lives in the base class (readNewRows(lastRowId, limit)) so no subclass
can reintroduce an unbounded query. A cycle drains up to 10 batches of 1000 rows and
persists the cursor after each batch, so a stop mid-catch-up replays nothing. Draining
multiple batches per cycle avoids the other failure mode: a single 1000-row batch per 30s
poll would need over four hours to work through a 500k-row backlog.

qoder-work-sqlite already had LIMIT 1000; this brings the other two implementations in
line and makes the bound a property of the base class rather than of each subclass
remembering to add it.

Measured effect

On a seeded 60,000-row backlog:

peak heap
batched (this PR) 40.4 MB
single unbounded read (before) 221.7 MB

The batched figure is flat in backlog size while the previous one is linear in it, so the
gap widens as the backlog grows. Emitted AgentActivityEntry content is unchanged — a test
asserts the batched output is identical to a single unbounded read.

Drive-by removal

BoundedTtlCache in utils/git-context.ts is deleted. It was exported but imported
nowhere, and its eviction called the module-level evictStaleEntries, which compares
against the hardcoded MAX_CACHE_ENTRIES (256) rather than the instance's own
maxEntries — so any instance built with a different capacity was either never bounded or
silently trimmed to 256. gitContextCache keeps its own eviction, since git context must
expire relative to the fetch rather than the last read.

Test plan

  • npm run typecheck clean
  • npm run build passes
  • New tests/unit/utils/bounded-lru-map.test.ts (10 cases): capacity never exceeded,
    LRU eviction order, read-counts-as-use survival through churn, the boundary where a
    burst larger than the whole map does evict a recently used entry, in-place mutation
    retained
  • New tests/unit/inputs/base-sqlite-input-batching.test.ts (8 cases): multi-batch
    drain in ascending rowid order, no query exceeds the batch limit, stop at the
    per-cycle budget and resume with no gap or replay, per-batch cursor persistence,
    rows yielding no entry still advance the cursor, a failing later batch keeps
    already-collected entries without rewinding the cursor, and equivalence with a
    single unbounded read
  • Extended tests/unit/inputs/qoder-cn-trace-input.test.ts (5 new cases): orphan
    turns merge into a live anchor with contiguous step numbering across cycles, an
    anchor survives a long idle gap, a forgotten anchor is evicted and the later orphan
    turn then keeps its own ids, capacity is never exceeded, and an anchor read each
    cycle survives heavy churn
  • Existing qoder-cn-trace, qoder-cn-sqlite-reader, qoder-sqlite and
    qoder-work-sqlite suites pass unchanged (66/66 across the affected files)

Note on the full suite: 10 test files fail on unmodified main in my local environment
(mostly tests/unit/hooks/** .mjs files, plus hermes-agent-event-log-flow,
file-pipeline and daemon-cache-dir). I baselined the same subset with these changes
stashed and got the same failures, and none of those files reference any module touched
here — so they are pre-existing/environmental rather than caused by this PR.

Follow-ups not in this PR

  • readHookJsonl / base-hook-input.ts:160 allocate Buffer.alloc(stat.size - offset),
    reading the whole unread tail of the hook JSONL in one go. That is a transient spike
    rather than a monotonic leak, and it is shared by every hook input, so it deserves its
    own change.
  • These were the only unbounded structures found by code inspection, but confirming which
    one dominated a specific multi-GB outlier still needs a heap snapshot from an affected
    host.

… reads

Two structures in the qoder / qoder-cn collection path had no upper bound, so
the long-lived daemon's RSS grew with usage instead of staying flat.

`QoderCnTraceInput.sessionAnchor` was a plain Map that was only ever written
and read, never deleted: every new IDE session added a permanent entry for the
lifetime of the process. It now uses a capacity-bounded LRU map (500 entries),
where a read counts as use so an actively merged session is never dropped.
There is deliberately no idle TTL — a user can leave a session open and resume
it later, and expiring the anchor on a timer would re-parent the resumed turns
onto a new turn id, splitting one conversation into two traces.

`BaseSqliteInput.collect()` called `readNewRows()`, and both subclasses issued
`WHERE rowid > ? ORDER BY rowid ASC` with no LIMIT. A collector that fell behind
would materialise the entire backlog in one cycle — every row, every resulting
entry, and the whole flusher hand-off at once. The row limit now lives in the
base class (`readNewRows(lastRowId, limit)`) so no subclass can reintroduce an
unbounded query, and a cycle drains up to 10 batches of 1000 rows, persisting
the cursor after each batch so a stop mid-catch-up replays nothing.
`qoder-work-sqlite` already had `LIMIT 1000`; this brings the other two in line.

Measured on a 60k-row backlog: peak heap 40.4 MB batched vs 221.7 MB for the
previous single unbounded read, and the batched figure is flat in backlog size
rather than linear in it.

Also removes `BoundedTtlCache` from `utils/git-context.ts`. It was exported but
imported nowhere, and its eviction compared against the hardcoded module
constant instead of the instance's own capacity, so any other capacity was
either never bounded or silently trimmed to 256.

@ralf0131 ralf0131 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Fixes two unbounded data structures in the long-lived collector daemon that caused RSS to grow with usage: sessionAnchor (plain MapBoundedLruMap(500)) and readNewRows (unbounded SQL → batched reads of 1000 rows × 10 batches/cycle). The measured peak heap reduction from 221.7 MB to 40.4 MB on a 60k-row backlog is compelling.

Findings

  • [Info] base-sqlite-input.ts:52 — Moving LIMIT into the base class abstract method signature is a strong structural guard against regression.
  • [Info] bounded-lru-map.ts:38 — The delete-during-iteration pattern is safe in ES6+ but could benefit from a brief inline comment.
  • [Info] qoder-cn-trace-input.ts:50 — No-idle-TTL decision is well-justified; eviction log throttle is a nice touch.

Review Notes

Strengths:

  • Crash safety: cursor persisted per batch, not per cycle — a shutdown mid-catch-up replays nothing.
  • The BoundedLruMap is a clean, dependency-free implementation with proper LRU semantics (read = use via delete+reinsert).
  • Test coverage is thorough: 24 new tests covering batching edge cases, LRU eviction, session anchor bounds, and output equivalence.
  • The drive-by removal of unused BoundedTtlCache keeps the codebase clean.

No blocking concerns. The code is well-structured, the measurements validate the approach, and the tests assert behavioral equivalence with the previous unbounded implementation.


Automated review by github-manager-bot

let rows: SqliteRow[];

for (const row of rows) {
try {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good design choice — moving the LIMIT into the base class signature makes it impossible for subclasses to reintroduce unbounded queries. The abstract method contract (limit: number as a required parameter) enforces this at compile time.

/** Returns how many entries were evicted to stay within capacity. */
set(key: string, value: V): number {
this.entries.delete(key);
this.entries.set(key, value);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The for (const k of this.map.keys()) + this.map.delete(k) pattern is safe in ES6+ (Map iterators skip deleted keys correctly), but worth a brief code comment noting this, since it's a common source of confusion in code reviews.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[not-fixed] 同意此建议,本轮核实 bounded-lru-map.ts:38-45 的驱逐循环仍无该注释。

证据: head=dadd3b32 src/utils/bounded-lru-map.tsset()for (const oldest of this.entries.keys()) { ... this.entries.delete(oldest) } 边遍历边删,行为在 ES6+ 安全但缺注释。属 Low 级可读性建议,不阻断合入,建议顺手补一行说明。


Generated by LoongSuite-Pilot Code Review Agent

// through sessions drops the least-recently-used anchors instead of growing
// this map forever.
private readonly sessionAnchor = new BoundedLruMap<{ turnId: string; maxStep: number }>(
SESSION_ANCHOR_LIMIT,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The decision to omit an idle TTL is well-justified in the PR description and the code comment. The ANCHOR_EVICTION_LOG_INTERVAL_MS throttle is a nice touch to avoid log spam during catch-up.

@Snssn Snssn changed the title fix(collector): bound unbounded session anchor map and SQLite backlog reads fix(qoder-cn): bound unbounded session anchor map and SQLite backlog reads Aug 7, 2026
@linrunqi08

Copy link
Copy Markdown
Collaborator

🔍 Code Review Summary

评审对象 head=dadd3b32。改动聚焦、注释翔实、测试覆盖到位——修复了 qoder/qoder-cn 采集路径两处长驻 daemon 的无界内存增长(session anchor map、SQLite backlog 全量读)。经 3 个专项子 agent 与主评审独立核验,未发现阻断级缺陷。

Severity Count
Critical 0
High 0
Medium 0
Low 2

Lifecycle Verdict

Check Result
资源释放 PASS
死锁/卡死风险 PASS
状态恢复正确性 PASS

崩溃语义三段核验:collect 中途→未 emit 未 save,干净重放;emit 后 save 前→整段重放(at-least-once,与旧代码一致,非回归);save 后→干净。cursor 推进移出 try 还顺带修掉旧代码「尾部坏行永久卡住 cursor」的隐患。

Merge Gate(合入门禁)

APPROVE-READY ✅(阻断级别 Critical/High/Medium 均为 0;Lifecycle 三项全 PASS。Low 不阻断合入。)

总体结论

可以合入。两处 Low 均为可读性/注释表述问题,不影响功能与正确性,建议作者顺手处理:

  • [Low] src/inputs/base/base-sqlite-input.ts:71 — 批循环内 setRowId(...) 的注释「Persisted per batch」名不副实:setRowId 只改内存并置 dirty(state-store.ts:94-96),真正落盘只在 cycle 末 emit 之后的一次 save()base-input.ts:88-93),每批中间 cursor 会被覆盖,落盘频率仍是每 cycle 一次,与旧实现等价。所承诺的结果(mid-catch-up 崩溃不重放已发数据)实际成立、无运行时回归,仅注释把并不存在的「按批增量 checkpoint」当作机制,易误导后续维护者。建议改为如实描述。
  • [Low] src/utils/bounded-lru-map.ts:38 — 采纳 @ralf0131 的建议:for...keys() 边遍历边 delete 在 ES6+ 安全,但值得补一行注释。(对应历史评论,本轮判定 not-fixed,Low 不阻断)

Highlights(正向实践)

  • limit 提升到基类抽象方法签名,编译期强制子类分页,从根上杜绝子类重新引入无界查询。
  • cursor 推进移出 try,修掉旧代码尾部坏行 stall cursor 的隐患,并有测试覆盖。
  • readSqliteTokensForSession 显式注释「禁止加 LIMIT」,防止后人误分页按 session 限定的查询而丢 token。
  • 驱逐日志按时间窗聚合节流,避免满 map 每次写刷屏;no-idle-TTL 取舍论证充分(避免会话被拆成两条 trace)。

评审报告详见: code-review/pr-227/final-report.md
Generated by LoongSuite-Pilot Code Review Agent

@linrunqi08 linrunqi08 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Medium/High 及以上问题为 0,Lifecycle 三项全 PASS,本轮评审通过(approved)。仅余 2 处 Low(注释表述 + 一行可读性注释),不阻断合入。详见评审摘要。

Generated by LoongSuite-Pilot Code Review Agent

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.

3 participants