diff --git a/CHANGELOG.md b/CHANGELOG.md index 299dec5..202ed17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Change Log +- [#5] Allow for call-level age overrides as specified in the README. - [#4] Bump various dev-dependencies ### 1.1.0 @@ -17,3 +18,4 @@ [#1]: https://github.com/godaddy/out-of-band-cache/pull/1 [#2]: https://github.com/godaddy/out-of-band-cache/pull/2 [#4]: https://github.com/godaddy/out-of-band-cache/pull/4 +[#5]: https://github.com/godaddy/out-of-band-cache/pull/5 diff --git a/lib/multi-level.js b/lib/multi-level.js index 420f709..354c063 100644 --- a/lib/multi-level.js +++ b/lib/multi-level.js @@ -1,3 +1,20 @@ +const debug = require('diagnostics')('out-of-band-cache:multi-level'); + + +/** + * Gets a property from either the options object if defined there, otherwise the default value. + * + * @param {Object} opts An options object + * @param {String} key The name of the property to get + * @param {Any} defaultValue The default value + * + * @private + * @returns {Any} The value of the property + */ +function getValue(opts, key, defaultValue) { + return key in opts ? opts[key] : defaultValue; +} + /** * @typedef Cache * @prop {AsyncFunction} init Initialization function @@ -17,11 +34,11 @@ /** * A multi-level cache. * - * @param {Object} opts - Options for the Client instance - * @param {String} [opts.maxAge=600,000] - The duration, in milliseconds, before a cached item expires - * @param {String} [opts.maxStaleness=0] - The duration, in milliseconds, in which expired cache items are still served - * @param {ShouldCache} [opts.shouldCache=()=>true] - Function to determine whether to not we should cache the result - * @param {Cache[]} opts.caches - An Array of cache objects. See `./fs` and `./memory` for sample caches. + * @param {Object} opts Options for the Client instance + * @param {Number} [opts.maxAge=600000] The duration, in milliseconds, before a cached item expires (defaults to 600,000) + * @param {Number} [opts.maxStaleness=0] The duration, in milliseconds, in which expired cache items are still served + * @param {ShouldCache} [opts.shouldCache=()=>true] Function to determine whether to not we should cache the result + * @param {Cache[]} opts.caches An Array of cache objects. See `./fs` and `./memory` for sample caches. */ class MultiLevelCache { constructor(opts) { @@ -36,28 +53,26 @@ class MultiLevelCache { this.shouldCache = opts.shouldCache || (() => true); this._pendingRefreshes = {}; - this._initTask = Promise.all(opts.caches.map(cache => { - return cache.init().catch(err => { - throw err; - }); - })); + this._initTask = Promise.all(opts.caches.map(cache => cache.init())); } /** * Attempts a cache get * - * @param {String} key - The cache key - * @param {Object} opts - Options for this particular read - * @param {Boolean} [opts.skipCache=false] - Whether the cache should be bypassed (default false) - * @param {String} [opts.maxAge] - The duration in milliseconds before a cached item expires - * @param {ShouldCache} [opts.shouldCache] - A function to determine whether or not we should cache the item - * @param {UpdateFn} updateFn - async function that defines how to get a new value + * @param {String} key The cache key + * @param {Object} [opts={}] Options for this particular read + * @param {Boolean} [opts.skipCache=false] Whether the cache should be bypassed (default false) + * @param {String} [opts.maxAge] The duration in milliseconds before a cached item expires + * @param {Number} [opts.maxStaleness] The duration, in milliseconds, in which expired cache items are still served + * @param {ShouldCache} [opts.shouldCache] A function to determine whether or not we should cache the item + * @param {UpdateFn} updateFn async function that defines how to get a new value * * @async * @returns {Promise} a Promise which resolves to an object containing * a `value` property and a `fromCache` boolean indicator. */ async get(key, opts, updateFn) { + opts = opts || {}; if (opts.skipCache) { const value = await updateFn(key, null); return { @@ -73,16 +88,20 @@ class MultiLevelCache { return getChain.catch(() => cache.get(key)); }, Promise.reject()); } catch (e) { // cache miss - return this._refresh(key, null, updateFn, opts.shouldCache); + return this._refresh(key, null, updateFn, opts); } // cache hit const now = Date.now(); if (item.expiry < now) { - const refreshTask = this._refresh(key, item, updateFn, opts.shouldCache); - if (item.expiry + this._maxStaleness < now) { - return refreshTask; + if (item.expiry + getValue(opts, 'maxStaleness', this._maxStaleness) < now) { + return this._refresh(key, item, updateFn, opts); } + + // Update the cache, but ignore failures + this._refresh(key, item, updateFn, opts).catch(err => { + debug('background refresh failed for %s with %s', key && JSON.stringify(key), err && err.message); + }); } return { @@ -103,16 +122,18 @@ class MultiLevelCache { /** * Refresh the cache for a given key value pair - * @param {String} key cache key - * @param {JSONSerializable} staleItem cache value - * @param {UpdateFn} updateFn async function that defines how to get a new value - * @param {ShouldCache} [shouldCache] function that determines whether or not we should cache the item + * @param {String} key cache key + * @param {JSONSerializable} staleItem cache value + * @param {UpdateFn} updateFn async function that defines how to get a new value + * @param {Object} opts An options object + * @param {ShouldCache} [opts.shouldCache] Function that determines whether or not we should cache the item + * @param {Number} [opts.maxAge] The duration, in milliseconds, before a cached item expires * * @private * @async * @returns {Promise} a promise that resolves once we have refreshed the correct key */ - async _refresh(key, staleItem, updateFn, shouldCache) { + async _refresh(key, staleItem, updateFn, opts = {}) { if (!(key in this._pendingRefreshes)) { try { const task = updateFn(key, staleItem && staleItem.value); @@ -121,16 +142,16 @@ class MultiLevelCache { const value = await task; const cacheItem = { value, - expiry: Date.now() + this._maxAge + expiry: Date.now() + getValue(opts, 'maxAge', this._maxAge) }; - if (shouldCache && typeof shouldCache !== 'function') { + const shouldCache = getValue(opts, 'shouldCache', this.shouldCache); + if (typeof shouldCache !== 'function') { throw new TypeError('shouldCache has to be a function'); } - const willCache = shouldCache || this.shouldCache; // Given that we are not ignoring this value, perform an out-of-band cache update - if (willCache(cacheItem.value)) { + if (shouldCache(cacheItem.value)) { // NB: an in-band update would `await` this Promise.all block Promise.all(this._caches.map(cache => cache.set(key, cacheItem))).catch(err => { throw new Error(`Error caching ${key}`, err); @@ -143,7 +164,6 @@ class MultiLevelCache { } return { value, fromCache: false }; - } catch (err) { delete this._pendingRefreshes[key]; throw err; diff --git a/package-lock.json b/package-lock.json index abcd105..de2d1f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -141,9 +141,9 @@ } }, "acorn": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.0.7.tgz", - "integrity": "sha512-HNJNgE60C9eOTgn974Tlp3dpLZdUr+SoxxDwPaY9J/kDNOLQTkaDgwBUXAF4SSsrAwD9RpdxuHK/EbuF+W9Ahw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.1.0.tgz", + "integrity": "sha512-MW/FjM+IvU9CgBzjO3UIPCE2pyEwUsoFl+VGdczOPEdxfGFjuKny/gN54mOuX7Qxmb9Rg9MCn2oKiSUeW+pjrw==", "dev": true }, "acorn-jsx": { @@ -153,9 +153,9 @@ "dev": true }, "ajv": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.8.1.tgz", - "integrity": "sha512-eqxCp82P+JfqL683wwsL73XmFs1eG6qjw+RD3YHx+Jll1r0jNd4dh8QG9NYAeNGA/hnZjeEDgtTskgJULbxpWQ==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.9.1.tgz", + "integrity": "sha512-XDN92U311aINL77ieWHmqCcNlwjoP5cHXDxIxbf2MaPYuCXOHS7gHH8jktxeK5omgd52XbSTX6a4Piwd1pQmzA==", "dev": true, "requires": { "fast-deep-equal": "^2.0.1", @@ -284,11 +284,19 @@ "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", "dev": true }, + "color": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/color/-/color-3.0.0.tgz", + "integrity": "sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w==", + "requires": { + "color-convert": "^1.9.1", + "color-string": "^1.5.2" + } + }, "color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "requires": { "color-name": "1.1.3" } @@ -296,8 +304,30 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "color-string": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.3.tgz", + "integrity": "sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==", + "requires": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "colornames": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/colornames/-/colornames-1.1.1.tgz", + "integrity": "sha1-+IiQMGhcfE/54qVZ9Qd+t2qBb5Y=" + }, + "colorspace": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.1.tgz", + "integrity": "sha512-pI3btWyiuz7Ken0BWh9Elzsmv2bM9AhA7psXib4anUXy/orfZ/E0MbQwhSOG/9L8hLlalqrU0UhOuqxW1YjmVw==", + "requires": { + "color": "3.0.x", + "text-hex": "1.0.x" + } }, "commander": { "version": "2.15.1", @@ -347,6 +377,16 @@ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, + "diagnostics": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/diagnostics/-/diagnostics-1.1.1.tgz", + "integrity": "sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ==", + "requires": { + "colorspace": "1.1.x", + "enabled": "1.0.x", + "kuler": "1.0.x" + } + }, "diff": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", @@ -362,6 +402,25 @@ "esutils": "^2.0.2" } }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "enabled": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz", + "integrity": "sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M=", + "requires": { + "env-variable": "0.0.x" + } + }, + "env-variable": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/env-variable/-/env-variable-0.0.5.tgz", + "integrity": "sha512-zoB603vQReOFvTg5xMl9I1P2PnHsHQQKTEowsKKD7nseUfJq6UWzK+4YtlWUO1nhiQUxe6XMkk+JleSZD1NZFA==" + }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -599,9 +658,9 @@ } }, "globals": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.10.0.tgz", - "integrity": "sha512-0GZF1RiPKU97IHUO5TORo9w1PwrH/NBPl+fS7oMLdaTRiYmYbwK4NWoZWrAdd0/abG9R2BU+OiwyQpTpE6pdfQ==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.11.0.tgz", + "integrity": "sha512-WHq43gS+6ufNOEqlrDBxVEbb8ntfXrfAUU2ZOpCxrBdGKW3gyv8mCxAfIBD0DroPKGrJ2eSsXsLtY9MPntsyTw==", "dev": true }, "graceful-fs": { @@ -711,6 +770,11 @@ } } }, + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, "is-buffer": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz", @@ -814,6 +878,14 @@ "integrity": "sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw==", "dev": true }, + "kuler": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-1.0.1.tgz", + "integrity": "sha512-J9nVUucG1p/skKul6DU3PUZrhs0LPulNaeUOox0IyXDi8S4CztTHs1gQphhuZmzXG7VOQSf6NJfKuzteQLv9gQ==", + "requires": { + "colornames": "^1.1.1" + } + }, "left-pad": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", @@ -843,9 +915,9 @@ "dev": true }, "lolex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lolex/-/lolex-3.0.0.tgz", - "integrity": "sha512-hcnW80h3j2lbUfFdMArd5UPA/vxZJ+G8vobd+wg3nVEQA0EigStbYcrG030FJxL6xiDDPEkoMatV9xIh5OecQQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-3.1.0.tgz", + "integrity": "sha512-zFo5MgCJ0rZ7gQg69S4pqBsLURbFw11X68C18OcJjJQbqaXm2NoTrGl1IMM3TIz0/BnN1tIs2tzmmqvCsOMMjw==", "dev": true }, "mimic-fn": { @@ -2220,6 +2292,14 @@ "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "requires": { + "is-arrayish": "^0.3.1" + } + }, "sinon": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/sinon/-/sinon-7.2.3.tgz", @@ -2293,15 +2373,43 @@ } }, "table": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/table/-/table-5.2.2.tgz", - "integrity": "sha512-f8mJmuu9beQEDkKHLzOv4VxVYlU68NpdzjbGPl69i4Hx0sTopJuNxuzJd17iV2h24dAfa93u794OnDA5jqXvfQ==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/table/-/table-5.2.3.tgz", + "integrity": "sha512-N2RsDAMvDLvYwFcwbPyF3VmVSSkuF+G1e+8inhBLtHpvwXGw4QRPEZhihQNeEN0i1up6/f6ObCJXNdlRG3YVyQ==", "dev": true, "requires": { - "ajv": "^6.6.1", + "ajv": "^6.9.1", "lodash": "^4.17.11", - "slice-ansi": "^2.0.0", - "string-width": "^2.1.1" + "slice-ansi": "^2.1.0", + "string-width": "^3.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.0.0.tgz", + "integrity": "sha512-iB5Dda8t/UqpPI/IjsejXu5jOGDrzn41wJyljwPH65VCIbk6+1BzFIMJGFwTNrYXT1CrD+B4l19U7awiQ8rk7w==", + "dev": true + }, + "string-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.0.0.tgz", + "integrity": "sha512-rr8CUxBbvOZDUvc5lNIJ+OC1nPVpz+Siw9VBtUjB9b6jZehZLFt0JMCZzShFHIsI8cbhm0EsNIfWJMFV3cu3Ew==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.0.0" + } + }, + "strip-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.0.0.tgz", + "integrity": "sha512-Uu7gQyZI7J7gn5qLn1Np3G9vcYGTVqB+lFTytnDJv83dd8T22aGH451P3jueT2/QemInJDfxHB5Tde5OzgG1Ow==", + "dev": true, + "requires": { + "ansi-regex": "^4.0.0" + } + } } }, "text-encoding": { @@ -2310,6 +2418,11 @@ "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=", "dev": true }, + "text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/package.json b/package.json index c59433d..3cf70cc 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "lib/index.js", "scripts": { "test": "nyc npm run test:mocha", - "pretest": "npm run lint", + "posttest": "npm run lint", "lint": "eslint-godaddy lib/*.js test/*.js example/*.js", "test:mocha": "mocha test/*.test.js", "test:debug": "mocha --inspect-brk test/*.test.js", @@ -35,6 +35,7 @@ "sinon": "^7.2.3" }, "dependencies": { + "diagnostics": "^1.1.1", "rimraf": "^2.6.2" } } diff --git a/test/index.test.js b/test/index.test.js index 59adc5f..6769aec 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,3 +1,4 @@ +/* eslint max-statements: 0 */ const path = require('path'); const assume = require('assume'); const Cache = require('../lib/'); @@ -88,16 +89,45 @@ describe('Out of Band cache', () => { assume(calledRefresh).is.truthy(); }); + it('can override the cache-level maxAge', async function () { + const fridge = new Cache({ maxAge: -10, maxStaleness: -10 }); // items are immediately stale + await fridge.get('good milk', { maxAge: 1000 }, async () => 'milk'); + const milk = await fridge.get('good milk', { }, async () => { throw new Error('gross'); }); + assume(milk.fromCache).is.truthy(); + }); + it('refreshes on an expired item', async function () { const fridge = new Cache({ maxAge: -10, maxStaleness: -10 }); // items are immediately stale await fridge.get('old milk', {}, simpleGet); await sleep(100); - const expired = await fridge.get('old milk', { maxAge: 10, maxStaleness: 10 }, simpleGet); + const expired = await fridge.get('old milk', { }, simpleGet); assume(expired.fromCache).is.falsey(); }); + it('refreshes on an expired item, but returns stale if within staleness', async function () { + const fridge = new Cache({ maxAge: -10, maxStaleness: 1000 }); // items are immediately stale + + await fridge.get('old milk', {}, async () => 'milk'); + await sleep(100); + const expired = await fridge.get('old milk', { }, async () => { throw new Error('gross'); }); + + assume(expired.fromCache).is.truthy(); + assume(expired.value).to.equal('milk'); + }); + + it('can override the cache-level maxStaleness', async function () { + const fridge = new Cache({ maxAge: -10, maxStaleness: -10 }); // items are immediately stale + + await fridge.get('old milk', {}, async () => 'milk'); + await sleep(100); + const expired = await fridge.get('old milk', { maxStaleness: 1000 }, async () => { throw new Error('gross'); }); + + assume(expired.fromCache).is.truthy(); + assume(expired.value).to.equal('milk'); + }); + it('does not add an item to the cache if preconfigured not to', async function () { const dopey = new Cache({ shouldCache: () => false }); @@ -185,7 +215,7 @@ describe('Out of Band cache', () => { cache._pendingRefreshes.nuclearLaunchCodes = 12345; const stub = sinon.stub(); - const value = await cache._refresh('nuclearLaunchCodes', null, stub); + const value = await cache._refresh('nuclearLaunchCodes', null, stub, {}); assume(JSON.stringify(value)).equals(JSON.stringify({ value: 12345, fromCache: false })); assume(stub.called).is.falsey(); }); @@ -198,7 +228,7 @@ describe('Out of Band cache', () => { let caught; try { - await cache._refresh('data', null, badGetter); + await cache._refresh('data', null, badGetter, {}); } catch (err) { caught = true; assume(err).matches('that was no bueno'); @@ -218,7 +248,7 @@ describe('Out of Band cache', () => { const tasks = Array(10).fill(0).map((_, i) => { // no await because we want to proceed before they resolve - return cache._refresh(i, null, slowGetter); + return cache._refresh(i, null, slowGetter, {}); }); assume(Object.entries(cache._pendingRefreshes)).has.length(10);