mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
019a87b8af
commit
083f0c9ecc
4 files changed
+336
-13
No files matched your search
@@ -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
@@ -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 = {}) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user