Skip to content

Add two-tier conversation caching with race condition and duplicate fixes - #633

Merged
2witstudios merged 3 commits into
masterfrom
cache-convos
Feb 13, 2026
Merged

2witstudios merged 3 commits into
masterfrom
cache-convos

Conversation

@2witstudios

@2witstudios 2witstudios commented Feb 13, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Implements L1 (memory) + L2 (Redis) conversation caching to reduce database load for active AI chat sessions
  • Fixes P1 race condition: await appendMessage to prevent stale reads when conversation is in Redis but not L1
  • Fixes P2 duplicate messages: add upsert logic in appendMessage to deduplicate on retry, mirroring DB's onConflictDoUpdate

Changes

  • ConversationCache service with automatic TTL and cleanup
  • Graceful Redis degradation (falls back to memory-only)
  • Cache invalidation on message edit/delete
  • Comprehensive test coverage (36 tests)

Test plan

  • All 36 conversation cache tests pass
  • TypeScript compiles without errors
  • Manual: Start fresh session, send message to AI agent with Redis enabled, verify user message appears in model context

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Implemented a two-tier conversation caching system with in-memory and distributed cache layers for AI chat functionality.
  • Improvements

    • Conversation cache automatically invalidates when messages are edited or deleted, ensuring subsequent reads reflect the latest data.
  • Tests

    • Added comprehensive test coverage for conversation caching functionality.

…ixes

Implements L1 (memory) + L2 (Redis) conversation caching to reduce
database load for active AI chat sessions.

Key fixes in this commit:
- P1: Await appendMessage to prevent race condition where getConversation
  reads stale data before async Redis write completes
- P2: Add upsert logic in appendMessage to deduplicate messages on retry,
  mirroring the DB's onConflictDoUpdate behavior

Features:
- ConversationCache service with automatic TTL and cleanup
- Graceful Redis degradation (falls back to memory-only)
- Cache invalidation on message edit/delete
- Comprehensive test coverage (36 tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Feb 13, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@2witstudios has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 3 minutes and 33 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

This PR introduces a comprehensive two-tier conversation caching system (in-memory L1 + optional Redis L2) for AI chat conversations. Cache invalidation is integrated across message update/delete operations, and cache population occurs on conversation reads with fallback to database.

Changes

Cohort / File(s) Summary
Cache Service Implementation
packages/lib/src/services/conversation-cache.ts, packages/lib/src/__tests__/conversation-cache.test.ts
Introduces ConversationCache class with dual-tier (in-memory + Redis) caching, TTL management, message truncation, performance metrics, and comprehensive test coverage for set/get operations, cache misses, TTL handling, invalidation, and edge cases.
Public API Exports
packages/lib/src/index.ts, packages/lib/src/server.ts
Exports new cache service entities: conversationCache singleton instance, ConversationCache class, and types CachedMessage, CachedConversation, ConversationCacheMetrics.
Chat Route Cache Integration
apps/web/src/app/api/ai/chat/route.ts
Implements cache-first conversation history retrieval: attempts cache hit first, falls back to database on miss, populates cache with structured CachedMessage entries, and logs cache hit/miss status.
Message Operations Cache Invalidation
apps/web/src/app/api/ai/chat/messages/[messageId]/route.ts, apps/web/src/app/api/ai/page-agents/[agentId]/conversations/[conversationId]/route.ts
Adds cache invalidation calls after PATCH/DELETE message operations and conversation soft-deletes to ensure subsequent reads reflect changes.
Message Persistence Cache Integration
apps/web/src/lib/ai/core/message-utils.ts
Appends CachedMessage entries to conversationCache after database writes in saveMessageToDatabase with fallback error handling.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant ChatAPI as Chat API Route
    participant Cache as ConversationCache<br/>(L1 Memory)
    participant Redis as Redis<br/>(L2)
    participant Database

    Client->>ChatAPI: GET /chat (conversationId, pageId)
    ChatAPI->>Cache: getConversation(pageId, conversationId)
    
    alt Cache Hit
        Cache-->>ChatAPI: return CachedConversation
        ChatAPI-->>Client: 200 OK (from cache)
    else Cache Miss
        Cache->>Database: fetch conversation messages
        Database-->>Cache: messages[]
        Cache->>Cache: convert to CachedMessage[]
        Cache->>Cache: store in L1 memory
        Cache->>Redis: async setConversation (fire-and-forget)
        Redis-->>Cache: ✓ (async)
        Cache-->>ChatAPI: return messages
        ChatAPI-->>Client: 200 OK (from database)
    end

    Client->>ChatAPI: PATCH /chat/messages/[messageId]
    ChatAPI->>Database: update message content
    Database-->>ChatAPI: ✓
    ChatAPI->>Cache: invalidateConversation(pageId, conversationId)
    Cache->>Cache: remove from L1
    Cache->>Redis: async delete (fire-and-forget)
    ChatAPI-->>Client: 200 OK
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly Related PRs

  • Claude/test ai system routes jw ai9 #81: Modifies chat message PATCH/DELETE handlers in apps/web/src/app/api/ai/chat/messages/[messageId]/route.ts; introduces repository abstractions that may interact with cache invalidation logic in this PR.

Poem

🐰 A cache so swift, in tiers it stands,
L1 memory, Redis at hand—
Whisper to database when we miss,
Cache invalidate with a promise's kiss!
Fast conversations hop and bound,
Your chat speed's now rabbit-round! ✨

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (18 files):

⚔️ apps/web/src/app/api/ai/chat/messages/[messageId]/route.ts (content)
⚔️ apps/web/src/app/api/ai/chat/route.ts (content)
⚔️ apps/web/src/app/api/ai/global/[id]/messages/route.ts (content)
⚔️ apps/web/src/app/api/ai/page-agents/[agentId]/conversations/[conversationId]/route.ts (content)
⚔️ apps/web/src/components/ai/shared/AiUsageMonitor.tsx (content)
⚔️ apps/web/src/hooks/useAiUsage.ts (content)
⚔️ apps/web/src/lib/ai/core/index.ts (content)
⚔️ apps/web/src/lib/ai/core/message-utils.ts (content)
⚔️ packages/db/drizzle/meta/_journal.json (content)
⚔️ packages/db/src/schema/integrations.ts (content)
⚔️ packages/lib/package.json (content)
⚔️ packages/lib/src/index.ts (content)
⚔️ packages/lib/src/integrations/index.ts (content)
⚔️ packages/lib/src/integrations/rate-limit/integration-rate-limiter.ts (content)
⚔️ packages/lib/src/integrations/saga/execute-tool.ts (content)
⚔️ packages/lib/src/integrations/types.ts (content)
⚔️ packages/lib/src/server.ts (content)
⚔️ pnpm-lock.yaml (content)

These conflicts must be resolved before merging into master.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main changes: implementing two-tier conversation caching and fixing race conditions and duplicates.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch cache-convos

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/web/src/app/api/ai/chat/route.ts`:
- Around line 649-722: The cache population step currently hardcodes messageType
to 'standard' inside the messagesToCache mapping, losing any actual
msg.messageType (e.g., 'todo_list'); update the mapping that builds
messagesToCache (used before calling conversationCache.setConversation) to read
and pass msg.messageType directly from the DB row (from dbMessages /
msg.messageType) so the cached CachedMessage.messageType preserves the original
enum value; ensure the mapping still falls back safely if msg.messageType is
null/undefined.
🧹 Nitpick comments (4)
packages/lib/src/services/conversation-cache.ts (4)

380-400: Use SCAN instead of KEYS for production Redis safety; fix Biome lint warning.

Two items on this section:

  1. Redis KEYS command (Line 386): KEYS is O(N) over the entire keyspace and blocks the Redis server. While the scoped pattern limits matches, this can still cause latency spikes if the Redis instance has many keys. Consider using SCAN with a cursor-based iteration instead. The same concern applies to clearAll at Line 444.

  2. Biome lint (Line 380): Map.delete() returns a boolean that forEach discards. Use a for...of loop instead.

Suggested fixes

For the lint fix:

-    keysToDelete.forEach(key => this.memoryCache.delete(key));
+    for (const key of keysToDelete) {
+      this.memoryCache.delete(key);
+    }

For the KEYS → SCAN migration (if desired):

// Helper to iterate with SCAN instead of KEYS
private async scanKeys(pattern: string): Promise<string[]> {
  if (!this.redis) return [];
  const keys: string[] = [];
  let cursor = '0';
  do {
    const [nextCursor, batch] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
    cursor = nextCursor;
    keys.push(...batch);
  } while (cursor !== '0');
  return keys;
}

136-170: Cleanup metrics conflate TTL expirations with size-limit evictions.

Line 160 increments ttlExpirations for both expired entries and entries evicted due to the max memory limit. This makes the metric less meaningful for monitoring. Consider tracking size-limit evictions separately.


85-93: Singleton getInstance silently ignores config on subsequent calls.

If getInstance is called with a config after the singleton already exists, the provided config is silently ignored. This is inherent to the singleton pattern, but consumers may not realize their config isn't applied. Consider logging a warning when config is passed but the instance already exists, similar to how this is handled in other cache services.


175-212: L2→L1 promotion doesn't validate TTL before promoting.

When a Redis L2 hit is promoted to L1 (Line 198), the entry's cachedAt timestamp from Redis is used as-is. If there's clock drift or if the entry was cached long ago but Redis hasn't expired it yet, the promoted L1 entry could immediately appear expired on the next getConversation call (Line 181 check). In practice, this is unlikely since Redis's SETEX TTL and the application-level TTL are set simultaneously, but it's worth noting as a potential edge case.

Comment thread apps/web/src/app/api/ai/chat/route.ts
2witstudios and others added 2 commits February 12, 2026 23:23
- Preserve messageType from DB when populating cache (was hardcoded to 'standard')
- Use SCAN instead of KEYS for Redis operations (non-blocking)
- Fix Biome lint: use for...of instead of forEach for Map.delete
- Separate sizeEvictions metric from ttlExpirations for clearer monitoring
- Warn when getInstance config is ignored due to existing singleton

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add conversationCache.invalidateConversation mock to test files that
were missing it after the cache integration was added to the routes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
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