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.
This commit is contained in:
google-labs-jules[bot] committed 2026-01-17 22:15:46 +00:00
1 parent 019a87b8af
commit 083f0c9ecc
4 files changed
+336 -13

No files matched your search

+2 -2
View File
@@ -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,
},
+82 -11
View File
@@ -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 = {}) {
+143
View File
@@ -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,
+109
View File
@@ -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();
}
}