├── .gitignore ├── .travis.yml ├── README.md ├── index.js ├── package-lock.json ├── package.json └── test └── redis-memoizer.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 8.8 5 | 6 | services: 7 | - redis -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | redis-memoizer [![Build Status](https://travis-ci.org/errorception/redis-memoizer.svg?branch=master)](https://travis-ci.org/errorception/redis-memoizer) 2 | === 3 | 4 | An asynchronous function memoizer for node.js, using redis as the memo store. Memos expire after a specified timeout. Great as a drop-in performance optimization / caching layer for heavy asynchronous functions. 5 | 6 | The current version (1.x) is designed to work with promises and async/await on node 8.x+. You might want to use the older version (0.x) if you want to work with the old callback style and with older versions of node (0.6+). See migration notes at the bottom of this readme. 7 | 8 | Wikipedia [explains it best](http://en.wikipedia.org/wiki/Memoization): 9 | > ...memoization is an optimization technique used primarily to speed up computer programs by having function calls avoid repeating the calculation of results for previously processed inputs. 10 | 11 | ```javascript 12 | const redisClient = require('redis').createClient(); 13 | const memoize = require("redis-memoizer")(redisClient); 14 | 15 | const expensiveOperation = async (arg1, arg2) => { 16 | // later... 17 | return result; 18 | } 19 | 20 | const memoizedExpensiveOperation = memoize(expensiveOperation, { name: 'expensiveOperation' }); 21 | 22 | const result = await memoizedExpensiveOperation(1, 2); 23 | ``` 24 | 25 | Or with promises: 26 | ```javascript 27 | const expensiveOperation = (arg1, arg2) => { 28 | return new Promise((resolve, reject) => { 29 | // ...later 30 | resolve(result); 31 | }); 32 | } 33 | 34 | const memoizedExpensiveOperation = memoize(expensiveOperation, { name: 'expensiveOperation' }); 35 | 36 | memoizedExpensiveOperation(1, 2).then(result => ...); 37 | 38 | ``` 39 | 40 | Now, calls to `memoizedExpensiveOperation` will have the same effect as calling `expensiveOperation`, except it will be much faster. The results of the first call are stored in redis and then looked up for subsequent calls. 41 | 42 | Redis effectively serves as a shared network-available cache for function calls. The memoization cache is available across processes, so that if the same function call is made from different processes they will reuse the cache. 43 | 44 | ## Uses 45 | 46 | Let's say you are making a DB call that's rather expensive. Let's say you've wrapped the call into a `getUserProfile` function that looks as follows: 47 | 48 | ```javascript 49 | const getUserProfile = async userId => { 50 | // Go over to the DB, perform expensive call, get user's profile 51 | return userProfile; 52 | } 53 | ``` 54 | 55 | Let's say this call takes a lot of resources (IO/CPU/RAM) to complete. You want to make it faster, and don't care about the fact that the value of `userProfile` might be slightly outdated (until the cache expires in redis). You could simply do the following: 56 | 57 | ```javascript 58 | const memoizedGetUserProfile = memoize(getUserProfile, { name: 'getUserProfile' }); 59 | 60 | // First call. This will take some time. 61 | let userProfile = await memoizedGetUserProfile('user1'); 62 | 63 | // Second call. This will be blazingly fast. 64 | userProfile = await memoizedGetUserProfile('user1'); 65 | ``` 66 | 67 | This can similarly be used for any network or disk bound async calls where you are tolerant of slightly outdated values. 68 | 69 | ## Usage 70 | 71 | ### Initialization 72 | ```javascript 73 | const memoize = require("redis-memoizer")(redisClient); 74 | ``` 75 | 76 | Initializes the module with a redis client object, created by calling `.createConnection()` on the [node-redis](https://github.com/mranney/node_redis#rediscreateclientport-host-options) module. 77 | 78 | ### memoize(asyncFunction, options) 79 | 80 | Memoizes an async function and returns it. 81 | 82 | * `asyncFunction` must be an asynchronous function that needs to be memoized. The function must be an `AsyncFunction` (using the `async` keyword), or must return a promise. 83 | 84 | * `options` must be an object with the following properties: 85 | * `name`: Required. A name for the function. This name is used in the key in redis. All function with this name will share the memo cache. 86 | * `ttl`: Default 120000. The amount of time in milliseconds for which the result of the function call should be cached in redis. Once the timeout is hit, the value is deleted from redis automatically. This is done using the redis [`psetex` command](http://redis.io/commands/psetex). The timeout is only set the first time, so the value expires after the timeout has expired since the first call. The timeout is not reset with every call to the memoized function. Once the value has expired in redis, this module will treat the function call as though it's called the first time again. `ttl` can alternatively be a function, if you want to dynamically determine the cache time based on the data returned. The returned data will be passed into the ttl function. 87 | 88 | ```javascript 89 | const httpCallMemoized = memoize(makeHttpCall, { 90 | name: 'makeHttpCall', 91 | ttl: res => { 92 | // return number of ms based on say response's 'expires' header 93 | } 94 | }); 95 | 96 | const result = await httpCallMemoized(...); 97 | ``` 98 | * `lockTimeout`: Default: 5000. The amount in time in milliseconds for the lock timeout. This is passed on to [redis-lock](https://github.com/errorception/redis-lock), which maintains a lock during the first call to prevent a cache stampede. Read the section below for details. The rule of thumb is that this time should be as high as the wort-case-scenario longest time it'll take to execute the function, but no higher. 99 | 100 | ## Cache Stampedes 101 | 102 | This module does its best to minimize the effect of a [cache stampede](http://en.wikipedia.org/wiki/Cache_stampede). If multiple calls are made at roughly the same time before the first call has completed, only the first call is actually really made. Subsequent calls are deferred and are responded to as soon as the result of the first call is available. This is even true across processes, if you have multiple apps running the same function. 103 | 104 | Cache stampedes are prevented by using the [redis-lock](https://github.com/errorception/redis-lock) module, which ensures that only one function can be executed at a time. Since the lock itself is held in redis, it is shared across processes. So, for the same arguments, the same function will only be executed in one process. Other processes will wait for the first one to finish its job and cache its result to redis. Once the lock has been released, other functions will then use the cached value from redis. 105 | 106 | redis-lock is only used when there's no memo found in redis. When memos exist in redis, there's no locking. 107 | 108 | ## Installation 109 | 110 | Use npm to install redis-memoizer: 111 | ``` 112 | npm install redis-memoizer 113 | ``` 114 | 115 | To run the tests, install the dev-dependencies by `cd`'ing into `node_modules/redis-memoizer` and running `npm install` once, and then `npm test`. 116 | 117 | ## Other notes 118 | 119 | * If your function or the resulting promise throws an error, the error isn't cached to redis. In this scenario, this module acts as a pass-through. 120 | * There has been a lot of effort to make the whole thing feel JavaScript-y. However, under the hood, there's JSON serialization/deserialization going on. Hence this module can only work with datatypes that can be serialized to JSON without losing fidelity. This applies to both the arguments that you pass to the function, and to the return value you get when the function's promise is resolved. Since we are limited to what JSON can do, prototype chains aren't preserved, functions can't be passed around, and complex types (such as Dates) are `.toJSON`ed or `.toString`ed. That said, this module does put in some effort to handle `null`s, `undefined`s and booleans correctly so that primitive types work seamlessly. Plain objects, arrays, strings and numbers work just fine at any level of nesting. 121 | 122 | ## Migrating from 0.x to 1.x 123 | 124 | 1.x is essentially a rewrite of the module, though it hasn't changed much in principle. The primary change is that 1.x drops support for the old callback-style node code, in favor of supporting async/await based on native node promises. 125 | 126 | When migrating, you'll need to modify your code as follows: 127 | * Since this module doesn't support the old callback style of flow control, you'll either need to modify your functions to be a promise-based API, or you'll need to wrap it in a suitable wrapper that makes it appear to have a promise-based API. 128 | * 0.x used to take the redis port, host and options as arguments when initializing. Instead, 1.x takes a pre-configured redis client object created by calling `redis.createClient(...)`. 129 | * The `memoize` function now takes an options object as its second argument. It previously took a number or function that determined the timeout. When migrating, you can set `options.ttl` to specify the timeout. `options.ttl` can either be a number or a function, so it mirrors the behaviour of the old `timeout` argument. 130 | * `options.name` wasn't exposed before, but is now a reqired property. This module will `throw` without it. The 0.x version used the `function.toString()` argument to identify the function. This was problematic, and could cause very hard to debug issues due to cache collision. By taking the `.name` property explicitly, the problem is avoided. 131 | 132 | The other thing you should be aware of when migrating is that the 0.x versions used to prevent cache-stampedes by doing stuff in memory. This restricted the cache-stampede prevention mechanism to the same process. The 1.x version uses redis-lock to prevent cache-stampedes, which works across processes. This shouldn't really affect anything externally, but it's worth knowing that there is the additional lock property being stored in redis. 133 | 134 | ## License 135 | 136 | MIT -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const redisLock = require('redis-lock'); 3 | const { promisify } = require('util'); 4 | 5 | const sha1 = str => crypto.createHmac('sha1', 'memo').update(str).digest('hex'); 6 | 7 | const undefinedMarker = '__redis_memoizer_undefined'; 8 | const nullMarker = '__redis_memoizer_null'; 9 | const internalNotFoundInRedis = '__redis_memoizer_not_found'; 10 | const defaultTtl = 120000; 11 | 12 | module.exports = client => { 13 | const redisGet = promisify(client.get).bind(client); 14 | const redisPsetex = promisify(client.psetex).bind(client); 15 | const lock = promisify(redisLock(client)); 16 | 17 | const getResultFromRedis = async (ns, key) => { 18 | const valueFromRedis = await redisGet(`memos:${ns}:${key}`) 19 | 20 | if(valueFromRedis === null) return internalNotFoundInRedis; 21 | if(valueFromRedis === undefinedMarker) return undefined; 22 | if(valueFromRedis === nullMarker) return null; 23 | return JSON.parse(valueFromRedis); 24 | } 25 | 26 | const writeResultToRedis = async (ns, key, value, ttl) => { 27 | if(ttl === 0) return; 28 | 29 | let serializedValue; 30 | if(typeof value === 'undefined') { 31 | serializedValue = undefinedMarker; 32 | } else if(value === null) { 33 | serializedValue = nullMarker; 34 | } else { 35 | serializedValue = JSON.stringify(value); 36 | } 37 | 38 | return redisPsetex(`memos:${ns}:${key}`, ttl, serializedValue); 39 | } 40 | 41 | return function memoize(fn, { ttl = defaultTtl, lockTimeout = 5000, name } = {}) { 42 | if(!name) throw new Error('You must provide a options.name for the function to memoize.'); 43 | 44 | const ttlfn = typeof ttl === 'function' ? ttl : () => ttl; 45 | 46 | const memoizedFunction = async function(...args) { 47 | const argsStringified = sha1(JSON.stringify(args)); 48 | 49 | // Return directly without locks if possible 50 | const redisCacheValue = await getResultFromRedis(name, argsStringified); 51 | if(redisCacheValue !== internalNotFoundInRedis) return redisCacheValue; 52 | 53 | // Lock ensures only one fn executes at a time. 54 | const unlock = await lock(sha1(`${name}:${argsStringified}`), lockTimeout); 55 | try { 56 | // Return from redis, if cache has been populated now 57 | const redisCacheRetry = await getResultFromRedis(name, argsStringified); 58 | if(redisCacheRetry !== internalNotFoundInRedis) return redisCacheRetry; 59 | 60 | const result = await fn.apply(this, args); 61 | const ttl = ttlfn(result); 62 | 63 | await writeResultToRedis( 64 | name, 65 | argsStringified, 66 | result, 67 | typeof ttl === 'number' ? ttl : defaultTtl 68 | ); 69 | 70 | return result; 71 | } catch(e) { 72 | throw e; 73 | } finally { 74 | unlock(); 75 | } 76 | }; 77 | 78 | return memoizedFunction; 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redis-memoizer", 3 | "version": "1.0.2", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "balanced-match": { 8 | "version": "1.0.0", 9 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", 10 | "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", 11 | "dev": true 12 | }, 13 | "brace-expansion": { 14 | "version": "1.1.8", 15 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", 16 | "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=", 17 | "dev": true, 18 | "requires": { 19 | "balanced-match": "1.0.0", 20 | "concat-map": "0.0.1" 21 | } 22 | }, 23 | "browser-stdout": { 24 | "version": "1.3.0", 25 | "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.0.tgz", 26 | "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=", 27 | "dev": true 28 | }, 29 | "commander": { 30 | "version": "2.11.0", 31 | "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", 32 | "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", 33 | "dev": true 34 | }, 35 | "concat-map": { 36 | "version": "0.0.1", 37 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 38 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", 39 | "dev": true 40 | }, 41 | "debug": { 42 | "version": "3.1.0", 43 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", 44 | "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", 45 | "dev": true, 46 | "requires": { 47 | "ms": "2.0.0" 48 | } 49 | }, 50 | "delay2": { 51 | "version": "1.0.2", 52 | "resolved": "https://registry.npmjs.org/delay2/-/delay2-1.0.2.tgz", 53 | "integrity": "sha512-MHRade8FkfxJjgxlINxKzC122DeiWcTSIDYE1lODhyIZj52LX2usJ6lTGdyDVmqlqd9OhWmv3OlEVUegYzyyHQ==", 54 | "dev": true 55 | }, 56 | "diff": { 57 | "version": "3.3.1", 58 | "resolved": "https://registry.npmjs.org/diff/-/diff-3.3.1.tgz", 59 | "integrity": "sha512-MKPHZDMB0o6yHyDryUOScqZibp914ksXwAMYMTHj6KO8UeKsRYNJD3oNCKjTqZon+V488P7N/HzXF8t7ZR95ww==", 60 | "dev": true 61 | }, 62 | "double-ended-queue": { 63 | "version": "2.1.0-0", 64 | "resolved": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz", 65 | "integrity": "sha1-ED01J/0xUo9AGIEwyEHv3XgmTlw=", 66 | "dev": true 67 | }, 68 | "escape-string-regexp": { 69 | "version": "1.0.5", 70 | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", 71 | "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", 72 | "dev": true 73 | }, 74 | "fs.realpath": { 75 | "version": "1.0.0", 76 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 77 | "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", 78 | "dev": true 79 | }, 80 | "glob": { 81 | "version": "7.1.2", 82 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", 83 | "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", 84 | "dev": true, 85 | "requires": { 86 | "fs.realpath": "1.0.0", 87 | "inflight": "1.0.6", 88 | "inherits": "2.0.3", 89 | "minimatch": "3.0.4", 90 | "once": "1.4.0", 91 | "path-is-absolute": "1.0.1" 92 | } 93 | }, 94 | "growl": { 95 | "version": "1.10.3", 96 | "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.3.tgz", 97 | "integrity": "sha512-hKlsbA5Vu3xsh1Cg3J7jSmX/WaW6A5oBeqzM88oNbCRQFz+zUaXm6yxS4RVytp1scBoJzSYl4YAEOQIt6O8V1Q==", 98 | "dev": true 99 | }, 100 | "has-flag": { 101 | "version": "2.0.0", 102 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", 103 | "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=", 104 | "dev": true 105 | }, 106 | "he": { 107 | "version": "1.1.1", 108 | "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", 109 | "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", 110 | "dev": true 111 | }, 112 | "inflight": { 113 | "version": "1.0.6", 114 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 115 | "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", 116 | "dev": true, 117 | "requires": { 118 | "once": "1.4.0", 119 | "wrappy": "1.0.2" 120 | } 121 | }, 122 | "inherits": { 123 | "version": "2.0.3", 124 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", 125 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", 126 | "dev": true 127 | }, 128 | "minimatch": { 129 | "version": "3.0.4", 130 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", 131 | "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", 132 | "dev": true, 133 | "requires": { 134 | "brace-expansion": "1.1.8" 135 | } 136 | }, 137 | "minimist": { 138 | "version": "0.0.8", 139 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", 140 | "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", 141 | "dev": true 142 | }, 143 | "mkdirp": { 144 | "version": "0.5.1", 145 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", 146 | "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", 147 | "dev": true, 148 | "requires": { 149 | "minimist": "0.0.8" 150 | } 151 | }, 152 | "mocha": { 153 | "version": "4.0.1", 154 | "resolved": "https://registry.npmjs.org/mocha/-/mocha-4.0.1.tgz", 155 | "integrity": "sha512-evDmhkoA+cBNiQQQdSKZa2b9+W2mpLoj50367lhy+Klnx9OV8XlCIhigUnn1gaTFLQCa0kdNhEGDr0hCXOQFDw==", 156 | "dev": true, 157 | "requires": { 158 | "browser-stdout": "1.3.0", 159 | "commander": "2.11.0", 160 | "debug": "3.1.0", 161 | "diff": "3.3.1", 162 | "escape-string-regexp": "1.0.5", 163 | "glob": "7.1.2", 164 | "growl": "1.10.3", 165 | "he": "1.1.1", 166 | "mkdirp": "0.5.1", 167 | "supports-color": "4.4.0" 168 | } 169 | }, 170 | "ms": { 171 | "version": "2.0.0", 172 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 173 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", 174 | "dev": true 175 | }, 176 | "once": { 177 | "version": "1.4.0", 178 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 179 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 180 | "dev": true, 181 | "requires": { 182 | "wrappy": "1.0.2" 183 | } 184 | }, 185 | "path-is-absolute": { 186 | "version": "1.0.1", 187 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 188 | "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", 189 | "dev": true 190 | }, 191 | "redis": { 192 | "version": "2.8.0", 193 | "resolved": "https://registry.npmjs.org/redis/-/redis-2.8.0.tgz", 194 | "integrity": "sha512-M1OkonEQwtRmZv4tEWF2VgpG0JWJ8Fv1PhlgT5+B+uNq2cA3Rt1Yt/ryoR+vQNOQcIEgdCdfH0jr3bDpihAw1A==", 195 | "dev": true, 196 | "requires": { 197 | "double-ended-queue": "2.1.0-0", 198 | "redis-commands": "1.3.1", 199 | "redis-parser": "2.6.0" 200 | } 201 | }, 202 | "redis-commands": { 203 | "version": "1.3.1", 204 | "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.3.1.tgz", 205 | "integrity": "sha1-gdgm9F+pyLIBH0zXoP5ZfSQdRCs=", 206 | "dev": true 207 | }, 208 | "redis-lock": { 209 | "version": "0.1.4", 210 | "resolved": "https://registry.npmjs.org/redis-lock/-/redis-lock-0.1.4.tgz", 211 | "integrity": "sha512-7/+zu86XVQfJVx1nHTzux5reglDiyUCDwmW7TSlvVezfhH2YLc/Rc8NE0ejQG+8/0lwKzm29/u/4+ogKeLosiA==" 212 | }, 213 | "redis-parser": { 214 | "version": "2.6.0", 215 | "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.6.0.tgz", 216 | "integrity": "sha1-Uu0J2srBCPGmMcB+m2mUHnoZUEs=", 217 | "dev": true 218 | }, 219 | "should": { 220 | "version": "13.1.3", 221 | "resolved": "https://registry.npmjs.org/should/-/should-13.1.3.tgz", 222 | "integrity": "sha512-1m8FHuNTrJ37pXfjfhjmv1/jBw2eYZIWibQ1pH2EGYhn8TcPOSK6zjNbhF20sZ4Gc0XSIlSaSGupnYRKkk1pKQ==", 223 | "dev": true, 224 | "requires": { 225 | "should-equal": "2.0.0", 226 | "should-format": "3.0.3", 227 | "should-type": "1.4.0", 228 | "should-type-adaptors": "1.0.1", 229 | "should-util": "1.0.0" 230 | } 231 | }, 232 | "should-equal": { 233 | "version": "2.0.0", 234 | "resolved": "https://registry.npmjs.org/should-equal/-/should-equal-2.0.0.tgz", 235 | "integrity": "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==", 236 | "dev": true, 237 | "requires": { 238 | "should-type": "1.4.0" 239 | } 240 | }, 241 | "should-format": { 242 | "version": "3.0.3", 243 | "resolved": "https://registry.npmjs.org/should-format/-/should-format-3.0.3.tgz", 244 | "integrity": "sha1-m/yPdPo5IFxT04w01xcwPidxJPE=", 245 | "dev": true, 246 | "requires": { 247 | "should-type": "1.4.0", 248 | "should-type-adaptors": "1.0.1" 249 | } 250 | }, 251 | "should-type": { 252 | "version": "1.4.0", 253 | "resolved": "https://registry.npmjs.org/should-type/-/should-type-1.4.0.tgz", 254 | "integrity": "sha1-B1bYzoRt/QmEOmlHcZ36DUz/XPM=", 255 | "dev": true 256 | }, 257 | "should-type-adaptors": { 258 | "version": "1.0.1", 259 | "resolved": "https://registry.npmjs.org/should-type-adaptors/-/should-type-adaptors-1.0.1.tgz", 260 | "integrity": "sha1-7+VVPN9oz/ZuXF9RtxLcNRx3vqo=", 261 | "dev": true, 262 | "requires": { 263 | "should-type": "1.4.0", 264 | "should-util": "1.0.0" 265 | } 266 | }, 267 | "should-util": { 268 | "version": "1.0.0", 269 | "resolved": "https://registry.npmjs.org/should-util/-/should-util-1.0.0.tgz", 270 | "integrity": "sha1-yYzaN0qmsZDfi6h8mInCtNtiAGM=", 271 | "dev": true 272 | }, 273 | "supports-color": { 274 | "version": "4.4.0", 275 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.4.0.tgz", 276 | "integrity": "sha512-rKC3+DyXWgK0ZLKwmRsrkyHVZAjNkfzeehuFWdGGcqGDTZFH73+RH6S/RDAAxl9GusSjZSUWYLmT9N5pzXFOXQ==", 277 | "dev": true, 278 | "requires": { 279 | "has-flag": "2.0.0" 280 | } 281 | }, 282 | "wrappy": { 283 | "version": "1.0.2", 284 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 285 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", 286 | "dev": true 287 | } 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redis-memoizer", 3 | "version": "1.0.2", 4 | "description": "A memoizer using redis as a shared cache, with TTL.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "./node_modules/mocha/bin/mocha --reporter spec --timeout 5000" 8 | }, 9 | "engines": { 10 | "node": ">=8.0" 11 | }, 12 | "repository": "", 13 | "author": "", 14 | "license": "MIT", 15 | "dependencies": { 16 | "redis-lock": "^0.1.4" 17 | }, 18 | "devDependencies": { 19 | "redis": "^2.8.0", 20 | "delay2": "^1.0.2", 21 | "mocha": "^4.0.1", 22 | "should": "^13.1.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/redis-memoizer.js: -------------------------------------------------------------------------------- 1 | const client = require('redis').createClient(); 2 | const memoize = require('../')(client); 3 | const crypto = require('crypto'); 4 | const should = require('should'); 5 | const { promisify } = require('util'); 6 | const delay = require('delay2'); 7 | 8 | const redisDel = promisify(client.del).bind(client); 9 | 10 | const hash = string => crypto.createHmac('sha1', 'memo').update(string).digest('hex'); 11 | 12 | const clearCache = async (fnName, args = []) => { 13 | await redisDel('memos:' + fnName + ':' + hash(JSON.stringify(args))); 14 | } 15 | 16 | describe('redis-memoizer', () => { 17 | after(process.exit); 18 | 19 | it('should memoize a value correctly', async () => { 20 | const functionDelayTime = 10; 21 | let callCount = 0; 22 | const functionToMemoize = async (val1, val2) => { 23 | callCount++; 24 | await delay(functionDelayTime); 25 | return { val1, val2 }; 26 | }; 27 | const memoized = memoize(functionToMemoize, { name: 'testFn' }); 28 | 29 | let start = Date.now(); 30 | let { val1, val2 } = await memoized(1, 2); 31 | val1.should.equal(1); 32 | val2.should.equal(2); 33 | (Date.now() - start >= functionDelayTime).should.be.true; // First call should go to the function itself 34 | callCount.should.equal(1); 35 | 36 | start = Date.now(); 37 | ({ val1, val2 } = await memoized(1, 2)); 38 | val1.should.equal(1); 39 | val2.should.equal(2); 40 | (Date.now() - start < functionDelayTime).should.be.true; // Second call should be faster 41 | callCount.should.equal(1); 42 | 43 | await clearCache('testFn', [1, 2]); 44 | }); 45 | 46 | it('should memoize separate function separately', async () => { 47 | const function1 = async arg => { await delay(10); return 1; }; 48 | const function2 = async arg => { await delay(10); return 2; }; 49 | 50 | const memoizedFn1 = memoize(function1, { name: 'function1' }); 51 | const memoizedFn2 = memoize(function2, { name: 'function2' }); 52 | 53 | (await memoizedFn1('x')).should.equal(1); 54 | (await memoizedFn2('y')).should.equal(2); 55 | (await memoizedFn1('x')).should.equal(1); 56 | 57 | await clearCache('function1', ['x']); 58 | await clearCache('function2', ['y']); 59 | }); 60 | 61 | it('should prevent a cache stampede', async () => { 62 | try { 63 | const functionDelayTime = 10; 64 | const iterationCount = 10; 65 | let callCount = 0; 66 | 67 | const fn = async () => { 68 | callCount++; 69 | await delay(functionDelayTime); 70 | }; 71 | const memoized = memoize(fn, { name: 'testFn' }); 72 | 73 | let start = Date.now(); 74 | await Promise.all([ ...Array(iterationCount).keys() ].map(() => memoized())); 75 | (Date.now() - start < functionDelayTime * iterationCount).should.be.true; 76 | callCount.should.equal(1); 77 | 78 | await clearCache('testFn'); 79 | } catch(e) { console.log(e); } 80 | }); 81 | 82 | it(`should respect 'this'`, async () => { 83 | function Obj() { this.x = 1; } 84 | Obj.prototype.y = async function() { 85 | await delay(10); 86 | return this.x; 87 | }; 88 | 89 | const obj = new Obj(); 90 | const memoizedY = memoize(obj.y, {name: 'Obj.y'}).bind(obj); 91 | 92 | (await memoizedY()).should.equal(1); 93 | 94 | await clearCache('Obj.y'); 95 | }); 96 | 97 | it('should respect the ttl', async () => { 98 | const ttl = 100; 99 | const functionDelayTime = 10; 100 | 101 | const fn = async () => await delay(functionDelayTime); 102 | const memoized = memoize(fn, { name: 'testFn', ttl }); 103 | 104 | let start = Date.now(); 105 | await memoized(); 106 | (Date.now() - start >= functionDelayTime).should.be.true; 107 | 108 | // Call immediately again. Should be a cache hit. 109 | start = Date.now(); 110 | await memoized(); 111 | (Date.now() - start < functionDelayTime).should.be.true; 112 | 113 | // Wait some time, ttl should have expired 114 | await delay(ttl + 10); 115 | start = Date.now(); 116 | await memoized(); 117 | (Date.now() - start >= functionDelayTime).should.be.true; 118 | 119 | await clearCache('testFn'); 120 | }); 121 | 122 | it('should allow ttl to be a function', async () => { 123 | const functionDelayTime = 10; 124 | const ttl = 100; 125 | const fn = async () => await delay(functionDelayTime); 126 | const memoized = memoize(fn, { ttl: () => ttl, name: 'testFn' }); 127 | 128 | let start = Date.now(); 129 | await memoized(); 130 | (Date.now() - start >= functionDelayTime).should.be.true; 131 | 132 | // Call immediately again. Should be a cache hit 133 | start = Date.now(); 134 | await memoized(); 135 | (Date.now() - start <= functionDelayTime).should.be.true; 136 | 137 | // Wait some time, ttl should have expired; 138 | await delay(ttl + 10); 139 | 140 | start = Date.now(); 141 | await memoized(); 142 | (Date.now() - start >= functionDelayTime).should.be.true; 143 | 144 | await clearCache('testFn'); 145 | }); 146 | 147 | it('should work if complex types are accepted and returned', async () => { 148 | const functionDelayTime = 10; 149 | const fn = async arg1 => { 150 | await delay(functionDelayTime); 151 | return { arg1, some: ['other', 'data'] } 152 | }; 153 | 154 | const memoized = memoize(fn, { name: 'testFn' }); 155 | 156 | let start = Date.now(); 157 | let { arg1, some } = await memoized({ input: 'data' }); 158 | (Date.now() - start >= functionDelayTime).should.be.true; 159 | arg1.should.eql({ input: 'data' }); 160 | some.should.eql(['other', 'data']); 161 | 162 | start = Date.now(); 163 | ({ arg1, some } = await memoized({ input: 'data' })); 164 | (Date.now() - start <= functionDelayTime).should.be.true; 165 | arg1.should.eql({ input: 'data' }); 166 | some.should.eql(['other', 'data']); 167 | 168 | await clearCache(fn, [{input: "data"}]); 169 | }); 170 | 171 | it('should memoize even if result is falsy', async () => { 172 | await Promise.all([undefined, null, false, ''].map(async falsyValue => { 173 | let callCount = 0; 174 | const fn = async () => { 175 | callCount++; 176 | return falsyValue; 177 | } 178 | 179 | const memoized = memoize(fn, { name: 'testFn' }); 180 | 181 | (await memoized() === falsyValue).should.be.true; 182 | (await memoized() === falsyValue).should.be.true; // Repeated, presumably cache-hit 183 | callCount.should.equal(1); // Verify cache hit 184 | 185 | await clearCache('testFn'); 186 | })); 187 | }); 188 | 189 | it(`shouldn't memoize errors`, async () => { 190 | let callCount = 0; 191 | const fn = async () => { 192 | callCount++; 193 | throw new Error('Test error'); 194 | } 195 | 196 | const memoized = memoize(fn, { name: 'testFn' }); 197 | 198 | try { 199 | await memoized(); 200 | } catch(e) { 201 | callCount.should.equal(1); 202 | } 203 | 204 | try { 205 | await memoized(); 206 | } catch(e) { 207 | callCount.should.equal(2); 208 | } 209 | }); 210 | }); --------------------------------------------------------------------------------