├── .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 | [](https://www.npmjs.com/package/cached-lookup)
6 | [](https://www.npmjs.com/package/cached-lookup)
7 | [](https://github.com/kartikk221/cached-lookup/issues)
8 | [](https://github.com/kartikk221/cached-lookup/stargazers)
9 | [](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 |
--------------------------------------------------------------------------------