Skip to content

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

Description

@atomantic

Problem

In server/services/tribeOutreach.js:269-373, findUnansweredTribeThreads() detects unanswered 1:1 messages from Tribe members within the actionable window (withinDays = 14).

To detect unanswered conversations, line 316-337 executes fetchScope for every chat source (imessage, signal) and every two-way email account (gmail, etc.):

  const fetchScope = (label, filter) =>
    Promise.all([
      listEvents({ ...filter, from, kind: 'message.received', limit: EVENT_CAP }),
      listEvents({ ...filter, from, kind: 'message.sent', limit: EVENT_CAP }),
    ])

With EVENT_CAP = 2000, 2 chat sources and 2 active email accounts fire up to 8 queries requesting up to 2,000 rows each (up to 16,000 events total). Each query pulls full rows from human_activity_events (title, summary, participants JSONB, and metadata JSONB).

Then in tribeOutreach.js:348-365, it loops through every single received event:

  for (const ev of received) {
    if (ev.metadata?.isReaction) continue;
    if (!isTwoWay(ev)) continue;
    if (Array.isArray(ev.participants) && ev.participants.length > 1) continue;
    const enriched = enrichActivityEvent(ev, ctx);
    const personId = enriched.personId || null;
    ...

In server/services/identityResolve.js:157-169, enrichActivityEvent(event, ctx) reads event.metadata?.handle and calls resolveHandle(handle, ctx). Unlike resolveHandles (:113-121) which maintains a handle cache, enrichActivityEvent has no memoization. For hundreds or thousands of received messages from the same contacts, resolveHandle repeatedly executes:

  • identityFromHandle(raw) regex matching
  • normalizePhone / normalizeIdentifier string normalization
  • matchPerson(identity, tribeIndex) map/set traversals
  • resolveHandleAgainstContacts(raw, contactIndex) cache lookups

Furthermore, findUnansweredTribeThreads has no short-term cache or invalidation key. Every 120 seconds, the dashboard widget triggers this full multi-scope query and handle resolution loop, even when no new messages have been ingested.

Trigger

  • client/src/components/ProactiveAlertsWidget.jsx:58-61 mounts on the primary Dashboard (/) and auto-refetches api.getAlertsSummary() every 120,000 ms (2 minutes).
  • getAlertsSummary() (server/services/proactiveAlerts.js:373-380) calls generateAlerts() -> checkUnansweredTribeThreads() (:291-329), invoking findUnansweredTribeThreads().
  • GET /api/tribe/outreach (server/routes/tribe.js:123-127) calls findUnansweredTribeThreads({ limit }) on Tribe page mount.

Impact

Every 2 minutes while the dashboard is open, PortOS issues up to 8 database queries across human_activity_events (one of the largest tables in PortOS), transferring and parsing JSONB metadata for thousands of events, followed by thousands of redundant handle parsing and regex matching operations in Node.js. On an install with active messaging history, this generates unnecessary database load and CPU churn for an alert state that rarely changes.

Fix

  1. Add handle memoization to the outreach resolution loop in server/services/tribeOutreach.js:348-365: maintain a local handleCache = new Map() so resolveHandle / enrichActivityEvent is called at most once per unique handle across all received events, rather than once per event turn.
  2. Cache the result of findUnansweredTribeThreads() with a 60–120s TTL (or memoize the alert check in server/services/proactiveAlerts.js), serving back-to-back dashboard requests and 2-minute polls without re-querying up to 16,000 events when no sync has occurred.
  3. Rejected alternative: Querying only unread/unanswered messages in SQL — rejected because "unanswered" requires pairing inbound turns against subsequent outbound turns in the same conversation across multiple sources (chatGuid, threadId, conversationId), which is already cleanly unified in groupUnansweredThreads.

Files: server/services/tribeOutreach.js, server/services/identityResolve.js, server/services/proactiveAlerts.js, server/services/tribeOutreach.test.js.

Tests:

  • In server/services/tribeOutreach.test.js, add a test asserting that resolveHandle is called only once per unique handle across multiple received events from the same sender.
  • In server/services/tribeOutreach.test.js or server/services/proactiveAlerts.test.js, verify that calls within the TTL return cached results without re-querying listEvents.
  • cd server && npm test green.

Acceptance criteria

  • Counterpart handle resolution in findUnansweredTribeThreads is memoized so each distinct handle is resolved at most once per detection pass.
  • Back-to-back calls from the dashboard widget within the TTL reuse the detection result rather than re-querying human_activity_events.
  • Detected unanswered threads, sorting, and outreach draft generation behavior remain identical.
  • cd server && npm test green.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions