diff --git a/README.md b/README.md index fae047e..b1aee4e 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Creates a new cache. Options: * `ttl`: the maximum time a cache entry can live, default `0`; if `0`, an element is removed from the cache as soon as the promise resolves. +* `stale`: the time after which the value is served from the cache after the ttl has expired. * `onDedupe`: a function that is called every time it is defined is deduped. * `onError`: a function that is called every time there is a cache error. * `onHit`: a function that is called every time there is a hit in the cache. @@ -93,6 +94,7 @@ in the cache. The cache key for `arg` is computed using [`safe-stable-stringify` Options: * `ttl`: a number or a function that returns a number of the maximum time a cache entry can live, default as defined in the cache; default is zero, so cache is disabled, the function will be only the deduped. The first argument of the function is the result of the original function. +* `stale`: the time after which the value is served from the cache after the ttl has expired. * `serialize`: a function to convert the given argument into a serializable object (or string). * `onDedupe`: a function that is called every time there is defined is deduped. * `onError`: a function that is called every time there is a cache error. diff --git a/src/cache.js b/src/cache.js index 1fde2cd..5f54608 100644 --- a/src/cache.js +++ b/src/cache.js @@ -1,6 +1,6 @@ 'use strict' -const { kValues, kStorage, kStorages, kTTL, kOnDedupe, kOnError, kOnHit, kOnMiss } = require('./symbol') +const { kValues, kStorage, kStorages, kTTL, kOnDedupe, kOnError, kOnHit, kOnMiss, kStale } = require('./symbol') const stringify = require('safe-stable-stringify') const createStorage = require('./storage') @@ -40,6 +40,11 @@ class Cache { throw new Error('onMiss must be a function') } + // ttl _may_ be a function to defer the ttl decision until later + if (typeof options.stale === 'number' && !(Math.floor(options.stale) === options.stale && options.stale >= 0)) { + throw new Error('stale must be an integer greater or equal to 0') + } + this[kValues] = {} this[kStorage] = options.storage @@ -51,6 +56,7 @@ class Cache { this[kOnError] = options.onError || noop this[kOnHit] = options.onHit || noop this[kOnMiss] = options.onMiss || noop + this[kStale] = options.stale || 0 } /** @@ -108,12 +114,13 @@ class Cache { } const ttl = opts.ttl !== undefined ? opts.ttl : this[kTTL] + const stale = opts.stale !== undefined ? opts.stale : this[kStale] const onDedupe = opts.onDedupe || this[kOnDedupe] const onError = opts.onError || this[kOnError] const onHit = opts.onHit || this[kOnHit] const onMiss = opts.onMiss || this[kOnMiss] - const wrapper = new Wrapper(func, name, serialize, references, storage, ttl, onDedupe, onError, onHit, onMiss) + const wrapper = new Wrapper(func, name, serialize, references, storage, ttl, onDedupe, onError, onHit, onMiss, stale) this[kValues][name] = wrapper this[name] = wrapper.add.bind(wrapper) @@ -185,8 +192,9 @@ class Wrapper { * @param {function} onError * @param {function} onHit * @param {function} onMiss + * @param {stale} ttl */ - constructor (func, name, serialize, references, storage, ttl, onDedupe, onError, onHit, onMiss) { + constructor (func, name, serialize, references, storage, ttl, onDedupe, onError, onHit, onMiss, stale) { this.dedupes = new Map() this.func = func this.name = name @@ -199,6 +207,7 @@ class Wrapper { this.onError = onError this.onHit = onHit this.onMiss = onMiss + this.stale = stale } getKey (args) { @@ -244,18 +253,29 @@ class Wrapper { if (data !== undefined) { this.onHit(key) + if (this.stale > 0) { + const remainingTTL = await this.storage.getTTL(storageKey) + if (remainingTTL <= this.stale) { + this._wrapFunction(storageKey, args, key).catch(noop) + } + } return data + } else { + this.onMiss(key) } - - this.onMiss(key) } + return this._wrapFunction(storageKey, args, key) + } + + async _wrapFunction (storageKey, args, key) { const result = await this.func(args, key) - const ttl = typeof this.ttl === 'function' ? this.ttl(result) : this.ttl + let ttl = typeof this.ttl === 'function' ? this.ttl(result) : this.ttl if (ttl === undefined || ttl === null || (typeof ttl !== 'number' || !Number.isInteger(ttl))) { this.onError(new Error('ttl must be an integer')) return result } + ttl += this.stale if (ttl < 1) { return result } diff --git a/src/storage/memory.js b/src/storage/memory.js index e0a675f..7c98ec2 100644 --- a/src/storage/memory.js +++ b/src/storage/memory.js @@ -67,6 +67,26 @@ class StorageMemory extends StorageInterface { } } + /** + * retrieve the remaining TTL value by key + * @param {string} key + * @returns {undefined|*} undefined if key not found or expired + */ + getTTL (key) { + this.log.debug({ msg: 'acd/storage/memory.getTTL', key }) + + const entry = this.store.peek(key) + let ttl = 0 + if (entry) { + ttl = entry.start + entry.ttl - now() + if (ttl < 0) { + ttl = 0 + } + } + + return ttl + } + /** * set value by key * @param {string} key diff --git a/src/storage/redis.js b/src/storage/redis.js index 1a90013..86f9a3e 100644 --- a/src/storage/redis.js +++ b/src/storage/redis.js @@ -72,6 +72,24 @@ class StorageRedis extends StorageInterface { } } + /** + * retrieve the remaining TTL value by key + * @param {string} key + * @returns {undefined|*} undefined if key not found or expired + */ + async getTTL (key) { + this.log.debug({ msg: 'acd/storage/memory.getTTL', key }) + + let pttl = await this.store.pttl(key) + if (pttl < 0) { + return 0 + } + + pttl = Math.ceil(pttl / 1000) + + return pttl + } + /** * set value by key * @param {string} key diff --git a/src/symbol.js b/src/symbol.js index 6f65b3e..32457a5 100644 --- a/src/symbol.js +++ b/src/symbol.js @@ -8,5 +8,6 @@ const kOnDedupe = Symbol('kOnDedupe') const kOnError = Symbol('kOnError') const kOnHit = Symbol('kOnHit') const kOnMiss = Symbol('kOnMiss') +const kStale = Symbol('kStale') -module.exports = { kValues, kStorage, kStorages, kTTL, kOnDedupe, kOnError, kOnHit, kOnMiss } +module.exports = { kValues, kStorage, kStorages, kTTL, kOnDedupe, kOnError, kOnHit, kOnMiss, kStale } diff --git a/test/stale.test.js b/test/stale.test.js new file mode 100644 index 0000000..76f44ad --- /dev/null +++ b/test/stale.test.js @@ -0,0 +1,86 @@ +'use strict' + +const t = require('tap') +const { promisify } = require('util') +const { Cache } = require('../src/cache') +const createStorage = require('../src/storage') + +const sleep = promisify(setTimeout) + +const { test } = t + +t.jobs = 3 + +test('stale', async (t) => { + t.plan(11) + + const storage = createStorage() + + const cache = new Cache({ + storage, + ttl: 1, + stale: 9 + }) + + let toReturn = 42 + + cache.define('fetchSomething', async (query) => { + t.equal(query, 42) + return { k: toReturn } + }) + + t.same(await cache.fetchSomething(42), { k: 42 }) + t.same(await cache.fetchSomething(42), { k: 42 }) + + t.equal(storage.getTTL('fetchSomething~42'), 10) + await sleep(2500) + t.equal(storage.getTTL('fetchSomething~42') < 10, true) + + // This value will be revalidated + toReturn++ + t.same(await cache.fetchSomething(42), { k: 42 }) + t.equal(storage.getTTL('fetchSomething~42'), 10) + + await sleep(500) + + t.same(await cache.fetchSomething(42), { k: 43 }) + + t.same(await cache.fetchSomething(42), { k: 43 }) + t.equal(storage.getTTL('fetchSomething~42'), 10) +}) + +test('global stale is a positive integer', async (t) => { + t.plan(1) + + try { + // eslint-disable-next-line no-new + new Cache({ ttl: 42, stale: 3.14, storage: createStorage() }) + } catch (err) { + t.equal(err.message, 'stale must be an integer greater or equal to 0') + } +}) + +test('stale as parameter', async (t) => { + t.plan(6) + + const cache = new Cache({ + storage: createStorage(), + ttl: 1 + }) + + cache.define('fetchSomething', { stale: 9 }, async (query) => { + t.equal(query, 42) + return { k: query } + }) + + t.same(await cache.fetchSomething(42), { k: 42 }) + t.same(await cache.fetchSomething(42), { k: 42 }) + + await sleep(2500) + + t.same(await cache.fetchSomething(42), { k: 42 }) + + await sleep(500) + + t.same(await cache.fetchSomething(42), { k: 42 }) +}) diff --git a/test/storage-memory.test.js b/test/storage-memory.test.js index cb66a1c..ec2081d 100644 --- a/test/storage-memory.test.js +++ b/test/storage-memory.test.js @@ -66,6 +66,56 @@ test('storage memory', async (t) => { }) }) + test('getTTL', async (t) => { + test('should get the TTL of a previously key stored', async (t) => { + const storage = createStorage('memory') + + storage.set('foo', 'bar', 100) + + t.equal(storage.getTTL('foo'), 100) + + await sleep(1000) + + t.equal(storage.getTTL('foo'), 99) + }) + + test('should get the TTL of a a key without TTL', async (t) => { + const storage = createStorage('memory') + + storage.set('foo', 'bar', 0) + + t.equal(storage.getTTL('foo'), 0) + }) + + test('should get the TTL of a previously key stored', async (t) => { + const storage = createStorage('memory') + + storage.set('foo', 'bar', 1) + + t.equal(storage.getTTL('foo'), 1) + + await sleep(1000) + + t.equal(storage.getTTL('foo'), 0) + }) + + test('no key', async (t) => { + const storage = createStorage('memory') + + t.equal(storage.getTTL('foo'), 0) + }) + + test('should get the TTL of a previously key stored', async (t) => { + const storage = createStorage('memory') + + storage.set('foo', 'bar', 1) + + await sleep(2000) + + t.equal(storage.getTTL('foo'), 0) + }) + }) + test('set', async (t) => { test('should set a value, with ttl', async (t) => { const storage = createStorage('memory') diff --git a/test/storage-redis.test.js b/test/storage-redis.test.js index 851db4f..8e95409 100644 --- a/test/storage-redis.test.js +++ b/test/storage-redis.test.js @@ -977,4 +977,44 @@ test('storage redis', async (t) => { }) }) }) + + test('getTTL', async (t) => { + test('should get the TTL of a previously key stored', async (t) => { + const storage = new StorageRedis({ client: redisClient, invalidation: false }) + + storage.set('foo', 'bar', 100) + + t.equal(await storage.getTTL('foo'), 100) + + await sleep(1000) + + t.equal(await storage.getTTL('foo'), 99) + }) + + test('should get the TTL of a a key without TTL', async (t) => { + const storage = new StorageRedis({ client: redisClient, invalidation: false }) + + storage.set('foo', 'bar', 0) + + t.equal(await storage.getTTL('foo'), 0) + }) + + test('should get the TTL of a previously key stored', async (t) => { + const storage = new StorageRedis({ client: redisClient, invalidation: false }) + + storage.set('foo', 'bar', 1) + + t.equal(await storage.getTTL('foo'), 1) + + await sleep(1000) + + t.equal(await storage.getTTL('foo'), 0) + }) + + test('no key', async (t) => { + const storage = new StorageRedis({ client: redisClient, invalidation: false }) + + t.equal(await storage.getTTL('foo'), 0) + }) + }) })