Skip to content

Commit

Permalink
Re-implemented cache for better performance, fixed argument serializa…
Browse files Browse the repository at this point in the history
…tion to prevent collisons, added .get() method
  • Loading branch information
kartikk221 committed Jul 24, 2023
1 parent 46ce426 commit c3944d5
Show file tree
Hide file tree
Showing 5 changed files with 138 additions and 66 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
96 changes: 90 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/

/**
Expand Down Expand Up @@ -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.
*/

/**
Expand Down Expand Up @@ -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 : {}),
});
}
Expand All @@ -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;
}

Expand All @@ -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
}

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
12 changes: 4 additions & 8 deletions tests/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

Expand Down Expand Up @@ -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');
Expand Down
86 changes: 38 additions & 48 deletions types/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
type LookupHandler<T extends unknown> = () => T | Promise<T>;
type LookupHandler<T, Args extends any[]> = (...args: Args) => T | Promise<T>;
type SupportedTypes = string | number | boolean;
type Argument = SupportedTypes | SupportedTypes[];

interface ValueRecord<T = unknown> {
value: T;
Expand All @@ -11,94 +12,83 @@ interface ConstructorOptions {
purge_age_factor?: number;
}

export default class CachedLookup<T extends unknown> {
/**
* 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<T, Args extends Argument[] = Argument[]> {
/**
* The lookup function that is used to resolve fresh values for the provided arguments.
* @type {function(...(SupportedArgumentTypes|Array<SupportedArgumentTypes>)):T|Promise<T>}
* @type {LookupHandler<T, Args>} lookup The lookup function used to resolve fresh values
*/
lookup: LookupHandler<T>;
lookup: LookupHandler<T, Args>;

/**
* Stores the cached values identified by the serialized arguments from lookup calls.
* @type {Map<string, ValueRecord<T>>}
* @type {Map<string, ValueRecord<T>>} cache Map storing the cached values
*/
cache: Map<string, ValueRecord<T>>;

/**
* Stores the in-flight promises for any pending lookup calls identified by the serialized arguments.
* @type {Map<string, Promise<T>>}
* @type {Map<string, Promise<T>>} promises Map storing the in-flight promises for any pending lookup calls
*/
promises: Map<string, Promise<T>>;

/**
* 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<T, Args>} lookup The lookup function
*/
constructor(lookup: LookupHandler<T>);
constructor(lookup: LookupHandler<T, Args>);

/**
* 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<T, Args>} lookup The lookup function
*/
constructor(options: ConstructorOptions, lookup: LookupHandler<T>);
constructor(options: ConstructorOptions, lookup: LookupHandler<T, Args>);

/**
* 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<SupportedTypes>} 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<T>}
*/
cached(max_age: number, ...args: SupportedTypes[]): Promise<T>;
cached(max_age: number, ...args: Args): Promise<T>;

/**
* 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<SupportedTypes>} 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<T>}
*/
rolling(max_age: number, ...args: SupportedTypes[]): Promise<T>;
rolling(max_age: number, ...args: Args): Promise<T>;

/**
* Returns a fresh value for the provided arguments.
* Note! This method will automatically update the internal cache with the fresh value.
*
* @param {Array<SupportedTypes>} args
* @param {...Args} args The arguments for the lookup function
* @returns {Promise<T>}
*/
fresh(...args: SupportedTypes[]): Promise<T>;
fresh(...args: Args): Promise<T>;

/**
* Expires the cached value for the provided set of arguments.
*
* @param {Array<SupportedTypes>} 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<SupportedTypes>} 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<SupportedTypes>} 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.
Expand Down

0 comments on commit c3944d5

Please sign in to comment.