/* Copyright 2024 New Vector Ltd. Copyright 2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import { logger } from "matrix-js-sdk/src/logger"; interface CacheItem { key: K; value: V; /** Next item in the list */ next: CacheItem | null; /** Previous item in the list */ prev: CacheItem | null; } /** * Least Recently Used cache. * Can be initialised with a capacity and drops the least recently used items. * This cache should be error robust: Cache miss on error. * * Implemented via a key lookup map and a double linked list: * head tail * a next → b next → c → next null * null ← prev a ← prev b ← prev c * * @template K - Type of the key used to look up the values inside the cache * @template V - Type of the values inside the cache */ export class LruCache { /** Head of the list. */ private head: CacheItem | null = null; /** Tail of the list */ private tail: CacheItem | null = null; /** Key lookup map */ private map: Map>; /** * @param capacity - Cache capcity. * @throws {Error} - Raises an error if the cache capacity is less than 1. */ public constructor(private capacity: number) { if (this.capacity < 1) { throw new Error("Cache capacity must be at least 1"); } this.map = new Map(); } /** * Whether the cache contains an item under this key. * Marks the item as most recently used. * * @param key - Key of the item * @returns true: item in cache, else false */ public has(key: K): boolean { try { return this.getItem(key) !== undefined; } catch (e) { // Should not happen but makes it more robust to the unknown. this.onError(e); return false; } } /** * Returns an item from the cache. * Marks the item as most recently used. * * @param key - Key of the item * @returns The value if found, else undefined */ public get(key: K): V | undefined { try { return this.getItem(key)?.value; } catch (e) { // Should not happen but makes it more robust to the unknown. this.onError(e); return undefined; } } /** * Adds an item to the cache. * A newly added item will be the set as the most recently used. * * @param key - Key of the item * @param value - Item value */ public set(key: K, value: V): void { try { this.safeSet(key, value); } catch (e) { // Should not happen but makes it more robust to the unknown. this.onError(e); } } /** * Deletes an item from the cache. * * @param key - Key of the item to be removed */ public delete(key: K): void { const item = this.map.get(key); // Unknown item. if (!item) return; try { this.removeItemFromList(item); this.map.delete(key); } catch (e) { // Should not happen but makes it more robust to the unknown. this.onError(e); } } /** * Clears the cache. */ public clear(): void { this.map = new Map(); this.head = null; this.tail = null; } /** * Returns an iterator over the cached values. */ public *values(): IterableIterator { for (const item of this.map.values()) { yield item.value; } } private safeSet(key: K, value: V): void { const item = this.getItem(key); if (item) { // The item is already stored under this key. Update the value. item.value = value; return; } const newItem: CacheItem = { key, value, next: null, prev: null, }; if (this.head) { // Put item in front of the list. this.head.prev = newItem; newItem.next = this.head; } this.setHeadTail(newItem); // Store item in lookup map. this.map.set(key, newItem); if (this.tail && this.map.size > this.capacity) { // Map size exceeded cache capcity. Drop tail item. this.delete(this.tail.key); } } private onError(e: unknown): void { logger.warn("LruCache error", e); this.clear(); } private getItem(key: K): CacheItem | undefined { const item = this.map.get(key); // Not in cache. if (!item) return undefined; // Item is already at the head of the list. // No update required. if (item === this.head) return item; this.removeItemFromList(item); // Put item to the front. if (this.head) { this.head.prev = item; } item.prev = null; item.next = this.head; this.setHeadTail(item); return item; } private setHeadTail(item: CacheItem): void { if (item.prev === null) { // Item has no previous item → head this.head = item; } if (item.next === null) { // Item has no next item → tail this.tail = item; } } private removeItemFromList(item: CacheItem): void { if (item === this.head) { this.head = item.next; } if (item === this.tail) { this.tail = item.prev; } if (item.prev) { item.prev.next = item.next; } if (item.next) { item.next.prev = item.prev; } } }