Line data Source code
1 : /// The eviction policy of the query result cache.
2 : ///
3 : /// The cache of the app stores query results as Parquet files in the folder `cache/` of the origin private file
4 : /// system (see `opfs_query_result_cache.ts`). Listing and deleting the files live behind `QueryResultCacheStore`, the
5 : /// policy below is shared and unit-testable.
6 : ///
7 : /// Eviction is a true LRU keyed on `lastAccessMs`. The origin private file system exposes no last-access time, and
8 : /// re-touching a file on a hit would rewrite a potentially large file and clobber its write time. So each file has an
9 : /// empty sibling marker (`<file>.last_access`) whose own mtime is bumped on every hit; the store reports that marker's
10 : /// mtime as `lastAccessMs`. The marker is advisory, a file without a marker falls back to its write time.
11 : ///
12 : /// The cache is best-effort, not transactional: under concurrent writes the size/count totals are approximate, a
13 : /// delete of an already-gone file is tolerated, and a file that is read is kept (see `deleteCacheFile`).
14 :
15 : /// Size/recency metadata for a single cached file.
16 : export interface CacheFileStat {
17 : /// The file name, unique within the cache folder.
18 : name: string;
19 : /// The file size in bytes.
20 : size: number;
21 : /// The last-modified (write) time in milliseconds since the epoch. Doubles as the "cached at"
22 : /// timestamp surfaced in the UI (a cache hit never re-touches the payload).
23 : mtimeMs: number;
24 : /// The last-*access* time in milliseconds since the epoch: the mtime of the entry's
25 : /// `<file>.last_access` marker, bumped on every cache hit. Eviction drops the
26 : /// least-recently-accessed entries first. When no marker exists the backend falls back to
27 : /// `mtimeMs` here, so an un-accessed entry evicts by its write time.
28 : lastAccessMs: number;
29 : }
30 :
31 : /// The primitives of the cache folder that the eviction policy operates over.
32 : export interface QueryResultCacheStore {
33 : /// List every file of the cache folder with its size and times. Returns an empty array when the folder does not
34 : /// exist yet.
35 : listCacheFiles(): Promise<CacheFileStat[]>;
36 : /// Delete a single cache file by name. Must tolerate a missing file (treat NotFound as success). Resolves false if
37 : /// the file is kept because it is read.
38 : deleteCacheFile(name: string): Promise<boolean | void>;
39 : }
40 :
41 : /// Default cap on the total size of the query result cache of the app.
42 1 : export const DEFAULT_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
43 : /// Default cap on the number of cached query results of the app.
44 1 : export const DEFAULT_CACHE_MAX_FILES = 1000;
45 :
46 : /// Evict least-recently-accessed cache files until the cache is within both the size and count thresholds, e.g.
47 : /// after a write.
48 : ///
49 : /// Age is last-access time (`lastAccessMs`, the marker's mtime — see the module header): the
50 : /// least-recently-*used* entries are dropped first, so this is a true LRU. Files are deleted
51 : /// oldest-access-first until `totalBytes <= maxBytes` and `count <= maxFiles`. A file that is read stays, the next
52 : /// one goes instead, so a single file larger than `maxBytes` empties the rest of the cache while it is read.
53 1 : export async function evictToFit(
54 1 : store: QueryResultCacheStore,
55 1 : maxBytes: number = DEFAULT_CACHE_MAX_BYTES,
56 1 : maxFiles: number = DEFAULT_CACHE_MAX_FILES,
57 1 : ): Promise<void> {
58 1 : const files = await store.listCacheFiles();
59 1 : let totalBytes = files.reduce((total, f) => total + f.size, 0);
60 1 : let count = files.length;
61 1 : const fits = () => totalBytes <= maxBytes && count <= maxFiles;
62 1 : if (fits()) return;
63 :
64 : // Least-recently-accessed first (ascending lastAccessMs) so we drop the coldest entries.
65 1 : const byAge = [...files].sort((a, b) => a.lastAccessMs - b.lastAccessMs);
66 1 : for (const f of byAge) {
67 1 : if (fits()) break;
68 : // A file that is read stays, the next one goes instead
69 1 : if (await store.deleteCacheFile(f.name) === false) continue;
70 1 : totalBytes -= f.size;
71 1 : count -= 1;
72 1 : }
73 1 : }
|