├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── index.js ├── package.json ├── tests ├── index.js └── operators.js └── types └── index.d.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tests/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Kartik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CachedLookup: A Simple Package To Cache And Save On Expensive Lookups & Operations. 2 | 3 |
4 | 5 | [![NPM version](https://img.shields.io/npm/v/cached-lookup.svg?style=flat)](https://www.npmjs.com/package/cached-lookup) 6 | [![NPM downloads](https://img.shields.io/npm/dm/cached-lookup.svg?style=flat)](https://www.npmjs.com/package/cached-lookup) 7 | [![GitHub issues](https://img.shields.io/github/issues/kartikk221/cached-lookup)](https://github.com/kartikk221/cached-lookup/issues) 8 | [![GitHub stars](https://img.shields.io/github/stars/kartikk221/cached-lookup)](https://github.com/kartikk221/cached-lookup/stargazers) 9 | [![GitHub license](https://img.shields.io/github/license/kartikk221/cached-lookup)](https://github.com/kartikk221/cached-lookup/blob/master/LICENSE) 10 | 11 |
12 | 13 | ## Motivation 14 | This package aims to simplify the task of implementing a short-lived caching system for an endpoint which may be calling another third party API under the hood with a usage/rate limit. This package can also help to alleviate pressure when consuming data from databases or I/O network operations by implementing a short lived cache that does not scale relative to incoming requests. 15 | 16 | ## Features 17 | - Simple-to-use API 18 | - TypeScript Support 19 | - Dynamic Cache Consumption 20 | - CPU & Memory Efficient 21 | - No Dependencies 22 | 23 | ## Installation 24 | CachedLookup can be installed using node package manager (`npm`) 25 | ``` 26 | npm i cached-lookup 27 | ``` 28 | 29 | ## How To Use? 30 | Below is a small snippet that shows how to use a `CachedLookup` instance. 31 | 32 | ```javascript 33 | const CachedLookup = require('cached-lookup'); 34 | 35 | // Create a cached lookup instance which fetches music concerts from different cities on a specific date 36 | const ConcertsLookup = new CachedLookup(async (country, state, city) => { 37 | // Assume that the function get_city_concerts() is calling a Third-Party API which has a rate limit 38 | const concerts = await get_city_concerts(country, state, city); 39 | 40 | // Simply return the data and CachedLookup will handle the rest 41 | return concerts; 42 | }); 43 | 44 | // Create some route which serves this data with a 10 second intermittent cache 45 | webserver.get('/api/concerts/:country/:state/:city', async (request, response) => { 46 | // Retrieve the city value from the request - assume there is user validation done on this here 47 | const { country, state, city } = request.path_parameters; 48 | 49 | // Retrieve data from the CachedLookup with the cached() and pass the city in the call to the lookup handler 50 | // Be sure to specify the first parameter as the max_age of the cached value in milliseconds 51 | // In our case, 10 seconds would be 10,000 milliseconds 52 | const concerts = await ConcertsLookup.cached(1000 * 10, country, state, city); 53 | 54 | // Simply return the data to the user 55 | // Because we retrieved this data from the ConcertsLookup with the cached() method 56 | // We can safely assume that we will only perform up to 1 Third-Party API request per city every 10 seconds 57 | return response.json({ 58 | concerts 59 | }); 60 | }); 61 | ``` 62 | 63 | ## CachedLookup 64 | Below is a breakdown of the `CachedLookup` class. 65 | 66 | #### Constructor Parameters 67 | * `new CachedLookup(Function: lookup)`: Creates a new CachedLookup instance with default `options`. 68 | * `new CachedLookup(Object?: options, Function(...arguments): lookup)`: Creates a new CachedLookup instance with custom `options`. 69 | * `options` [`Object`]: Constructor options for this instance. 70 | * `auto_purge` [`Boolean`]: Whether to automatically purge cache values when they have aged past their last known maximum age. 71 | * **Default**: `true` 72 | * `purge_age_factor` [`Number`]: The factor by which to multiply the last known maximum age of a cache value to determine the age at which it should be purged. 73 | * **Default**: `1.5` 74 | * `lookup` [`Function`]: Lookup handler which is called to get fresh values. 75 | * **Note!** this callback can be either `synchronous` or `asynchronous`. 76 | * **Note!** you must `return`/`resolve` a value through this callback for the caching to work properly. 77 | * **Note!** `arguments` passed to the methods below will be available in each call to this `lookup` handler. 78 | 79 | #### CachedLookup Properties 80 | | Property | Type | Description | 81 | | :-------- | :------- | :------------------------- | 82 | | `lookup` | `function(...arguments)` | Lookup handler of this instance. | 83 | | `cache` | `Map` | Internal map of cached values. | 84 | | `promises` | `Map>` | Internal map of promises for pending lookups. | 85 | 86 | #### CachedLookup Methods 87 | * `cached(Number: max_age, ...arguments)`: Returns the `cached` value for the provided set of `arguments` from the lookup handler. Automatically falls back to a `fresh()` value if no cached value within the `max_age` is available. 88 | * **Returns** a `Promise` which is resolved to the `cached` value with a fall back to the `fresh` value. 89 | * **Note** the parameter `max_age` should be a `Number` in `milliseconds` to specify the maximum acceptable cache age. 90 | * **Note** this method will automatically fall back to a `fresh()` call if no viable cache value is available. 91 | * **Note** the returned `Promise` will **reject** when the lookup handler also rejects. 92 | * **Note** the provided `arguments` after the `max_age` will be available inside of the `lookup` handler function. 93 | * **Note** this method should be used over `rolling()` if you want to ensure cache freshness within the `max_age` threshold at the sacrifice of increased latency whenever a `fresh()` is resolved to satify the `max_age` requirement. 94 | * `rolling(Number: target_age, ...arguments)`: Returns the `cached` value for the provided set of `arguments` from the lookup handler. Instantly resolves the most recently cached value while triggering a `fresh()` value call in the background to reload the cache on a rolling basis according to the `target_age`. 95 | * **Note** this method has the same signature as the `cached()` method above. 96 | * **Note** this method should be used over `cached()` if you want to maintain low latency at the sacrifice of guaranteed cache freshness. 97 | * `fresh(...arguments)`: Retrieves the `fresh` value for the provided set of arguments from the lookup handler. 98 | * **Returns** a `Promise` which is resolved to the `fresh` value. 99 | * `get(...arguments)`: Returns the `cached` value for the provided set of arguments if one exists in cache. 100 | * **Returns** the `cached` value or `undefined`. 101 | * `expire(...arguments)`: Expires the `cached` value for the provided set of arguments. 102 | * **Returns** a `Boolean` which specifies whether a `cached` value was expired or not. 103 | * `in_flight(...arguments)`: Checks whether a `fresh` value is currently being resolved for the provided set of arguments. 104 | * **Returns** a `Boolean` to specify the result. 105 | * `updated_at(...arguments)`: Returns the last value update `timestamp` in **milliseconds** for the provided set of arguments. 106 | * **Returns** a `Number` or `undefined` if no cached value exists. 107 | * `clear()`: Clears all the cached values and resets the internal cache state. 108 | * **Note** the `...arguments` are **optional** but must be of the following types: `Boolean`, `Number`, `String` or an `Array` of these types. 109 | 110 | #### CachedLookup Events 111 | * [`fresh`]: The `fresh` event is emitted whenever a fresh value is retrieved from the `lookup` function with a given set of arguments. 112 | * **Example:** `CachedLookup.on('fresh', (value, arg1, arg2, arg3) => { /* Your Code */ });` 113 | * [`purge`]: The `purge` event is emitted whenever a stale cache value is purged from the cache. 114 | * **Example:** `CachedLookup.on('purge', (value, arg1, arg2, arg3) => { /* Your Code */ });` 115 | 116 | ### ValueRecord Properties 117 | | Property | Type | Description | 118 | | :-------- | :------- | :------------------------- | 119 | | `value` | `T (Generic)` | The cached value. | 120 | | `max_age` | `undefined | Number` | The smallest known `max_age` of value. | 121 | | `updated_at` | `Number` | Timestamp (In milliseconds) of when this value was cached. | 122 | 123 | ## License 124 | [MIT](./LICENSE) 125 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | 3 | /** 4 | * The types of arguments that can be serialized on each call. 5 | * @typedef {boolean|number|string|null} SerializableArgumentTypes 6 | */ 7 | 8 | /** 9 | * @typedef {('purge' | 'fresh')} CachedLookupEventTypes 10 | */ 11 | 12 | /** 13 | * @template T 14 | * @callback CachedLookupEvent 15 | * @param {T} value 16 | * @param {...(undefined | null | boolean | string | number)[]} args 17 | */ 18 | 19 | /** 20 | * @template T 21 | */ 22 | class CachedLookup extends EventEmitter { 23 | #delimiter = ','; 24 | #cleanup = { 25 | timeout: null, 26 | expected_at: null, 27 | }; 28 | 29 | /** 30 | * @typedef {Object} CachedRecord 31 | * @property {T} value 32 | * @property {number=} max_age 33 | * @property {number} updated_at 34 | */ 35 | 36 | /** 37 | * The acceptable argument types of the lookup function. 38 | * We allow Arrays of these types as well as they are automatically serialized. 39 | * @typedef {function(...(SerializableArgumentTypes|Array)):T|Promise} LookupFunction 40 | */ 41 | 42 | /** 43 | * CachedLookup constructor options. 44 | * @type {ConstructorOptions} 45 | */ 46 | options; 47 | 48 | /** 49 | * The lookup function that is used to resolve fresh values for the provided arguments. 50 | * @type {LookupFunction} 51 | */ 52 | lookup; 53 | 54 | /** 55 | * Stores the cached values identified by the serialized arguments from lookup calls. 56 | * @type {Map} 57 | */ 58 | cache = new Map(); 59 | 60 | /** 61 | * Stores the in-flight promises for any pending lookup calls identified by the serialized arguments. 62 | * @type {Map>} 63 | */ 64 | promises = new Map(); 65 | 66 | /** 67 | * @typedef {Object} ConstructorOptions 68 | * @property {boolean} [auto_purge=true] - Whether to automatically purge cache values when they have aged past their last known maximum age. 69 | * @property {number} [purge_age_factor=1.5] - The factor by which to multiply the last known maximum age of a stale cache value to determine the age after which it should be purged from memory. 70 | * @property {number} [max_purge_eloop_tick=5000] - The number of items to purge from the cache per event loop tick. Decrease this value to reduce the impact of purging stale cache values on the event loop when working with many unique arguments. 71 | */ 72 | 73 | /** 74 | * Creates a new CachedLookup instance with the specified lookup function. 75 | * The lookup function can be both synchronous or asynchronous. 76 | * 77 | * @param {(LookupOptions|LookupFunction)} options - The constructor options or lookup function. 78 | * @param {LookupFunction} [lookup] - The lookup function if the first argument is the constructor options. 79 | */ 80 | constructor(options, lookup) { 81 | super(); 82 | 83 | // Ensure the options parameter is either the lookup function or an object. 84 | if (typeof options === 'function') { 85 | lookup = options; 86 | } else if (!options || typeof options !== 'object') { 87 | throw new Error('new CachedLookup(options, lookup) -> options must be an Object.'); 88 | } 89 | 90 | // Ensure the lookup function always exists. 91 | if (typeof lookup !== 'function') { 92 | if (typeof options === 'function') { 93 | throw new Error('new CachedLookup(lookup) -> lookup must be a Function.'); 94 | } else { 95 | throw new Error('new CachedLookup(options, lookup) -> lookup must be a Function.'); 96 | } 97 | } 98 | 99 | // Store the lookup function and options 100 | this.lookup = lookup; 101 | this.options = Object.freeze({ 102 | auto_purge: true, // By default automatically purge cache values when they have aged past their last known maximum age 103 | purge_age_factor: 1.5, // By default purge values that are one and half times their maximum age 104 | max_purge_eloop_tick: 5000, // By default purge 5000 items per event loop tick 105 | ...(typeof options === 'object' ? options : {}), 106 | }); 107 | } 108 | 109 | /** 110 | * @param {CachedLookupEventTypes} event 111 | * @param {CachedLookupEvent} listener 112 | * @returns {this} 113 | */ 114 | on(event, listener) { 115 | return super.on(...arguments); 116 | } 117 | 118 | /** 119 | * @param {CachedLookupEventTypes} event 120 | * @param {CachedLookupEvent} listener 121 | * @returns {this} 122 | */ 123 | once(event, listener) { 124 | return super.once(...arguments); 125 | } 126 | 127 | /** 128 | * @param {CachedLookupEventTypes} event 129 | * @param {CachedLookupEvent} args 130 | * @returns {boolean} 131 | */ 132 | emit(event, ...args) { 133 | return super.emit(...arguments); 134 | } 135 | 136 | /** 137 | * Returns an Array of arguments from a serialized string. 138 | * @private 139 | * @param {string} serialized 140 | * @returns {SerializableArgumentTypes[]} 141 | */ 142 | _parse_arguments(serialized) { 143 | return serialized.split(this.#delimiter).map((arg) => { 144 | // Handle null values 145 | if (arg === 'null') return null; 146 | 147 | // Handle boolean values 148 | if (arg === 'true') return true; 149 | if (arg === 'false') return false; 150 | 151 | // Handle number values 152 | if (!isNaN(arg)) return Number(arg); 153 | 154 | // Handle string values 155 | return arg; 156 | }); 157 | } 158 | 159 | /** 160 | * Reads the most up to date cached value record for the provided set of arguments if it exists and is not older than the specified maximum age. 161 | * 162 | * @private 163 | * @param {string} identifier 164 | * @param {number=} max_age 165 | * @returns {CachedRecord=} 166 | */ 167 | _get_from_cache(identifier, max_age) { 168 | // Ensure the cached value record exists in the cache 169 | const record = this.cache.get(identifier); 170 | if (!record) return; 171 | 172 | // Schedule a cache cleanup for this entry if a max_age was provided 173 | if (max_age !== undefined) this._schedule_cache_cleanup(max_age); 174 | 175 | // Ensure the value is not older than the specified maximum age if provided 176 | if (max_age !== undefined && Date.now() - max_age > record.updated_at) return; 177 | 178 | // Update the record max_age if it is smaller than the provided max_age 179 | if (max_age !== undefined && max_age < (record.max_age || Infinity)) record.max_age = max_age; 180 | 181 | // Return the cached value record 182 | return record; 183 | } 184 | 185 | /** 186 | * Writes the provided value to the cache as the most up to date cached value for the provided set of arguments. 187 | * 188 | * @private 189 | * @param {string} identifier 190 | * @param {number=} max_age 191 | * @param {T} value 192 | */ 193 | _set_in_cache(identifier, max_age, value) { 194 | const now = Date.now(); 195 | 196 | // Retrieve the cached value record for this identifier from the cache 197 | const record = this.cache.get(identifier) || { 198 | value, 199 | max_age, 200 | updated_at: now, 201 | }; 202 | 203 | // Update the record values 204 | record.value = value; 205 | record.updated_at = now; 206 | record.max_age = max_age; 207 | 208 | // Store the updated cached value record in the cache 209 | this.cache.set(identifier, record); 210 | 211 | // Schedule a cache cleanup for this entry if a max_age was provided 212 | if (max_age !== undefined) this._schedule_cache_cleanup(max_age); 213 | } 214 | 215 | /** 216 | * Schedules a cache cleanup to purge stale cache values if the provided `max_age` is earlier than the next expected cleanup. 217 | * 218 | * @param {number} max_age 219 | * @returns {boolean} Whether a sooner cleanup was scheduled. 220 | */ 221 | _schedule_cache_cleanup(max_age) { 222 | // Do not schedule anything if auto_purge is disabled 223 | if (!this.options.auto_purge) return false; 224 | 225 | // Increase the max_age by the purge_age_factor to determine the true max_age of the cached value 226 | max_age *= this.options.purge_age_factor; 227 | 228 | // Return false if the scheduled expected cleanup is sooner than the provided max_age as there is no need to expedite the cleanup 229 | const now = Date.now(); 230 | const { timeout, expected_at } = this.#cleanup; 231 | if (timeout && expected_at && expected_at <= now + max_age) return false; 232 | 233 | // Clear the existing cleanup timeout if one exists 234 | if (timeout) clearTimeout(timeout); 235 | 236 | // Create a new cleanup timeout to purge stale cache values 237 | this.#cleanup.expected_at = now + max_age; 238 | this.#cleanup.timeout = setTimeout(async () => { 239 | // Clear the existing cleanup timeout 240 | this.#cleanup.timeout = null; 241 | this.#cleanup.expected_at = null; 242 | 243 | // Purge stale cache values 244 | let count = 0; 245 | let now = Date.now(); 246 | let nearest_expiry_at = Number.MAX_SAFE_INTEGER; 247 | for (const [identifier, record] of this.cache) { 248 | // Flush the event loop every max purge items per synchronous event loop tick 249 | if (count % this.options.max_purge_eloop_tick === 0) { 250 | await new Promise((resolve) => setTimeout(resolve, 0)); 251 | } 252 | count++; 253 | 254 | // Skip if the cached value does not have a max value to determine if it is stale 255 | if (record.max_age === undefined) continue; 256 | 257 | // Skip this cached value if it is not stale 258 | const true_max_Age = record.max_age * this.options.purge_age_factor; 259 | const stale = now - true_max_Age > record.updated_at; 260 | if (!stale) { 261 | // Update the nearest expiry timestamp if this cached value is closer than the previous one 262 | const expiry_at = record.updated_at + true_max_Age; 263 | if (expiry_at < nearest_expiry_at) nearest_expiry_at = expiry_at; 264 | 265 | // Skip this cached value 266 | continue; 267 | } 268 | 269 | // Emit a purge event with the stale value and the provided arguments 270 | this.emit('purge', record.value, ...this._parse_arguments(identifier)); 271 | 272 | // Delete the stale cached value 273 | this.cache.delete(identifier); 274 | } 275 | 276 | // Schedule another cleanup if there are still more values remaining in the cache 277 | if (this.cache.size && nearest_expiry_at < Number.MAX_SAFE_INTEGER) { 278 | this._schedule_cache_cleanup(nearest_expiry_at - now); 279 | } 280 | }, Math.min(max_age, 2147483647)); // Do not allow the timeout to exceed the maximum timeout value of 2147483647 as it will cause an overflow error 281 | } 282 | 283 | /** 284 | * Fetches a fresh value for the provided set of arguments and stores it in the cache for future use. 285 | * 286 | * @private 287 | * @param {string} identifier 288 | * @param {number=} max_age 289 | * @param {...(SerializableArgumentTypes|Array)} args 290 | * @returns {Promise} 291 | */ 292 | _get_fresh_value(identifier, max_age, ...args) { 293 | // Resolve an already in-flight promise if one exists for this identifier 294 | const in_flight = this.promises.get(identifier); 295 | if (in_flight) return in_flight; 296 | 297 | // Create a new cause Error to keep track of this error trace 298 | const cause = new Error(`CachedLookup.fresh(${args.join(', ')}) -> lookup function threw an error.`); 299 | 300 | // Initialize a new Promise to resolve the fresh value for this identifier 301 | const promise = new Promise(async (resolve, reject) => { 302 | // Attempt to resolve the value for the specified arguments from the lookup 303 | let value, error; 304 | try { 305 | value = await this.lookup(...args); 306 | } catch (e) { 307 | error = e; 308 | } 309 | 310 | // Delete the in-flight promise for this identifier 311 | this.promises.delete(identifier); 312 | 313 | // Check if a value was resolved from the lookup without any errors 314 | if (value !== undefined) { 315 | // Cache the fresh value for this identifier 316 | this._set_in_cache(identifier, max_age, value); 317 | 318 | // Emit a 'fresh' event with the fresh value and the provided arguments 319 | this.emit('fresh', value, ...args); 320 | 321 | // Resolve the fresh value 322 | resolve(value); 323 | } else { 324 | // Generate a new error if no value was resolved from the lookup 325 | error = 326 | error || 327 | new Error( 328 | `CachedLookup.fresh(${args.join(', ')}) -> No value was returned by the lookup function.` 329 | ); 330 | 331 | // Add the cause Error to the thrown error 332 | error.cause = cause; 333 | 334 | // Reject the fresh value promise with the error 335 | reject(error); 336 | } 337 | }); 338 | 339 | // Store the in-flight promise for this identifier so that future calls can re-use it 340 | this.promises.set(identifier, promise); 341 | 342 | // Return the in-flight promise to the caller 343 | return promise; 344 | } 345 | 346 | /** 347 | * Returns a `cached` value that is up to `max_age` milliseconds old from now. 348 | * Otherwise, It will fetch a fresh value and update the cache in the background. 349 | * Use this method over `rolling` if you want to guarantee that the cached value is at most `max_age` milliseconds old at the cost of increased latency whenever a `fresh` value is fetched on a cache miss. 350 | * 351 | * @param {Number} max_age In Milliseconds 352 | * @param {...(SerializableArgumentTypes|Array)} args 353 | * @returns {Promise} 354 | */ 355 | cached(max_age, ...args) { 356 | if (typeof max_age !== 'number' || isNaN(max_age) || max_age < 0 || max_age > Number.MAX_SAFE_INTEGER) 357 | throw new Error('CachedLookup.cached(max_age) -> max_age must be a valid number.'); 358 | 359 | // Serialize the arguments into an identifier 360 | const identifier = args.join(this.#delimiter); 361 | 362 | // Attempt to resolve the cached value from the cached value record 363 | const record = this._get_from_cache(identifier, max_age); 364 | if (record) return Promise.resolve(record.value); 365 | 366 | // Resolve the fresh value for the provided arguments in array serialization 367 | return this._get_fresh_value(identifier, max_age, ...args); 368 | } 369 | 370 | /** 371 | * Returns the most up to date `cached` value even if stale if one is available and automatically fetches a fresh value to ensure the cache is as up to date as possible to the `max_age` provided in milliseconds. 372 | * Use this method over `cached` if you want lower latency at the cost of a temporarily stale cached value while a `fresh` value is being fetched in the background. 373 | * 374 | * @param {Number} target_age In Milliseconds 375 | * @param {...(SerializableArgumentTypes|Array)} args 376 | * @returns {Promise} 377 | */ 378 | rolling(target_age, ...args) { 379 | if ( 380 | typeof target_age !== 'number' || 381 | isNaN(target_age) || 382 | target_age < 0 || 383 | target_age > Number.MAX_SAFE_INTEGER 384 | ) 385 | throw new Error('CachedLookup.rolling(target_age) -> target_age must be a valid number.'); 386 | 387 | // Serialize the arguments into an identifier 388 | const identifier = args.join(this.#delimiter); 389 | 390 | // Attempt to resolve the cached value from the cached value record 391 | const record = this._get_from_cache(identifier, target_age); 392 | if (record) return Promise.resolve(record.value); 393 | 394 | // Lookup the cached value for the provided arguments 395 | const cached = this._get_from_cache(identifier); 396 | if (cached) { 397 | // Check if the cached value is stale for the provided target_age 398 | const stale = Date.now() - target_age > cached.updated_at; 399 | if (stale) { 400 | // Trigger a fresh lookup for the provided arguments if one is not already in-flight 401 | const in_flight = this.promises.has(identifier); 402 | if (!in_flight) this._get_fresh_value(identifier, target_age, ...args); 403 | } 404 | 405 | // Resolve the stale cached value for the provided arguments while a fresh value is being fetched in the background 406 | return Promise.resolve(cached.value); 407 | } else { 408 | // Resolve a fresh value for the provided arguments as there is no cached value available 409 | return this._get_fresh_value(identifier, target_age, ...args); 410 | } 411 | } 412 | 413 | /** 414 | * Fetches and returns a fresh value for the provided set of arguments. 415 | * Note! This method will automatically cache the fresh value for future use for the provided set of arguments. 416 | * 417 | * @param {...(SerializableArgumentTypes|Array)} args 418 | * @returns {Promise} 419 | */ 420 | fresh(...args) { 421 | // Resolve the fresh value for the provided serialized arguments 422 | return this._get_fresh_value(args.join(this.#delimiter), undefined, ...args); 423 | } 424 | 425 | /** 426 | * Returns the cached value for the provided set of arguments if it exists. 427 | * @param {...(SerializableArgumentTypes|Array)} args 428 | * @returns {T=} 429 | */ 430 | get(...args) { 431 | // Return the cached value for the specified arguments 432 | return this.cache.get(args.join(this.#delimiter))?.value; 433 | } 434 | 435 | /** 436 | * Expires the cached value for the provided set of arguments. 437 | * 438 | * @param {...(SerializableArgumentTypes|Array)} args 439 | * @returns {boolean} Returns `true` if the cache value was expired, `false` otherwise. 440 | */ 441 | expire(...args) { 442 | // Remove the cached value record for the specified arguments 443 | return this.cache.delete(args.join(this.#delimiter)); 444 | } 445 | 446 | /** 447 | * Returns whether a fresh value is currently pending / being resolved for the provided set of arguments. 448 | * 449 | * @param {...(SerializableArgumentTypes|Array)} args 450 | * @returns {boolean} Returns `true` if there is an in-flight promise for the specified arguments, `false` otherwise. 451 | */ 452 | in_flight(...args) { 453 | // Return true if there is a promise for the specified arguments 454 | return this.promises.has(args.join(this.#delimiter)); 455 | } 456 | 457 | /** 458 | * Returns the timestamp in `milliseconds` since the UNIX epoch when the cached value for the provided set of arguments was last updated if it exists. 459 | * 460 | * @param {...(SerializableArgumentTypes|Array)} args 461 | * @returns {number=} 462 | */ 463 | updated_at(...args) { 464 | // Return the updated_at timestamp for the specified arguments 465 | return this.cache.get(args.join(this.#delimiter))?.updated_at; 466 | } 467 | 468 | /** 469 | * Clears the lookup instance by removing all cached values from the cache. 470 | */ 471 | clear() { 472 | this.cache.clear(); 473 | } 474 | } 475 | 476 | module.exports = CachedLookup; 477 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cached-lookup", 3 | "version": "5.4.0", 4 | "description": "A Simple Package To Cache And Save On Expensive Lookups & Operations.", 5 | "main": "index.js", 6 | "types": "./types/index.d.ts", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/kartikk221/cached-lookup.git" 13 | }, 14 | "keywords": [ 15 | "simple", 16 | "api", 17 | "lookup", 18 | "cache", 19 | "memory", 20 | "intermittent", 21 | "efficient", 22 | "async", 23 | "fast" 24 | ], 25 | "author": "kartikk221", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/kartikk221/cached-lookup/issues" 29 | }, 30 | "homepage": "https://github.com/kartikk221/cached-lookup#readme" 31 | } 32 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const CachedLookup = require('../index.js'); 3 | const { log, assert_log, async_wait, with_duration } = require('./operators.js'); 4 | 5 | // Track the last lookup arguments 6 | let lookup_delay = 50; 7 | let error_margin_ms = 50; 8 | let last_lookup_arguments; 9 | async function lookup_handler() { 10 | await async_wait(lookup_delay); 11 | last_lookup_arguments = Array.from(arguments); 12 | return Array.from(arguments).concat([crypto.randomUUID()]).join(''); 13 | } 14 | 15 | let auto_purge = true; 16 | async function test_instance() { 17 | const group = 'LOOKUP'; 18 | const candidate = 'CachedLookup'; 19 | const seralized = Array.from(arguments); 20 | log('LOOKUP', 'Testing With Arguments: ' + (seralized.length > 0 ? JSON.stringify(seralized) : 'None')); 21 | 22 | // Create a new CachedLookup instance 23 | const lookup = new CachedLookup( 24 | { 25 | auto_purge, 26 | }, 27 | lookup_handler 28 | ); 29 | 30 | // Flip the auto_purge flag 31 | auto_purge = !auto_purge; 32 | 33 | // Perform the first lookup 34 | let [cached_value_1, cached_duration_1] = await with_duration(lookup.cached(lookup_delay, ...arguments)); // This should be fresh 35 | let updated_at_1 = lookup.updated_at(...arguments); 36 | 37 | // Assert that the lookup arguments are the same as the last lookup arguments 38 | assert_log( 39 | group, 40 | candidate + ' - Lookup Handler Arguments Test', 41 | () => JSON.stringify(seralized) === JSON.stringify(last_lookup_arguments) 42 | ); 43 | 44 | // Wait for the old value to expire and perform the second lookup 45 | await async_wait(lookup_delay + 1); 46 | let [cached_value_2, cached_duration_2] = await with_duration(lookup.cached(lookup_delay, ...arguments)); // This should be fresh 47 | let updated_at_2 = lookup.updated_at(...arguments); 48 | 49 | // Perform the third lookup instantly 50 | let [cached_value_3, cached_duration_3] = await with_duration(lookup.cached(lookup_delay, ...arguments)); // This should be cached 51 | let updated_at_3 = lookup.updated_at(...arguments); 52 | 53 | // Wait for the old value to expire and perform the fourth lookup 54 | await async_wait(lookup_delay + 1); 55 | let [cached_value_4, cached_duration_4] = await with_duration(lookup.cached(lookup_delay, ...arguments)); // This should be fresh 56 | 57 | // Perform the fifth lookup instantly 58 | let [cached_value_5, cached_duration_5] = await with_duration(lookup.cached(lookup_delay, ...arguments)); // This should be cached 59 | 60 | // Perform the first fresh lookup 61 | let [fresh_value_1, fresh_duration_1] = await with_duration(lookup.fresh(...arguments)); // This should be fresh 62 | 63 | // Perform the six and seventh cached lookup after expiring any existing cached value 64 | let expire_attempt_1 = lookup.expire(...arguments); // Should be true 65 | let expire_attempt_2 = lookup.expire(...arguments); // Should be false 66 | let [cached_value_6, cached_duration_6] = await with_duration(lookup.cached(lookup_delay, ...arguments)); // This should be fresh 67 | let [cached_value_7, cached_duration_7] = await with_duration(lookup.cached(lookup_delay, ...arguments)); // This should be cached 68 | 69 | // Assert that the CachedLookup.cached() method returned the correct values 70 | assert_log( 71 | group, 72 | candidate + '.cached() - Lookup Values Test', 73 | () => 74 | cached_value_1 !== cached_value_2 && 75 | cached_value_2 === cached_value_3 && 76 | cached_value_3 !== cached_value_4 && 77 | cached_value_4 == cached_value_5 78 | ); 79 | 80 | // Assert that the CachedLookup.cached() lookup times were correct and in the correct order 81 | assert_log(group, candidate + '.cached() - Lookup Timings Test', () => { 82 | const times = [cached_duration_1, cached_duration_2, cached_duration_3, cached_duration_4, cached_duration_5]; 83 | const sum = times.reduce((acc, curr) => acc + curr, 0); 84 | 85 | // The total duration of all lookups should be less than the time of 4 lookups 86 | // We do 2 fresh lookups with 2 delayed awaits of the same lookup delay + 1ms 87 | return sum <= (lookup_delay + 1) * 4; 88 | }); 89 | 90 | // Assert that the CachedLookup.fresh() method returned the valid value and in valid time 91 | assert_log( 92 | group, 93 | candidate + '.fresh() - Lookup Values & Timings Test', 94 | () => fresh_value_1 !== cached_value_5 && fresh_duration_1 >= lookup_delay 95 | ); 96 | 97 | // Assert that the CachedLookup.expire() method worked correctly 98 | assert_log( 99 | group, 100 | candidate + '.expire() - Lookup Values & Timings Test', 101 | () => 102 | cached_value_5 !== cached_value_6 && 103 | cached_value_6 === cached_value_7 && 104 | cached_duration_6 >= lookup_delay && 105 | cached_duration_7 < lookup_delay && 106 | expire_attempt_1 && 107 | !expire_attempt_2 108 | ); 109 | 110 | // Assert that the CachedLookup cache values are expired 111 | await async_wait( 112 | lookup_delay * (lookup.options.auto_purge ? lookup.options.purge_age_factor : 1) + error_margin_ms 113 | ); 114 | const args = Array.from(arguments); 115 | if (lookup.options.auto_purge) { 116 | assert_log( 117 | group, 118 | candidate + '.cached() - Cache Expiration/Cleanup Test', 119 | () => 120 | lookup.cache.size === 0 && lookup.get(...args) === undefined && lookup.get(Math.random()) === undefined 121 | ); 122 | } else { 123 | assert_log( 124 | group, 125 | candidate + '.cached() - Cache Retention Test', 126 | () => 127 | lookup.cache.size === 1 && lookup.get(...args) !== undefined && lookup.get(Math.random()) === undefined 128 | ); 129 | } 130 | 131 | // Determine if the CachedLookup instance is in flight by performing the second fresh lookup 132 | let in_flight_1 = lookup.in_flight(...arguments); 133 | let fresh_lookup_2_promise = with_duration(lookup.fresh(...arguments)); 134 | let in_flight_2 = lookup.in_flight(...arguments); 135 | let in_flight_3 = lookup.in_flight(Math.random()); // This should be false 136 | await fresh_lookup_2_promise; 137 | 138 | // Assert that the CachedLookup.in_flight() method returned the correct values 139 | assert_log( 140 | group, 141 | candidate + '.in_flight() - Lookup States Test', 142 | () => !in_flight_1 && in_flight_2 && !in_flight_3 143 | ); 144 | 145 | // Assert that the CachedLookup.updated_at() method returned the correct values 146 | assert_log( 147 | group, 148 | candidate + '.updated_at() - Cache Timestamps Test', 149 | () => updated_at_1 < updated_at_2 && updated_at_2 === updated_at_3 150 | ); 151 | 152 | // Perform a rolling lookup test 153 | lookup.cache.clear(); 154 | let rolling_start = Date.now(); 155 | let rolling_lookup_1 = await lookup.rolling(lookup_delay, ...arguments); 156 | let rolling_finish_1 = Date.now() - rolling_start; 157 | 158 | rolling_start = Date.now(); 159 | let rolling_lookup_2 = await lookup.rolling(lookup_delay, ...arguments); 160 | let rolling_finish_2 = Date.now() - rolling_start; 161 | 162 | await async_wait(lookup_delay + 1); 163 | rolling_start = Date.now(); 164 | let rolling_lookup_3 = await lookup.rolling(lookup_delay, ...arguments); 165 | let rolling_finish_3 = Date.now() - rolling_start; 166 | 167 | await async_wait(lookup_delay + 1); 168 | rolling_start = Date.now(); 169 | let rolling_lookup_4 = await lookup.rolling(lookup_delay, ...arguments); 170 | let rolling_finish_4 = Date.now() - rolling_start; 171 | 172 | // Assert that the CachedLookup.rolling() method returned the correct values 173 | assert_log( 174 | group, 175 | candidate + '.rolling() - Lookup Values Test', 176 | () => 177 | // Ensure all the rolling lookup resolve times are constant and lower than the lookup delay 178 | rolling_finish_1 > lookup_delay && 179 | rolling_finish_2 < lookup_delay && 180 | rolling_finish_3 < lookup_delay && 181 | rolling_finish_4 < lookup_delay && 182 | // Ensure the values 1, 2, and 3 are same while 4 is different because value 3 should trigger the rolling lookup but resolve by the time 4 is called 183 | rolling_lookup_1 === rolling_lookup_2 && 184 | rolling_lookup_2 === rolling_lookup_3 && 185 | rolling_lookup_3 !== rolling_lookup_4 186 | ); 187 | 188 | // Perform a clear test by setting random value and clearing 189 | await lookup.cached(lookup_delay, ...arguments); 190 | lookup.clear(); 191 | assert_log( 192 | group, 193 | candidate + '.clear() - Cache Clear Test', 194 | () => lookup.cache.size === 0 && lookup.get(...args) === undefined 195 | ); 196 | 197 | // Perform a test on the internal cache cleanup scheduler if auto_purge is enabled 198 | if (lookup.options.auto_purge) { 199 | await Promise.all([ 200 | lookup.cached(lookup_delay * 3, Math.random()), 201 | lookup.cached(lookup_delay * 2, Math.random()), 202 | lookup.cached(lookup_delay, Math.random()), 203 | ]); 204 | assert_log(group, candidate + ' Cache Cleanup Scheduler Populated Test', () => lookup.cache.size === 3); 205 | 206 | // Wait for the cache cleanup scheduler to run and clear the cache 207 | await async_wait( 208 | lookup_delay * (lookup.options.auto_purge ? lookup.options.purge_age_factor : 1) * 4 + error_margin_ms 209 | ); 210 | assert_log(group, candidate + ' Cache Cleanup Scheduler Cleared Test', () => lookup.cache.size === 0); 211 | } 212 | 213 | log('LOOKUP', 'Finished Testing CachedLookup'); 214 | console.log('\n'); 215 | } 216 | 217 | // Run tests with different argument sets 218 | (async () => { 219 | // Run a test with no arguments 220 | await test_instance(); 221 | 222 | // Run a test with 2 random arguments of supported types 223 | await test_instance( 224 | Math.random() > 0.5, // Boolean, 225 | Math.random() // Number 226 | ); 227 | 228 | // Run a test with 4 random arguments of supported types 229 | await test_instance( 230 | Math.random() > 0.5, // Boolean, 231 | Math.random(), // Number 232 | crypto.randomUUID(), // String 233 | [ 234 | // Array of supported argument types 235 | Math.random() > 0.5, // Boolean, 236 | Math.random(), // Number 237 | crypto.randomUUID(), // String 238 | ] 239 | ); 240 | 241 | log('TESTING', 'Successfully Tested All Specified Tests For CachedLookup!'); 242 | process.exit(); 243 | })(); 244 | -------------------------------------------------------------------------------- /tests/operators.js: -------------------------------------------------------------------------------- 1 | function log(logger = 'SYSTEM', message) { 2 | let dt = new Date(); 3 | let timeStamp = dt.toLocaleString([], { hour12: true, timeZone: 'America/New_York' }).replace(', ', ' ').split(' '); 4 | timeStamp[1] += ':' + dt.getMilliseconds().toString().padStart(3, '0') + 'ms'; 5 | timeStamp = timeStamp.join(' '); 6 | console.log(`[${timeStamp}][${logger}] ${message}`); 7 | } 8 | 9 | function async_wait(ms) { 10 | return new Promise((resolve) => setTimeout(resolve, ms)); 11 | } 12 | 13 | function assert_log(group, target, assertion) { 14 | try { 15 | let result = assertion(); 16 | if (result) { 17 | log(group, 'Verified ' + target); 18 | } else { 19 | throw new Error('Failed To Verify ' + target + ' @ ' + group + ' -> ' + assertion.toString()); 20 | } 21 | } catch (error) { 22 | console.log(error); 23 | throw new Error('Failed To Verify ' + target + ' @ ' + group + ' -> ' + assertion.toString()); 24 | } 25 | } 26 | 27 | async function with_duration(promise) { 28 | const start = Date.now(); 29 | const result = await promise; 30 | const end = Date.now(); 31 | return [result, end - start]; 32 | } 33 | 34 | module.exports = { 35 | log, 36 | async_wait, 37 | assert_log, 38 | with_duration, 39 | }; 40 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | 3 | type ArgsType = T extends (...args: infer U) => any ? U : never; 4 | type ResolvedType = T extends PromiseLike ? U : T; 5 | 6 | type LookupHandler any> = T; 7 | 8 | interface CachedLookupEvents> { 9 | purge: [T, ...U]; 10 | fresh: [T, ...U]; 11 | } 12 | 13 | interface ConstructorOptions { 14 | auto_purge?: boolean; 15 | purge_age_factor?: number; 16 | } 17 | 18 | interface ValueRecord { 19 | value: T; 20 | max_age?: number; 21 | updated_at: number; 22 | } 23 | 24 | export default class CachedLookup any> extends EventEmitter { 25 | lookup: LookupHandler; 26 | cache: Map>>>; 27 | promises: Map>>>; 28 | 29 | constructor(lookup: LookupHandler); 30 | constructor(options: ConstructorOptions, lookup: LookupHandler); 31 | 32 | // Override the default `EventEmitter` methods to provide type safety 33 | on>, ArgsType>>( 34 | event: K, 35 | listener: (...args: CachedLookupEvents>, ArgsType>[K]) => void 36 | ): this; 37 | once>, ArgsType>>( 38 | event: K, 39 | listener: (...args: CachedLookupEvents>, ArgsType>[K]) => void 40 | ): this; 41 | emit>, ArgsType>>( 42 | event: K, 43 | ...args: CachedLookupEvents>, ArgsType>[K] 44 | ): boolean; 45 | 46 | /** 47 | * Returns a `cached` value that is up to `max_age` milliseconds old from now. 48 | * Otherwise, It will fetch a fresh value and update the cache in the background. 49 | * Use this method over `rolling` if you want to guarantee that the cached value is at most `max_age` milliseconds old at the cost of increased latency whenever a `fresh` value is fetched on a cache miss. 50 | */ 51 | cached(max_age: number, ...args: ArgsType): Promise>>; 52 | 53 | /** 54 | * Returns the most up to date `cached` value even if stale if one is available and automatically fetches a fresh value to ensure the cache is as up to date as possible to the `max_age` provided in milliseconds. 55 | * Use this method over `cached` if you want lower latency at the cost of a temporarily stale cached value while a `fresh` value is being fetched in the background. 56 | */ 57 | rolling(max_age: number, ...args: ArgsType): Promise>>; 58 | 59 | /** 60 | * Fetches and returns a fresh value for the provided set of arguments. 61 | * Note! This method will automatically cache the fresh value for future use for the provided set of arguments. 62 | */ 63 | fresh(...args: ArgsType): Promise>>; 64 | 65 | /** 66 | * Returns the cached value for the provided set of arguments if it exists. 67 | */ 68 | get(...args: ArgsType): ResolvedType> | undefined; 69 | 70 | /** 71 | * Expires the cached value for the provided set of arguments. 72 | * @returns {boolean} Returns `true` if the cache value was expired, `false` otherwise. 73 | */ 74 | expire(...args: ArgsType): boolean; 75 | 76 | /** 77 | * Returns whether a fresh value is currently pending / being resolved for the provided set of arguments. 78 | * @returns {boolean} Returns `true` if there is an in-flight promise for the specified arguments, `false` otherwise. 79 | */ 80 | in_flight(...args: ArgsType): boolean; 81 | 82 | /** 83 | * Returns the timestamp in `milliseconds` since the UNIX epoch when the cached value for the provided set of arguments was last updated if it exists. 84 | */ 85 | updated_at(...args: ArgsType): number | undefined; 86 | 87 | /** 88 | * Clears the lookup instance by removing all cached values from the cache. 89 | */ 90 | clear(): void; 91 | } 92 | --------------------------------------------------------------------------------