5 changed files with 1362 additions and 156 deletions
@ -0,0 +1,308 @@ |
|||||||
|
import { Event as NEvent } from 'nostr-tools' |
||||||
|
import logger from '@/lib/logger' |
||||||
|
|
||||||
|
interface CachedThreadData { |
||||||
|
replies: NEvent[] |
||||||
|
timestamp: number |
||||||
|
rootInfo: { |
||||||
|
type: 'E' | 'A' | 'I' |
||||||
|
id: string |
||||||
|
pubkey?: string |
||||||
|
eventId?: string |
||||||
|
relay?: string |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
interface CachedDiscussionsListData { |
||||||
|
eventMap: Map<string, any> |
||||||
|
dynamicTopics: { |
||||||
|
mainTopics: any[] |
||||||
|
subtopics: any[] |
||||||
|
allTopics: any[] |
||||||
|
} |
||||||
|
timestamp: number |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Cache service for discussion feed data (thread replies/comments) |
||||||
|
* Uses in-memory cache with timestamp-based expiration |
||||||
|
*/ |
||||||
|
class DiscussionFeedCacheService { |
||||||
|
static instance: DiscussionFeedCacheService |
||||||
|
private cache: Map<string, CachedThreadData> = new Map() |
||||||
|
private discussionsListCache: CachedDiscussionsListData | null = null |
||||||
|
private readonly CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||||
|
private readonly DISCUSSIONS_LIST_CACHE_TTL_MS = 2 * 60 * 1000 // 2 minutes for discussions list
|
||||||
|
|
||||||
|
static getInstance(): DiscussionFeedCacheService { |
||||||
|
if (!DiscussionFeedCacheService.instance) { |
||||||
|
DiscussionFeedCacheService.instance = new DiscussionFeedCacheService() |
||||||
|
} |
||||||
|
return DiscussionFeedCacheService.instance |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cache key for a thread |
||||||
|
*/ |
||||||
|
private getCacheKey(rootInfo: CachedThreadData['rootInfo']): string { |
||||||
|
if (rootInfo.type === 'E') { |
||||||
|
return `thread:E:${rootInfo.id}` |
||||||
|
} else if (rootInfo.type === 'A') { |
||||||
|
return `thread:A:${rootInfo.id}` |
||||||
|
} else if (rootInfo.type === 'I') { |
||||||
|
return `thread:I:${rootInfo.id}` |
||||||
|
} |
||||||
|
return `thread:unknown:${rootInfo.id}` |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if cached data is stale |
||||||
|
*/ |
||||||
|
private isStale(cachedData: CachedThreadData): boolean { |
||||||
|
const age = Date.now() - cachedData.timestamp |
||||||
|
return age > this.CACHE_TTL_MS |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cached replies for a thread |
||||||
|
* Returns null if cache is empty, but returns data even if stale (for instant display) |
||||||
|
*/ |
||||||
|
getCachedReplies(rootInfo: CachedThreadData['rootInfo']): NEvent[] | null { |
||||||
|
const cacheKey = this.getCacheKey(rootInfo) |
||||||
|
const cachedData = this.cache.get(cacheKey) |
||||||
|
|
||||||
|
if (!cachedData) { |
||||||
|
logger.debug('[DiscussionFeedCache] Cache miss for thread:', cacheKey) |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
// Verify rootInfo matches (in case thread structure changed)
|
||||||
|
if ( |
||||||
|
cachedData.rootInfo.type !== rootInfo.type || |
||||||
|
cachedData.rootInfo.id !== rootInfo.id |
||||||
|
) { |
||||||
|
logger.debug('[DiscussionFeedCache] Cache rootInfo mismatch for thread:', cacheKey) |
||||||
|
this.cache.delete(cacheKey) |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
// Return cached data even if stale (caller will fetch fresh data in background)
|
||||||
|
const isStale = this.isStale(cachedData) |
||||||
|
if (isStale) { |
||||||
|
logger.debug('[DiscussionFeedCache] Cache hit (stale) for thread:', cacheKey, 'age:', Date.now() - cachedData.timestamp, 'ms', 'replies:', cachedData.replies.length) |
||||||
|
} else { |
||||||
|
logger.debug('[DiscussionFeedCache] Cache hit (fresh) for thread:', cacheKey, 'replies:', cachedData.replies.length) |
||||||
|
} |
||||||
|
|
||||||
|
return cachedData.replies |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if cached data exists and is fresh (not stale) |
||||||
|
*/ |
||||||
|
hasFreshCache(rootInfo: CachedThreadData['rootInfo']): boolean { |
||||||
|
const cacheKey = this.getCacheKey(rootInfo) |
||||||
|
const cachedData = this.cache.get(cacheKey) |
||||||
|
|
||||||
|
if (!cachedData) { |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
// Verify rootInfo matches
|
||||||
|
if ( |
||||||
|
cachedData.rootInfo.type !== rootInfo.type || |
||||||
|
cachedData.rootInfo.id !== rootInfo.id |
||||||
|
) { |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
return !this.isStale(cachedData) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Store replies in cache |
||||||
|
* Merges new replies with existing cached replies to prevent count from going down |
||||||
|
*/ |
||||||
|
setCachedReplies(rootInfo: CachedThreadData['rootInfo'], replies: NEvent[]): void { |
||||||
|
const cacheKey = this.getCacheKey(rootInfo) |
||||||
|
const existingData = this.cache.get(cacheKey) |
||||||
|
|
||||||
|
let mergedReplies: NEvent[] |
||||||
|
if (existingData &&
|
||||||
|
existingData.rootInfo.type === rootInfo.type && |
||||||
|
existingData.rootInfo.id === rootInfo.id) { |
||||||
|
// Merge with existing cached replies - keep all unique replies
|
||||||
|
const existingReplyIds = new Set(existingData.replies.map(r => r.id)) |
||||||
|
const newReplies = replies.filter(r => !existingReplyIds.has(r.id)) |
||||||
|
mergedReplies = [...existingData.replies, ...newReplies] |
||||||
|
logger.debug('[DiscussionFeedCache] Merged replies for thread:', cacheKey, 'existing:', existingData.replies.length, 'new:', newReplies.length, 'total:', mergedReplies.length) |
||||||
|
} else { |
||||||
|
// No existing cache or rootInfo mismatch, use new replies
|
||||||
|
mergedReplies = [...replies] |
||||||
|
logger.debug('[DiscussionFeedCache] Cached new replies for thread:', cacheKey, 'replies:', replies.length) |
||||||
|
} |
||||||
|
|
||||||
|
const cachedData: CachedThreadData = { |
||||||
|
replies: mergedReplies, // Create a copy to avoid mutations
|
||||||
|
timestamp: Date.now(), |
||||||
|
rootInfo: { ...rootInfo } // Create a copy
|
||||||
|
} |
||||||
|
|
||||||
|
this.cache.set(cacheKey, cachedData) |
||||||
|
|
||||||
|
// Clean up stale entries periodically (every 10th set operation)
|
||||||
|
if (this.cache.size > 50 && Math.random() < 0.1) { |
||||||
|
this.cleanupStaleEntries() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear cache for a specific thread |
||||||
|
*/ |
||||||
|
clearCache(rootInfo: CachedThreadData['rootInfo']): void { |
||||||
|
const cacheKey = this.getCacheKey(rootInfo) |
||||||
|
this.cache.delete(cacheKey) |
||||||
|
logger.debug('[DiscussionFeedCache] Cleared cache for thread:', cacheKey) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear all cached data |
||||||
|
*/ |
||||||
|
clearAllCache(): void { |
||||||
|
this.cache.clear() |
||||||
|
logger.debug('[DiscussionFeedCache] Cleared all cache') |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Remove stale entries from cache |
||||||
|
*/ |
||||||
|
private cleanupStaleEntries(): void { |
||||||
|
let cleaned = 0 |
||||||
|
for (const [key, data] of this.cache.entries()) { |
||||||
|
if (this.isStale(data)) { |
||||||
|
this.cache.delete(key) |
||||||
|
cleaned++ |
||||||
|
} |
||||||
|
} |
||||||
|
if (cleaned > 0) { |
||||||
|
logger.debug('[DiscussionFeedCache] Cleaned up', cleaned, 'stale entries') |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cache statistics (for debugging) |
||||||
|
*/ |
||||||
|
getCacheStats(): { size: number; entries: Array<{ key: string; age: number; replyCount: number }> } { |
||||||
|
const entries = Array.from(this.cache.entries()).map(([key, data]) => ({ |
||||||
|
key, |
||||||
|
age: Date.now() - data.timestamp, |
||||||
|
replyCount: data.replies.length |
||||||
|
})) |
||||||
|
return { |
||||||
|
size: this.cache.size, |
||||||
|
entries |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cached discussions list data |
||||||
|
* Returns null if cache is empty, but returns data even if stale (for merging purposes) |
||||||
|
*/ |
||||||
|
getCachedDiscussionsList(): CachedDiscussionsListData | null { |
||||||
|
if (!this.discussionsListCache) { |
||||||
|
logger.debug('[DiscussionFeedCache] Discussions list cache miss') |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
const age = Date.now() - this.discussionsListCache.timestamp |
||||||
|
const isStale = age > this.DISCUSSIONS_LIST_CACHE_TTL_MS |
||||||
|
|
||||||
|
if (isStale) { |
||||||
|
logger.debug('[DiscussionFeedCache] Discussions list cache hit (stale), age:', age, 'ms') |
||||||
|
} else { |
||||||
|
logger.debug('[DiscussionFeedCache] Discussions list cache hit (fresh), age:', age, 'ms') |
||||||
|
} |
||||||
|
|
||||||
|
// Return cached data even if stale (caller will merge and update)
|
||||||
|
return this.discussionsListCache |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if cached discussions list data exists and is fresh (not stale) |
||||||
|
*/ |
||||||
|
hasFreshDiscussionsListCache(): boolean { |
||||||
|
if (!this.discussionsListCache) { |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
const age = Date.now() - this.discussionsListCache.timestamp |
||||||
|
return age <= this.DISCUSSIONS_LIST_CACHE_TTL_MS |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Store discussions list data in cache |
||||||
|
* Merges new threads with existing cached threads to prevent count from going down |
||||||
|
* When merge=true, ALWAYS preserves all existing threads and adds new ones |
||||||
|
*/ |
||||||
|
setCachedDiscussionsList(eventMap: Map<string, any>, dynamicTopics: { mainTopics: any[]; subtopics: any[]; allTopics: any[] }, merge = true): void { |
||||||
|
let mergedEventMap: Map<string, any> |
||||||
|
const existingCacheSize = this.discussionsListCache?.eventMap.size || 0 |
||||||
|
const newDataSize = eventMap.size |
||||||
|
|
||||||
|
if (merge && this.discussionsListCache) { |
||||||
|
// Merge with existing cached threads - keep all threads we've ever seen
|
||||||
|
// Start with ALL existing cached threads - this is critical to prevent thread loss
|
||||||
|
mergedEventMap = new Map(this.discussionsListCache.eventMap) |
||||||
|
|
||||||
|
// Add or update threads from the new fetch
|
||||||
|
// For existing threads, prefer the new data (which has fresher counts)
|
||||||
|
// For new threads, add them
|
||||||
|
eventMap.forEach((entry, threadId) => { |
||||||
|
// Always update with new data if it exists (new data has fresher counts from latest fetch)
|
||||||
|
// This ensures we get updated comment/vote counts for all threads
|
||||||
|
mergedEventMap.set(threadId, entry) |
||||||
|
}) |
||||||
|
|
||||||
|
const finalSize = mergedEventMap.size |
||||||
|
logger.debug('[DiscussionFeedCache] Merged discussions list: existing:', existingCacheSize, 'new:', newDataSize, 'total:', finalSize, '(expected at least:', Math.max(existingCacheSize, newDataSize), ')') |
||||||
|
|
||||||
|
// Safety check: we should never have fewer threads than we started with (unless new data has fewer)
|
||||||
|
// But we should always have at least as many as the larger of the two sets
|
||||||
|
if (finalSize < Math.max(existingCacheSize, newDataSize)) { |
||||||
|
logger.warn('[DiscussionFeedCache] WARNING: Merge resulted in fewer threads! Existing:', existingCacheSize, 'New:', newDataSize, 'Final:', finalSize) |
||||||
|
} |
||||||
|
} else { |
||||||
|
// No existing cache or merge=false, use new data directly
|
||||||
|
mergedEventMap = new Map(eventMap) |
||||||
|
logger.debug('[DiscussionFeedCache] Cached new discussions list (no merge):', eventMap.size, 'threads') |
||||||
|
} |
||||||
|
|
||||||
|
// Store merged event map
|
||||||
|
this.discussionsListCache = { |
||||||
|
eventMap: mergedEventMap, |
||||||
|
dynamicTopics: { |
||||||
|
mainTopics: [...dynamicTopics.mainTopics], |
||||||
|
subtopics: [...dynamicTopics.subtopics], |
||||||
|
allTopics: [...dynamicTopics.allTopics] |
||||||
|
}, |
||||||
|
timestamp: Date.now() |
||||||
|
} |
||||||
|
|
||||||
|
// Final verification
|
||||||
|
if (this.discussionsListCache.eventMap.size !== mergedEventMap.size) { |
||||||
|
logger.error('[DiscussionFeedCache] ERROR: Cache eventMap size mismatch after storing! Expected:', mergedEventMap.size, 'Got:', this.discussionsListCache.eventMap.size) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear discussions list cache |
||||||
|
*/ |
||||||
|
clearDiscussionsListCache(): void { |
||||||
|
this.discussionsListCache = null |
||||||
|
logger.debug('[DiscussionFeedCache] Cleared discussions list cache') |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const instance = DiscussionFeedCacheService.getInstance() |
||||||
|
export default instance |
||||||
|
|
||||||
Loading…
Reference in new issue