Skip to content

[BUG] Resource-scoped OM: getOtherThreadsContext ignores lastObservedAt for threads not observed in the triggering cycle #15265

@szelemeh

Description

@szelemeh

Bug Description

When using resource-scoped Observational Memory (scope: "resource"), getOtherThreadsContext() loads all messages from all threads on every request — even after observation has successfully run — because lastObservedAt is missing from thread metadata for most threads.

Root Cause

getOtherThreadsContext() (in the ObservationalMemory class) reads lastObservedAt from thread metadata (thread.metadata.mastra.om.lastObservedAt) to decide whether to apply a date filter on listMessages:

const omMetadata = getThreadOMMetadata(thread.metadata);
const threadLastObservedAt = omMetadata?.lastObservedAt;
const startDate = threadLastObservedAt
  ? new Date(new Date(threadLastObservedAt).getTime() + 1)
  : void 0; // <-- undefined = no date filter = loads ALL messages

ResourceScopedObservationStrategy.persist() does write lastObservedAt to thread metadata — but only for threads in threadMetadataUpdates, i.e., threads that had unobserved messages in that particular observation cycle.

Threads that were already fully observed (no new messages since their observedMessageIds were recorded) are skipped by the observer and never get lastObservedAt written to their metadata. On subsequent requests, getOtherThreadsContext() sees lastObservedAt = undefined for those threads and loads their entire message history with no date filter.

Note that SyncObservationStrategy.persist() (thread-scoped) writes lastObservedMessageCursor to thread metadata but never writes lastObservedAt.

Impact

For a user with 18 threads where only 1 was observed in the latest cycle:

  • 17 threads have metadata.mastra.om.lastObservedAt = undefined
  • Every request triggers 17 × listMessages with no date filter (full history scan)
  • Produces ~72 DB spans and ~1.1s of pure DB I/O per request
  • This is an N+1 query pattern that scales linearly with thread count

Reproduction

  1. Create an agent with resource-scoped OM
  2. Send messages across multiple threads (e.g., 10+ threads)
  3. Wait for observation to trigger (or lower messageTokens threshold)
  4. After observation completes, send a new message to any thread
  5. Query mastra_threads and check metadata column: most threads will NOT have mastra.om.lastObservedAt

Verification query:

SELECT id, title,
       metadata->'mastra'->'om'->>'lastObservedAt' as thread_lastObservedAt,
       metadata->'mastra'->'om'->>'lastObservedMessageCursor' as cursor
FROM mastra.mastra_threads
WHERE "resourceId" = '<your-resource-id>';

Compare with the OM record:

SELECT id, "lastObservedAt", "observationTokenCount"
FROM mastra.mastra_observational_memory
WHERE "resourceId" = '<your-resource-id>';

Expected Behavior

After observation runs successfully, getOtherThreadsContext() should apply date filtering for ALL threads — not just the ones that happened to have unobserved messages during the triggering cycle.

Suggested Fix

Two complementary fixes:

  1. In ResourceScopedObservationStrategy.persist(): After processing threadMetadataUpdates, also update lastObservedAt on threads that were skipped (already fully observed). Use the OM record's lastObservedAt as the value.

  2. In getOtherThreadsContext(): Fall back to the OM record's lastObservedAt when thread.metadata.mastra.om.lastObservedAt is missing:

    const threadLastObservedAt = omMetadata?.lastObservedAt ?? record?.lastObservedAt;

Environment

  • @mastra/memory: 1.15.0
  • @mastra/core: 1.24.0
  • scope: "resource"
  • Storage: @mastra/pg (PostgreSQL)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions