LCOV - code coverage report
Current view: top level - dashql-app/src/storage - opfs_query_result_cache.ts Coverage Total Hit
Test: DashQL 915828b Lines: 93.3 % 194 181
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import type * as arrow from 'apache-arrow';
       2              : 
       3              : import type { QueryResultCache, QueryResultCacheEntry } from '../query/query_result_cache.js';
       4              : import { copyArrowTableAsParquet, type ParquetDatabase } from '../platform/hyperdb/hyperdb_parquet_export.js';
       5              : import { getOPFSRoot } from '../platform/opfs_root.js';
       6              : import { DEFAULT_CACHE_MAX_BYTES, DEFAULT_CACHE_MAX_FILES, evictToFit, type CacheFileStat, type QueryResultCacheStore } from './query_result_cache_eviction.js';
       7              : 
       8              : /// The folder of the query result cache in the origin private file system
       9            1 : export const QUERY_RESULT_CACHE_DIRECTORY = 'cache';
      10              : /// The mount path of the query result cache in the compute database
      11            1 : export const QUERY_RESULT_CACHE_MOUNT = '/mnt/external/cache';
      12              : /// The empty marker beside a file whose modification time is the last read of the file
      13            1 : const ACCESS_SUFFIX = '.last_access';
      14              : /// The file of a key, `<key>.<uuid>.parquet`
      15            1 : const KEY_FILE = /^([0-9a-f]{64})\.[0-9a-f-]{36}\.parquet$/;
      16              : /// The suffix of a transient file, a result that can't be reused or a file that is being written. A transient file
      17              : /// that nobody pins is garbage, every listing removes it.
      18            1 : const TRANSIENT_SUFFIX = '.transient.parquet';
      19              : /// The Web Lock of a file, readers hold it shared, eviction and deletion take it exclusively if nobody holds it
      20            1 : const LOCK_PREFIX = 'dashql-query-result-cache/';
      21              : 
      22              : /// The options of the query result cache
      23              : export interface OPFSQueryResultCacheOptions {
      24              :     /// The origin private file system, the root of the files of the app
      25              :     getRoot?: () => Promise<FileSystemDirectoryHandle>;
      26              :     /// The locks of the windows of the app
      27              :     locks?: LockManager;
      28              :     /// The maximum total size of the files
      29              :     maxBytes?: number;
      30              :     /// The maximum number of files
      31              :     maxFiles?: number;
      32              : }
      33              : 
      34              : /// The key of a file, null for transient files and files of other versions
      35            1 : export function cacheFileKey(name: string): string | null {
      36            1 :     return KEY_FILE.exec(name)?.[1] ?? null;
      37            1 : }
      38              : 
      39              : /// Is a file transient, i.e. removed once nobody pins it?
      40            1 : export function isTransientCacheFile(name: string): boolean {
      41            1 :     return name.endsWith(TRANSIENT_SUFFIX);
      42            1 : }
      43              : 
      44              : /// The query result cache of the app in the folder `cache/` of the origin private file system
      45            1 : export function createOPFSQueryResultCache(options: OPFSQueryResultCacheOptions = {}): QueryResultCache {
      46            1 :     const getRoot = options.getRoot ?? getOPFSRoot;
      47            1 :     const locks = options.locks ?? navigator.locks;
      48            1 :     const maxBytes = options.maxBytes ?? DEFAULT_CACHE_MAX_BYTES;
      49            1 :     const maxFiles = options.maxFiles ?? DEFAULT_CACHE_MAX_FILES;
      50            1 :     const directory = async () => await (await getRoot()).getDirectoryHandle(QUERY_RESULT_CACHE_DIRECTORY, { create: true });
      51              : 
      52              :     /// The Parquet files with their sizes and times. Transient files that nobody pins and markers without a file are
      53              :     /// removed.
      54            1 :     const listFiles = async (): Promise<CacheFileStat[]> => {
      55            1 :         const dir = await directory();
      56            1 :         const accessMs = new Map<string, number>();
      57            1 :         let files: { name: string; size: number; mtimeMs: number }[] = [];
      58            1 :         for await (const [name, handle] of dir.entries()) {
      59            1 :             if (handle.kind !== 'file') continue;
      60            1 :             try {
      61            1 :                 const file = await (handle as FileSystemFileHandle).getFile();
      62            1 :                 if (name.endsWith(ACCESS_SUFFIX)) accessMs.set(name.slice(0, -ACCESS_SUFFIX.length), file.lastModified);
      63            1 :                 else if (name.endsWith('.parquet')) files.push({ name, size: file.size, mtimeMs: file.lastModified });
      64            1 :             } catch {
      65              :                 // A file that another window removed meanwhile
      66            0 :             }
      67            1 :         }
      68              :         // The pinned transient files are written or read right now, they stay and count towards the limits. An empty
      69              :         // file of a key is being written without `move` or was left by a window that closed during the write: readers
      70              :         // find no result in it.
      71            1 :         const removed = new Set<string>();
      72            1 :         const incomplete = (file: { name: string; size: number }) => file.size === 0 && cacheFileKey(file.name) != null;
      73            1 :         for (const file of files) {
      74            1 :             if ((isTransientCacheFile(file.name) || incomplete(file)) && await tryRemove(file.name)) removed.add(file.name);
      75              :         }
      76            1 :         files = files.filter(file => !removed.has(file.name) && !incomplete(file));
      77            1 :         const names = new Set(files.map(file => file.name));
      78            1 :         for (const name of accessMs.keys()) {
      79            1 :             if (!names.has(name)) await dir.removeEntry(`${name}${ACCESS_SUFFIX}`).catch(() => {});
      80              :         }
      81            1 :         return files.map(file => ({ ...file, lastAccessMs: accessMs.get(file.name) ?? file.mtimeMs }));
      82            1 :     };
      83            1 :     const entries = async (): Promise<QueryResultCacheEntry[]> => (await listFiles()).flatMap(file => {
      84            1 :         const key = cacheFileKey(file.name);
      85            1 :         return key == null ? [] : [{ name: file.name, key, size: file.size, cachedAtMs: file.mtimeMs, lastAccessMs: file.lastAccessMs }];
      86            1 :     });
      87              : 
      88              :     // The compute database that the files are mounted in, shared by the readers and writers of this window
      89            1 :     let mountedIn: ParquetDatabase | null = null;
      90              :     // Did this window remove files since the mount was refreshed? The database still knows the files it opened.
      91            1 :     let mountStale = false;
      92            1 :     let mountChain: Promise<unknown> = Promise.resolve();
      93            1 :     const serialize = <T>(operation: () => Promise<T>): Promise<T> => {
      94            1 :         const result = mountChain.then(operation);
      95            1 :         mountChain = result.catch(() => {});
      96            1 :         return result;
      97            1 :     };
      98              : 
      99              :     /// Remove a file and its marker if nobody reads it, resolves true if the file is gone
     100            1 :     const tryRemove = async (name: string): Promise<boolean> => {
     101            1 :         return await locks.request(`${LOCK_PREFIX}${name}`, { mode: 'exclusive', ifAvailable: true }, async lock => {
     102            1 :             if (lock == null) return false;
     103            1 :             const dir = await directory();
     104            1 :             let gone = true;
     105            1 :             await dir.removeEntry(name).catch((error: unknown) => {
     106            0 :                 if ((error as DOMException)?.name !== 'NotFoundError') gone = false;
     107            1 :             });
     108            1 :             if (!gone) return false;
     109            1 :             await dir.removeEntry(`${name}${ACCESS_SUFFIX}`).catch(() => {});
     110            1 :             mountStale = true;
     111            1 :             return true;
     112            1 :         });
     113            1 :     };
     114            1 :     const store: QueryResultCacheStore = {
     115            1 :         listCacheFiles: listFiles,
     116            1 :         deleteCacheFile: async name => await tryRemove(name),
     117            1 :     };
     118            1 :     const touch = async (name: string) => {
     119            1 :         const writable = await (await (await directory()).getFileHandle(`${name}${ACCESS_SUFFIX}`, { create: true })).createWritable();
     120            1 :         await writable.close();
     121            1 :     };
     122            1 :     const pin = (name: string): Promise<() => Promise<void>> => new Promise(acquired => {
     123              :         // The request settles once the lock is released, not when the callback returns
     124            1 :         const held = locks.request(`${LOCK_PREFIX}${name}`, { mode: 'shared' }, () => new Promise<void>(release => {
     125            1 :             acquired(async () => {
     126            1 :                 release();
     127            1 :                 await held.catch(() => {});
     128            1 :             });
     129            1 :         }));
     130            1 :     });
     131            1 :     const mount = (database: ParquetDatabase) => serialize(async () => {
     132            1 :         if (mountedIn != null && mountedIn !== database) {
     133            1 :             await mountedIn.removeExternalDirectory(QUERY_RESULT_CACHE_MOUNT).catch(() => {});
     134            1 :             mountedIn = null;
     135            1 :         }
     136            1 :         if (mountedIn == null) {
     137            1 :             mountStale = false;
     138            1 :             await database.registerExternalDirectory(await directory(), QUERY_RESULT_CACHE_MOUNT, 'readwrite');
     139            1 :             mountedIn = database;
     140            1 :         } else if (mountStale) {
     141              :             // Removals during the refresh mark the mount stale again
     142            1 :             mountStale = false;
     143            1 :             try {
     144            1 :                 await database.refreshExternalDirectory(QUERY_RESULT_CACHE_MOUNT);
     145            1 :             } catch (error) {
     146            1 :                 mountStale = true;
     147            1 :                 throw error;
     148            1 :             }
     149            1 :         }
     150            1 :     });
     151              : 
     152            1 :     const lookup = async (key: string) => {
     153            1 :         const files = (await entries()).filter(entry => entry.key === key);
     154            1 :         files.sort((a, b) => b.cachedAtMs - a.cachedAtMs || b.name.localeCompare(a.name));
     155            1 :         return files[0] ?? null;
     156            1 :     };
     157              : 
     158            1 :     return {
     159            1 :         mountPath: QUERY_RESULT_CACHE_MOUNT,
     160            1 :         lookup,
     161            1 :         has: async key => (await entries()).some(entry => entry.key === key),
     162            1 :         retain: async key => {
     163              :             // Another window may remove the file between the lookup and the pin, then a newer file may be there
     164            1 :             for (let attempt = 0; attempt < 3; ++attempt) {
     165            1 :                 const entry = await lookup(key);
     166            1 :                 if (entry == null) return null;
     167            1 :                 const release = await pin(entry.name);
     168            1 :                 try {
     169            1 :                     await (await directory()).getFileHandle(entry.name);
     170            1 :                 } catch {
     171            0 :                     await release();
     172            0 :                     continue;
     173            0 :                 }
     174            1 :                 await touch(entry.name).catch(() => {});
     175            1 :                 return { entry, release };
     176            1 :             }
     177            0 :             return null;
     178            1 :         },
     179            1 :         list: entries,
     180            1 :         touch,
     181            1 :         delete: async key => {
     182            1 :             let gone = true;
     183            1 :             for (const entry of (await entries()).filter(entry => entry.key === key)) {
     184            1 :                 if (!await tryRemove(entry.name)) gone = false;
     185              :             }
     186            1 :             return gone;
     187            1 :         },
     188            1 :         clear: async () => {
     189              :             // Also the files without keys that nobody reads, e.g. those of runs of a window that was closed
     190            1 :             const keys = new Set<string>();
     191            1 :             for (const file of await listFiles()) {
     192            1 :                 const key = cacheFileKey(file.name);
     193            1 :                 if (await tryRemove(file.name) && key != null) keys.add(key);
     194            1 :             }
     195              :             // A key whose newer file is read stays cached
     196            1 :             const remaining = new Set((await entries()).map(entry => entry.key));
     197            1 :             return [...keys].filter(key => !remaining.has(key));
     198            1 :         },
     199            1 :         pin,
     200            1 :         newFileName: key => `${key}.${crypto.randomUUID()}.parquet`,
     201            1 :         newTransientFileName: () => `${crypto.randomUUID()}${TRANSIENT_SUFFIX}`,
     202            1 :         mount,
     203            1 :         writeTable: async (database: ParquetDatabase, table: arrow.Table, name: string) => {
     204            1 :             await mount(database);
     205            1 :             const dir = await directory();
     206            1 :             const key = cacheFileKey(name);
     207            1 :             if (key == null) {
     208            0 :                 await copyArrowTableAsParquet(database, table, `${QUERY_RESULT_CACHE_MOUNT}/${name}`);
     209            0 :                 return await (await dir.getFileHandle(name)).getFile();
     210            0 :             }
     211              :             // Readers of other windows find the file of a key once it is complete
     212            1 :             const temporary = `${crypto.randomUUID()}${TRANSIENT_SUFFIX}`;
     213            1 :             const releaseTemporary = await pin(temporary);
     214            1 :             try {
     215            1 :                 await copyArrowTableAsParquet(database, table, `${QUERY_RESULT_CACHE_MOUNT}/${temporary}`);
     216            1 :                 const handle = await dir.getFileHandle(temporary);
     217            1 :                 if ('move' in handle && typeof (handle as { move?: unknown }).move === 'function') {
     218            1 :                     await (handle as FileSystemFileHandle & { move(name: string): Promise<void> }).move(name);
     219            0 :                 } else {
     220            0 :                     await (await handle.getFile()).stream().pipeTo(await (await dir.getFileHandle(name, { create: true })).createWritable());
     221            0 :                     await dir.removeEntry(temporary);
     222            0 :                 }
     223            1 :             } finally {
     224            1 :                 await releaseTemporary();
     225            1 :             }
     226            1 :             await touch(name);
     227              :             // The older files of the key are removed once nobody reads them, eviction keeps the files below the limits
     228            1 :             for (const entry of await entries()) {
     229            1 :                 if (entry.key === key && entry.name !== name) await tryRemove(entry.name);
     230              :             }
     231            1 :             await evictToFit(store, maxBytes, maxFiles);
     232            1 :             return await (await dir.getFileHandle(name)).getFile();
     233            1 :         },
     234            1 :         readTable: async (database: ParquetDatabase, name: string) => {
     235            1 :             const release = await pin(name);
     236            1 :             try {
     237              :                 // The file may have been removed between the lookup and the pin
     238            1 :                 await (await directory()).getFileHandle(name);
     239            1 :                 await mount(database);
     240            1 :                 const connection = await database.connect({ pooled: true });
     241            1 :                 try {
     242            1 :                     return await connection.query(`SELECT * FROM external('${QUERY_RESULT_CACHE_MOUNT}/${name}', format => 'parquet')`);
     243            1 :                 } finally {
     244            1 :                     await connection.close();
     245            1 :                 }
     246            1 :             } finally {
     247            1 :                 await release();
     248            1 :             }
     249            1 :         },
     250            1 :         remove: async name => { await tryRemove(name); },
     251            1 :     };
     252            1 : }
        

Generated by: LCOV version 2.0-1