fifo-ttl-cache
Version:
An efficient in-memory FIFO cache with a fixed TTL for Node.js projects, designed to automatically remove outdated records. Unlike widely used LRU caches, FIFO caches do not prioritize retaining popular keys for extended durations. This simplified approac
241 lines (224 loc) • 8.55 kB
text/typescript
/**
* Copyright 2024 Ori Cohen https://github.com/ori88c
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
interface ExpiringEntry<V> {
value: V;
expirationTimestamp: number;
}
/**
* FIFOCache
*
* The `FIFOCache` class provides a First-In-First-Out (FIFO) caching mechanism
* with a fixed Time-To-Live (TTL), ensuring automatic removal of outdated entries.
*
* ## Eviction Policy
* Cache entries are evicted under the following conditions:
* - The cache reaches its maximum capacity:
* The oldest key (based on insertion time) is removed.
* - A key's value becomes outdated (its TTL has expired).
*
* ## Event-Driven Eviction
* The eviction policy of this class is event-driven, avoiding periodic callbacks for
* removing outdated entries. Instead, outdated keys are removed under the following circumstances:
* - During a `set` operation: If adding a new entry exhausts the maximum allowed capacity, the
* oldest key (based on insertion time) is evicted.
* - During a `get` operation: Outdated keys are validated and removed to prevent returning stale
* cached values.
*
* ## Use Cases
* Unlike the widely used LRU Cache, a FIFO Cache does not prioritize keeping popular
* keys cached for extended durations. This simplicity reduces implementation overhead
* and generally offers faster response times.
* FIFO caches are particularly suitable when **freshness** (up-to-date values)
* is critical, such as in security-sensitive scenarios, or when key popularity
* is uniform and predictable.
*
* ## Relying on JavaScript's Map Guarantees
* JavaScript's `Map` maintains the insertion order of keys, offering a reliable and
* often overlooked guarantee for iteration. This guarantee is leveraged in `FIFOCache`
* to eliminate the need for manually managing insertion order for eviction purposes.
* For details, refer to the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map):
* "The Map object holds key-value pairs and remembers the original insertion order of the keys".
*/
export class FIFOCache<K,V> {
private readonly _cache = new Map<K,ExpiringEntry<V>>();
/**
* Constructor.
*
* @param _capacity The maximum number of key-value pairs allowed in the cache.
* @param _ttlMs The maximum duration (in milliseconds) after which an inserted
* key is considered outdated and removed from the cache.
*/
constructor(
private readonly _capacity: number,
private readonly _ttlMs: number
) {
if (!isNaturalNumber(this._capacity)) {
throw new Error(
`Failed to instantiate FIFOCache: _capacity must ` +
`be a natural number, but received ${this._capacity}`
);
}
if (!isNaturalNumber(this._ttlMs)) {
throw new Error(
`Failed to instantiate FIFOCache: _ttlMs must ` +
`be a natural number, but received ${this._ttlMs}`
);
}
}
/**
* size
*
* @returns The number of items currently stored in this instance.
*/
public get size(): number {
return this._cache.size;
}
/**
* isEmpty
*
* @returns True if and only if the cache does not contain any entry.
*/
public get isEmpty(): boolean {
return this._cache.size === 0;
}
/**
* set
*
* Adds or updates a key-value pair in the cache.
*
* ## Potential Side Effects
* - If the specified key already exists in the cache, its value will be updated
* with the provided value.
* - If the cache is full and the given key does not already exist, the oldest
* key-value pair will be evicted to make room for the new entry.
*
* @param key The unique identifier for the cached entry.
* @param value The value to associate with the specified key.
*/
public set(key: K, value: V): void {
const expirationTimestamp = Date.now() + this._ttlMs;
this._cache.delete(key);
this._cache.set(key, { value, expirationTimestamp });
if (this._cache.size > this._capacity) {
// The following guarantee is provided by the JavaScript standard and validated
// through this component's unit tests:
// "The Map object holds key-value pairs and remembers the original insertion order of the keys".
const oldestKey = this._cache.keys().next().value;
this._cache.delete(oldestKey);
}
}
/**
* get
*
* Retrieves the value associated with the specified key, provided the key
* exists in the cache and its TTL has not expired.
*
* @param key The unique identifier for the cached entry.
* @returns The value associated with the key if it exists and is valid;
* otherwise, `undefined`.
*
* @remarks
* This method ensures that expired entries are treated as non-existent,
* helping to maintain cache integrity by verifying both the presence and
* validity of the entry before returning the result.
*/
public get(key: K): V | undefined {
return this._getExpiringEntry(key)?.value;
}
/**
* has
*
* Determines whether the cache contains a valid, non-expired entry for the
* specified key.
*
* ## Use Cases
* This method is particularly useful when the cache is employed as a Set-like
* structure, where the presence of a key is significant but the associated
* value is secondary or unnecessary.
*
* ### Example
* In an authentication system, this method can be used to determine whether
* a user's session token is still active without needing to retrieve the
* token's associated metadata or details.
*
* @param key - The unique identifier for the cached entry.
* @returns `true` if the cache contains a non-expired entry for the key;
* otherwise, `false`.
*
* @remarks
* This method ensures that expired entries are treated as non-existent,
* helping to maintain cache integrity by verifying both the presence and
* validity of the entry before returning the result.
*/
public has(key: K): boolean {
return !!this._getExpiringEntry(key);
}
/**
* delete
*
* Removes the cached entry associated with the specified key, if such a entry
* exists in the cache.
*
* ## Return Value Clarification
* Due to the event-driven eviction mechanism (see class documentation for details),
* the `delete` method may return `true` for an outdated key that remains in the cache.
* This occurs because a key's expiration is validated only during `get`, `has` or `set`
* operations, not when calling `delete`.
*
* @param key The unique identifier for the cached entry.
* @returns `true` if the key existed in the cache (whether up-to-date or outdated);
* `false` otherwise.
*/
public delete(key: K): boolean {
return this._cache.delete(key);
}
/**
* clear
*
* Removes all entries from the cache, leaving it empty.
*/
public clear(): void {
this._cache.clear();
}
/**
* Retrieves an expiring entry from the cache if it is still valid (not expired).
* If the entry's TTL has expired, it deletes the entry from the cache and returns `undefined`.
*
* @param key - The unique identifier for the cached entry.
* @returns The corresponding `ExpiringEntry<V>` if its TTL has not expired; otherwise, `undefined`.
*
* @remarks
* This method ensures that expired entries are proactively removed from the cache
* during access attempts, maintaining cache integrity and optimizing memory usage.
*/
private _getExpiringEntry(key: K): ExpiringEntry<V> | undefined {
const entry = this._cache.get(key);
if (!entry) {
return undefined;
}
const { expirationTimestamp } = entry;
const isOutdated = Date.now() >= expirationTimestamp;
if (isOutdated) {
this._cache.delete(key);
return undefined;
}
return entry;
}
}
function isNaturalNumber(num: number): boolean {
const floored = Math.floor(num);
return floored >= 1 && floored === num;
}