From 083f0c9ecce55d1e68fc1fbb099ca46180d622d5 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 17 Jan 2026 22:15:46 +0000 Subject: [PATCH] feat: implement shared LRU cache for video comments and reactions - Added `js/utils/lruCache.js` with hit/miss tracking. - Updated `js/nostr/cachePolicies.js` to set 5-minute TTL for comments/reactions. - Integrated caching and incremental fetching in `js/nostr/commentEvents.js`. - Implemented `listVideoReactions` in `js/nostr/reactionEvents.js` with caching logic. --- js/nostr/cachePolicies.js | 4 +- js/nostr/commentEvents.js | 93 +++++++++++++++++++++--- js/nostr/reactionEvents.js | 143 +++++++++++++++++++++++++++++++++++++ js/utils/lruCache.js | 109 ++++++++++++++++++++++++++++ 4 files changed, 336 insertions(+), 13 deletions(-) create mode 100644 js/utils/lruCache.js diff --git a/js/nostr/cachePolicies.js b/js/nostr/cachePolicies.js index d923d9ba..696e14cc 100644 --- a/js/nostr/cachePolicies.js +++ b/js/nostr/cachePolicies.js @@ -62,13 +62,13 @@ export const CACHE_POLICIES = Object.freeze({ }, [NOTE_TYPES.VIDEO_COMMENT]: { storage: STORAGE_TIERS.MEMORY, - ttl: 0, // Session only + ttl: 5 * 60 * 1000, // 5 minutes addressing: "id", merge: MERGE_STRATEGIES.APPEND_ONLY, }, [NOTE_TYPES.VIDEO_REACTION]: { storage: STORAGE_TIERS.MEMORY, - ttl: 0, + ttl: 5 * 60 * 1000, // 5 minutes addressing: "id", merge: MERGE_STRATEGIES.APPEND_ONLY, }, diff --git a/js/nostr/commentEvents.js b/js/nostr/commentEvents.js index 87a30840..687cb05b 100644 --- a/js/nostr/commentEvents.js +++ b/js/nostr/commentEvents.js @@ -8,7 +8,13 @@ import { publishEventToRelay } from "../nostrPublish.js"; import { RELAY_URLS } from "./toolkit.js"; import { normalizePointerInput } from "./watchHistory.js"; import { devLogger, userLogger } from "../utils/logger.js"; +import { LRUCache } from "../utils/lruCache.js"; +import { CACHE_POLICIES } from "./cachePolicies.js"; + const COMMENT_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT); +const CACHE_POLICY = CACHE_POLICIES[NOTE_TYPES.VIDEO_COMMENT]; + +const commentCache = new LRUCache({ maxSize: 100 }); export const COMMENT_EVENT_KIND = Number.isFinite(COMMENT_EVENT_SCHEMA?.kind) ? COMMENT_EVENT_SCHEMA.kind : 1111; @@ -1272,25 +1278,79 @@ export async function listVideoComments(client, targetInput, options = {}) { } let descriptor; - let filters; + let filterTemplate; try { - ({ descriptor, filters } = createVideoCommentFilters(targetInput, options)); + // createVideoCommentFilters returns { descriptor, filters: [...] } + const result = createVideoCommentFilters(targetInput, options); + descriptor = result.descriptor; + // We assume the first filter is the primary one we want to augment with 'since' + // or use in per-relay logic. For now, we use the full array. + filterTemplate = result.filters; } catch (error) { devLogger.warn("[nostr] Failed to build comment filters:", error); return []; } - const relayList = sanitizeRelayList(options.relays, client.relays); + const cacheKey = descriptor.videoEventId; + const cached = commentCache.get(cacheKey); + const ttl = CACHE_POLICY.ttl; + const now = Date.now(); + const forceRefresh = options?.forceRefresh === true; - let rawResults; - try { - rawResults = await pool.list(relayList, filters); - } catch (error) { - devLogger.warn("[nostr] Failed to list video comments:", error); - return []; + if (cached && !forceRefresh && (now - cached.fetchedAt < ttl)) { + devLogger.debug(`[nostr] Comments cache hit for ${cacheKey}`); + return cached.items; } - const flattened = flattenListResults(rawResults); + if (cached) { + devLogger.debug(`[nostr] Comments cache stale for ${cacheKey}, refreshing...`); + } else { + devLogger.debug(`[nostr] Comments cache miss for ${cacheKey}`); + } + + const relayList = sanitizeRelayList(options.relays, client.relays); + const lastSeens = cached?.lastSeenPerRelay || {}; + const mergedLastSeens = { ...lastSeens }; + + const rawResults = []; + + // Parallel fetch from relays with incremental logic + await Promise.all( + relayList.map(async (url) => { + if (!url) return; + const lastSeen = lastSeens[url] || 0; + // Deep clone filters to apply 'since' safely per relay + const relayFilters = filterTemplate.map(f => { + const copy = { ...f }; + if (lastSeen > 0) { + copy.since = lastSeen + 1; + } + return copy; + }); + + try { + const events = await pool.list([url], relayFilters); + let maxCreated = lastSeen; + if (Array.isArray(events)) { + for (const ev of events) { + rawResults.push(ev); + if (ev.created_at > maxCreated) { + maxCreated = ev.created_at; + } + } + } + if (maxCreated > lastSeen) { + mergedLastSeens[url] = maxCreated; + } + } catch (err) { + devLogger.warn(`[nostr] Failed to fetch comments from ${url}:`, err); + } + }) + ); + + // If we have cached items, add them to the raw list for deduplication/merging + const combinedRaw = cached ? [...cached.items, ...rawResults] : rawResults; + const flattened = flattenListResults(combinedRaw); const dedupe = new Map(); const order = []; @@ -1323,7 +1383,7 @@ export async function listVideoComments(client, targetInput, options = {}) { } } - return order + const finalItems = order .map((entry) => { if (!entry) { return null; @@ -1337,6 +1397,17 @@ export async function listVideoComments(client, targetInput, options = {}) { return null; }) .filter(Boolean); + + // Update cache + if (cacheKey) { + commentCache.set(cacheKey, { + items: finalItems, + lastSeenPerRelay: mergedLastSeens, + fetchedAt: Date.now() + }); + } + + return finalItems; } export function subscribeVideoComments(client, targetInput, options = {}) { diff --git a/js/nostr/reactionEvents.js b/js/nostr/reactionEvents.js index fba80c4c..341d296c 100644 --- a/js/nostr/reactionEvents.js +++ b/js/nostr/reactionEvents.js @@ -3,6 +3,12 @@ import { publishEventToRelay } from "../nostrPublish.js"; import { RELAY_URLS } from "./toolkit.js"; import { normalizePointerInput } from "./watchHistory.js"; import { devLogger, userLogger } from "../utils/logger.js"; +import { LRUCache } from "../utils/lruCache.js"; +import { CACHE_POLICIES } from "./cachePolicies.js"; +import { NOTE_TYPES } from "../nostrEventSchemas.js"; + +const CACHE_POLICY = CACHE_POLICIES[NOTE_TYPES.VIDEO_REACTION]; +const reactionCache = new LRUCache({ maxSize: 100 }); function sanitizeRelayList(primary, fallback) { if (Array.isArray(primary) && primary.length) { @@ -127,6 +133,143 @@ function extractEventRelayFromPointerInput(pointerInput) { return ""; } +export async function listVideoReactions(client, targetInput, options = {}) { + let pool = client?.pool; + const ensurePool = + typeof client?.ensurePool === "function" + ? client.ensurePool.bind(client) + : null; + + if (!pool || typeof pool.list !== "function") { + if (ensurePool) { + try { + pool = await ensurePool(); + } catch (error) { + devLogger.warn( + "[nostr] Unable to list video reactions: pool init failed.", + error, + ); + return []; + } + } + } + + if (!pool || typeof pool.list !== "function") { + devLogger.warn("[nostr] Unable to list video reactions: pool missing."); + return []; + } + + // Normalize target to get video event ID + let videoEventId = ""; + if (typeof targetInput === "string") { + videoEventId = targetInput.trim(); + } else if (targetInput && typeof targetInput === "object") { + videoEventId = + normalizeString(targetInput.id) || + normalizeString(targetInput.eventId) || + normalizeString(targetInput.videoEventId) || + extractEventIdFromPointerInput(targetInput); + } + + if (!videoEventId) { + devLogger.warn("[nostr] Invalid target for listVideoReactions (missing ID)."); + return []; + } + + const cacheKey = videoEventId; + const cached = reactionCache.get(cacheKey); + const ttl = CACHE_POLICY.ttl; + const now = Date.now(); + const forceRefresh = options?.forceRefresh === true; + + if (cached && !forceRefresh && (now - cached.fetchedAt < ttl)) { + devLogger.debug(`[nostr] Reactions cache hit for ${cacheKey}`); + return cached.items; + } + + if (cached) { + devLogger.debug(`[nostr] Reactions cache stale for ${cacheKey}, refreshing...`); + } else { + devLogger.debug(`[nostr] Reactions cache miss for ${cacheKey}`); + } + + const relayList = sanitizeRelayList(options.relays, client.relays); + const lastSeens = cached?.lastSeenPerRelay || {}; + const mergedLastSeens = { ...lastSeens }; + + const rawResults = []; + const filters = [{ kinds: [7], "#e": [videoEventId] }]; + + // Parallel fetch from relays with incremental logic + await Promise.all( + relayList.map(async (url) => { + if (!url) return; + const lastSeen = lastSeens[url] || 0; + // Deep clone filters to apply 'since' safely per relay + const relayFilters = filters.map(f => { + const copy = { ...f }; + if (lastSeen > 0) { + copy.since = lastSeen + 1; + } + return copy; + }); + + try { + const events = await pool.list([url], relayFilters); + let maxCreated = lastSeen; + if (Array.isArray(events)) { + for (const ev of events) { + rawResults.push(ev); + if (ev.created_at > maxCreated) { + maxCreated = ev.created_at; + } + } + } + if (maxCreated > lastSeen) { + mergedLastSeens[url] = maxCreated; + } + } catch (err) { + devLogger.warn(`[nostr] Failed to fetch reactions from ${url}:`, err); + } + }) + ); + + // Merge with cached items + const combinedRaw = cached ? [...cached.items, ...rawResults] : rawResults; + + // Deduplicate by ID and sort by created_at (descending) + const dedupe = new Map(); + for (const event of combinedRaw) { + if (!event || typeof event.id !== "string") continue; + // Basic validation: must be kind 7 and tag the video + if (event.kind !== 7) continue; + // We could verify the 'e' tag matches videoEventId, but the filter usually handles it. + // Being defensive: + const tags = Array.isArray(event.tags) ? event.tags : []; + const hasETag = tags.some(t => t[0] === 'e' && t[1] === videoEventId); + // Note: some clients might use 'a' tag for parameterized events, but we filtered by #e. + if (!hasETag) continue; + + const existing = dedupe.get(event.id); + if (!existing || event.created_at > existing.created_at) { + dedupe.set(event.id, event); + } + } + + const finalItems = Array.from(dedupe.values()).sort((a, b) => b.created_at - a.created_at); + + // Update cache + if (cacheKey) { + reactionCache.set(cacheKey, { + items: finalItems, + lastSeenPerRelay: mergedLastSeens, + fetchedAt: Date.now() + }); + } + + return finalItems; +} + export async function publishVideoReaction( client, pointerInput, diff --git a/js/utils/lruCache.js b/js/utils/lruCache.js new file mode 100644 index 00000000..dd4bef78 --- /dev/null +++ b/js/utils/lruCache.js @@ -0,0 +1,109 @@ + +/** + * A generic Least Recently Used (LRU) cache implementation. + * It supports a maximum size limit. When the limit is reached, + * the least recently accessed item is evicted. + * + * Note: This cache does not automatically evict items based on TTL. + * It is up to the consumer to check timestamps if expiration is needed. + */ +export class LRUCache { + /** + * @param {object} options + * @param {number} [options.maxSize=100] - Maximum number of entries. + */ + constructor({ maxSize = 100 } = {}) { + this.maxSize = maxSize > 0 ? maxSize : 100; + this.cache = new Map(); + this.hits = 0; + this.misses = 0; + } + + /** + * Retrieves an item from the cache. + * If found, the item is marked as most recently used and hit count increments. + * If not found, miss count increments. + * + * @param {string} key + * @returns {*} The cached value, or undefined if not found. + */ + get(key) { + const value = this.cache.get(key); + if (value !== undefined) { + // Refresh recency: delete and re-set + this.cache.delete(key); + this.cache.set(key, value); + this.hits++; + } else { + this.misses++; + } + return value; + } + + /** + * Adds or updates an item in the cache. + * The item is marked as most recently used. + * If the cache exceeds maxSize, the least recently used item is evicted. + * + * @param {string} key + * @param {*} value + */ + set(key, value) { + if (this.cache.has(key)) { + this.cache.delete(key); + } else if (this.cache.size >= this.maxSize) { + // The first key in a Map is the oldest (insertion order) + const oldestKey = this.cache.keys().next().value; + this.cache.delete(oldestKey); + } + this.cache.set(key, value); + } + + /** + * Checks if a key exists in the cache without updating its recency or metrics. + * + * @param {string} key + * @returns {boolean} + */ + has(key) { + return this.cache.has(key); + } + + /** + * Returns the number of items in the cache. + * + * @returns {number} + */ + size() { + return this.cache.size; + } + + /** + * Clears all items from the cache and resets counters. + */ + clear() { + this.cache.clear(); + this.hits = 0; + this.misses = 0; + } + + /** + * Returns current statistics for the cache. + * @returns {{ hits: number, misses: number, size: number, maxSize: number }} + */ + getStats() { + return { + hits: this.hits, + misses: this.misses, + size: this.cache.size, + maxSize: this.maxSize, + }; + } + + /** + * Returns an iterator of [key, value] pairs, from oldest to newest. + */ + entries() { + return this.cache.entries(); + } +}