fix(storage): add idx_traces_ts to unblock event loop on newest-first trace reads - #2284
Conversation
… trace reads
Problem
-------
The daemon froze solid on installs with a large `traces` table: HTTP requests
were accepted by the kernel backlog but never answered, boot took 40-60s of
near-100% CPU, and any liveness watchdog restart-looped the process forever
(300+ restarts/day observed in production). Every doomed generation re-ran
the scan at boot and was killed mid-scan, making the storm self-sustaining.
Root cause (CPU-profiled, 28% of all samples in one statement)
-------
`traces.list({limit:1})` -- used by `latestTraceTs()` (3x per
`/api/v1/health` request) and by the pipeline's recent-events replay at
bootstrap -- issues `SELECT ... FROM traces ORDER BY ts DESC, id DESC
LIMIT 1`. No `traces` index leads with bare `ts` (`EXPLAIN QUERY PLAN`:
`SCAN traces` + `USE TEMP B-TREE FOR ORDER BY`), so each call was a full
table scan + sort. better-sqlite3 runs statements synchronously on the JS
event loop, so the scan blocked ALL request handling while it ran.
Fix
---
- 013-traces-ts-index.sql: `CREATE INDEX IF NOT EXISTS idx_traces_ts ON
traces(ts DESC, id DESC)` -- turns the lookup into an index seek.
- migrator.ts: same tableExists guard as 012 for partial test schemas, plus a
release-train heal: DBs migrated by the other train carry schema_migrations
rows whose VERSION numbers collide under different NAMES (observed: 13 =
'skill-repair-origin', 14 = 'episode-outcome'), which silently skipped any
same-numbered migration from this build. Additive, guarded migrations now
still run under such collisions and repair their bookkeeping row via upsert;
all others keep the conservative skip behaviour.
- migrator.test.ts: regression tests for both behaviours (8/8 pass).
Validation
----------
- EXPLAIN after: `SCAN traces USING INDEX idx_traces_ts`; newest-trace lookup
~700ms -> ~0.7ms warm (>1000x); index build ~1s per 100k rows.
- Live sandbox reproduction (2.0.15 build, prod-sized DB copy): daemon that
previously never answered a single request in 150s served 200 OK within 6s
of boot and kept serving.
- Production cutover: pipeline.ready 60s+ -> <1s; health 200 OK @ 205ms;
restart storm stopped (was 321 restarts that day, zero since).
- tests/unit/storage/: 82/82 pass; tsc clean for storage/*.
Commit-message-only note: no runtime code paths changed other than schema;
the migration is additive and idempotent.
🤖 Open Code ReviewTarget: PR #2284 🔍 OpenCodeReview found 2 issue(s) in this PR. 1.
|
✅ Automated Test Results: PASSEDAll tests passed (8/8 executed). memos_local_plugin/unit: 8/8. Duration: 3s [advisory, non-gating] AI-generated tests on branch test/auto-gen-7588bca7b16bfc30-20260826123140: 27/34 passed, 7 failed — these do NOT affect the PR verdict; review the branch manually. Branch: |
|
Thanks @OPEN Code Review — both findings accepted, both addressed in d6ab77f (comment-only, no behavior change; tests/unit/storage still 82/82). On (1) — version 11: agreed the omission deserved an inline explanation, so one is now there. One correction to the suggested rationale: 011's SQL is actually fully idempotent — every statement is On (2) — allowlist/guard coupling: agreed; added the cross-reference comment above the guarded case chain. If maintainers would prefer structural enforcement over comments, happy to follow up with a small variant that derives the allowlist from the guarded-case table itself so the two cannot drift. |
✅ Automated Test Results: PASSEDAll tests passed (8/8 executed). memos_local_plugin/unit: 8/8. Duration: 3s [advisory, non-gating] AI-generated tests on branch test/auto-gen-4269caffd382f99e-20260826181419: 26/36 passed, 10 failed — these do NOT affect the PR verdict; review the branch manually. Branch: |
Summary
Fixes a production daemon freeze/restart-storm caused by an unindexed newest-first trace read that blocked the Node event loop. Adds one index migration plus a migrator heal for release-train version collisions.
Problem
On an install whose
tracestable reached ~30k rows (~235 MB with embedding blobs + tool-call JSON), thememos-local-plugindaemon:Root cause (CPU-profiled)
A
--cpu-profcapture showed 28% of all samples in one statement:all() @ connection.jsunder two call paths:/api/v1/health→latestTraceTs()→traces.list({ limit: 1 })(three calls per health request)createPipeline→traces.list({ limit: 30 }))Both issue
SELECT ... FROM traces ORDER BY ts DESC, id DESC LIMIT n. Every existingtracesindex leads withowner_agent_kind,owner_profile_id,share_scope,session_id, orepisode_id, so the unfiltered newest-first read degenerates to:Because better-sqlite3 executes statements synchronously on the JS event loop, the scan blocks all request handling while it runs.
Fix
013-traces-ts-index.sql—CREATE INDEX IF NOT EXISTS idx_traces_ts ON traces(ts DESC, id DESC).migrator.ts— sametableExists("traces")guard pattern as 012 (partial test schemas must not fail), plus a release-train collision heal: databases migrated by the other train carryschema_migrationsrows whose version numbers collide under different names (observed live:(13,'skill-repair-origin'),(14,'episode-outcome')). Version-only bookkeeping silently skipped any same-numbered migration from this build — including additive repair migrations like this one. Guarded/additive migrations now still apply under such collisions and repair their bookkeeping row via upsert; non-guarded migrations keep the conservative skip behaviour.migrator.test.ts.Validation
SCAN traces+ temp B-treeSCAN ... USING INDEX idx_traces_tspipeline.readytests/unit/storage/: 82/82 passtsc --noEmit: no errors in storage/* (remaining errors are pre-existingadapters/deepseek-harness/*optional-dep issues)Test plan
npx vitest run tests/unit/storage/migrator.test.ts(8/8)npx vitest run tests/unit/storage/(82/82)