perf(orchestration): batch the per-session unread_count into one query - #5040
Conversation
📝 WalkthroughWalkthroughUnread message counts are now fetched in bulk through a new store helper. Signal gathering and session listing use map lookups with zero defaults, while tests cover visibility filtering, cursors, and empty results. ChangesUnread count batching
Estimated code review effort: 2 (Simple) | ~10 minutes Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
senamakel
left a comment
There was a problem hiding this comment.
Automated technical review: not approved.
Unmet gate -- CI check failure:
One check fails: Rust Feature-Gate Smoke (gates off) fails due to a pre-existing allowlist drift -- a new gated test file (src/openhuman/composio/action_tool.rs) was added to the tree but the CI lane's EXPECTED allowlist was not updated to include it. This is unrelated to this PR (which touches only src/openhuman/orchestration/ files), but per the approval standard all reported checks must be passing.
N+1 claim: Verified correct. The new store::unread_counts function issues a single GROUP BY query with LEFT JOIN kv ON kv.k = 'read:' || m.session_id to batch the cursor lookup and count, replacing 2N queries (cursor lookup + COUNT per session) with 1. The COALESCE(kv.v, '') reproduces the scalar path's .unwrap_or_default(), and the event_kind predicate is copied verbatim. Both call sites (handle_sessions_list and gather_unread_signals) pre-compute the map and use .get().copied().unwrap_or(0). The parity test unread_counts_batches_and_matches_scalar_unread_count asserts byte-identical results across all cursor/visibility states (no cursor, partial, fully read, hidden event_kind, empty session). The scalar unread_count is retained as the parity oracle.
Next step: The Feature-Gate Smoke allowlist needs updating to include openhuman/composio/action_tool.rs (a change to .github/workflows/ci-lite.yml or the relevant script). Once CI is all green, this PR is ready for approval.
unread_count runs two queries per session (kv_get for the read cursor, then a COUNT), and was called once per session in two hot paths: handle_sessions_list (the roster; list_sessions has no LIMIT) and gather_unread_signals (the attention queue). Listing N sessions cost 2N queries. The sibling visible message count in the same sessions_list loop was already batched via visible_message_counts_by_session, leaving unread as the lone per-row DB hit. Add unread_counts(conn) -> HashMap<session_id, i64>: one GROUP BY with a LEFT JOIN on the read-cursor kv row. kv.k is PRIMARY KEY so the join can't fan out; COALESCE(kv.v,'') reproduces the scalar path's unwrap_or_default() empty cursor; the event_kind predicate is copied verbatim. Zero-unread sessions drop from the GROUP BY and both call sites already default missing entries to 0, so results are byte-identical. Compute the map once per loop and read from it. Test: unread_counts_batches_and_matches_scalar_unread_count asserts the batched map equals the scalar unread_count for every session across no-cursor, mid-stream cursor, fully-read, hidden-event_kind, and no-message states.
45c2fae to
89be9cd
Compare
|
| Filename | Overview |
|---|---|
| src/openhuman/orchestration/store.rs | Adds unread_counts batched query (correct SQL, kv-key format matches scalar, COALESCE semantics verified) and a thorough parity test covering all five cursor/visibility states. |
| src/openhuman/orchestration/schemas.rs | Replaces per-session unread_count call in the session-roster loop with a pre-computed map lookup; consistent with the existing visible_message_counts_by_session pattern in the same loop. |
| src/openhuman/orchestration/ops.rs | Replaces per-session unread_count call in gather_unread_signals with a pre-computed map lookup; minor note that master/subconscious entries are included in the map but are never consumed (skipped in the loop), which is harmless. |
Sequence Diagram
sequenceDiagram
participant Caller
participant store
Note over Caller,store: BEFORE (N sessions → 2N queries)
Caller->>store: list_sessions()
loop for each session (N times)
store->>store: kv_get(read:session_id)
store->>store: "COUNT(*) WHERE session_id=?"
store-->>Caller: unread count
end
Note over Caller,store: AFTER (N sessions → 1 query)
Caller->>store: unread_counts()
store->>store: "SELECT m.session_id, COUNT(*) FROM messages LEFT JOIN kv GROUP BY session_id"
store-->>Caller: "HashMap<session_id, i64>"
Caller->>store: list_sessions()
loop for each session (N times)
Caller->>Caller: map.get(session_id).copied().unwrap_or(0)
end
Reviews (1): Last reviewed commit: "perf(orchestration): batch the per-sessi..." | Re-trigger Greptile
Summary
unread_countran 2 queries per session and was called once per session in two hot RPC paths.unread_counts(conn) -> HashMap<session_id, i64>(oneGROUP BYwith aLEFT JOINon the read-cursor kv) and consume it insessions_listandgather_unread_signals.unread_countacross every cursor/visibility state.Problem
store::unread_count(conn, session_id)does two round-trips —kv_get(read_cursor_key(..))then aCOUNT(*)— and is called once per session in:handle_sessions_list(schemas.rs) — the session roster,for session in rows { unread_count(..) }, andlist_sessionshas no LIMIT, so listing N sessions is 2N queries.gather_unread_signals(ops.rs) — the attention queue, same per-session loop.This is especially visible in
handle_sessions_listbecause the sibling message count in the very same loop was already batched intovisible_message_counts/visible_message_counts_by_session(consumed via.get().copied().unwrap_or(0)); the unread count was simply left as the lone per-row DB hit.Solution
Add a batched query mirroring
visible_message_counts_by_session:kv.kisPRIMARY KEY, so theLEFT JOINmatches at most one cursor row per session — no fan-out.COALESCE(kv.v, '')reproduces the scalar path's.unwrap_or_default()empty cursor (a never-read session counts all its visible messages).event_kindpredicate is copied verbatim fromunread_count.GROUP BY; both call sites already default missing entries to0, so behaviour is identical.Each call site now computes the map once before its loop and reads
map.get(&session_id).copied().unwrap_or(0). The scalarunread_countis retained (still used by existing store tests and as the parity oracle).Submission Checklist
unread_counts_batches_and_matches_scalar_unread_countasserts the batched map equals the scalarunread_countfor every session across five states: no cursor, mid-stream cursor (partial), fully read (zero), a hidden excluded-event_kindrow, and a session with no messages.unread_countsquery and both swapped call sites are exercised by the parity test plus existingsessions_list/ attention coverage.cargo test -p openhuman --lib orchestration::storepasses.N/A: internal query batching, no user-visible feature change.## Related—N/A.N/A: does not touch a release-cut surface.Closes #NNN— no existing issue; found by inspection.Impact
openhuman.sessions_listand the attention queue now issue one unread query instead of 2N. No behaviour change (verified byte-identical by the parity test); no schema/migration.visible_message_countsbatching pattern in the same loop.Related
AI Authored PR Metadata
Linear Issue
Commit & Branch
perf/orchestration-batch-unread-counts45c2fae86Validation Run
pnpm --filter openhuman-app format:check— N/A (no frontend change)pnpm typecheck— N/A (no frontend change)cargo test -p openhuman --lib orchestration::storecargo fmtBehavior Changes
Parity Contract
unread_countsreturns exactly whatunread_countwould per session;COALESCE(kv.v,'')=.unwrap_or_default(),event_kindpredicate copied verbatim, zero-unread sessions default to 0 at both call sites.