Skip to content

Cache visible memory cards in RAM and defer card usage writes - #255

Merged
iamwavecut merged 2 commits into
mainfrom
feat/memory-card-cache
Oct 13, 2026
Merged

iamwavecut merged 2 commits into
mainfrom
feat/memory-card-cache

Conversation

@iamwavecut

Copy link
Copy Markdown
Owner

Summary

Two memory-card paths put avoidable load on Postgres:

  • The memory pipeline lists visible cards for the chat and each participant while it sizes a run's batches. The same lists are read many times per run; the statement is the most frequent card query.
  • Every dialog turn with recalled memory waited for an UPDATE memory_cards that records usage. Each update also moves updated_at, which is indexed, so it is never a HOT update and rewrites every index entry of the card, including its HNSW vector.

RAM cache of visible-card lists

  • MemoryCardCache (storage): LRU keyed by chat, thread, user, own-DM flag and limit; capacity from MEMORY_CARD_CACHE_ENTRIES (default 4096, 0 disables), entries expire after 5 minutes.
  • Only the memory pipeline reads through the cache (list_visible_cards_cached behind MemoryRunStore::list_visible_cards). The settings WebApp and agent tools keep reading Postgres, so a card a user deletes never reappears from the cache.
  • Invalidation: after each batch's card writes, the chat and the batch's message authors; after each subject-merge group, its chat and user; a settings delete, its chat and user. Admin edits, restores, archival and duplicate collapse clear the cache. Writers outside this process are covered by the TTL.
  • A miss records the cache generation before it reads Postgres; any invalidation bumps the generation, so a list read during a concurrent write is not stored.
  • The store instances of the web routes and the runtime share one cache, so their invalidations reach each other.

Write-behind card usage

  • MemoryCardUsageJournal collects recalled card ids with their latest use time and use count. Dialog input records into it without waiting on Postgres.
  • memory-card-usage-flush worker applies the journal in one UPDATE ... FROM unnest(...) every 30 s and once more on shutdown (ancillary phase, after the dialog workers stop). A failed flush is merged back into the journal for the next attempt.
  • updated_at still moves with usage; deferred reconsolidation selects recently touched cards.

Sizing

The flush worker logs memory card cache stats (entries, bytes, hits, misses, expired, evictions, invalidated) every 5 minutes. The cache size is configuration only, so it can be tuned after measuring production traffic.

Compatibility

  • No migrations. record_card_retrieval is replaced by record_card_usage(&[MemoryCardUsage]); the only production caller (dialog input) now uses the journal.
  • Usage counters can lag up to 30 s, and a hard kill (no graceful shutdown) loses at most the uses of the last interval.

Deployment risk and verification plan

  • Risk: a stale list in the memory pipeline's existing-card context for at most 5 minutes after a write from a path that does not invalidate (another process or direct SQL). This context is a hint for the extractor; dedup on write still applies.
  • Plan after deploy: read memory card cache stats from the logs after 10–30 minutes and compare hits with misses; compare calls and total time of the visible-card list statement and of the usage UPDATE in pg_stat_statements before and after; confirm use_count keeps growing for recalled cards; check dialog latency and failures.

Verification

  • cargo fmt --all, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace.
  • Unit tests: cache TTL, LRU eviction, chat and cross-chat user invalidation, a list read before an invalidation is not stored; journal counting and merge-back after a failed flush; config default and 0 disabling.
  • The live storage test (CI Postgres) exercises record_card_usage and checks use_count and last_used_at.

- The memory pipeline lists the same chat and participant cards many times
  while sizing a run's batches. Serve those lists from an LRU cache in the
  memory store (MEMORY_CARD_CACHE_ENTRIES, default 4096, 0 disables; 5 min
  TTL). Card writes of a batch or merge group invalidate their chat and
  users; admin and batch writes clear the cache; a generation counter keeps
  a list read during an invalidation out of the cache. Settings and agent
  listings still read Postgres.
- Dialog turns no longer wait on the card usage UPDATE. Recalled cards go
  to an in-memory journal that a worker applies in one statement every
  30 s and on shutdown; updated_at still moves for deferred
  reconsolidation.
- The flush worker logs cache statistics every 5 minutes for sizing.
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Messages
📖 Danger checked 8 changed file(s).

Generated by 🚫 dangerJS against 3a60b90

@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🔒 No security concerns identified
⚡ Recommended focus areas for review

Cache key normalization mismatch

In list_visible_cards_cached, the cache key normalizes the limit with positive_or_default(limit, 100), but the underlying list_visible_cards(scope, limit) is executed with the original limit. If the caller passes a non-positive or non-100 limit, the fetched list (computed with that different limit) is stored under the 100-limit key. A later request that genuinely uses limit 100 will hit that key and receive a list sized for a different limit (or vice versa, different non-positive limits collide). Either the query should run with the normalized limit, or the key should use the raw limit.

    limit: positive_or_default(limit, 100),
};
match cache.get(&key) {
    Ok(cards) => Ok(cards),
    Err(generation) => {
        let cards = self.list_visible_cards(scope, limit).await?;
        cache.put(key, &cards, generation);

Comment thread crates/openplotva-app/src/dialog_jobs/input.rs
Comment thread crates/openplotva-storage/src/lib.rs
@iamwavecut

Copy link
Copy Markdown
Owner Author

PR-Agent review guide, cache key normalization: not a mismatch in practice — list_visible_cards normalizes the limit with the same positive_or_default(limit, 100) before querying, so the key and the query always used the same limit. 3a60b90 makes that explicit by querying with the key's limit.

@iamwavecut
iamwavecut merged commit 2e09c5c into main Oct 13, 2026
12 checks passed
@iamwavecut
iamwavecut deleted the feat/memory-card-cache branch October 13, 2026 21:12
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.

1 participant