~james/core

core/web/src/lib/api/lookupCache.ts -rw-r--r-- 2.9 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
import { isDid } from "@atcute/lexicons/syntax";

type Entry = { value: Promise<unknown>; expires: number; tags: Set<string> };

export interface LookupStats {
	hits: number;
	misses: number;
	evictions: number;
	clears: number;
}

const MAX_ENTRIES = 10_000;

export const didTag = (did: string) => `did:${did}`;
export const handleTag = (handle: string) => `handle:${handle.toLowerCase()}`;
export const identifierTag = (identifier: string) =>
	isDid(identifier) ? didTag(identifier) : handleTag(identifier);

// shared by every request on the server. does nothing until configure() sets a ttl
export class LookupCache {
	#ttlMs: number;
	readonly #entries = new Map<string, Entry>();
	readonly #tagged = new Map<string, Set<string>>();
	stats: LookupStats = { hits: 0, misses: 0, evictions: 0, clears: 0 };

	constructor(ttlMs = 0) {
		this.#ttlMs = ttlMs;
	}

	get size(): number {
		return this.#entries.size;
	}

	configure(ttlMs: number): void {
		this.#ttlMs = ttlMs;
		this.clear();
	}

	// tagsOf adds more tags once the value has loaded. undefined drops the entry
	lookup<T>(
		key: string,
		tags: readonly string[],
		load: () => Promise<T>,
		tagsOf: (value: T) => readonly string[] | undefined
	): Promise<T> {
		if (this.#ttlMs <= 0) return load();
		const now = Date.now();
		const hit = this.#entries.get(key);
		if (hit && hit.expires > now) {
			this.stats.hits++;
			return hit.value as Promise<T>;
		}
		if (hit) this.#drop(key);
		this.stats.misses++;

		const value = load();
		const entry: Entry = { value, expires: now + this.#ttlMs, tags: new Set() };
		this.#entries.set(key, entry);
		for (const tag of tags) this.#tag(key, entry, tag);
		if (this.#entries.size > MAX_ENTRIES) {
			const oldest = this.#entries.keys().next().value;
			if (oldest !== undefined) this.#drop(oldest);
		}

		value.then(
			(loaded) => {
				if (this.#entries.get(key) !== entry) return;
				const more = tagsOf(loaded);
				if (more === undefined) this.#drop(key);
				else for (const tag of more) this.#tag(key, entry, tag);
			},
			() => {
				if (this.#entries.get(key) === entry) this.#drop(key);
			}
		);
		return value;
	}

	evict(tags: readonly string[]): number {
		let evicted = 0;
		for (const tag of tags) {
			for (const key of [...(this.#tagged.get(tag) ?? [])]) {
				this.#drop(key);
				evicted++;
			}
		}
		this.stats.evictions += evicted;
		return evicted;
	}

	clear(): void {
		this.#entries.clear();
		this.#tagged.clear();
		this.stats.clears++;
	}

	#tag(key: string, entry: Entry, tag: string): void {
		entry.tags.add(tag);
		let keys = this.#tagged.get(tag);
		if (!keys) this.#tagged.set(tag, (keys = new Set()));
		keys.add(key);
	}

	#drop(key: string): void {
		const entry = this.#entries.get(key);
		if (!entry) return;
		this.#entries.delete(key);
		for (const tag of entry.tags) {
			const keys = this.#tagged.get(tag);
			keys?.delete(key);
			if (keys?.size === 0) this.#tagged.delete(tag);
		}
	}
}

export const lookups = new LookupCache();