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
JavaScript
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;
}
}
}
}