UNPKG

igir

Version:

🕹 A zero-setup ROM collection manager that sorts, filters, extracts or archives, patches, and reports on collections of any size on any OS.

194 lines (193 loc) • 6.83 kB
import fs from 'node:fs'; import path from 'node:path'; import util from 'node:util'; import v8 from 'node:v8'; import zlib from 'node:zlib'; import { E_CANCELED, Mutex } from 'async-mutex'; import KeyedMutex from '../async/keyedMutex.js'; import Timer from '../async/timer.js'; import FsPoly from '../polyfill/fsPoly.js'; /** * A cache of a fixed size that ejects the oldest inserted key. */ export default class Cache { static BUFFER_ENCODING = 'binary'; keyValues = new Map(); keyedMutex = new KeyedMutex(1000); hasChanged = false; saveToFileTimeout; filePath; fileFlushMillis; saveMutex = new Mutex(); constructor(props) { this.filePath = props?.filePath; this.fileFlushMillis = props?.fileFlushMillis; if (props?.saveOnExit) { // WARN: Jest won't call this: https://github.com/jestjs/jest/issues/10927 process.once('beforeExit', async () => { await this.save(); }); } } /** * Return if a key exists in the cache, waiting for any existing operations to complete first. */ async has(key) { return this.keyedMutex.runExclusiveForKey(key, () => this.keyValues.has(key)); } /** * Return all the keys that exist in the cache. */ keys() { return new Set(this.keyValues.keys()); } /** * Return the count of keys in the cache. */ size() { return this.keyValues.size; } /** * Get the value of a key in the cache, waiting for any existing operations to complete first. */ async get(key) { return this.keyedMutex.runExclusiveForKey(key, () => this.keyValues.get(key)); } /** * Get the value of a key in the cache if it exists, or compute a value and set it in the cache * otherwise. */ async getOrCompute(key, runnable, shouldRecompute) { return this.keyedMutex.runExclusiveForKey(key, async () => { if (this.keyValues.has(key)) { const existingValue = this.keyValues.get(key); if (shouldRecompute === undefined || !(await shouldRecompute(existingValue))) { return existingValue; } } const val = await runnable(key); this.setUnsafe(key, val); return val; }); } /** * Set the value of a key in the cache. */ async set(key, val) { return this.keyedMutex.runExclusiveForKey(key, () => { this.setUnsafe(key, val); }); } setUnsafe(key, val) { const oldVal = this.keyValues.get(key); this.keyValues.set(key, val); if (val !== oldVal) { this.saveWithTimeout(); } } /** * Delete a key in the cache. */ async delete(key) { let keysToDelete; if (key instanceof RegExp) { keysToDelete = [...this.keys().keys()].filter((k) => k.match(key) !== null); } else { keysToDelete = [key]; } // Note: avoiding lockKey() because it could get expensive with many keys to delete await this.keyedMutex.runExclusiveGlobally(() => { keysToDelete.forEach((k) => { this.deleteUnsafe(k); }); }); } deleteUnsafe(key) { this.keyValues.delete(key); this.saveWithTimeout(); } /** * Load the cache from a file. */ async load() { if (this.filePath === undefined || !(await FsPoly.exists(this.filePath))) { // Cache doesn't exist, so there is nothing to load return this; } try { const compressed = await util.promisify(fs.readFile)(this.filePath); if (compressed.length === 0) { return this; } // NOTE(cemmer): util.promisify(zlib.inflate) seems to have issues not throwing correctly const decompressed = zlib.inflateSync(compressed); const keyValuesObject = v8.deserialize(decompressed); const keyValuesEntries = Object.entries(keyValuesObject); this.keyValues = new Map(keyValuesEntries); } catch { /* ignored */ } return this; } saveWithTimeout() { this.hasChanged = true; if (this.filePath === undefined || this.fileFlushMillis === undefined || this.saveToFileTimeout !== undefined) { return; } this.saveToFileTimeout = Timer.setTimeout(async () => this.save(), this.fileFlushMillis); } /** * Save the cache to a file. */ async save() { try { await this.saveMutex.runExclusive(async () => { // Clear any existing timeout if (this.saveToFileTimeout !== undefined) { this.saveToFileTimeout.cancel(); this.saveToFileTimeout = undefined; } if (this.filePath === undefined || !this.hasChanged) { return; } const keyValuesObject = Object.fromEntries(this.keyValues); const decompressed = v8.serialize(keyValuesObject); // NOTE(cemmer): util.promisify(zlib.deflate) seems to have issues not throwing correctly const compressed = zlib.deflateSync(decompressed); // Ensure the directory exists const dirPath = path.dirname(this.filePath); if (!(await FsPoly.exists(dirPath))) { await FsPoly.mkdir(dirPath, { recursive: true }); } // Write to a temp file first const tempFile = await FsPoly.mktemp(this.filePath); await FsPoly.writeFile(tempFile, compressed, { encoding: Cache.BUFFER_ENCODING }); // Validate the file was written correctly const tempFileCache = await new Cache({ filePath: tempFile }).load(); if (tempFileCache.size() !== Object.keys(keyValuesObject).length) { // The written file is bad, don't use it await FsPoly.rm(tempFile, { force: true }); return; } // Overwrite the real file with the temp file try { await FsPoly.mv(tempFile, this.filePath); } catch { return; } this.hasChanged = false; this.saveMutex.cancel(); // cancel all waiting locks, we just saved }); } catch (error) { if (error !== E_CANCELED) { throw error; } } } }