Browse Source

feat: add memoize package

master
Kiko Beats 3 years ago
parent
commit
25e1c4f941
No known key found for this signature in database GPG Key ID: 8FA93B22CCF04B96
  1. 2
      README.md
  2. 138
      packages/memoize/README.md
  3. 51
      packages/memoize/package.json
  4. 114
      packages/memoize/src/index.js
  5. 207
      packages/memoize/test/index.js

2
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/postgres](/packages/postgres) – PostgreSQL storage adapter for Keyv.
- [@keyvhq/redis](/packages/redis) – Redis storage adapter for Keyv. - [@keyvhq/redis](/packages/redis) – Redis storage adapter for Keyv.
- [@keyvhq/sqlite](/packages/sqlite) – SQLite 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 ### 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-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-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. - [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. - [quick-lru](https://github.com/sindresorhus/quick-lru) - Simple "Least Recently Used" (LRU) cache.

138
packages/memoize/README.md

@ -0,0 +1,138 @@
# @keyv/memoize [<img width="100" align="right" src="https://ghcdn.rawgit.org/microlinkhq/keyv/master/media/logo-sunset.svg" alt="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`<br>
*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`<br/>
Default: `identity`
##### ttl
Type: `Number` or `Function`<br/>
Default: `undefined`
The time-to-live quantity of time the value will considered as fresh.
##### stale
Type: `Number`<br/>
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.<br/>
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)

51
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"
}

114
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<object>} { 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

207
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);
// });
Loading…
Cancel
Save