You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
371 lines
13 KiB
371 lines
13 KiB
import { isNip25ReactionKind } from '@/lib/event' |
|
import { isThreadBoosterOnlyRow } from '@/lib/thread-response-filter' |
|
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 |
|
/** Cap in-memory thread caches so long sessions do not retain unbounded reply payloads. */ |
|
private readonly MAX_THREAD_CACHE_KEYS = 100 |
|
/** Cap merged discussions list `eventMap` so unbounded merges cannot grow RAM without limit. */ |
|
private readonly MAX_DISCUSSIONS_LIST_THREADS = 400 |
|
|
|
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.filter((r) => !isNip25ReactionKind(r.kind) && !isThreadBoosterOnlyRow(r)) |
|
} |
|
|
|
/** |
|
* 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].filter( |
|
(r) => !isNip25ReactionKind(r.kind) && !isThreadBoosterOnlyRow(r) |
|
) |
|
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.filter((r) => !isNip25ReactionKind(r.kind) && !isThreadBoosterOnlyRow(r)) |
|
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) |
|
|
|
this.trimThreadCacheIfNeeded() |
|
|
|
// Clean up stale entries periodically (every 10th set operation) |
|
if (this.cache.size > 50 && Math.random() < 0.1) { |
|
this.cleanupStaleEntries() |
|
} |
|
} |
|
|
|
/** Drop oldest threads by {@link CachedThreadData.timestamp} when over {@link MAX_THREAD_CACHE_KEYS}. */ |
|
private trimThreadCacheIfNeeded(): void { |
|
if (this.cache.size <= this.MAX_THREAD_CACHE_KEYS) return |
|
const entries = [...this.cache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp) |
|
const overflow = this.cache.size - this.MAX_THREAD_CACHE_KEYS |
|
for (let i = 0; i < overflow; i++) { |
|
const key = entries[i]?.[0] |
|
if (key) this.cache.delete(key) |
|
} |
|
} |
|
|
|
/** Best-effort recency for discussion thread rows (unknown shapes → 0). */ |
|
private discussionsEntryRecency(entry: unknown): number { |
|
if (!entry || typeof entry !== 'object') return 0 |
|
const o = entry as Record<string, unknown> |
|
for (const k of ['lastReplyAt', 'lastActivityAt', 'updatedAt', 'fetchedAt']) { |
|
const v = o[k] |
|
if (typeof v === 'number' && v > 0) return v |
|
} |
|
const root = o.rootEvent ?? o.event ?? o.threadRoot |
|
if (root && typeof root === 'object' && 'created_at' in root) { |
|
const ca = (root as { created_at?: unknown }).created_at |
|
if (typeof ca === 'number') return ca |
|
} |
|
return 0 |
|
} |
|
|
|
/** |
|
* When over {@link MAX_DISCUSSIONS_LIST_THREADS}, keep rows from the latest fetch first, then |
|
* next-most-recent by {@link discussionsEntryRecency}. |
|
*/ |
|
private trimDiscussionsEventMap( |
|
map: Map<string, unknown>, |
|
prioritizeIds: ReadonlySet<string> |
|
): Map<string, unknown> { |
|
if (map.size <= this.MAX_DISCUSSIONS_LIST_THREADS) return map |
|
const entries = [...map.entries()].sort((a, b) => { |
|
const pa = prioritizeIds.has(a[0]) ? 1 : 0 |
|
const pb = prioritizeIds.has(b[0]) ? 1 : 0 |
|
if (pa !== pb) return pb - pa |
|
return this.discussionsEntryRecency(b[1]) - this.discussionsEntryRecency(a[1]) |
|
}) |
|
const next = new Map<string, unknown>() |
|
for (let i = 0; i < this.MAX_DISCUSSIONS_LIST_THREADS && i < entries.length; i++) { |
|
const row = entries[i] |
|
if (row) next.set(row[0], row[1]) |
|
} |
|
return next |
|
} |
|
|
|
/** |
|
* 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 { |
|
const newIds = new Set(eventMap.keys()) |
|
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') |
|
} |
|
|
|
mergedEventMap = this.trimDiscussionsEventMap(mergedEventMap, newIds) as Map<string, any> |
|
|
|
// 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 |
|
|
|
|