diff --git a/README.md b/README.md index 1032dfc..d736873 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ const CachedLookup = require('cached-lookup'); const ConcertsLookup = new CachedLookup(async (country, state, city) => { // Assume that the function get_city_concerts() is calling a Third-Party API which has a rate limit const concerts = await get_city_concerts(country, state, city); - + // Simply return the data and CachedLookup will handle the rest return concerts; }); @@ -51,7 +51,7 @@ webserver.get('/api/concerts/:country/:state/:city', async (request, response) = // Be sure to specify the first parameter as the max_age of the cached value in milliseconds // In our case, 10 seconds would be 10,000 milliseconds const concerts = await ConcertsLookup.cached(1000 * 10, country, state, city); - + // Simply return the data to the user // Because we retrieved this data from the ConcertsLookup with the cached() method // We can safely assume that we will only perform up to 1 Third-Party API request per city every 10 seconds @@ -96,7 +96,9 @@ Below is a breakdown of the `CachedLookup` class. * **Note** this method has the same signature as the `cached()` method above. * **Note** this method should be used over `cached()` if you want to maintain low latency at the sacrifice of guaranteed cache freshness. * `fresh(...arguments)`: Retrieves the `fresh` value for the provided set of arguments from the lookup handler. - * **Returns** a `Promise` which is resolved to the `fresh` value. + * **Returns** a `Promise` which is resolved to the `fresh` value. +* `get(...arguments)`: Returns the `cached` value for the provided set of arguments if one exists in cache. + * **Returns** the `cached` value or `undefined`. * `expire(...arguments)`: Expires the `cached` value for the provided set of arguments. * **Returns** a `Boolean` which specifies whether a `cached` value was expired or not. * `in_flight(...arguments)`: Checks whether a `fresh` value is currently being resolved for the provided set of arguments. diff --git a/index.js b/index.js index eeda74b..46c0e58 100644 --- a/index.js +++ b/index.js @@ -10,11 +10,16 @@ const EventEmitter = require('events'); */ class CachedLookup extends EventEmitter { #delimiter = ','; + #cleanup = { + timeout: null, + expected_at: null, + }; /** * @typedef {Object} CachedRecord * @property {T} value - * @property {Number} updated_at + * @property {number=} max_age + * @property {number} updated_at */ /** @@ -48,9 +53,10 @@ class CachedLookup extends EventEmitter { promises = new Map(); /** - * @typedef {Object} LookupOptions + * @typedef {Object} ConstructorOptions * @property {boolean} [auto_purge=true] - Whether to automatically purge cache values when they have aged past their last known maximum age. * @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. + * @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. */ /** @@ -84,6 +90,7 @@ class CachedLookup extends EventEmitter { this.options = Object.freeze({ auto_purge: true, // By default automatically purge cache values when they have aged past their last known maximum age purge_age_factor: 1.5, // By default purge values that are one and half times their maximum age + max_purge_eloop_tick: 5000, // By default purge 5000 items per event loop tick ...(typeof options === 'object' ? options : {}), }); } @@ -101,9 +108,16 @@ class CachedLookup extends EventEmitter { const record = this.cache.get(identifier); if (!record) return; + // Schedule a cache cleanup for this entry if a max_age was provided + if (max_age !== undefined) this._schedule_cache_cleanup(max_age); + // Ensure the value is not older than the specified maximum age if provided if (max_age !== undefined && Date.now() - max_age > record.updated_at) return; + // Update the record max_age if it is smaller than the provided max_age + if (max_age !== undefined && max_age < (record.max_age || Infinity)) record.max_age = max_age; + + // Return the cached value record return record; } @@ -112,23 +126,93 @@ class CachedLookup extends EventEmitter { * * @private * @param {string} identifier + * @param {number=} max_age * @param {T} value */ - _set_in_cache(identifier, value) { + _set_in_cache(identifier, max_age, value) { const now = Date.now(); // Retrieve the cached value record for this identifier from the cache const record = this.cache.get(identifier) || { value, + max_age, updated_at: now, }; - // Update the cached value record with the provided value and the current timestamp + // Update the record values record.value = value; record.updated_at = now; + record.max_age = max_age; // Store the updated cached value record in the cache this.cache.set(identifier, record); + + // Schedule a cache cleanup for this entry if a max_age was provided + if (max_age !== undefined) this._schedule_cache_cleanup(max_age); + } + + /** + * Schedules a cache cleanup to purge stale cache values if the provided `max_age` is earlier than the next expected cleanup. + * + * @param {number} max_age + * @returns {boolean} Whether a sooner cleanup was scheduled. + */ + _schedule_cache_cleanup(max_age) { + // Do not schedule anything if auto_purge is disabled + if (!this.options.auto_purge) return false; + + // Increase the max_age by the purge_age_factor to determine the true max_age of the cached value + max_age *= this.options.purge_age_factor; + + // Return false if the scheduled expected cleanup is sooner than the provided max_age as there is no need to expedite the cleanup + const now = Date.now(); + const { timeout, expected_at } = this.#cleanup; + if (timeout && expected_at && expected_at <= now + max_age) return false; + + // Clear the existing cleanup timeout if one exists + if (timeout) clearTimeout(timeout); + + // Create a new cleanup timeout to purge stale cache values + this.#cleanup.expected_at = now + max_age; + this.#cleanup.timeout = setTimeout(async () => { + // Clear the existing cleanup timeout + this.#cleanup.timeout = null; + this.#cleanup.expected_at = null; + + // Purge stale cache values + let count = 0; + let now = Date.now(); + let nearest_expiry_at = Number.MAX_SAFE_INTEGER; + for (const [identifier, { max_age, updated_at, value }] of this.cache) { + // Flush the event loop every max purge items per synchronous event loop tick + if (count % this.options.max_purge_eloop_tick === 0) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + count++; + + // Skip if the cached value does not have a max value to determine if it is stale + if (!max_age) continue; + + // Skip this cached value if it is not stale + const stale = now - max_age > updated_at; + if (!stale) { + // Update the nearest expiry timestamp if this cached value is closer than the previous one + const expiry_at = updated_at + max_age; + if (expiry_at < nearest_expiry_at) nearest_expiry_at = expiry_at; + + // Skip this cached value + continue; + } + + // Delete the stale cached value + this.cache.delete(identifier); + } + + // Schedule another cleanup if there are still more values remaining in the cache + if (this.cache.size && nearest_expiry_at < Number.MAX_SAFE_INTEGER) { + this._schedule_cache_cleanup(nearest_expiry_at - now); + } + }, 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 } /** @@ -161,7 +245,7 @@ class CachedLookup extends EventEmitter { // Check if a value was resolved from the lookup without any errors if (value) { // Cache the fresh value for this identifier - this._set_in_cache(identifier, value); + this._set_in_cache(identifier, max_age, value); // Emit a 'fresh' event with the fresh value and the provided arguments this.emit('fresh', value, ...args); @@ -233,7 +317,7 @@ class CachedLookup extends EventEmitter { const identifier = args.join(this.#delimiter); // Attempt to resolve the cached value from the cached value record - const record = this._get_from_cache(identifier, max_age); + const record = this._get_from_cache(identifier, target_age); if (record) return Promise.resolve(record.value); // Lookup the cached value for the provided arguments diff --git a/package.json b/package.json index a12ed16..0662018 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cached-lookup", - "version": "5.2.2", + "version": "5.3.0", "description": "A Simple Package To Cache And Save On Expensive Lookups & Operations.", "main": "index.js", "types": "./types/index.d.ts", diff --git a/tests/index.js b/tests/index.js index e8d865d..ba2468a 100644 --- a/tests/index.js +++ b/tests/index.js @@ -107,25 +107,21 @@ async function test_instance() { ); // Assert that the CachedLookup cache values are expired - await async_wait(lookup_delay); + await async_wait(lookup_delay * (lookup.options.auto_purge ? lookup.options.purge_age_factor : 1) * 2); const args = Array.from(arguments); if (lookup.options.auto_purge) { assert_log( group, candidate + '.cached() - Cache Expiration/Cleanup Test', () => - lookup.cache.size === 0 && - lookup.cache.get(args.join('')) === undefined && - lookup.cache.get(Math.random()) === undefined + lookup.cache.size === 0 && lookup.get(...args) === undefined && lookup.get(Math.random()) === undefined ); } else { assert_log( group, candidate + '.cached() - Cache Retention Test', () => - lookup.cache.size === 1 && - lookup.cache.get(args.join('')) !== undefined && - lookup.cache.get(Math.random()) === undefined + lookup.cache.size === 1 && lookup.get(...args) !== undefined && lookup.get(Math.random()) === undefined ); } @@ -192,7 +188,7 @@ async function test_instance() { assert_log( group, candidate + '.clear() - Cache Clear Test', - () => lookup.cache.size === 0 && lookup.cache.get(args.join('')) === undefined + () => lookup.cache.size === 0 && lookup.get(...args) === undefined ); log('LOOKUP', 'Finished Testing CachedLookup'); diff --git a/types/index.d.ts b/types/index.d.ts index 374b212..e91a1e1 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,5 +1,6 @@ -type LookupHandler = () => T | Promise; +type LookupHandler = (...args: Args) => T | Promise; type SupportedTypes = string | number | boolean; +type Argument = SupportedTypes | SupportedTypes[]; interface ValueRecord { value: T; @@ -11,94 +12,83 @@ interface ConstructorOptions { purge_age_factor?: number; } -export default class CachedLookup { +/** + * Class representing a Cached Lookup + * @template T The type of data stored in the cache + * @template Args The types of arguments for the lookup function + */ +export default class CachedLookup { /** - * The lookup function that is used to resolve fresh values for the provided arguments. - * @type {function(...(SupportedArgumentTypes|Array)):T|Promise} + * @type {LookupHandler} lookup The lookup function used to resolve fresh values */ - lookup: LookupHandler; + lookup: LookupHandler; /** - * Stores the cached values identified by the serialized arguments from lookup calls. - * @type {Map>} + * @type {Map>} cache Map storing the cached values */ cache: Map>; /** - * Stores the in-flight promises for any pending lookup calls identified by the serialized arguments. - * @type {Map>} + * @type {Map>} promises Map storing the in-flight promises for any pending lookup calls */ promises: Map>; /** - * Creates a new CachedLookup instance with the specified lookup function. - * The lookup function can be both synchronous or asynchronous. - * - * @param {LookupHandler} [lookup] - The lookup function if the first argument is the constructor options. + * Constructor for CachedLookup class + * @param {LookupHandler} lookup The lookup function */ - constructor(lookup: LookupHandler); + constructor(lookup: LookupHandler); /** - * Creates a new CachedLookup instance with the specified lookup function. - * The lookup function can be both synchronous or asynchronous. - * - * @param {ConstructorOptions} [options] - The constructor options. - * @param {LookupHandler} [lookup] - The lookup function if the first argument is the constructor options. + * Constructor for CachedLookup class + * @param {ConstructorOptions} options The constructor options + * @param {LookupHandler} lookup The lookup function */ - constructor(options: ConstructorOptions, lookup: LookupHandler); + constructor(options: ConstructorOptions, lookup: LookupHandler); /** - * Returns a `cached` value that is up to `max_age` milliseconds old when available and falls back to a fresh value if not. - * Use this method over `rolling` if you want to guarantee that the cached value is up to `max_age` milliseconds old at the sacrifice of increased latency whenever a `fresh` value is required. - * - * @param {Number} max_age In Milliseconds - * @param {Array} args + * Returns a cached value that is up to max_age milliseconds old when available and falls back to a fresh value if not. + * @param {number} max_age The maximum age of the cached data + * @param {...Args} args The arguments for the lookup function * @returns {Promise} */ - cached(max_age: number, ...args: SupportedTypes[]): Promise; + cached(max_age: number, ...args: Args): Promise; /** - * Returns a `cached` value that is around `max_age` milliseconds old when available and instantly resolves the most recently `cached` value while also updating the cache with a fresh value in the background. - * Use this method over `cached` if you want low latency at the sacrifice of a guaranteed age of the cached value. - * - * @param {Number} max_age In Milliseconds - * @param {Array} args + * Returns a cached value that is around max_age milliseconds old when available and instantly resolves the most recently cached value while also updating the cache with a fresh value in the background. + * @param {number} max_age The maximum age of the cached data + * @param {...Args} args The arguments for the lookup function * @returns {Promise} */ - rolling(max_age: number, ...args: SupportedTypes[]): Promise; + rolling(max_age: number, ...args: Args): Promise; /** * Returns a fresh value for the provided arguments. - * Note! This method will automatically update the internal cache with the fresh value. - * - * @param {Array} args + * @param {...Args} args The arguments for the lookup function * @returns {Promise} */ - fresh(...args: SupportedTypes[]): Promise; + fresh(...args: Args): Promise; /** * Expires the cached value for the provided set of arguments. - * - * @param {Array} args - * @returns {Boolean} True if the cache value was expired, false otherwise. + * @param {...Args} args The arguments for the lookup function + * @returns {boolean} True if the cache value was expired, false otherwise */ - expire(...args: SupportedTypes[]): boolean; + expire(...args: Args): boolean; /** * Returns whether a fresh value is currently being resolved for the provided set of arguments. - * - * @param {Array} args - * @returns {Boolean} + * @param {...Args} args The arguments for the lookup function + * @returns {boolean} */ - in_flight(...args: SupportedTypes[]): boolean; + in_flight(...args: Args): boolean; /** * Returns the last value update timestamp in milliseconds for the provided set of arguments. - * - * @param {Array} args - * @returns {Boolean} + * @param {...Args} args The arguments for the lookup function + * @returns {number | undefined} */ - updated_at(...args: SupportedTypes[]): number | void; + updated_at(...args: Args): number | undefined; /** * Clears all the cached values and resets the internal cache state.