From 25e1c4f94115dc99e72d1dca28c3e31211d45b5a Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Fri, 30 Jul 2021 13:18:03 +0200 Subject: [PATCH] feat: add memoize package --- README.md | 2 +- packages/memoize/README.md | 138 ++++++++++++++++++++++ packages/memoize/package.json | 51 ++++++++ packages/memoize/src/index.js | 114 ++++++++++++++++++ packages/memoize/test/index.js | 207 +++++++++++++++++++++++++++++++++ 5 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 packages/memoize/README.md create mode 100644 packages/memoize/package.json create mode 100644 packages/memoize/src/index.js create mode 100644 packages/memoize/test/index.js diff --git a/README.md b/README.md index ed6ab14..3ff3aa2 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ You should also set a [`namespace`](#optionsnamespace) for your module so you ca - [@keyvhq/postgres](/packages/postgres) – PostgreSQL storage adapter for Keyv. - [@keyvhq/redis](/packages/redis) – Redis storage adapter for Keyv. - [@keyvhq/sqlite](/packages/sqlite) – SQLite storage adapter for Keyv. +- [@keyvhq/memoize](/packages/memoize) – Memoize any function using Keyv as storage backend. ### Community storage adapters @@ -160,7 +161,6 @@ You should also set a [`namespace`](#optionsnamespace) for your module so you ca - [keyv-mssql](https://github.com/pmorgan3/keyv-mssql) - Microsoft SQL Server adapter for Keyv. - [keyv-offline](https://github.com/Kikobeats/keyv-offline) – Adding offline capabilities for your keyv instance. - [keyv-s3](https://github.com/microlinkhq/keyv-s3) - Amazon S3 storage adapter for Keyv. -- [memoized-keyv](https://github.com/moeriki/memoized-keyv) - Memoize using keyv as storage backend. - [quick-lru](https://github.com/sindresorhus/quick-lru) - Simple "Least Recently Used" (LRU) cache. diff --git a/packages/memoize/README.md b/packages/memoize/README.md new file mode 100644 index 0000000..3c9e198 --- /dev/null +++ b/packages/memoize/README.md @@ -0,0 +1,138 @@ +# @keyv/memoize [keyv](https://github.com/microlinkhq/keyv) + +> Memoize any function using Keyv as storage backend. + +## Install + +```shell +npm install --save keyv @keyv/memoize +``` + +## Usage + +```js +const memoize = require('@keyvhq/memoize'); + +const memoizedRequest = memoize(request); + +memoizedRequest('http://example.com').then(resp => { /* from request */ }); +memoizedRequest('http://example.com').then(resp => { /* from cache */ }); +``` + +You can pass a [keyv](https://github.com/microlinkhq/keyv) instance or options to be used as argument. + +```js +memoize(request, { store: new Map() }); +memoize(request, 'redis://user:pass@localhost:6379'); +memoize(request, new Keyv()); +``` + +### Resolver + +By default the first argument of your function call is used as cache key. + +You can use a resolver if you want to change the key. The resolver is called with the same arguments as the function. + +```js +const sum = (n1, n2) => n1 + n2; + +const memoized = memoize(sum, new Keyv(), { + resolver: (n1, n2) => `${n1}+${n2}` +}); + +// cached as { '1+2': 3 } +memoized(1, 2); +``` + +The library uses flood protection internally based on the result of this resolver. This means you can make as many requests as you want simultaneously while being sure you won't flood your async resource. + +### TTL + +Set `ttl` to a `number` for a static TTL value. + +```js +const memoizedRequest = memoize(request, new Keyv(), { ttl: 60000 }); + +// cached for 60 seconds +memoizedRequest('http://example.com'); +``` + +Set `ttl` to a `function` for a dynamic TTL value. + +```js +const memoizedRequest = memoize(request, new Keyv(), { + ttl: (res) => res.statusCode === 200 ? 60000 : 0 +}); + +// cached for 60 seconds only if response was 200 OK +memoizedRequest('http://example.com'); +``` + +### Stale + +Set `stale` to any `number` of milliseconds. + +If the `ttl` of a requested resource is below this staleness threshold we will still return the stale value but meanwhile asynchronously refresh the value. + +```js +const memoizedRequest = memoize(request, new Keyv(), { + ttl: 60000, + stale: 10000 +}); + +// cached for 60 seconds +memoizedRequest('http://example.com'); + +// … 55 seconds later +// Our cache will expire in 5 seconds. +// This is below the staleness threshold of 10 seconds. +// returns cached result + refresh cache on background +memoizedRequest('http://example.com'); +``` + +When the `stale` option is set we won't delete expired items either. The same logic as above applies. + +## API + +### memoize(fn, \[keyvOptions], \[options]) + +#### fn + +Type: `Function`
+*Required* + +Promise-returning or async function to be memoized. + +#### keyvOptions + +Type: `Object` + +The [Keyv]https://github.com/microlinkhq/keyv] instance or [keyv#options](https://github.com/microlinkhq/keyv#options) to be used. + +#### options + +##### resolver + +Type: `Function`
+Default: `identity` + +##### ttl + +Type: `Number` or `Function`
+Default: `undefined` + +The time-to-live quantity of time the value will considered as fresh. + +##### stale + +Type: `Number`
+Default: `undefined` + +The staleness threshold we will still return the stale value but meanwhile asynchronously refresh the value. + +## License + +**@keyvhq/memoize** © [Microlink](https://microlink.io), Released under the [MIT](https://github.com/microlinkhq/keyv/blob/master/LICENSE.md) License.
+Authored and maintained by [Microlink](https://microlink.io) with help from [contributors](https://github.com/microlinkhq/keyv/contributors). + +> [microlink.io](https://microlink.io) · GitHub [@MicrolinkHQ](https://github.com/microlinkhq) · Twitter [@microlinkhq](https://twitter.com/microlinkhq) diff --git a/packages/memoize/package.json b/packages/memoize/package.json new file mode 100644 index 0000000..362cb56 --- /dev/null +++ b/packages/memoize/package.json @@ -0,0 +1,51 @@ +{ + "name": "@keyvhq/memoize", + "description": "Memoize any function using Keyv as storage backend.", + "homepage": "https://keyv.js.org", + "version": "1.0.2", + "main": "src/index.js", + "author": { + "email": "hello@microlink.io", + "name": "microlink.io", + "url": "https://microlink.io" + }, + "repository": { + "directory": "packages/memo", + "type": "git", + "url": "git+https://github.com/microlinkhq/keyv.git" + }, + "bugs": { + "url": "https://github.com/microlinkhq/keyv/issues" + }, + "keywords": [ + "cache", + "key", + "memo", + "memoize", + "store", + "ttl", + "value" + ], + "dependencies": { + "@keyvhq/core": "^1.0.2", + "json-buffer": "^3.0.0", + "mimic-fn": "~3.0.0", + "p-any": "~2.1.0" + }, + "devDependencies": { + "ava": "latest", + "delay": "~5.0.0", + "nyc": "latest", + "p-event": "~4.2.0" + }, + "engines": { + "node": ">= 12" + }, + "files": [ + "src" + ], + "scripts": { + "test": "nyc --temp-dir ../../.nyc_output ava" + }, + "license": "MIT" +} diff --git a/packages/memoize/src/index.js b/packages/memoize/src/index.js new file mode 100644 index 0000000..06ff3eb --- /dev/null +++ b/packages/memoize/src/index.js @@ -0,0 +1,114 @@ +'use strict' + +const Keyv = require('@keyvhq/core') +const mimicFn = require('mimic-fn') +const pAny = require('p-any') + +const identity = value => value + +function memoize ( + fn, + keyvOptions, + { resolver = identity, ttl: rawTtl, stale: rawStale } = {} +) { + const keyv = keyvOptions instanceof Keyv ? keyvOptions : new Keyv(keyvOptions) + const pending = {} + const ttl = typeof rawTtl === 'function' ? rawTtl : () => rawTtl + const stale = typeof rawStale === 'number' ? rawStale : undefined + + /** + * This can be better. Check: + * - https://github.com/lukechilds/keyv/issues/36 + * + * @param {string} key + * @return {Promise} { expires:number, value:* } + */ + async function getRaw (key) { + const raw = await keyv.store.get(keyv._getKeyPrefix(key)) + return typeof raw === 'string' ? keyv.deserialize(raw) : raw + } + + /** + * @param {string} key + * @return {Promise<*>} value + * @throws if not found + */ + function getStoredValue (key) { + return getRaw(key).then(data => { + if (!data || data.value === undefined) { + throw new Error('Not found') + } + return data.value + }) + } + + /** + * @param {string} key + * @param {*[]} args + * @return {Promise<*>} value + */ + async function refreshValue (key, args) { + return updateStoredValue(key, await fn(...args)) + } + + /** + * @param {string} key + * @param {*} value + * @return {Promise} resolves when updated + */ + async function updateStoredValue (key, value) { + await keyv.set(key, value, ttl(value)) + return value + } + + /** + * @return {Promise<*>} + */ + function memoized (...args) { + const key = resolver(...args) + + if (pending[key] !== undefined) { + return pAny([getStoredValue(key), pending[key]]) + } + + pending[key] = getRaw(key).then(async data => { + const hasValue = data ? data.value !== undefined : false + const hasExpires = hasValue && typeof data.expires === 'number' + const ttlValue = hasExpires ? data.expires - Date.now() : undefined + const isExpired = stale === undefined && hasExpires && ttlValue < 0 + const isStale = stale !== undefined && hasExpires && ttlValue < stale + + if (hasValue && !isExpired && !isStale) { + pending[key] = undefined + return data.value + } + + if (isExpired) keyv.delete(key) + const promise = refreshValue(key, args) + + if (isStale) { + promise + .then(value => keyv.emit('memoize.fresh.value', value)) + .catch(error => keyv.emit('memoize.fresh.error', error)) + return data.value + } + + try { + const value = await promise + pending[key] = undefined + return value + } catch (error) { + pending[key] = undefined + throw error + } + }) + + return pending[key] + } + + mimicFn(memoized, fn) + + return Object.assign(memoized, { keyv, resolver, ttl }) +} + +module.exports = memoize diff --git a/packages/memoize/test/index.js b/packages/memoize/test/index.js new file mode 100644 index 0000000..ee54a8e --- /dev/null +++ b/packages/memoize/test/index.js @@ -0,0 +1,207 @@ +'use strict' + +const Keyv = require('@keyvhq/core') +const pEvent = require('p-event') +const delay = require('delay') +const test = require('ava') + +const memoize = require('../src') + +const deferred = () => { + const defer = {} + defer.promise = new Promise((resolve, reject) => { + defer.resolve = resolve + defer.reject = reject + }) + return defer +} + +const asyncSum = (...numbers) => + numbers.reduce((wait, n) => wait.then(sum => sum + n), Promise.resolve(0)) + +const syncSum = (...numbers) => numbers.reduce((sum, n) => sum + n, 0) + +test('should store result as arg0', async t => { + const memoizedSum = memoize(asyncSum) + await memoizedSum(1, 2) + t.is(await memoizedSum.keyv.get('1'), 3) +}) + +test('should store result as resolver result', async t => { + const memoizedSum = memoize(asyncSum, undefined, { resolver: syncSum }) + await memoizedSum(1, 2, 3) + t.is(await memoizedSum.keyv.get('6'), 6) +}) + +test('should return result', async t => { + const memoized = memoize(asyncSum) + t.is(await memoized(1, 2), 3) +}) + +test('should return pending result', async t => { + let called = 0 + const defer = deferred() + const spy = () => { + ++called + return defer.promise + } + const memoized = memoize(spy) + + const results = Promise.all([memoized('test'), memoized('test')]) + defer.resolve('result') + + t.deepEqual(await results, ['result', 'result']) + t.is(called, 1) +}) + +test('should return cached result', async t => { + let called = 0 + + const memoized = memoize(n => { + ++called + return asyncSum(n) + }) + + await memoized.keyv.set('5', 5) + + await memoized(5) + + t.is(called, 0) +}) + +test('should throw error', async t => { + const memoized = memoize(() => Promise.reject(new Error('NOPE'))) + await t.throwsAsync(memoized) + const error = await t.throwsAsync(memoized) + t.is(error.message, 'NOPE') +}) + +test('should not cache error', async t => { + let called = 0 + + const memoized = memoize(() => { + ++called + return Promise.reject(new Error('NOPE')) + }) + + await t.throwsAsync(memoized) + const error = await t.throwsAsync(memoized) + + t.is(error.message, 'NOPE') + t.is(called, 2) +}) + +test('should return fresh result', async t => { + const keyv = new Keyv() + + let called = 0 + + const fn = n => { + console.log(called) + ++called + return asyncSum(n) + } + + const memoizedSum = memoize(fn, keyv, { stale: 100 }) + keyv.set('5', 5, 200) + await delay(10) + + t.is(await memoizedSum(5), 5) + t.is(called, 0) +}) + +test('should return stale result but refresh', async t => { + const keyv = new Keyv() + let lastArgs + + const fn = (...args) => { + lastArgs = args + return asyncSum(...args) + } + + const memoizedSum = memoize(fn, keyv, { stale: 10 }) + await memoizedSum.keyv.set('1', 1, 5) + const sum = await memoizedSum(1, 2) + + t.is(sum, 1) + t.deepEqual(lastArgs, [1, 2]) + t.is(await pEvent(keyv, 'memoize.fresh.value'), 3) +}) + +test('should emit on stale refresh error', async t => { + const fn = () => Promise.reject(new Error('NOPE')) + const keyv = new Keyv() + const memoizedSum = memoize(fn, keyv, { stale: 10 }) + + await keyv.set('1', 1, 5) + memoizedSum(1) + + const error = await pEvent(keyv, 'memoize.fresh.error') + t.is(error.message, 'NOPE') +}) + +test('should return cached result if a stale refresh is pending', async t => { + const defer = deferred() + const keyv = new Keyv() + + let called = 0 + + const fn = () => { + ++called + return defer.promise + } + + const memoizedSum = memoize(fn, keyv, { stale: 10 }) + await memoizedSum.keyv.set('1', 1, 5) + + t.is(await memoizedSum(1), 1) + t.is(await memoizedSum(1), 1) + t.is(called, 1) +}) + +test('should delete expired result and return fresh result', async t => { + const keyv = new Keyv() + const memoizedSum = memoize(asyncSum, keyv) + + await keyv.set('1', 1, 1) + await delay(5) + + t.is(await memoizedSum(1, 2), 3) +}) + +test('should not store result if undefined', async t => { + const fn = async () => undefined + const keyv = new Keyv() + + const memoizedSum = memoize(fn, keyv) + await memoizedSum(5) + + t.false(await keyv.has(5)) +}) + +test('should use existing Keyv instance', t => { + const keyv = new Keyv() + const memoizedSum = memoize(asyncSum, keyv) + t.deepEqual(memoizedSum.keyv, keyv) +}) + +test('should create new Keyv instance', t => { + const store = new Map() + const memoizedSum = memoize(asyncSum, { store }) + t.true(memoizedSum.keyv instanceof Keyv) + t.deepEqual(memoizedSum.keyv.store, store) +}) + +// test.only('should store result with static ttl', async t => { +// const memoizedSum = memoize(asyncSum, null, { ttl: 5 }) +// memoizedSum.keyv.set = jest.fn(memoizedSum.keyv.set.bind(memoizedSum.keyv)) +// await memoizedSum(1, 2) +// expect(memoizedSum.keyv.set).toHaveBeenCalledWith(1, 3, 5) +// }) + +// it('should store result with dynamic ttl', async () => { +// const memoizedSum = memoize(asyncSum, null, { ttl: syncSum }); +// memoizedSum.keyv.set = jest.fn(memoizedSum.keyv.set.bind(memoizedSum.keyv)); +// await memoizedSum(1, 2, 3); +// expect(memoizedSum.keyv.set).toHaveBeenCalledWith(1, 6, 6); +// });