acebase/src/node-cache.js
2019-11-22 09:35:32 +01:00

232 lines
No EOL
7.4 KiB
JavaScript

// 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<string, NodeCacheEntry>} */
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<NodeInfo>|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 };