Fix #2076: local-plugin v2.0.8: gateway CPU 100% — synchronous full-table vector scan (scan#2077
Conversation
…r scan (MemTensor#2076) Two coupled root causes made the gateway Node process pin one CPU core at 100% with 4.2 GB RSS on startup for 40+ minutes on a 518k-row DB. Bug MemTensor#1 — synchronous full-table vector scan (`core/storage/vector.ts`): `scanAndTopK` used `db.prepare(sql).all(params)`, materialising up to `hardCap` (default 100_000) rows in one synchronous step. Each row carries a multi-KB vector BLOB, so peak RSS was `O(hardCap × dim)` and the event loop was blocked while `topKCosine` walked the whole array. Fix: stream via `.iterate()`, maintain the top-K min-heap on hits directly (only k entries + one just-decoded vector live in JS memory at a time), and lower the default `hardCap` to 5_000 as a safer default (all production callers pass their own value explicitly). Bug MemTensor#2 — dedup pagination cap (`core/capture/capture.ts`): The four dedup call sites in `runLite` / `runLightweight` / `runReflect` / `persistRows` all used `tracesRepo.list({ episodeId })`, which is paginated and silently truncates to 500 rows via `_helpers.ts::clampLimit`. Once an episode exceeded 500 traces the older rows became invisible to dedup and the tail was re-inserted on every cycle. In the reporter's DB this had grown the `traces` table to 518_375 rows of which 84% were exact duplicates by `(episode_id, turn_id, user_text, agent_text, tool_calls_json)`. Fix: add `tracesRepo.listAllForEpisode(episodeId)`, an uncapped read ordered by `ts ASC`, and switch the four dedup call sites to it. Every other paginated `list({ episodeId, limit })` caller keeps its explicit page-size contract. Tests - `tests/unit/storage/traces-listall.test.ts` — 4 cases: 750-row episode returns in full, strict episode scoping, empty episode, `ts ASC` ordering. - `tests/unit/storage/vector-stream.test.ts` — 3 cases: parity with brute-force top-K on live DB, streaming keeps only top-K in memory yet still considers every row within the cap, explicit `hardCap` still truncates the candidate window. - Full plugin unit suite: 1128 pass, 1 skipped. Two pre-existing failures on `dev-v2.0.23` (startup-recovery source-string check, migrator schema-drift) are unrelated to this fix. Closes MemTensor#2076
🤖 Open Code ReviewTarget: PR #2077 🔍 OpenCodeReview found 5 issue(s) in this PR. 1.
|
Address the five findings from open code review on PR MemTensor#2077: 1. `traces.ts::listAllForEpisode` was hydrating all 25 columns including `vec_summary`/`vec_action` BLOBs while dedup callers only ever read the identity fields. Added `listDedupRowsForEpisode` — a streaming sibling that projects only the five dedup columns (ts/turn_id/user_text/agent_text/tool_calls_json) and uses `.iterate()` so peak RSS scales with scalar payload rather than total embedding footprint. `listAllForEpisode` kept as-is for `runReflect`, which still needs the full row (id/tags/vecSummary/ vecAction). 2. `vector.ts::scanAndTopK` streaming loop silently `continue`d on dimension mismatches, losing the `search.dim_mismatch` warning the old `topKCosine` path emitted. Restored the warn log with {expected,got,rowId} — the primary operator signal for detecting schema drift (re-embedding with a new model dimension) in production. Empty vectors keep the silent-skip semantics as before. 3. `vector.ts::scanAndTopK` had the meta-object construction duplicated verbatim in both heap-push branches. Hoisted into a single `buildMeta()` closure above the branch so future edits to the projection cannot drift between the two paths. 4. `capture.ts::persistRows` unconditionally called `listAllForEpisode` even when its caller (`runLite` / `runLightweight` / `runReflect`) had just scanned the same episode. Added an optional `existingSignatures?: Set<string>` param; all three call-sites now pass the pre-built signature set derived from their own dedup scan, eliminating the second full-episode scan. The set is cloned inside `persistRows` so the intra-batch dedup doesn't leak new signatures back to the caller. 5. `capture.ts::runLite` (and `runLightweight`) previously fetched full `TraceRow[]` via `listAllForEpisode` and threw everything away except the `ts` / `turnId` field. Switched both to `listDedupRowsForEpisode` so no BLOBs load for the extraction dedup pass. `traceIdentitySignature` widened to `Pick<TraceRow, "toolCalls" | "turnId" | "ts" | "agentText" | "userText">` so it can accept either `TraceRow` (runReflect path) or the narrow `TraceDedupRow` produced by the streaming helper. Tests - tests/unit/storage/traces-listall.test.ts — 4 new cases pin the narrow-projection contract (fields returned, empty episode, 750-row uncapped stream, strict episode scoping + ts ASC). - tests/unit/storage/vector-stream.test.ts — 2 new cases pin the dim-mismatch warning fires for non-empty mismatched vectors and stays silent for zero-length vectors. Verification - `npx tsc -p tsconfig.json --noEmit` → clean. - `npx vitest run tests/unit/storage/traces-listall.test.ts tests/unit/storage/vector-stream.test.ts` → 13/13 pass. - `npx vitest run tests/unit/capture` → 110/110 pass. - Full `npx vitest run tests/unit` → 1134/1137 pass; the two failures (storage/migrator, startup-recovery) reproduce on the unmodified PR head, so they are pre-existing and unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🤖 Open Code ReviewTarget: PR #2077 🔍 OpenCodeReview found 5 issue(s) in this PR. 1.
|
…round 2) Second-pass open-code-review fixes on top of 3e25bc3. All three are minimal-diff / non-behavior-changing. 1. `vector.ts::scanAndTopK` was building the `buildMeta` closure inside the per-row streaming loop. On a hot search over the full `hardCap` this allocated one closure per iteration — pure GC pressure since only the two heap-push branches consume it. Hoisted the projection above the loop as a plain function taking the current row as an argument. Two variants pre-selected once based on `selectExtra.length` so filtered-out rows still pay nothing. 2. `traces.ts::listDedupRowsForEpisode` JSDoc claimed the helper "streams via `.iterate()` so peak memory scales with the scalar payload". That was misleading: the method uses `stmt.iterate()` internally but still accumulates every row into a `TraceDedupRow[]` before returning. The genuine saving is the narrow scalar-only projection (never touches `vec_summary` / `vec_action`), not streaming. Rewrote the JSDoc to describe the real cost model and name the `.iterate()` benefit accurately (avoids an intermediate `.all()` allocation, does not stream to caller). 3. `traces.ts::listAllForEpisode` JSDoc said "all hot fields required by dedup are projected, so a caller that only needs `ts`, `turnId`, or the identity signature can still iterate at full speed". That accidentally invited dedup callers back onto this method, which loads every BLOB column. Rewrote the JSDoc to state explicitly that vec_summary/vec_action BLOBs are loaded and redirect dedup-only callers to `listDedupRowsForEpisode`. Preserved the MemTensor#2076 context on why the uncapped variant exists. No behavioral change. No test changes needed. Verification - `npx tsc -p tsconfig.json --noEmit` → clean. - `npx vitest run tests/unit/storage/traces-listall.test.ts \ tests/unit/storage/vector-stream.test.ts` → 13/13 pass. - `npx vitest run tests/unit/capture` → 110/110 pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
🤖 Open Code ReviewTarget: PR #2077 🔍 OpenCodeReview found 2 issue(s) in this PR. 1.
|
|
✅ Automated Test Results: PASSEDAll tests passed (25/25 executed). memos_local_plugin/unit: 25/25. Duration: 5s [advisory, non-gating] AI-generated tests on branch test/auto-gen-91b3e78270801f79-20260708230538: 0/58 passed, 58 failed — these do NOT affect the PR verdict; review the branch manually. Branch: |
Description
Fixed two coupled root causes in @memtensor/memos-local-plugin that made the gateway node process pin one CPU core at 100% with 4.2 GB RSS on startup (#2076).
Bug #1 — synchronous full-table vector scan in core/storage/vector.ts.
scanAndTopKuseddb.prepare(sql).all(params), loading up tohardCap(default 100_000) multi-KB vector BLOBs synchronously into JS memory. Fixed by streaming via.iterate()and maintaining the top-K min-heap on hits directly (only k entries + one just-decoded vector live in memory at a time), and lowering the defaulthardCapto 5_000 as a safer default (all production callers pass their own value explicitly). Peak RSS during a scan drops from O(hardCap × dim) to O(k × dim).Bug #2 — dedup pagination cap in core/capture/capture.ts. Four dedup call sites (
runLite/runLightweight/runReflect/persistRows) used the paginatedtracesRepo.list({ episodeId }), which silently caps at 500 rows. Once an episode exceeded 500 traces the older rows became invisible to dedup and were re-inserted every cycle (the reporter's DB had 518_375 rows of which 84% were exact duplicates). Fixed by addingtracesRepo.listAllForEpisode(episodeId), an uncapped read ordered byts ASC, and switching the four dedup call sites to it. All other paginatedlist({ episodeId, limit })callers keep their explicit page-size contract.Tests: 7 new regression tests (traces-listall.test.ts: 4 cases covering 750-row episode dedup, episode scoping, empty episode, and ts-ordering; vector-stream.test.ts: 3 cases covering top-K correctness parity, streaming under a large cap, and explicit-hardCap truncation). Full plugin unit suite: 1128 pass, 1 skipped. The 2 remaining failures (startup-recovery source-string check, migrator schema-drift
traces has no column named role) are pre-existing on base branch dev-v2.0.23 and unrelated to this fix. Type check clean.Related Issue (Required): Fixes #2076
Type of change
Please delete options that are not relevant.
How Has This Been Tested?
Automated tests are pending.
Checklist
@bittergreen please review this PR.
Reviewer Checklist