Files
pezkuwi-api/packages/rpc-provider/src/lru.ts
T

198 lines
4.6 KiB
TypeScript

// Copyright 2017-2025 @pezkuwi/rpc-provider authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Assuming all 1.5MB responses, we apply a default allowing for 192MB
// cache space (depending on the historic queries this would vary, metadata
// for Zagros/Pezkuwi/Bizinikiwi falls between 600-750K, 2x for estimate)
export const DEFAULT_CAPACITY = 1024;
export const DEFAULT_TTL = 30000; // 30 seconds
const MAX_TTL = 1800_000; // 30 minutes
// If the user decides to disable the TTL we set the value
// to a very high number (A year = 365 * 24 * 60 * 60 * 1000).
const DISABLED_TTL = 31_536_000_000;
class LRUNode {
readonly key: string;
#expires: number;
#ttl: number;
readonly createdAt: number;
public next: LRUNode;
public prev: LRUNode;
constructor (key: string, ttl: number) {
this.key = key;
this.#ttl = ttl;
this.#expires = Date.now() + ttl;
this.createdAt = Date.now();
this.next = this.prev = this;
}
public refresh (): void {
this.#expires = Date.now() + this.#ttl;
}
public get expiry (): number {
return this.#expires;
}
}
// https://en.wikipedia.org/wiki/Cache_replacement_policies#LRU
export class LRUCache {
readonly capacity: number;
readonly #data = new Map<string, unknown>();
readonly #refs = new Map<string, LRUNode>();
#length = 0;
#head: LRUNode;
#tail: LRUNode;
readonly #ttl: number;
constructor (capacity = DEFAULT_CAPACITY, ttl: number | null = DEFAULT_TTL) {
// Validate capacity
if (!Number.isInteger(capacity) || capacity < 0) {
throw new Error(`LRUCache initialization error: 'capacity' must be a non-negative integer. Received: ${capacity}`);
}
// Validate ttl
if (ttl !== null && (!Number.isFinite(ttl) || ttl < 0 || ttl > MAX_TTL)) {
throw new Error(`LRUCache initialization error: 'ttl' must be between 0 and ${MAX_TTL} ms or null to disable. Received: ${ttl}`);
}
this.capacity = capacity;
ttl ? this.#ttl = ttl : this.#ttl = DISABLED_TTL;
this.#head = this.#tail = new LRUNode('<empty>', this.#ttl);
}
get ttl (): number | null {
return this.#ttl;
}
get length (): number {
return this.#length;
}
get lengthData (): number {
return this.#data.size;
}
get lengthRefs (): number {
return this.#refs.size;
}
entries (): [string, unknown][] {
const keys = this.keys();
const count = keys.length;
const entries = new Array<[string, unknown]>(count);
for (let i = 0; i < count; i++) {
const key = keys[i];
entries[i] = [key, this.#data.get(key)];
}
return entries;
}
keys (): string[] {
const keys: string[] = [];
if (this.#length) {
let curr = this.#head;
while (curr !== this.#tail) {
keys.push(curr.key);
curr = curr.next;
}
keys.push(curr.key);
}
return keys;
}
get <T> (key: string): T | null {
const data = this.#data.get(key);
if (data) {
this.#toHead(key);
// Evict TTL once data is refreshed
this.#evictTTL();
return data as T;
}
this.#evictTTL();
return null;
}
set <T> (key: string, value: T): void {
if (this.#data.has(key)) {
this.#toHead(key);
} else {
const node = new LRUNode(key, this.#ttl);
this.#refs.set(node.key, node);
if (this.length === 0) {
this.#head = this.#tail = node;
} else {
this.#head.prev = node;
node.next = this.#head;
this.#head = node;
}
if (this.#length === this.capacity) {
this.#data.delete(this.#tail.key);
this.#refs.delete(this.#tail.key);
this.#tail = this.#tail.prev;
this.#tail.next = this.#head;
} else {
this.#length += 1;
}
}
// Evict TTL once data is refreshed or added
this.#evictTTL();
this.#data.set(key, value);
}
#evictTTL () {
// Find last node to keep
// traverse map to find the expired nodes
while (this.#tail.expiry && this.#tail.expiry < Date.now() && this.#length > 0) {
this.#refs.delete(this.#tail.key);
this.#data.delete(this.#tail.key);
this.#length -= 1;
this.#tail = this.#tail.prev;
this.#tail.next = this.#head;
}
if (this.#length === 0) {
this.#head = this.#tail = new LRUNode('<empty>', this.#ttl);
}
}
#toHead (key: string): void {
const ref = this.#refs.get(key);
if (ref && ref !== this.#head) {
ref.refresh();
ref.prev.next = ref.next;
ref.next.prev = ref.prev;
ref.next = this.#head;
this.#head.prev = ref;
this.#head = ref;
}
}
}