Skip to content

perf: memoize handle resolution and cache the unanswered-thread scan (#6025) - #6133

Merged
atomantic merged 1 commit into
mainfrom
claim/issue-6025
Sep 3, 2026
Merged

perf: memoize handle resolution and cache the unanswered-thread scan (#6025)#6133
atomantic merged 1 commit into
mainfrom
claim/issue-6025

Conversation

@atomantic

Copy link
Copy Markdown
Owner

Summary

findUnansweredTribeThreads() ran a full timeline scan on every call. The dashboard alerts widget polls it every 120s and the Tribe page hits it again on mount, so back-to-back callers each paid for up to eight 2,000-row queries against human_activity_events (title, summary, participants + metadata JSONB) plus an unmemoized handle-resolution pass over every inbound turn.

Two changes, both behavior-preserving:

  • Memoized handle resolution. createHandleResolver(ctx) (new, in identityResolve.js) wraps resolveHandle in a per-pass Map. enrichActivityEvent(event, ctx, resolve) takes it as an optional third argument and is unchanged when omitted. The outreach scan builds one resolver per pass, so a busy 1:1 thread's repeated counterpart handle runs the handle regexes, phone/email normalization, Tribe index match and Contacts lookup once instead of once per message.
  • Detection-pass cache. The scan result is cached for DETECTION_CACHE_TTL_MS (120s), keyed by detection window (withinDays/staleAfterHours) and holding the unsliced thread list, so the alerts sweep and GET /api/tribe/outreach — which differ only in limit — share one result. The cached value is the promise, so concurrent callers collapse onto a single scan; a failed pass is never cached. invalidateUnansweredThreadsCache() is exported for tests and for any caller that must see fresh state.

Detected threads, sorting and outreach-draft generation are unchanged. The rejected SQL-side alternative from the issue (pairing inbound against subsequent outbound in SQL) was not attempted — that pairing lives in groupUnansweredThreads across three different conversation-key shapes.

Test plan

  • server/services/identityResolve.test.js — new test: a resolver returns the identical resolution object for a repeated handle, and enrichment through an injected resolver is toEqual the uncached default path.
  • server/services/tribeOutreach.test.js — new suite: one resolver is created per pass and passed to every enrichActivityEvent call; a repeat call within the TTL issues no new listEvents queries and a different limit is served from the same cached list; the scan re-runs once the TTL lapses. Both fail against the pre-fix code (verified).
  • cd server && npm test — 1917 files / 38,709 tests passing.

Notes for review

  • Trade-off: a reply sent by the user can take up to 120s to drop out of the outreach list, matching the widget's existing poll interval. Event-ingest-driven invalidation (calling invalidateUnansweredThreadsCache() from the timeline ingest paths) would remove that lag — deliberately left out to keep this diff to the perf fix.
  • Scope: only findUnansweredTribeThreads is touched. suggestTribeImports (suggestTribeImports loads 2,000 full event rows with JSONB to compute handle frequency in Node memory #6026) is untouched, and no shared helper was extracted from it.

Closes #6025

https://claude.ai/code/session_01RA3pD5YM2dukQwbZ3pC6WA

…6025)

The Tribe "unanswered thread" detector ran a full timeline scan on every
call: the dashboard alerts widget polls it every 120s and the Tribe page
hits it again on mount, so back-to-back callers each paid for up to eight
2,000-row queries against the activity timeline plus a handle-resolution
pass over every inbound turn.

- Resolve counterpart handles through one memoizing resolver per scan
  (`createHandleResolver`), so a busy 1:1 thread's repeated handle runs the
  handle regexes, phone/email normalization and the Tribe/Contacts match
  once instead of once per message. `enrichActivityEvent` takes the
  resolver as an optional argument and behaves identically without it.
- Cache the detection pass for 120s, keyed by detection window and holding
  the unsliced thread list so callers asking for different limits share one
  result. Caching the promise also collapses concurrent callers onto a
  single scan; a failed pass is never cached.

Detected threads, ordering and outreach-draft behavior are unchanged.

Claude-Session: https://claude.ai/code/session_01RA3pD5YM2dukQwbZ3pC6WA
@atomantic
atomantic merged commit ce1cee1 into main Sep 3, 2026
7 checks passed
@atomantic
atomantic deleted the claim/issue-6025 branch September 3, 2026 22:53
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.

findUnansweredTribeThreads scans up to 16,000 events and runs unmemoized handle resolution regexes every 120s

1 participant