Skip to content

GET /api/brain/inbox reads and parses every inbox record twice per request #5440

Description

@atomantic

Problem

brainStorage.getInboxLog() (server/services/brainStorage.js:803-814) is getAll('inbox') — it reads and JSON.parses every inbox record (capturedText + classification + extracted + ai metadata) — then sorts with new Date(b.capturedAt) - new Date(a.capturedAt) inside the comparator (re-parsing each timestamp O(n log n) times), filters by status, and finally slices the page. GET /api/brain/inbox (server/routes/brainCapture.js:36-43) runs that and getInboxLogCounts() (:853-873, a second full getAll('inbox')) per request.

The inbox never shrinks: markInboxDone, resolveReview, fixClassification only flip status, and there is no prune, so N is every capture the user has ever made. Links got the paged/indexed read path in #3509 (getLinksPage, :1074-1095); the inbox — polled far more often — still has the full-walk one.

Trigger

  • client/src/pages/OpenWorld.jsx:427-431useAutoRefetch(() => api.getBrainInbox({ status: 'needs_review', limit: 1 }), 60_000): two full inbox walks every 60 s while the OpenWorld page is open, to get one count for the memory-district well.
  • client/src/components/brain/tabs/InboxTab.jsx:77 (default page of 50) and TrustTab.jsx:33 (limit 100) on every tab open, filter change, and after each brain:classified socket event refresh.
  • server/services/jobGates.js:22 brainReviewGate on every CoS job-scheduler eligibility check; server/services/askService.js:199 and server/services/voice/tools/brain.js:70,105 (limit 200) per ask/voice turn; runWeeklyReview (brain.js:660, limit 500); recoverStuckClassifications (brain.js:828) at boot.

Impact

N inbox records ⇒ 2N file reads + 2N parses + an O(n log n) date-parsing sort per request, for a page of ≤100 records. A user who has captured 5,000 thoughts pays 10,000 reads per minute from OpenWorld alone, and every capture makes the next Inbox tab open slower. Because the page bodies are the only thing actually rendered, all of the (N − limit) parsed bodies are discarded.

Fix

Page the inbox from a projection index, mirroring getLinksPage (#3509), in server/services/brainStorage.js.

  1. Extend the summary projection from Brain summary poll parses every brain record body every 30s to render three counts #5438 (or add it if that lands second) with the inbox sort key: { status: record.status, isGitHubRepo: record.isGitHubRepo, capturedAtMs: safeDate(record.capturedAt) }, null for tombstone/unparseable — one index, one invalidation path, shared by getSummary, getInboxLogCounts, and this.
  2. Rewrite getInboxLog({ status, limit = 50, offset = 0 }) to: resolve the index for 'inbox', drop null rows, filter on status when given (strict ===, as today), sort by capturedAtMs desc with an id tiebreak (bulk captures and imports share a timestamp; an unstable order across two page requests drops/duplicates rows at the slice boundary — same reasoning as getLinksPage:1084-1087), slice, then Promise.all(pageIds.map((id) => getById('inbox', id))) and drop nulls (deleted between index and body read). Keep the signature and the plain-array return so the seven callers above don't change.
  3. getInboxLogCounts() tallies status from the same rows (done in Brain summary poll parses every brain record body every 30s to render three counts #5438 if it lands first).

Behavioral notes to preserve/accept: a missing/unparseable capturedAt currently yields NaN in the comparator (undefined order); with safeDate it becomes 0 and sorts last deterministically — same choice #3509 made for createdAt. createInboxLog always stamps capturedAt, so this only affects hand-edited records.

Rejected: a status-keyed sidecar index file on disk — a second source of truth that must be kept in step with per-record writes and peer applies; the in-memory projection rebuilds from readdir and is already the established pattern. Rejected: pruning done/filed entries — a data-retention policy change, not a perf fix.

Files: server/services/brainStorage.js, server/services/brainStorage.test.js.

Tests (in brainStorage.test.js beside describe('getLinksPage …', :438), same temp-dir store + readJSONFile spy as the :474 test):

  • seeded inbox of ~8 entries with mixed statuses, two sharing a capturedAt, one tombstoned, one with no capturedAt: getInboxLog({ limit, offset }), getInboxLog({ status }) return the same ids in the same order as the current implementation (tombstone excluded, missing-date last), and two adjacent pages neither overlap nor skip.
  • a repeat page read loads only limit record bodies (spy count), not N.
  • updateInboxLog(id, { status: 'done' }) moves the record across the status filter on the next call; deleteInboxLog(id) removes it.

Acceptance criteria

  • GET /api/brain/inbox?status=needs_review&limit=1 reads at most 1 record body from disk in steady state (index warm), not 2N.
  • Page contents, ordering, and counts are identical to the current implementation for a seeded store (tests above).
  • getInboxLog keeps its { status, limit, offset } signature and array return; no caller changes.
  • cd server && npm test green.

Metadata

Metadata

Assignees

Labels

area:brainBrain notes/memories/goals/knowledge graphbugs-perfeffort:mediumEffort: mediummodel:lightModel size: lightplanTracked by /do:replan

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions