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
- Create an agent with resource-scoped OM
- Send messages across multiple threads (e.g., 10+ threads)
- Wait for observation to trigger (or lower
messageTokens threshold)
- After observation completes, send a new message to any thread
- 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:
-
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.
-
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)
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 — becauselastObservedAtis missing from thread metadata for most threads.Root Cause
getOtherThreadsContext()(in theObservationalMemoryclass) readslastObservedAtfrom thread metadata (thread.metadata.mastra.om.lastObservedAt) to decide whether to apply a date filter onlistMessages:ResourceScopedObservationStrategy.persist()does writelastObservedAtto thread metadata — but only for threads inthreadMetadataUpdates, i.e., threads that had unobserved messages in that particular observation cycle.Threads that were already fully observed (no new messages since their
observedMessageIdswere recorded) are skipped by the observer and never getlastObservedAtwritten to their metadata. On subsequent requests,getOtherThreadsContext()seeslastObservedAt = undefinedfor those threads and loads their entire message history with no date filter.Note that
SyncObservationStrategy.persist()(thread-scoped) writeslastObservedMessageCursorto thread metadata but never writeslastObservedAt.Impact
For a user with 18 threads where only 1 was observed in the latest cycle:
metadata.mastra.om.lastObservedAt = undefinedlistMessageswith no date filter (full history scan)Reproduction
messageTokensthreshold)mastra_threadsand checkmetadatacolumn: most threads will NOT havemastra.om.lastObservedAtVerification query:
Compare with the OM record:
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:
In
ResourceScopedObservationStrategy.persist(): After processingthreadMetadataUpdates, also updatelastObservedAton threads that were skipped (already fully observed). Use the OM record'slastObservedAtas the value.In
getOtherThreadsContext(): Fall back to the OM record'slastObservedAtwhenthread.metadata.mastra.om.lastObservedAtis missing:Environment
@mastra/memory: 1.15.0@mastra/core: 1.24.0scope: "resource"@mastra/pg(PostgreSQL)