// TODO: Rename to NodeInfoCache const { NodeInfo } = require('./node-info'); const { PathInfo } = require('acebase-core'); const SECOND = 1000; const MINUTE = 60000; const DEBUG_MODE = false; const CACHE_TIMEOUT = DEBUG_MODE ? 5 * MINUTE : MINUTE; class NodeCacheEntry { /** * * @param {NodeInfo} nodeInfo */ constructor(nodeInfo) { this.nodeInfo = nodeInfo; this.created = Date.now(); this.keepAlive(); } keepAlive() { this.expires = (this.created || this.updated) + NodeCache.CACHE_DURATION; } /** * * @param {NodeInfo} nodeInfo */ update(nodeInfo) { this.nodeInfo = nodeInfo; this.updated = Date.now(); this.keepAlive(); } } class NodeCache { static get CACHE_DURATION() { return CACHE_TIMEOUT; } constructor() { // Isolated cache, this enables using multiple databases each with their own cache this. _cleanupTimeout = null; /** @type {Map} */ this._cache = new Map(); //{ }; this._announcements = new Map(); // For announced lookups, will bind subsequent .find calls to a promise that resolves once the cache item is set } _assertCleanupTimeout() { if (this._cleanupTimeout === null) { this._cleanupTimeout = setTimeout(() => { this.cleanup(); this._cleanupTimeout = null; if (this._cache.size > 0) { this._assertCleanupTimeout(); } }, CACHE_TIMEOUT); } } announce(path) { let announcement = this._announcements.get(path); if (!announcement) { announcement = { resolve: null, reject: null, promise: null } announcement.promise = new Promise((resolve, reject) => { announcement.resolve = resolve; announcement.reject = reject; }); this._announcements.set(path, announcement); } } /** * Updates or adds a NodeAddress to the cache * @param {NodeInfo} nodeInfo * @param {boolean} [overwrite=true] if the cache must be overwritten if the entry already exists */ update(nodeInfo, overwrite = true) { if (!(nodeInfo instanceof NodeInfo)) { throw new TypeError(`nodeInfo must be an instance of NodeInfo`); } if (nodeInfo.path === "") { // Don't cache root address, it has to be retrieved from storage.rootAddress return; } let entry = this._cache.get(nodeInfo.path); if (entry) { if (!overwrite) { DEBUG_MODE && console.error(`CACHE SKIP ${nodeInfo}`); } else { DEBUG_MODE && console.error(`CACHE UPDATE ${nodeInfo}`); entry.update(nodeInfo); } } else { // New entry DEBUG_MODE && console.error(`CACHE INSERT ${nodeInfo}`); entry = new NodeCacheEntry(nodeInfo); this._cache.set(nodeInfo.path, entry); } const announcement = this._announcements.get(nodeInfo.path); if (announcement) { this._announcements.delete(nodeInfo.path); announcement.resolve(nodeInfo); } this._assertCleanupTimeout(); } /** * Invalidates a node and (optionally) its children by removing them from cache * @param {string} path * @param {boolean|((path: string) => boolean)} recursive * @param {string} reason */ invalidate(path, recursive, reason) { const entry = this._cache.get(path); const pathInfo = PathInfo.get(path); if (entry) { DEBUG_MODE && console.error(`CACHE INVALIDATE ${reason} => ${entry.nodeInfo}`); this._cache.delete(path); } if (typeof recursive === 'function') { this._cache.forEach((entry, cachedPath) => { if (pathInfo.isAncestorOf(cachedPath)) { const invalidate = recursive(cachedPath); if (invalidate) { DEBUG_MODE && console.error(`CACHE INVALIDATE ${reason} => (child) ${entry.nodeInfo}`); this._cache.delete(cachedPath); } } }); } else if (recursive) { this._cache.forEach((entry, cachedPath) => { if (pathInfo.isAncestorOf(cachedPath)) { DEBUG_MODE && console.error(`CACHE INVALIDATE ${reason} => (child) ${entry.nodeInfo}`); this._cache.delete(cachedPath); } }); } } /** * Marks the node at path, and all its descendants as deleted * @param {string} path */ delete(path) { this.update(new NodeInfo({ path, exists: false })); const pathInfo = PathInfo.get(path); this._cache.forEach((entry, cachedPath) => { if (pathInfo.isAncestorOf(cachedPath)) { this.update(new NodeInfo({ path: cachedPath, exists: false })); } }); } cleanup() { const now = Date.now(); const entriesBefore = this._cache.size; this._cache.forEach((entry, path) => { if (entry.expires <= now) { this._cache.delete(path); } }); const entriesAfter = this._cache.size; const entriesRemoved = entriesBefore - entriesAfter; DEBUG_MODE && console.log(`CACHE Removed ${entriesRemoved} cache entries (${entriesAfter} remain cached)`); } clear() { this._cache.clear(); } /** * Finds cached NodeInfo for a given path. Returns null if the info is not found in cache * @param {string} path * @returns {NodeInfo|Promise|null} cached info, a promise, or null */ find(path, checkAnnounced = false) { if (checkAnnounced === true) { const announcement = this._announcements.get(path); if (announcement) { let resolve; const p = new Promise(rs => { resolve = rs; }); announcement.promise = announcement.promise.then(info => { resolve(info); return info; }); return p; } } let entry = this._cache.get(path) || null; if (entry && entry.nodeInfo.path !== "") { if (entry.expires <= Date.now()) { // expired this._cache.delete(path); entry = null; } else { // Increase lifetime entry.keepAlive(); } } this._assertCleanupTimeout(); DEBUG_MODE && console.error(`CACHE FIND ${path}: ${entry ? entry.nodeInfo : 'null'}`); return entry ? entry.nodeInfo : null; } /** * Finds the first cached NodeInfo for the closest ancestor of a given path * @param {string} path * @returns {NodeInfo} cached info for an ancestor */ findAncestor(path) { while (true) { path = getPathInfo(path).parent; if (path === null) { return null; } const entry = this.find(path); if (entry) { return entry; } } } } module.exports = { NodeCache, NodeCacheEntry };