|
| 1 | +--- |
| 2 | +title: "refactor: Storage correctness, performance, and MCP completeness" |
| 3 | +type: refactor |
| 4 | +status: active |
| 5 | +date: 2026-03-09 |
| 6 | +--- |
| 7 | + |
| 8 | +# Storage Correctness, Performance, and MCP Completeness |
| 9 | + |
| 10 | +## Overview |
| 11 | + |
| 12 | +Address five issues identified in code review: a lock ordering violation that risks deadlock, a performance bottleneck where `list` loads all thread files, a missing `reindex` MCP tool, unnecessary full-index loads in semantic search, and silent error swallowing in the `track` hook. |
| 13 | + |
| 14 | +## Problem Statement |
| 15 | + |
| 16 | +1. **Lock ordering deadlock** — `updateThread()` acquires a per-thread lock, then calls `updateMetaIndex()` which acquires the meta lock. If another process holds the meta lock and needs a thread lock, classic deadlock. The 10s stale timeout makes this non-fatal but causes operation failures. |
| 17 | + |
| 18 | +2. **`list` loads all thread files** — `listThreads()` calls `loadAllThreads()` just to count snippets and files per thread. At 50+ threads this becomes a noticeable disk I/O bottleneck. The meta index only stores summary and dates — not counts. |
| 19 | + |
| 20 | +3. **No `reindex` MCP tool** — Users working exclusively through Claude Code's MCP interface can't trigger a semantic index rebuild. The semantic search tells them to run `threadlinking reindex` but they have no way to do it from MCP. |
| 21 | + |
| 22 | +4. **`semanticSearch` loads full index** — Every semantic search reads all thread files via `loadIndex()` just to resolve thread metadata for results. Could use the lightweight meta index instead. |
| 23 | + |
| 24 | +5. **`track` swallows all errors** — The hook's `catch {}` means a corrupted `pending.json` silently stops tracking files until someone notices. |
| 25 | + |
| 26 | +## Technical Approach |
| 27 | + |
| 28 | +### Architecture |
| 29 | + |
| 30 | +The fix centers on `src/core/storage.ts`. The key insight: **move the meta index update outside the thread lock** in `updateThread()`. This eliminates the nested lock acquisition that causes deadlock risk, and naturally leads to enriching the meta index with counts (since we need the meta update to be self-contained). |
| 31 | + |
| 32 | +### Implementation Phases |
| 33 | + |
| 34 | +#### Phase 1: Fix Lock Ordering (Correctness) |
| 35 | + |
| 36 | +**Goal:** Eliminate deadlock risk by ensuring no function holds two locks simultaneously. |
| 37 | + |
| 38 | +**Approach:** In `updateThread()`, release the thread lock *before* calling `updateMetaIndex()`. The thread file is already written atomically at that point — the meta update is a best-effort sync of metadata. |
| 39 | + |
| 40 | +**Tasks:** |
| 41 | + |
| 42 | +- [ ] `src/core/storage.ts` — Restructure `updateThread()`: |
| 43 | + 1. Acquire thread lock |
| 44 | + 2. Load, mutate, atomicWrite thread file |
| 45 | + 3. Capture the updated thread data |
| 46 | + 4. Release thread lock |
| 47 | + 5. Call `updateMetaIndex()` (now outside thread lock) |
| 48 | + - The try/finally block needs restructuring — release must happen before the meta call |
| 49 | +- [ ] `src/core/storage.ts` — Audit `saveThread()`: |
| 50 | + - Currently: atomicWrite thread (no lock) → updateMetaIndex |
| 51 | + - This is fine (only one lock), but document the ordering |
| 52 | +- [ ] `src/core/operations/relate.ts` — Fix sequential `updateThread` calls: |
| 53 | + - Sort thread IDs alphabetically before acquiring locks to prevent A→B vs B→A deadlock |
| 54 | + - After Phase 1's `updateThread` fix, each call only holds one lock at a time, so this becomes less critical — but deterministic ordering is still good practice |
| 55 | +- [ ] `src/commands/rename.ts` — Audit sequential `saveThread` + `deleteThreadFile`: |
| 56 | + - Both only acquire meta lock — no deadlock risk, but document |
| 57 | +- [ ] `src/commands/clear.ts` — Audit loop of `deleteThreadFile`: |
| 58 | + - Sequential meta locks — no deadlock, but contention. Consider batching into single meta update |
| 59 | +- [ ] Add doc comment to `updateThread()` explaining lock ordering contract |
| 60 | +- [ ] Add doc comment to `updateMetaIndex()` explaining it must never be called while holding a thread lock (enforced by design after this change) |
| 61 | + |
| 62 | +**Tests:** |
| 63 | + |
| 64 | +- [ ] `tests/operations.test.ts` — Add concurrency test: two `addSnippet` calls to different threads in parallel should both succeed |
| 65 | +- [ ] `tests/operations.test.ts` — Add concurrency test: `addSnippet` + `listThreads` in parallel should not deadlock |
| 66 | +- [ ] `tests/operations.test.ts` — Verify `relateThreads(a, b)` and `relateThreads(b, a)` produce same result (deterministic ordering) |
| 67 | + |
| 68 | +**Risk:** Brief window between thread write and meta update where they're inconsistent. This is acceptable because: |
| 69 | +- Meta index is advisory (listing/display only) |
| 70 | +- Thread file is the source of truth |
| 71 | +- `loadAllThreads()` doesn't use meta index |
| 72 | +- Worst case: a `list` shows stale count for <1 second |
| 73 | + |
| 74 | +**Files:** `src/core/storage.ts`, `src/core/operations/relate.ts`, `tests/operations.test.ts` |
| 75 | + |
| 76 | +--- |
| 77 | + |
| 78 | +#### Phase 2: Meta Index Carries Counts (Performance) |
| 79 | + |
| 80 | +**Goal:** Eliminate `loadAllThreads()` from `list` by storing counts in the meta index. |
| 81 | + |
| 82 | +**Approach:** Enrich `ThreadMeta` with `snippetCount` and `fileCount`. Update `extractMeta()` to include them. The meta index stays small (two extra numbers per thread) but removes the need to load full thread files for listing. |
| 83 | + |
| 84 | +**Tasks:** |
| 85 | + |
| 86 | +- [ ] `src/core/storage.ts` — Add to `ThreadMeta` interface: |
| 87 | + ```typescript |
| 88 | + export interface ThreadMeta { |
| 89 | + summary: string; |
| 90 | + date_created: string; |
| 91 | + date_modified?: string; |
| 92 | + snippetCount?: number; // Optional for backward compat with existing index.json |
| 93 | + fileCount?: number; |
| 94 | + } |
| 95 | + ``` |
| 96 | +- [ ] `src/core/storage.ts` — Update `extractMeta()`: |
| 97 | + ```typescript |
| 98 | + function extractMeta(thread: Thread): ThreadMeta { |
| 99 | + return { |
| 100 | + summary: thread.summary, |
| 101 | + date_created: thread.date_created, |
| 102 | + date_modified: thread.date_modified, |
| 103 | + snippetCount: (thread.snippets || []).length, |
| 104 | + fileCount: (thread.linked_files || []).length, |
| 105 | + }; |
| 106 | + } |
| 107 | + ``` |
| 108 | +- [ ] `src/core/operations/list.ts` — Remove `loadAllThreads()` call: |
| 109 | + - Use `meta.threads[id].snippetCount ?? 0` instead of loading full thread |
| 110 | + - For the `allLinkedFiles` set (used to filter pending), fall back to `loadAllThreads()` only if any meta entry lacks `fileCount` (migration path) |
| 111 | + - Alternatively: track linked files in a separate lightweight structure (future optimization) |
| 112 | +- [ ] `src/core/operations/list.ts` — Handle migration gracefully: |
| 113 | + - If `snippetCount` is undefined in meta, fall back to `loadAllThreads()` (one-time until all threads are touched) |
| 114 | + - Consider adding a `rebuildMetaCounts()` function that iterates all threads once and updates meta |
| 115 | +- [ ] Migration: Add `rebuildMetaCounts()` to `src/core/storage.ts`: |
| 116 | + - Loads all threads once, updates meta with accurate counts |
| 117 | + - Called during `ensureMigrated()` if any thread lacks counts |
| 118 | + - Or exposed as CLI command for manual trigger |
| 119 | + |
| 120 | +**Complication — `allLinkedFiles` set:** |
| 121 | +The `list` operation builds a set of all linked files across all threads to filter the pending list. This still requires reading all threads OR maintaining a separate index of linked files. Options: |
| 122 | +- **Option A:** Accept that `list` with pending files still loads all threads. Only optimize the thread listing part. |
| 123 | +- **Option B:** Store a flat `allLinkedFiles: string[]` in the meta index. Updated whenever attach/detach runs. |
| 124 | +- **Option C:** Skip the linked-file filter for pending — show all pending files regardless. |
| 125 | + |
| 126 | +**Recommendation:** Option A for now. The snippet/file counts are the big win (avoids parsing all snippet content). The linked-files filter is a secondary optimization. |
| 127 | + |
| 128 | +**Tests:** |
| 129 | + |
| 130 | +- [ ] `tests/operations.test.ts` — Verify `listThreads()` returns correct counts from meta index |
| 131 | +- [ ] `tests/operations.test.ts` — Verify counts update after `addSnippet` |
| 132 | +- [ ] `tests/operations.test.ts` — Verify counts update after `attachFile` / `detachFile` |
| 133 | +- [ ] `tests/operations.test.ts` — Verify backward compat: old meta without counts falls back correctly |
| 134 | + |
| 135 | +**Files:** `src/core/storage.ts`, `src/core/operations/list.ts`, `tests/operations.test.ts` |
| 136 | + |
| 137 | +--- |
| 138 | + |
| 139 | +#### Phase 3: Add `reindex` MCP Tool (Workflow Gap) |
| 140 | + |
| 141 | +**Goal:** Let MCP-only users trigger semantic index rebuild. |
| 142 | + |
| 143 | +**Tasks:** |
| 144 | + |
| 145 | +- [ ] `src/mcp/server.ts` — Add `threadlinking_reindex` tool: |
| 146 | + ```typescript |
| 147 | + server.tool( |
| 148 | + 'threadlinking_reindex', |
| 149 | + 'Rebuild the semantic search index. Run after adding many snippets, or if semantic search returns stale results.', |
| 150 | + {}, |
| 151 | + async () => { |
| 152 | + const result = await rebuildSemanticIndex(); |
| 153 | + return { |
| 154 | + content: [{ type: 'text', text: result.message }], |
| 155 | + isError: !result.success, |
| 156 | + }; |
| 157 | + } |
| 158 | + ); |
| 159 | + ``` |
| 160 | +- [ ] Verify `rebuildSemanticIndex` is already exported from `src/core/index.js` |
| 161 | +- [ ] Update README MCP tools table — add `threadlinking_reindex` (13 tools now) |
| 162 | +- [ ] Update `threadlinking_status` tool to mention reindex availability |
| 163 | + |
| 164 | +**Tests:** |
| 165 | + |
| 166 | +- [ ] Manual test via MCP: call `threadlinking_reindex`, verify index rebuilds |
| 167 | + |
| 168 | +**Files:** `src/mcp/server.ts`, `README.md` |
| 169 | + |
| 170 | +--- |
| 171 | + |
| 172 | +#### Phase 4: Optimize Semantic Search (Performance) |
| 173 | + |
| 174 | +**Goal:** Stop loading all thread files on every semantic search. |
| 175 | + |
| 176 | +**Approach:** Use `loadMetaIndex()` instead of `loadIndex()` in `semanticSearch()`. The search results only need thread ID, summary, and dates — all available in meta. Only load full thread data if the caller specifically needs snippet content. |
| 177 | + |
| 178 | +**Tasks:** |
| 179 | + |
| 180 | +- [ ] `src/core/operations/semantic.ts` — In `semanticSearch()`: |
| 181 | + - Replace `loadIndex()` with `loadMetaIndex()` |
| 182 | + - Build results using `meta.threads[threadId]` instead of `threadIndex[threadId]` |
| 183 | + - The `SemanticSearchResult` type already uses a subset that maps to `ThreadMeta` |
| 184 | +- [ ] `src/core/types.ts` — Verify `SemanticSearchResult` shape: |
| 185 | + - Check if it references full `Thread` or just metadata fields |
| 186 | + - If it needs full thread data (e.g., matched snippet content), load only the matched thread files (top N) |
| 187 | +- [ ] `src/mcp/server.ts` — Verify MCP semantic search handler doesn't depend on full thread data |
| 188 | + |
| 189 | +**Risk:** If `SemanticSearchResult` includes matched snippet content (not just IDs), we'd need to load the specific matched thread files. This is still better than loading all threads — typically 3-5 results vs 50+ threads. |
| 190 | + |
| 191 | +**Tests:** |
| 192 | + |
| 193 | +- [ ] `tests/operations.test.ts` — Semantic search returns correct results (existing tests should cover) |
| 194 | +- [ ] Performance: manual test with 50+ seeded threads to verify improvement |
| 195 | + |
| 196 | +**Files:** `src/core/operations/semantic.ts`, possibly `src/core/types.ts` |
| 197 | + |
| 198 | +--- |
| 199 | + |
| 200 | +#### Phase 5: Track Error Logging (Observability) |
| 201 | + |
| 202 | +**Goal:** Make `track` command failures observable without blocking the editing workflow. |
| 203 | + |
| 204 | +**Approach:** Write errors to a debug log file instead of swallowing silently. The hook stays async and non-blocking — it just leaves a trail. |
| 205 | + |
| 206 | +**Tasks:** |
| 207 | + |
| 208 | +- [ ] `src/commands/track.ts` — In the catch block: |
| 209 | + ```typescript |
| 210 | + catch (error) { |
| 211 | + try { |
| 212 | + const logPath = join(homedir(), '.threadlinking', 'debug.log'); |
| 213 | + const timestamp = new Date().toISOString(); |
| 214 | + const message = error instanceof Error ? error.message : String(error); |
| 215 | + appendFileSync(logPath, `[${timestamp}] track error: ${message}\n`); |
| 216 | + } catch { |
| 217 | + // If we can't even log, truly swallow |
| 218 | + } |
| 219 | + } |
| 220 | + ``` |
| 221 | +- [ ] Consider log rotation — if `debug.log` exceeds 100KB, truncate to last 50KB on write |
| 222 | +- [ ] Add `debug.log` to `.threadlinkingignore` default content so it doesn't show as pending |
| 223 | + |
| 224 | +**Tests:** |
| 225 | + |
| 226 | +- [ ] `tests/operations.test.ts` — Verify track with corrupted pending.json writes to debug.log |
| 227 | + |
| 228 | +**Files:** `src/commands/track.ts`, possibly `src/core/ignore.ts` (default ignore content) |
| 229 | + |
| 230 | +--- |
| 231 | + |
| 232 | +## Acceptance Criteria |
| 233 | + |
| 234 | +### Functional Requirements |
| 235 | + |
| 236 | +- [ ] No function holds two locks simultaneously |
| 237 | +- [ ] `threadlinking list` with 50+ threads does not load all thread files (when meta has counts) |
| 238 | +- [ ] `threadlinking_reindex` MCP tool exists and rebuilds the semantic index |
| 239 | +- [ ] `semanticSearch` does not call `loadAllThreads()` or `loadIndex()` |
| 240 | +- [ ] `track` errors are logged to `~/.threadlinking/debug.log` |
| 241 | + |
| 242 | +### Non-Functional Requirements |
| 243 | + |
| 244 | +- [ ] All 195 existing tests pass after each phase |
| 245 | +- [ ] Build succeeds with no TypeScript errors after each phase |
| 246 | +- [ ] Backward compatible: existing `index.json` without counts works (graceful fallback) |
| 247 | +- [ ] Lock ordering documented in code comments |
| 248 | + |
| 249 | +### Quality Gates |
| 250 | + |
| 251 | +- [ ] Concurrency tests added for Phase 1 |
| 252 | +- [ ] Count accuracy tests added for Phase 2 |
| 253 | +- [ ] Each phase is a separate commit for clean revert if needed |
| 254 | + |
| 255 | +## Dependencies & Prerequisites |
| 256 | + |
| 257 | +- Phase 2 depends on Phase 1 (meta update logic changes in Phase 1 affect how counts are written) |
| 258 | +- Phase 3 is independent (can be done anytime) |
| 259 | +- Phase 4 is independent (can be done anytime) |
| 260 | +- Phase 5 is independent (can be done anytime) |
| 261 | + |
| 262 | +``` |
| 263 | +Phase 1 (lock ordering) ──→ Phase 2 (meta counts) |
| 264 | +Phase 3 (reindex MCP) [independent] |
| 265 | +Phase 4 (semantic perf) [independent] |
| 266 | +Phase 5 (track logging) [independent] |
| 267 | +``` |
| 268 | + |
| 269 | +## Risk Analysis & Mitigation |
| 270 | + |
| 271 | +| Risk | Likelihood | Impact | Mitigation | |
| 272 | +|------|-----------|--------|------------| |
| 273 | +| Phase 1 introduces brief meta inconsistency | Certain | Low | Meta is advisory; thread file is source of truth | |
| 274 | +| Phase 2 migration misses threads | Low | Medium | Fallback to `loadAllThreads()` when counts missing | |
| 275 | +| Phase 2 counts drift from reality | Low | Low | `extractMeta()` recalculates on every thread update | |
| 276 | +| Phase 4 breaks semantic search result format | Low | Medium | Verify result type compatibility before changing | |
| 277 | + |
| 278 | +## References |
| 279 | + |
| 280 | +### Internal References |
| 281 | + |
| 282 | +- Storage layer: `src/core/storage.ts` — all locking, atomic writes, migration |
| 283 | +- Meta index structure: `src/core/storage.ts:42-51` (`ThreadMeta`, `MetaIndex`) |
| 284 | +- Lock options: `src/core/storage.ts:66-69` (stale: 10s, update: 5s) |
| 285 | +- `updateThread`: `src/core/storage.ts:278-313` |
| 286 | +- `updateMetaIndex`: `src/core/storage.ts:220-236` |
| 287 | +- `extractMeta`: `src/core/storage.ts:117-123` |
| 288 | +- `loadAllThreads`: `src/core/storage.ts:339-356` |
| 289 | +- List operation: `src/core/operations/list.ts:14-17` (hardcoded `needCounts = true`) |
| 290 | +- Semantic search: `src/core/operations/semantic.ts` |
| 291 | +- Track command: `src/commands/track.ts` |
| 292 | +- MCP server: `src/mcp/server.ts` |
| 293 | +- Test patterns: `tests/operations.test.ts` |
| 294 | +- Relate operation (new): `src/core/operations/relate.ts` — sequential `updateThread` calls |
| 295 | + |
| 296 | +### Lock Acquisition Map |
| 297 | + |
| 298 | +| Function | Lock 1 | Lock 2 (nested) | Risk | |
| 299 | +|----------|--------|-----------------|------| |
| 300 | +| `updateThread(id, fn)` | Per-thread file | Meta index (via `updateMetaIndex`) | **Deadlock** | |
| 301 | +| `updateMetaIndex(fn)` | Meta index | — | Safe | |
| 302 | +| `saveThread(id, thread)` | — | Meta index (via `updateMetaIndex`) | Safe | |
| 303 | +| `deleteThreadFile(id)` | — | Meta index (via `updateMetaIndex`) | Safe | |
| 304 | +| `updatePending(fn)` | Pending file | — | Safe | |
| 305 | +| `updateIndex(fn)` | Meta index (global) | — | Safe | |
0 commit comments