Skip to content

perf(conversations): track message stats in threads log so list_threads is O(threads.jsonl) - #1517

Merged
senamakel merged 3 commits into
tinyhumansai:mainfrom
senamakel:perf/threads-list-stats-in-log
May 12, 2026
Merged

senamakel merged 3 commits into
tinyhumansai:mainfrom
senamakel:perf/threads-list-stats-in-log

Conversation

@senamakel

@senamakel senamakel commented May 12, 2026 •

Copy link
Copy Markdown
Member

Summary

  • ConversationStore::list_threads no longer reads every per-thread messages JSONL on every call. It folds message_count and last_message_at straight out of threads.jsonl.
  • Adds two new ThreadLogEntry variants: MessageAppended (incremental, emitted from append_message) and Stats (absolute snapshot, used to backfill legacy data).
  • Legacy threads with no stat history get backfilled on the first list_threads call after upgrade — one slow per-file read each, then a persisted Stats snapshot so every subsequent call is fast.
  • Old-format threads.jsonl still parses (#[serde(default)] on new fields, Upsert/Delete shape unchanged).

Problem

Sentry OPENHUMAN-REACT-X surfaced as a 30 s timeout on openhuman.threads_list (unhandled-rejection band-aid landed in #1513). Tracing the call into the core revealed the actual root cause:

list_threads_unlocked
 └─ for each thread:
     thread_summary_unlocked(id)
       ├─ thread_index_unlocked()           ← re-reads threads.jsonl per thread
       └─ read_jsonl::<ConversationMessage> ← reads the ENTIRE per-thread messages file

That's O(threads² + total messages bytes) on every list_threads, all inside the global CONVERSATION_STORE_LOCK — so any other conversation RPC queues behind it. A user with 100 threads + a year of chat history can easily push a single call past 30 s on slow disk.

Solution

Push stats into threads.jsonl so folding the log gives us everything:

Variant Purpose
Upsert (unchanged) thread metadata (title, labels, created_at, parent)
Delete (unchanged) remove from index
MessageAppended { thread_id, last_message_at } bump count by 1, overwrite timestamp — emitted by append_message under the existing lock
Stats { thread_id, message_count, last_message_at } absolute snapshot — used to backfill legacy threads once, after which the fast path takes over

The fold in thread_index_unlocked carries (message_count: Option<usize>, last_message_at: Option<String>) per thread. None means "no stat history yet" → list_threads_unlocked does the one-time slow read and writes a Stats entry. thread_summary_unlocked (single-thread queries) reads the index first and only falls back to a per-thread file read when stats are unknown.

The hot path is now one sequential read of threads.jsonl + a sort. No per-thread file opens.

Trade-off / follow-up

The log now grows by one entry per message append. A compaction pass (rewriting threads.jsonl as a folded snapshot when it crosses a size threshold) is a sane future improvement but not needed for correctness. Not in scope for this PR.

Submission Checklist

  • Tests added — 4 new tests covering: fast path after warm-up, legacy backfill writes Stats snapshot, old-format log still parses, delete clears index stats. Existing 15 tests in store_tests.rs all still pass.
  • Diff coverage ≥ 80% — every new branch in store.rs is exercised by the new tests (MessageAppended fold, Stats fold, backfill loop, measure_messages_unlocked, thread_summary_unlocked index-first path).
  • N/A: behaviour-only change to an existing storage path, no feature matrix rows affected
  • N/A: same — no feature IDs to list
  • No new external network dependencies introduced
  • N/A: no release-cut smoke surface touched
  • N/A: no linked issue (Sentry OPENHUMAN-REACT-X; this PR refs but does not close it — fix(conversations): catch loadThreads rejection on mount to silence Sentry noise #1513 is the user-facing fix)

Impact

  • Runtime/platform: desktop. Same on-disk layout, no migration needed.
  • Performance: list_threads drops from O(threads² + total message bytes) to O(threads.jsonl). For a user with a year of chat history, expect a multi-second → sub-millisecond improvement on warm cache, plus the global CONVERSATION_STORE_LOCK is released sooner so concurrent conversation RPCs stop queuing.
  • Compatibility: forward-compatible read of legacy logs (verified by test legacy_log_without_stats_still_parses). Backward-compatible writes are not relevant — once MessageAppended / Stats entries are in the log, an older binary opening the same workspace would skip them (serde tagged-enum default behaviour is to fail; this would surface as warnings via the existing read_jsonl line-skip path). In practice this is fine because we don't expect downgrades.
  • Storage: threads.jsonl grows by one short entry per message append (~80 bytes). For a user with 10 k messages that's ~800 KB. Acceptable until we add compaction.

Related


AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

  • Key: N/A
  • URL: N/A

Commit & Branch

  • Branch: perf/threads-list-stats-in-log
  • Commit SHA: e3ff27d

Validation Run

  • N/A: format check is run by CI; cargo fmt was applied locally before commit
  • N/A: this PR touches only the Rust core; no app-side TypeScript changes
  • Focused tests: cargo test --lib conversations::store — 19 / 19 pass (15 pre-existing + 4 new)
  • Rust fmt/check: cargo fmt -- --check src/openhuman/memory/conversations/store{.rs,_tests.rs} clean; cargo check --manifest-path Cargo.toml clean
  • N/A: no Tauri shell changes

Validation Blocked

  • command: N/A
  • error: N/A
  • impact: N/A

Behavior Changes

  • Intended behavior change: list_threads returns the same data shape and ordering, computed from the threads-log index instead of from per-thread file reads. append_message emits a MessageAppended entry to threads.jsonl.
  • User-visible effect: faster thread list loads (especially for users with long chat history); reduced lock-hold time on CONVERSATION_STORE_LOCK improves concurrent-RPC behaviour. Same UX otherwise.

Parity Contract

  • Legacy behavior preserved: ordering by (last_message_at desc, created_at desc); message_count fallback to 0 and last_message_at fallback to created_at when no messages exist; existing tests unchanged.
  • Guard/fallback/dispatch parity checks: thread_summary_unlocked falls back to the old per-file read path when index stats are missing; list_threads triggers a one-shot backfill that persists a Stats snapshot, restoring the fast path on the next call.

Duplicate / Superseded PR Handling

Summary by CodeRabbit

  • New Features

    • Improved error message formatting for conversation loading failures.
  • Bug Fixes

    • Enhanced compatibility with legacy conversation data through automatic backfill.
    • Better handling of missing thread statistics.
  • Performance

    • Optimized conversation list loading by reducing unnecessary file access.
  • Tests

    • Expanded test coverage for error formatting and conversation indexing behavior.

Review Change Stack

…ds is O(threads.jsonl)

`ConversationStore::list_threads` was reading every per-thread messages
JSONL file from disk on every call — purely to compute `message_count`
and `last_message_at` for sorting. For a user with many threads and a
year of chat history this could be hundreds of MB of disk reads per call,
all under the global `CONVERSATION_STORE_LOCK`, blocking every other
conversation RPC. This is the root cause behind the 30s
`openhuman.threads_list` timeouts surfaced in Sentry (OPENHUMAN-REACT-X)
and only gets worse as users age in.

Push the stats into `threads.jsonl` itself:

- `ThreadLogEntry::MessageAppended { thread_id, last_message_at }` is
  emitted from `append_message` under the existing lock; the index fold
  increments `message_count` and overwrites `last_message_at`.
- `ThreadLogEntry::Stats { thread_id, message_count, last_message_at }`
  is an absolute snapshot, used to backfill legacy threads (data written
  before this commit). The first `list_threads` after upgrade does one
  slow per-file read for any thread with no stat history and persists a
  `Stats` entry — every subsequent call hits the fast path.
- `list_threads_unlocked` no longer opens per-thread files. The hot path
  is one sequential read of `threads.jsonl` + sort.
- `thread_summary_unlocked` (single-thread queries) also reads index
  stats first, falling back to a per-thread file read only when stats
  are unknown.

Tests cover:
- after warm-up, deleting every per-thread file leaves count + last_at
  intact (proves the fast path)
- legacy log without stat history backfills correctly + persists a
  `Stats` snapshot
- legacy `threads.jsonl` written before this commit still parses
- deleting a thread clears stats from the folded index
- the existing 15 tests all still pass

Open follow-up: the threads log now grows by one entry per message
append. Compaction (rewriting `threads.jsonl` as a folded snapshot
periodically) is a sane future improvement but not needed here.

Refs OPENHUMAN-REACT-X
@senamakel
senamakel requested a review from a team May 12, 2026 03:00
@coderabbitai

coderabbitai Bot commented May 12, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR adds a TypeScript error formatter utility for thread-load failures and introduces Rust-side message-count and last-message timestamp caching to the JSONL thread index, with automatic legacy backfill for existing data.

Changes

Error Formatter Utility

Layer / File(s) Summary
Error formatter definition and integration
app/src/pages/Conversations.tsx
formatThreadLoadError normalizes unknown rejection values (native Error, { message } objects, fallback to String()) for consistent logging in the loadThreads error handler.
Formatter test suite
app/src/pages/__tests__/Conversations.test.tsx
Test cases cover native Error instances, Redux-like serialized error shapes, and fallback scenarios (missing/non-string message, string/null/undefined inputs).

Thread Stats Indexing and Backfill

Layer / File(s) Summary
Schema: log entries and index fields
src/openhuman/memory/conversations/store.rs
ThreadLogEntry enum gains MessageAppended (per-append stat update) and Stats (absolute snapshot) variants. ThreadIndexEntry gains optional message_count and last_message_at fields for cached stats.
Message appending with stat logging
src/openhuman/memory/conversations/store.rs
append_message now emits a MessageAppended log entry to threads.jsonl with message created_at, enabling stat increments without re-reading thread files.
Index management and thread listing with backfill
src/openhuman/memory/conversations/store.rs
list_threads_unlocked detects threads lacking stat history, performs one-time per-thread file reads, writes Stats snapshots, and updates the in-memory index. thread_index_unlocked folding logic handles MessageAppended (increment when baseline exists, otherwise leave None for legacy) and Stats (overwrite snapshot) while preserving upsert/delete behavior.
Message measurement and summary computation
src/openhuman/memory/conversations/store.rs
measure_messages_unlocked reads per-thread JSONL to compute count and newest timestamp. thread_summary_unlocked prefers index-tracked optional stats and falls back to measuring files with created_at default when stats are missing.
Test coverage for indexing and backfill
src/openhuman/memory/conversations/store_tests.rs
Tests verify indexed stats persist after per-thread files are deleted, legacy threads without MessageAppended entries trigger backfill, graceful fallback to created_at when Stats is absent, and delete_thread clears indexed stats from future list_threads results.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • tinyhumansai/openhuman#1513: Adds a .catch handler to the same dispatch(loadThreads()) mount effect, related to the new error formatter's integration point.
  • tinyhumansai/openhuman#635: Refactors the mount-time loadThreads() effect with cleanup and cancellation logic affecting the same error handler area.

Poem

🐰 Threads now remember their count with grace,
Stats logged swift, no need to retrace—
Legacy threads backfill their way,
Error strings formatted, bright as day.
Indexing hops forward, performance's best friend!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and specifically summarizes the primary performance optimization: moving message stats tracking into the threads log to make list_threads O(threads.jsonl) instead of O(threads² + total messages bytes).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

senamakel added 2 commits May 11, 2026 20:05
…anches

The mount-time .catch() block added in tinyhumansai#1513 normalises Redux's
SerializedError plain object into a string for console.warn. Pull that
logic into an exported pure helper so it can be unit-tested without
mounting the full page, then exercise every branch (Error,
SerializedError-shaped object, plain object, string, null/undefined,
non-string message field). Coverage Gate on changed lines goes from
0% (the original 4-line inline catch was untested) to ≥80%.

No behavior change beyond the refactor.
@senamakel

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/pages/Conversations.tsx (1)

275-313: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Convert .then().catch() chain to async/await in the mount effect

The guideline requires using async/await for promise handling instead of .then() chains in TypeScript code. Refactor the loadThreads() chain to use async/await syntax.

Proposed refactor
 useEffect(() => {
   let cancelled = false;
-
-  void dispatch(loadThreads())
-    .unwrap()
-    .then(data => {
+  void (async () => {
+    try {
+      const data = await dispatch(loadThreads()).unwrap();
       if (cancelled) return;
       const threadStateForSelect = store.getState().thread;
       if (data.threads.length > 0) {
         const persistedId = threadStateForSelect.selectedThreadId;
         const resumeId =
           persistedId && data.threads.some(t => t.id === persistedId)
             ? persistedId
             : data.threads[0].id;
         dispatch(setSelectedThread(resumeId));
         void dispatch(loadThreadMessages(resumeId));
       } else {
         void handleCreateNewThread();
       }
-    })
-    .catch(err => {
+    } catch (err) {
       if (cancelled) return;
       console.warn('[conversations] loadThreads failed on mount:', formatThreadLoadError(err));
-    });
+    }
+  })();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/pages/Conversations.tsx` around lines 275 - 313, Replace the
.then().catch() chain on dispatch(loadThreads()).unwrap() with an async IIFE or
an async function inside the mount effect: await the result via const data =
await dispatch(loadThreads()).unwrap(); then run the same selection logic (read
store.getState().thread into threadStateForSelect, compute persistedId/resumeId,
dispatch(setSelectedThread(resumeId)) and void
dispatch(loadThreadMessages(resumeId)) or call void handleCreateNewThread())
while guarding with if (cancelled) returns after the await; wrap the await+logic
in try/catch and in the catch call the same warning using
formatThreadLoadError(err) (keeping the cancelled check there too). Keep
references to dispatch, loadThreads, setSelectedThread, loadThreadMessages,
handleCreateNewThread, formatThreadLoadError and cancelled so the behavior is
unchanged.
🧹 Nitpick comments (1)
src/openhuman/memory/conversations/store.rs (1)

451-461: 💤 Low value

Consider adding a debug log for the fallback path.

When thread_summary_unlocked falls back to the slow measure_messages_unlocked path for legacy threads, a debug log would help diagnose unexpected per-thread file reads during performance investigations. This aligns with the coding guideline to add "copious debug/trace logs...on hard-to-infer branches."

🔧 Proposed addition
             _ => {
+                debug!(
+                    "{LOG_PREFIX} thread_summary fallback to file read thread_id={}",
+                    thread_id
+                );
                 let (count, last_at) = self.measure_messages_unlocked(thread_id)?;
                 (count, last_at.unwrap_or_else(|| entry.created_at.clone()))
             }

As per coding guidelines: "Add copious debug/trace logs while implementing features...on hard-to-infer branches."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/memory/conversations/store.rs` around lines 451 - 461,
thread_summary_unlocked currently falls back to the slow
measure_messages_unlocked path when entry.message_count or entry.last_message_at
is missing; add a debug log in that fallback branch to record that the slow path
was taken and include identifying info (thread_id, entry.created_at, and any
available entry.id or thread key) and the resulting (count, last_at) to aid
performance debugging; place the log call just before or immediately after
calling measure_messages_unlocked so it only executes on the legacy path and use
the same logger used elsewhere in this module for consistency.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@app/src/pages/Conversations.tsx`:
- Around line 275-313: Replace the .then().catch() chain on
dispatch(loadThreads()).unwrap() with an async IIFE or an async function inside
the mount effect: await the result via const data = await
dispatch(loadThreads()).unwrap(); then run the same selection logic (read
store.getState().thread into threadStateForSelect, compute persistedId/resumeId,
dispatch(setSelectedThread(resumeId)) and void
dispatch(loadThreadMessages(resumeId)) or call void handleCreateNewThread())
while guarding with if (cancelled) returns after the await; wrap the await+logic
in try/catch and in the catch call the same warning using
formatThreadLoadError(err) (keeping the cancelled check there too). Keep
references to dispatch, loadThreads, setSelectedThread, loadThreadMessages,
handleCreateNewThread, formatThreadLoadError and cancelled so the behavior is
unchanged.

---

Nitpick comments:
In `@src/openhuman/memory/conversations/store.rs`:
- Around line 451-461: thread_summary_unlocked currently falls back to the slow
measure_messages_unlocked path when entry.message_count or entry.last_message_at
is missing; add a debug log in that fallback branch to record that the slow path
was taken and include identifying info (thread_id, entry.created_at, and any
available entry.id or thread key) and the resulting (count, last_at) to aid
performance debugging; place the log call just before or immediately after
calling measure_messages_unlocked so it only executes on the legacy path and use
the same logger used elsewhere in this module for consistency.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2c997279-d41b-49d8-b875-1937f33c13c2

📥 Commits

Reviewing files that changed from the base of the PR and between ba3e116 and e61a0bf.

📒 Files selected for processing (4)
  • app/src/pages/Conversations.tsx
  • app/src/pages/__tests__/Conversations.test.tsx
  • src/openhuman/memory/conversations/store.rs
  • src/openhuman/memory/conversations/store_tests.rs

@senamakel
senamakel merged commit 7ce933b into tinyhumansai:main May 12, 2026
21 checks passed
AusAgentSmith pushed a commit to AusAgentSmith/openhuman that referenced this pull request May 23, 2026
senamakel added a commit to nocstah/openhuman that referenced this pull request Sep 11, 2026
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.

1 participant