Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: LRU cache with time-to-keep Expiration
Browse files Browse the repository at this point in the history
mrflip committed Aug 8, 2022
1 parent afade3a commit d279848
Showing 10 changed files with 888 additions and 2 deletions.
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -27,6 +27,7 @@ export {default as KDTree} from './kd-tree';
export {default as LinkedList} from './linked-list';
export {default as LRUCache} from './lru-cache';
export {default as LRUCacheWithDelete} from './lru-cache-with-delete';
export {default as LRUCacheWithExpiry} from './lru-cache-with-expiry';
export {default as LRUMap} from './lru-map';
export {default as LRUMapWithDelete} from './lru-map-with-delete';
export {default as MultiMap} from './multi-map';
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -36,6 +36,7 @@ module.exports = {
LinkedList: require('./linked-list.js'),
LRUCache: require('./lru-cache.js'),
LRUCacheWithDelete: require('./lru-cache-with-delete.js'),
LRUCacheWithExpiry: require('./lru-cache-with-expiry.js'),
LRUMap: require('./lru-map.js'),
LRUMapWithDelete: require('./lru-map-with-delete.js'),
MultiMap: require('./multi-map.js'),
24 changes: 24 additions & 0 deletions lru-cache-with-expiry.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Mnemonist LRUCacheWithExpiry Typings
* =====================================
*/
import LRUCacheWithDelete from './lru-cache-with-delete';

export interface Logger {
trace(message: string, story: any): void
debug(message: string, story: any): void
info(message: string, story: any): void
warn(message: string, story: any): void
error(message: string, story: any): void
}

export default class LRUCacheWithExpiry<K, V> extends LRUCacheWithDelete<K, V> {

expire(): void;

monitor(interval: number, options: {
logger: Logger,
didError: (err: Error, inst: LRUCacheWithExpiry<K, V>) => boolean,
didExpire: (inst: LRUCacheWithExpiry<K, V>, begT: number) => void,
}): void;
}
365 changes: 365 additions & 0 deletions lru-cache-with-expiry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,365 @@
/**
* Mnemonist LRUCacheWithExpiry
* =============================
*
* An extension of LRUCache with time expiry
*/

var LRUCacheWithDelete = require('./lru-cache-with-delete.js'),
forEach = require('obliterator/foreach'),
// typed = require('./utils/typed-arrays.js'),
iterables = require('./utils/iterables.js');

//
// This adds time-to-keep expiration to the LRUCacheWithDelete.
//
// Besides the other ledgers of the backing cache, on every write
// operation the update time is saved in a fixed-sized array. No other
// speed or memory overhead is required for regular operations: most
// importantly, that means there is no age verification on
// read. Customer must periodically call the expire method, which
// evicts items older than the time-to-keep.
//
// The target use case is a 15-minute time-to-keep on caches of 64k
// capacity, where we can tolerate a modest slop in expiration and
// small periodic expiry sweeps. The benchmark script demonstrates a
// 1M-element cache with a 10s ttk being expired every 2 seconds,
// spending 50-60ms on each expiry sweep. I don't know what use case
// would need those extremes but there you go.
//
// Limitations:
//
// * The time-to-live (maximum age of any record returned in a read
// operation) has only the weak guarantee of `(time-to-keep +
// maximum-delay-between-expires)`.
//
// * The expire operation must be done as a whole, and this does not
// offer to do it in a separate thread or make any attempt to be
// thread safe. However, the benchmarks in `benchmark/lru-cache`
// show that a full delete of every item in a 30,000 element cache
// runs in less than 10ms on a 2019 Macbook Pro.
//
// * Having two expire operations scheduled in the same
// thread should be harmless but would give no speedup, so if you
// found yourself in a situation where the expire was taking
// significant time it could be big trouble.
//
// * Due to floating-point shenanigans a custom clock returning
// fractional times may behave unexpectedly.
//
// Alternatives considered and discarded:
//
// * Rather than walking the full length of the age ledger (or linked
// list), keep a data structure (heap?) to reveal only the expirable
// records. This could shorten expiry times in general but I'd worry
// that if there was a large batch to expire the time spent grooming
// the heap could swamp the typical-case savings.
//
// * Maintain two or more generations of cached items, rotating then
// at ttl/2. (write to both, read from the new generation falling
// back to the old generation, and on each expire discard the oldest
// generation and add a new empty cache). This would make expiry
// O(1), with low impact on individual operations but a notable
// tradeoff in cache efficiency.
//
// * Checking the age on read would significantly impact the read performance.
//
// Potential Opportunities for improvement:
//
// * Reduce the memory footprint of the age ledger by chunking the
// timestamps to bytes or words. In the case of byte (256 age
// bins), a 10-minute ttk, and otherwise default parameters, an
// expire operation would discard everything older than (ttk -
// ttk/64) (guaranteeing the ttk but reaping an additional 1.5% of
// records). Expire must be called at least once every `2 * ttk` (20
// minutes) or the newest records would be indistinguishable from
// the oldest records. (Everything would work but it would be a damn
// shame for cache efficiency). A word-sized (64k bins) ledger makes
// the tradeoffs minimal, but offering both choices shouldn't be a
// problem.
//
// * For every record it expires we independently call delete, which
// doctors the read-age linked list. It may make more sense to walk
// the linked list.
//

/**
*
* LRU cache with time-to-keep expiration
*
* Trades fast read/writes for guarantees on timely expiration or fast
* expiration loops.
*
* @param {Iterable} iterable - Target iterable.
* @param {function} Keys - Array class for storing keys.
* @param {function} Values - Array class for storing values.
* @param {number} capacity - Cache's capacity.
* @param {object} capacity - Configuration
* @param {function} [options.getTime = Date.now] - replaces the meaning of time (for mocking and to allow eg a vector clock). Will be called with the object and action as parameters.
* @param {number} [options.ttk = 15 minutes] - when expire is called all items whose update time is older than ttk milliseconds will be deleted. Other items may be as well, depending on implementation.
*
* @return {LRUCacheWithExpiry}
*/
function LRUCacheWithExpiry(Keys, Values, capacity, options = {}) {
if (arguments.length <= 1) {
options = {}; capacity = Keys;
LRUCacheWithDelete.call(this, capacity);
}
else if (arguments.length <= 2) {
options = Values; capacity = Keys;
LRUCacheWithDelete.call(this, capacity);
}
else {
LRUCacheWithDelete.call(this, Keys, Values, capacity);
}
if (options.ttl || options.ttl === 0) {
throw new Error('Please supply options.ttk (time-to-**keep**), not ttl (and understand the difference)');
}
this.getTime = options.getTime || Date.now;
this.ttk = options.ttk || LRUCacheWithExpiry.minutes(15);
this.ages = new Float64Array(this.capacity);
//
this.initT = this.getTime('init', this);
this.lastT = this.initT;
}

// FIXME: remove?
LRUCacheWithExpiry.prototype.investigate = function(...args) {
// eslint-disable-next-line no-console
console.log(this.inspect({all: true}), ...args);
};

/**
* LRUCacheWithExpiry inherits from LRUCacheWithDelete
*/

for (var k in LRUCacheWithDelete.prototype)
LRUCacheWithExpiry.prototype[k] = LRUCacheWithDelete.prototype[k];

/**
* If possible, attaching the
* * #.entries method to Symbol.iterator (allowing `for (const foo of cache) { ... }`)
* * the summaryString method to Symbol.toStringTag (allowing `\`${cache}\`` to work)
*/
if (typeof Symbol !== 'undefined') {
LRUCacheWithExpiry.prototype[Symbol.iterator] = LRUCacheWithDelete.prototype[Symbol.iterator];
Object.defineProperty(LRUCacheWithExpiry.prototype, Symbol.toStringTag, {
get: function () { return `${this.constructor.name}:${this.size}/${this.capacity}`; },
});
}
Object.defineProperty(LRUCacheWithExpiry.prototype, 'summary', Object.getOwnPropertyDescriptor(LRUCacheWithDelete.prototype, 'summary'));

/**
* Method used to set the value for the given key in the cache.
*
* @param {any} key - Key.
* @param {any} value - Value.
* @return {undefined}
*/
LRUCacheWithExpiry.prototype.set = function(key, value) {
LRUCacheWithDelete.prototype.set.call(this, key, value);
var pointer = this.items[key];
this.ages[pointer] = this.getTime('set', this);
};

/**
* Method used to set the value for the given key in the cache
*
* @param {any} key - Key.
* @param {any} value - Value.
* @return {{evicted: boolean, key: any, value: any}} An object containing the
* key and value of an item that was overwritten or evicted in the set
* operation, as well as a boolean indicating whether it was evicted due to
* limited capacity. Return value is null if nothing was evicted or overwritten
* during the set operation.
*/
LRUCacheWithExpiry.prototype.setpop = function(key, value) {
var result = LRUCacheWithDelete.prototype.setpop.call(this, key, value);
var pointer = this.items[key];
this.ages[pointer] = this.getTime('set', this);
return result;
};

/**
* All items written before curfew time are due for expiry
*/
Object.defineProperty(LRUCacheWithExpiry.prototype, 'curfew', {
get() { return this.getTime('check', this) - this.ttk; }
});

/**
* Duration of time since last expiration
*/
Object.defineProperty(LRUCacheWithExpiry.prototype, 'sinceExpiry', {
get() { return this.getTime('check', this) - this.lastT; }
});

/**
* Delete all cached items older than this.ttk
*/
LRUCacheWithExpiry.prototype.expire = function() {
this.getTime('startExpire', this);
var curfew = this.curfew;
var ii;
for (ii = 0; ii < this.capacity; ii++) {
if (this.ages[ii] <= curfew) {
this.delete(this.K[ii]);
}
}
this.lastT = this.getTime('doneExpire', this);
};

// /**
// * Delete all cached items older than this.ttk
// */
// LRUCacheWithExpiry.prototype.expireUsingLinkedList = function() {
// var currT = this.getTime('startExpire', this);
// var curfew = this.curfew
// // TODO: are there savings from unwinding the delete op into this?
// var pointer = this.head;
// while ((pointer !== this.tail) && (this.size > 1)) {
// if (this.ages[pointer] < curfew) {
// console.log('del', pointer, this.V[pointer], this.ages[pointer]);
// this.delete(this.K[pointer]);
// } else {
// this.pointer = this.backward[pointer]
// }
// }
// };

/**
*
* Method used to start an asynchronous monitor that calls #expire()
* on the given milliseconds interval. Supply an optional didError to
* receive any errors thrown during expiry. Unless you supply that
* callback and it returns true, the error will be re-thrown.
*
* Repeated calls to monitor will clear any previous timers.
*
* @param {number} interval - milliseconds between expire() calls
* @param {object} options - configuration
* @param {function} [options.didExpire] - will be invoked as didExpire(this, begT) with the millis timestamp immediately before calling expire. By default logs the duration of expire.
* @param {function} [options.didError] - will be invoked as didError(error, this) on any error.
* @param {function} [options.logger = console] - used to emit log messages.
*
* The return value of this method is unspecified. Use .stopMonitor to
* remove the monitor.
*/
LRUCacheWithExpiry.prototype.monitor = function(interval, options = {}) {
var self = this;
var logger = options.logger || console;
var name = options.name || this.constructor.name;
var begT;
var didError = options.didError || function(err, inst) {
logger.error(`Error in ${name}.expire: ${err}`, {err, cache: inst.inspect()});
return false;
};
var didExpire = options.didExpire || function(inst, facts) {
var endT = Date.now();
logger.debug(`${name}.expire: ${endT - facts.begT} ${facts.begDeletedSize} ${self.deletedSize}`);
};
// set up a very paranoid method to run in the background, presumably forever.
var doExpire = function () {
try {
begT = Date.now();
var {deletedSize: begDeletedSize, lastT: begLastT} = self;
self.expire();
didExpire(self, {begT, begDeletedSize, begLastT});
} catch (err) {
try {
var handled = didError(err, self);
if (handled) { return; }
} catch (errInHandler) { logger.error(`Double error ${errInHandler} in handler for ${self}`, errInHandler); } // eslint-disable-line no-console
try {
self.stopMonitor('error');
} catch (errStoppingMonitor) { console.error('Cascading error in expiration monitor', errStoppingMonitor); } // eslint-disable-line no-console
throw err;
}
};
// kill any previous monitors and start the new one running on given interval.
this.stopMonitor('starting');
this.timer = setInterval(doExpire, interval);
return this.timer; // lets tests cancel the timer directly; you should use stopMonitor()
};

/**
* Provide a reasonably-sized view of the object.
*
* @param {number} [depth] - When < 0, only the toString() summary is returned
* @param {object} [options = {}] - settings for output
* @param {boolean} [options.all = false] - When true, returns the object with all properties, ignoring limits
* @param {number} [options.maxToDump = 20] - When size > maxToDump, lists only the
* youngest `maxToDump - 2`, a placeholder with the number
* omitted, and the single oldest item. The secret variable
* LRUCache.defaultMaxToDump determines the default limit.
* @return {Map}
*
*/
LRUCacheWithExpiry.prototype.inspect = function(depth, options = {}) {
if (arguments.length <= 1) { options = depth || {}; depth = 2; }
var inspected = LRUCacheWithDelete.prototype.inspect.call(this, depth, options);
if (options.all) { return inspected; }
const {ttk, initT, lastT, deletedSize, curfew, sinceExpiry} = this;
Object.assign(inspected, {ttk, initT, lastT, deletedSize, curfew, sinceExpiry});
return inspected;
};

if (typeof Symbol !== 'undefined')
LRUCacheWithExpiry.prototype[Symbol.for('nodejs.util.inspect.custom')] = LRUCacheWithExpiry.prototype.inspect;

/*
* Helper to convert given number of minutes to milliseconds
*/
LRUCacheWithExpiry.minutes = function(mins) { return 1000 * 60 * mins; };

/**
*
* Method used to stop the monitor, if any.
* NOTE: you must ensure that this method is not stripped of its this:
* setTimeout(cache.stopMonitor, 100000) // WILL THROW
* setTimeout(() => cache.stopMonitor, 100000) // WILL WORK
*/
LRUCacheWithExpiry.prototype.stopMonitor = function() {
if (! (/LRU/.test(this.constructor.name))) {
throw new Error('cache.stopMonitor called with this-less bindings');
}
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
};

/**
* Static @.from function taking an arbitrary iterable & converting it into
* a structure.
*
* @param {Iterable} iterable - Target iterable.
* @param {function} Keys - Array class for storing keys.
* @param {function} Values - Array class for storing values.
* @param {number} capacity - Cache's capacity.
* @return {LRUCacheWithExpiry}
*/
LRUCacheWithExpiry.from = function(iterable, Keys, Values, capacity, options) {
if (arguments.length < 2) {
capacity = iterables.guessLength(iterable);

if (typeof capacity !== 'number')
throw new Error('mnemonist/lru-cache.from: could not guess iterable length. Please provide desired capacity as last argument.');
}
else if (arguments.length === 2) {
capacity = Keys;
Keys = null;
Values = null;
options = {};
}

var cache = new LRUCacheWithExpiry(Keys, Values, capacity, options);

forEach(iterable, function(value, key) {
cache.set(key, value);
});

return cache;
};

module.exports = LRUCacheWithExpiry;
5 changes: 5 additions & 0 deletions lru-cache.js
Original file line number Diff line number Diff line change
@@ -369,6 +369,11 @@ LRUCache.prototype.entries = function() {
});
};

/**
* No-op for compatibility with time-expirable caches
*/
LRUCache.prototype.expire = function () { /**/ };

/**
* Return a short string for interpolation: `LRUCache:size/capacity`
*/
2 changes: 2 additions & 0 deletions lru-map.js
Original file line number Diff line number Diff line change
@@ -212,6 +212,8 @@ LRUMap.prototype.keys = LRUCache.prototype.keys;
LRUMap.prototype.values = LRUCache.prototype.values;
LRUMap.prototype.entries = LRUCache.prototype.entries;
LRUMap.prototype.summaryString = LRUCache.prototype.summaryString;
LRUMap.prototype.expire = LRUCache.prototype.expire;
LRUMap.prototype.investigate = LRUCache.prototype.investigate;

/**
* Inherit methods
48 changes: 48 additions & 0 deletions test/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//

// mock logger that swallows all informational messages.
// error-level messages are presumably unexpected so they are also sent to the console.
function dummyLogger() {
var logger = {
traces: [], debugs: [], infos: [], warns: [], errors: [],
};
logger.trace = function(...args) { logger.traces.push(args); };
logger.debug = function(...args) { logger.debugs.push(args); };
logger.info = function(...args) { logger.info.push(args); };
logger.warn = function(...args) { logger.warns.push(args); };
logger.error = function(...args) { console.error(...args); logger.errors.push(args); }; // eslint-disable-line no-console
return logger;
}

// mock logger that swallows all messages, including error-level messages.
function captureLogger() {
var logger = captureLogger;
logger.error = function(...args) { logger.errors.push(args); };
return logger;
}

function testNear(actual, expected, deltaLo = null, deltaHi = null) {
if (deltaLo === null) { deltaLo = 0.001 * expected; }
if (deltaHi === null) { deltaHi = deltaLo; }
try {
if ((actual >= (expected - deltaLo)) && (actual <= (expected + deltaHi))) {
return true;
}
} catch (err) { console.error('error in testNear', err); } // eslint-disable-line no-console
console.error('actual should be near expected', actual, expected, actual - expected, deltaLo, deltaHi); // eslint-disable-line no-console
return false;
}

function testMatches(actual, expected) {
try {
if (expected.test(actual)) {
return true;
}
} catch (err) { console.error('error in testMatches', err); } // eslint-disable-line no-console
console.error('actual should match expected', actual, expected); // eslint-disable-line no-console
return false;
}

module.exports = {
dummyLogger, captureLogger, testNear, testMatches,
};
425 changes: 425 additions & 0 deletions test/lru-cache-with-expiry.js

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions test/lru-cache.js
Original file line number Diff line number Diff line change
@@ -6,7 +6,9 @@ var assert = require('assert'),
LRUCache = require('../lru-cache.js'),
LRUMap = require('../lru-map.js'),
LRUCacheWithDelete = require('../lru-cache-with-delete.js'),
LRUMapWithDelete = require('../lru-map-with-delete.js');
LRUMapWithDelete = require('../lru-map-with-delete.js'),
LRUCacheWithExpiry = require('../lru-cache-with-expiry.js');
//
var NodeUtil = require('util');

function makeTests(Cache, name) {
@@ -502,7 +504,7 @@ function makeTests(Cache, name) {
}

it('toString() states the name size and capacity', function () {
var cache = new Cache(null, null, 200);
var cache = new Cache(null, null, 200, {ttk: 900000});
cache.set(0, 'cero'); cache.set(1, 'uno');
assert.deepStrictEqual(cache.toString(), `[object ${name}:2/200]`);
if (typeof Symbol !== 'undefined') {
@@ -612,3 +614,4 @@ makeTests(LRUCache, 'LRUCache');
makeTests(LRUMap, 'LRUMap');
makeTests(LRUCacheWithDelete, 'LRUCacheWithDelete');
makeTests(LRUMapWithDelete, 'LRUMapWithDelete');
makeTests(LRUCacheWithExpiry, 'LRUCacheWithExpiry');
12 changes: 12 additions & 0 deletions test/types.ts
Original file line number Diff line number Diff line change
@@ -21,6 +21,7 @@ import {
LinkedList,
LRUCache,
LRUCacheWithDelete,
LRUCacheWithExpiry,
LRUMap,
LRUMapWithDelete,
MultiSet,
@@ -167,6 +168,17 @@ lrucwdItem = lrucwd.remove('one');
let lrucwdDead: string | null = lrucwd.remove('one', null);
let lrucwdWasRemoved: boolean = lrucwd.delete('one');

/**
* LRUCacheWithExpiry
*/
let lrucwe: LRUCacheWithExpiry<string, string> = new LRUCacheWithExpiry(10);
lrucwe.set('one', 'uno');
let lrucweItem: string = lrucwe.get('one');
lrucweItem = lrucwe.remove('one');
let lrucweDead: string | null = lrucwe.remove('one', null);
let lrucweWasRemoved: boolean = lrucwe.delete('one');
lrucwe.expire();

/**
* LRUMap.
*/

0 comments on commit d279848

Please sign in to comment.