Skip to content

Instantly share code, notes, and snippets.

@mmarinero
Last active August 29, 2015 14:07
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mmarinero/81d4ec6d92f62ba0ca61 to your computer and use it in GitHub Desktop.
Save mmarinero/81d4ec6d92f62ba0ca61 to your computer and use it in GitHub Desktop.
promiseCache jquery plugin: lacking real world testing but plenty of tests ;).
(function ($) {
"use strict";
/**
* Partial insertion sort function implemented to create a sorted array
* of promises to evict on one pass
* @param {array[Promise]} promises set of promises to scan
* @param {int} nEvicted number of promises to return
* @param {string} prop key name of the counter on the promise
* @param {Boolean} desc To invert the sort order
* @return {array[Promise]} promises to evict
*/
var evictionSort = function(promises, nEvicted, prop, desc){
desc = Boolean(desc);
var limit;
if (promises[0]) {
limit = promises[0][prop];
}
var evicted = [];
var evictedSize = 0;
$.each(promises, function (key, promise) {
if (desc === (limit <= promise[prop]) || evictedSize < nEvicted) {
var i = 1;
var notFilled = i < nEvicted && !evicted[i];
while (notFilled || (evicted[i] && desc === (evicted[i][prop]) <= promise[prop])) {
evicted[i - 1] = evicted[i];
i += 1;
notFilled = i < nEvicted && !evicted[i];
}
evicted[i - 1] = {propKey: key};
evicted[i - 1][prop] = promise[prop];
evictedSize += 1;
limit = evicted[0] ? evicted[0][prop] : limit;
}
});
return evicted;
};
/**
* Least recent used cache eviction implementation
* @see PromiseCache::evict(int)
*/
var Lru = (function () {
var LruCons = function () {
this.counter = 0;
};
LruCons.prototype.init = function (cache) {
this.cache = cache;
};
LruCons.prototype.set = function (key, promise) {
promise.lru = 0;
};
LruCons.prototype.get = function (key, promise) {
this.counter += 1;
promise.lru = this.counter;
};
/**
* The evict method is somewhat costly since get a set are
* trivial, it finds nEvicted elements in a pass over the cache.
* @param {int} nEvicted number of elements to evict from the cache
*/
LruCons.prototype.evict = function (nEvicted) {
var cache = this.cache;
var evicted = evictionSort(cache._promises, nEvicted, 'lru');
$.each(evicted, function (key, obj) {
cache.remove(obj.propKey);
});
};
return LruCons;
})();
/**
* Most recent used eviction algorithm
* @see PromiseCache::evict()
*/
var Mru = (function () {
var MruCons = function () {
this.counter = 0;
};
MruCons.prototype.init = function (cache) {
this.cache = cache;
};
MruCons.prototype.set = function (key, promise) {
promise.mru = 0;
};
MruCons.prototype.get = function (key, promise) {
this.counter += 1;
promise.mru = this.counter;
};
/**
* @see Lru::evict(int)
* @param {int} nEvicted number of elements to evict from the cache
*/
MruCons.prototype.evict = function (nEvicted) {
var cache = this.cache;
var evicted = evictionSort(cache._promises, nEvicted, 'mru', true);
$.each(evicted, function (key, obj) {
cache.remove(obj.propKey);
});
};
return MruCons;
})();
/**
* Least frequently used eviction algorithm
* @see PromiseCache::evict()
*/
var Lfu = (function () {
var LfuCons = function () {};
LfuCons.prototype.init = function (cache) {
this.cache = cache;
};
LfuCons.prototype.set = function (key, promise) {
promise.lfu = 0;
};
LfuCons.prototype.get = function (key, promise) {
promise.lfu += 1;
};
/**
* @see Lru::evict(int)
* @param {int} nEvicted number of elements to evict from the cache
*/
LfuCons.prototype.evict = function (nEvicted) {
var cache = this.cache;
var evicted = evictionSort(cache._promises, nEvicted, 'lfu');
$.each(evicted, function (key, obj) {
cache.remove(obj.propKey);
});
};
return LfuCons;
})();
/**
* Map from algorithms names to classes.
* @type {Object}
*/
var algorithms = {
lru: Lru,
mru: Mru,
lfu: Lfu
};
/**
* The promise cache is a small cache implementation for with some features
* to manage promises as failure management and expire time.
* It has an eviction interface that decouples the algorithm and offers LRU,
* MRU and LFU implementations.
* It's based on jQuery Deferred objects
* @param {Object[key- > promise]} promises Initial set of promises to cache with the keys
* present in the object
* @param {Object} options {
* eviction Object|string: eviction algorithm
* ('lru', 'mru', 'lfu') or object implementing
* the eviction interface @see PromiseCache::evict(int)
* capacity int: Cache max number of promises, it will call
* evict when full
* evictRate int: Number of promises to evict when the cache
* is full, it may be more efficient if the eviction algorihm
* is costly.
* discarded function(key, promise): optional default function
* @see PromiseCache::set
* expireTime int: optional default number of seconds before
* the promise is removed from the cache
* fail function(dfr: Deferred, key, promise): optional default
* function @see PromiseCache::set
* }
*/
$.PromiseCache = function (promises, options) {
return new PromiseCacheCons(promises, options);
};
var noop = function () {};
/**
* Constructor, initializes the cache and eviction, finally sets
* the initial set of promises
* @param {Object[key- > promise]} @see PromiseCache
* @param {Object} options @see PromiseCache
*/
var PromiseCacheCons = function (promises, options) {
options = options || {};
this._promises = {};
this.length = 0;
var eviction;
if (options.eviction) {
if (algorithms[options.eviction]) {
eviction = new algorithms[options.eviction]();
} else {
eviction = options.eviction;
}
}
eviction = eviction || {};
eviction.init = eviction.init || noop;
eviction.get = eviction.get || noop;
eviction.set = eviction.set || noop;
eviction.remove = eviction.remove || noop;
if (options.capacity === undefined) {
eviction.evict = eviction.evict || noop;
} else if (!eviction.evict) {
throw new Error('There is a capacity but no evict function set');
}
this.eviction = eviction;
this.eviction.init(this, promises, options);
this.capacity = options.capacity;
this.evictRate = options.evictRate || 1;
this.discarded = options.discarded;
this.expireTime = options.expireTime;
this.fail = options.fail;
var self = this;
$.each(promises || {}, function (key, promise) {
self.set(key, promise, options);
});
};
/**
* Sets a promise in the cache with key and options that override the default versions,
* can trigger eviction if capacity is exceeded.
* @param {string} key to access the cached promise
* @param {Promise} promise Promise to save in the cache
* @param {Object} options { This options override the defaults available in the constructor
* discarded function(key, promise): optional, function to be called
* when the element is removed from the cache
* expireTime int: optional, number of seconds before the promise is
* removed from the cache
* fail function(dfr: Deferred, key, promise): optional, if present
* or the default exists a new promise will be created and set in
* the cache, this promise will be succeed when the original is
* resolved.
* If the original promise is rejected the fail function will be
* called and dfr can be used to resolve or reject the new promise
* that all the users of the get method have.
* This allow the setter to centralize error handling and
* potentially provide transparent retries and recovery procedures
* for the getters.
* }
*/
PromiseCacheCons.prototype.set = function (key, promise, options) {
if (promise) options = options || {};
var self = this;
var interceptor;
if (!promise || !promise.then) {
throw new Error('promise: ' + promise + ' is not a Promise');
}
if (!this._promises[key]){
this.length += 1;
if (this.capacity < this.length) {
this.evict(this.evictRate);
}
}
var fail = options.fail || this.fail;
var promiseObj;
if (fail) {
var dfr = $.Deferred();
interceptor = dfr.promise();
promiseObj = {
promise: interceptor
};
this._promises[key] = promiseObj;
promise.then(function () {
dfr.resolveWith(this, arguments);
}, function () {
fail(dfr, key, promise);
});
} else {
promiseObj = {
promise: promise
};
this._promises[key] = promiseObj;
promise.fail(function () {
if (self._promises[key] && self._promises[key] === promiseObj) {
this.remove(key);
}
});
}
this._promises[key].discarded = options.discarded || this.discarded || noop;
var expireTime = options.expireTime !== undefined ? options.expireTime : this.expireTime;
if (expireTime !== undefined) {
window.setTimeout(function () {
if (self._promises[key] && self._promises[key] === promiseObj) {
self.remove(key);
}
}, expireTime);
}
this.eviction.set(key, promiseObj, promise, options);
};
/**
* Remove the key from the cache and calls the discarded callback if it exists, it is called
* by the eviction algorithms when clearing the cache.
* @param {string} key cache entry to remove
* @return {Promise|undefined} Removed promise or undefined it it doesn't exist
*/
PromiseCacheCons.prototype.remove = function (key) {
var promise = this._promises[key];
if (promise !== undefined) {
delete this._promises[key];
this.length -= 1;
this.eviction.remove(key, promise);
promise.discarded(key, promise.promise);
return promise.promise;
}
};
/**
* Retrieves the promise in the cache stored with the key
* @param {string} key cache entry to retrieve
* @return {Promise|undefined} Promise stored with the key or undefined if it doesn't exist
*/
PromiseCacheCons.prototype.get = function (key) {
if (this._promises[key]) {
this.eviction.get(key, this._promises[key]);
return this._promises[key].promise;
}
};
/**
* Object containing all promises in the cache with their keys, the object is an independent
* copy of the internal promises store.
* @return {Object} promises
*/
PromiseCacheCons.prototype.promises = function () {
var cleanCopy = {};
$.each(this._promises, function (key, promise) {
cleanCopy[key] = promise.promise;
});
return cleanCopy;
};
/**
* Will remove nEvicted promises from the cache or all if larger than the number of promises
* @param {int} nEvicted number of cache entries to clear
*/
PromiseCacheCons.prototype.evict = function (nEvicted) {
this.eviction.evict(nEvicted);
};
}(jQuery));
/**
* Unit tests QUnit 1.15
*/
(function () {
"use strict";
QUnit.module('Basic');
QUnit.test("set/get", function (assert) {
var dfr = $.Deferred();
assert.equal($.PromiseCache({
'first': dfr
}).get('first'), dfr, 'set -> get returns same promise');
assert.equal($.PromiseCache().get('first'), undefined, 'not set key get returns undefined');
});
QUnit.test("change set promise", function (assert) {
var dfr1 = $.Deferred();
var dfr2 = $.Deferred();
var cache = $.PromiseCache({
'first': dfr1
});
assert.strictEqual(cache.get('first'), dfr1, 'first promise match');
cache.set('first', dfr2);
assert.strictEqual(cache.get('first'), dfr2, 'second promise match');
});
QUnit.test("remove", function (assert) {
var dfr = $.Deferred();
var cache = $.PromiseCache({
'first': dfr
});
assert.strictEqual(cache.remove('first'), dfr, 'remove returns promise');
assert.strictEqual(cache.get('first'), undefined, 'key is no longer in cache after removed');
assert.strictEqual(cache.remove('second'), undefined, 'not set key returns undefined when removed');
});
QUnit.test("promises method", function (assert) {
var dfr1 = $.Deferred();
var dfr2 = $.Deferred();
var promises = {
'first': dfr1,
'second': dfr2,
'third': dfr1
};
var cache = $.PromiseCache(promises);
var cached = cache.promises();
assert.strictEqual(cached.first, promises.first, '1 promises method returns all promises in cache');
assert.strictEqual(cached.second, promises.second, '2 promises method returns all promises in cache');
assert.strictEqual(cached.third, promises.third, '3 promises method returns all promises in cache');
});
QUnit.module('Settings');
QUnit.test("capacity and evict initialization", function (assert) {
expect(3);
assert.throws(function () {
$.PromiseCache({}, {
capacity: 100
}, Error, 'capacity requires eviction method');
});
$.PromiseCache({}, {
capacity: 100,
eviction: {
evict: function () {
assert.ok(true, 'evict called');
}
}
}).evict();
$.PromiseCache({}, {
capacity: 100,
eviction: 'lru'
});
assert.ok(true, 'algorithm does not throw');
});
QUnit.test("discarded", function (assert) {
expect(5);
var dfr = $.Deferred();
$.PromiseCache({
'first': dfr
}, {
discarded: function (key, promise) {
assert.strictEqual(key, 'first', 'discarded key ok');
assert.strictEqual(promise, dfr, 'discarded promise ok');
}
}).remove('first');
var cache = $.PromiseCache(null, {
discarded: function () {
throw new Error('I should be overrided');
}
});
cache.set('first', dfr, {
discarded: function (key, promise) {
assert.strictEqual(key, 'first', 'discarded key ok');
assert.strictEqual(promise, dfr, 'discarded promise ok');
}
});
cache.remove('first');
cache.set('first', dfr);
assert.throws(function () {
cache.remove('first');
});
});
QUnit.asyncTest("promise fail interception", function (assert) {
expect(6);
var dfr = $.Deferred();
var dfr2 = $.Deferred().reject();
var dfr3 = $.Deferred();
var cache = $.PromiseCache({
'first': dfr,
'second': dfr2
}, {
fail: function (deferred, key, promise) {
assert.ok(true, 'fail called');
if (key === 'first') {
assert.strictEqual(promise, dfr, 'fail promise/key ok');
}
deferred.resolve();
}
});
dfr.reject();
cache.get('first').done(function () {
assert.ok(true, 'resolved');
cache.set('third', dfr3, {
fail: function (dfr, key, promise) {
assert.ok(true, 'override fail ok');
QUnit.start();
}
});
dfr3.reject();
}).fail(function () {
assert.ok(false, 'never called');
});
cache.get('second').done(function () {
assert.ok(true, 'resolved');
});
});
QUnit.asyncTest("expireTime method", function (assert) {
expect(4);
var dfr = $.Deferred();
var cache = $.PromiseCache({
'first': dfr
}, {
expireTime: 1,
discarded: function (key, promise) {
assert.ok(true, key + ' expired');
}
});
cache.set('second', dfr);
var millis = Date.now();
cache.set('third', dfr, {
expireTime: 10,
discarded: function (key) {
var elapsed = Date.now() - millis;
//8 for tolerance
assert.ok(elapsed > 8, 'third expireTime override expected >= 10, elapsed: ' + elapsed);
QUnit.start();
}
});
cache.set('fourth', dfr, {
expireTime: 5,
discarded: function (key) {
var elapsed = Date.now() - millis;
//8 for tolerance
assert.ok(elapsed < 8, 'fourth deleted before expire expected immediate removal, elapsed: ' + elapsed);
}
});
cache.remove('fourth');
});
QUnit.test("eviction methods", function (assert) {
expect(5);
var dfr = $.Deferred();
var cache = $.PromiseCache(null, {
eviction: {
init: function (cache, promises, options) {
var check = cache.get && options.eviction && promises === null;
assert.ok(check, 'check init parameters');
},
set: function (key, promiseObj, promise, options) {
var check = key === 'first' && promise === dfr && promiseObj.promise === dfr && options.custom;
assert.ok(check, 'check set parameters');
},
get: function (key, promise) {
var check = key === 'first' && promise.promise === dfr;
assert.ok(check, 'check get parameters');
},
evict: function (nEvicted) {
var check = nEvicted === 3;
assert.ok(check, 'check evict parameters');
},
remove: function (key, promise) {
var check = !cache.promises().first && key === 'first' && promise.promise === dfr;
assert.ok(check, 'check remove key already removed and parameters');
}
}
});
cache.set('first', dfr, {
custom: true
});
cache.get('first');
cache.get('none'); //no method called
cache.evict(3);
cache.remove('first');
});
QUnit.module('eviction algorithms');
var testCache = function () {
var dfrs = [$.Deferred(), $.Deferred(), $.Deferred(), $.Deferred()];
return {
promises: {
0: dfrs[0],
1: dfrs[1],
2: dfrs[2],
3: dfrs[3]
},
options: {
capacity: 2
}
};
};
var getSequence = function(cache, getsArray){
$.each(getsArray, function(i, key){
cache.get(key);
});
};
QUnit.test("LRU get", function (assert) {
var ch = testCache();
ch.options.eviction = 'lru';
ch.options.capacity = 10;
var cache = $.PromiseCache(ch.promises, ch.options);
assert.strictEqual(cache._promises[0].lru, 0, 'After set promise lru 0');
cache.get(0);
assert.strictEqual(cache._promises[0].lru, 1, 'After get promise lru 1');
cache.get(1);
assert.strictEqual(cache._promises[1].lru, 2, 'After other get promise lru 2');
});
QUnit.test("LRU eviction", function (assert) {
expect(6);
var ch = testCache();
var order = 0;
ch.options.discarded = function (key, promise) {
switch (order) {
case 0:
assert.equal(key, 1, 'first evicted 1');
order++;
break;
case 1:
assert.equal(key, 2, 'second evicted 2');
order++;
break;
case 2:
assert.equal(key, 0, 'third evicted 0');
order++;
break;
}
};
ch.options.eviction = 'lru';
var cache = $.PromiseCache(null, ch.options);
cache.set(0, ch.promises[0]);
cache.set(1, ch.promises[1]);
getSequence(cache, [0,1,0]);
cache.set(2, ch.promises[2]);
cache.set(3, ch.promises[3]);
cache.get(3);
cache.set(1, ch.promises[1]);
order = 0;
var ch2 = testCache();
ch2.options.discarded = ch.options.discarded;
ch2.options.evictRate = 3;
ch2.options.capacity = 4;
ch2.options.eviction = 'lru';
var cache2 = $.PromiseCache(ch2.promises, ch2.options);
getSequence(cache2, [0,1,2,1,3,2,1,3,3]);
cache2.set(5, ch.promises[1]);
});
QUnit.test("MRU get", function (assert) {
var ch = testCache();
ch.options.eviction = 'mru';
ch.options.capacity = 10;
var cache = $.PromiseCache(ch.promises, ch.options);
assert.strictEqual(cache._promises[0].mru, 0, 'After set promise mru 0');
cache.get(0);
assert.strictEqual(cache._promises[0].mru, 1, 'After get promise mru 1');
});
QUnit.test("MRU eviction", function (assert) {
expect(6);
var ch = testCache();
var order = 0;
ch.options.discarded = function (key, promise) {
switch (order) {
case 0:
assert.equal(key, 0, 'first evicted 1');
order++;
break;
case 1:
assert.equal(key, 1, 'second evicted 2');
order++;
break;
case 2:
assert.equal(key, 3, 'third evicted 0');
order++;
break;
}
};
ch.options.eviction = 'mru';
var cache = $.PromiseCache(null, ch.options);
cache.set(0, ch.promises[0]);
cache.set(1, ch.promises[1]);
getSequence(cache, [0,1,0]);
cache.set(2, ch.promises[2]);
cache.set(3, ch.promises[3]);
cache.get(3);
cache.set(1, ch.promises[1]);
order = 0;
var ch2 = testCache();
ch2.options.discarded = ch.options.discarded;
ch2.options.evictRate = 3;
ch2.options.capacity = 4;
ch2.options.eviction = 'mru';
var cache2 = $.PromiseCache(ch2.promises, ch2.options);
getSequence(cache2, [1,2,0,2,3,0,1,3,3]);
cache2.set(5, ch.promises[1]);
});
QUnit.test("LFU get", function (assert) {
var ch = testCache();
ch.options.eviction = 'lfu';
ch.options.capacity = 10;
var cache = $.PromiseCache(ch.promises, ch.options);
assert.strictEqual(cache._promises[0].lfu, 0, 'After set promise lfu 0');
cache.get(0);
assert.strictEqual(cache._promises[0].lfu, 1, 'After get promise lfu 1');
});
QUnit.test("LFU eviction", function (assert) {
expect(6);
var ch = testCache();
var order = 0;
ch.options.discarded = function (key, promise) {
switch (order) {
case 0:
assert.equal(key, 1, 'first evicted 1');
order++;
break;
case 1:
assert.equal(key, 2, 'second evicted 2');
order++;
break;
case 2:
assert.equal(key, 3, 'third evicted 0');
order++;
break;
}
};
ch.options.eviction = 'lfu';
var cache = $.PromiseCache(null, ch.options);
cache.set(0, ch.promises[0]);
cache.set(1, ch.promises[1]);
getSequence(cache, [0,1,0]);
cache.set(2, ch.promises[2]);
cache.set(3, ch.promises[3]);
cache.get(3);
cache.set(1, ch.promises[1]);
order = 0;
var ch2 = testCache();
ch2.options.discarded = ch.options.discarded;
ch2.options.evictRate = 3;
ch2.options.capacity = 4;
ch2.options.eviction = 'lfu';
var cache2 = $.PromiseCache(ch2.promises, ch2.options);
getSequence(cache2, [3,2,0,2,1,0,0,0,1,1]);
cache2.set(5, ch.promises[1]);
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment