Refactor DiskCache to use a synchronization queue and update cache key generation

This commit is contained in:
Cohee
2025-03-22 17:03:41 +02:00
parent 50340103de
commit a20c8978f9

View File

@@ -21,6 +21,7 @@ import { parse, write } from '../character-card-parser.js';
import { readWorldInfoFile } from './worldinfo.js'; import { readWorldInfoFile } from './worldinfo.js';
import { invalidateThumbnail } from './thumbnails.js'; import { invalidateThumbnail } from './thumbnails.js';
import { importRisuSprites } from './sprites.js'; import { importRisuSprites } from './sprites.js';
import { getUserDirectories } from '../users.js';
const defaultAvatarPath = './public/img/ai4.png'; const defaultAvatarPath = './public/img/ai4.png';
// With 100 MB limit it would take roughly 3000 characters to reach this limit // With 100 MB limit it would take roughly 3000 characters to reach this limit
@@ -34,28 +35,29 @@ const useDiskCache = !!getConfigValue('performance.useDiskCache', true, 'boolean
class DiskCache { class DiskCache {
/** /**
* @typedef {object} CacheRemovalQueueItem * @type {string}
* @property {string} item Path to the character file * @readonly
* @property {number} timestamp Timestamp of the last access
*/ */
/** @type {string} */
static DIRECTORY = 'characters'; static DIRECTORY = 'characters';
/** @type {number} */ /**
static REMOVAL_INTERVAL = 5 * 60 * 1000; * @type {number}
* @readonly
*/
static SYNC_INTERVAL = 5 * 60 * 1000;
/** @type {import('node-persist').LocalStorage} */ /** @type {import('node-persist').LocalStorage} */
#instance; #instance;
/** @type {NodeJS.Timeout} */ /** @type {NodeJS.Timeout} */
#removalInterval; #syncInterval;
/** /**
* Queue for removal of cache entries. * Queue of user handles to sync.
* @type {CacheRemovalQueueItem[]} * @type {Set<string>}
* @readonly
*/ */
removalQueue = []; syncQueue = new Set();
/** /**
* Path to the cache directory. * Path to the cache directory.
@@ -74,49 +76,21 @@ class DiskCache {
} }
/** /**
* Processes the removal queue. * Processes the synchronization queue.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async #removeCacheEntries() { async #syncCacheEntries() {
// TODO: consider running this.verify() for a user instead as getting all cache keys
// is a heavy operation, since it requires to read all files in the disk cache.
try { try {
if (!useDiskCache || this.removalQueue.length === 0) { if (!useDiskCache || this.syncQueue.size === 0) {
return; return;
} }
/** @type {Map<string, number>} */ const directories = [...this.syncQueue].map(entry => getUserDirectories(entry));
const latestTimestamps = new Map(); this.syncQueue.clear();
for (const { item, timestamp } of this.removalQueue) {
if (!latestTimestamps.has(item) || timestamp > (latestTimestamps.get(item) ?? 0)) {
latestTimestamps.set(item, timestamp);
}
}
this.removalQueue.length = 0;
const cache = await this.instance(); await this.verify(directories);
const keys = await cache.keys();
for (const [item, timestamp] of latestTimestamps.entries()) {
const itemKeys = keys.filter(k => k.startsWith(item));
if (!itemKeys.length) {
continue;
}
for (const key of itemKeys) {
const datumPath = cache.getDatumPath(key);
if (!fs.existsSync(datumPath)) {
continue;
}
const stat = fs.statSync(datumPath);
if (stat.mtimeMs > timestamp) {
continue;
}
await cache.removeItem(key);
}
}
} catch (error) { } catch (error) {
console.error('Error while removing cache entries:', error); console.error('Error while synchronizing cache entries:', error);
} }
} }
@@ -131,7 +105,7 @@ class DiskCache {
this.#instance = storage.create({ dir: this.cachePath, ttl: false }); this.#instance = storage.create({ dir: this.cachePath, ttl: false });
await this.#instance.init(); await this.#instance.init();
this.#removalInterval = setInterval(this.#removeCacheEntries.bind(this), DiskCache.REMOVAL_INTERVAL); this.#syncInterval = setInterval(this.#syncCacheEntries.bind(this), DiskCache.SYNC_INTERVAL);
return this.#instance; return this.#instance;
} }
@@ -151,8 +125,8 @@ class DiskCache {
const files = fs.readdirSync(dir.characters, { withFileTypes: true }); const files = fs.readdirSync(dir.characters, { withFileTypes: true });
for (const file of files.filter(f => f.isFile() && path.extname(f.name) === '.png')) { for (const file of files.filter(f => f.isFile() && path.extname(f.name) === '.png')) {
const filePath = path.join(dir.characters, file.name); const filePath = path.join(dir.characters, file.name);
const stat = fs.statSync(filePath); const cacheKey = getCacheKey(filePath);
validKeys.add(path.parse(cache.getDatumPath(`${filePath}-${stat.mtimeMs}`)).base); validKeys.add(path.parse(cache.getDatumPath(cacheKey)).base);
} }
} }
for (const key of this.hashedKeys) { for (const key of this.hashedKeys) {
@@ -163,14 +137,28 @@ class DiskCache {
} }
dispose() { dispose() {
if (this.#removalInterval) { if (this.#syncInterval) {
clearInterval(this.#removalInterval); clearInterval(this.#syncInterval);
} }
} }
} }
export const diskCache = new DiskCache(); export const diskCache = new DiskCache();
/**
* Gets the cache key for the specified image file.
* @param {string} inputFile - Path to the image file
* @returns {string} - Cache key
*/
function getCacheKey(inputFile) {
if (fs.existsSync(inputFile)) {
const stat = fs.statSync(inputFile);
return `${inputFile}-${stat.mtimeMs}`;
}
return inputFile;
}
/** /**
* Reads the character card from the specified image file. * Reads the character card from the specified image file.
* @param {string} inputFile - Path to the image file * @param {string} inputFile - Path to the image file
@@ -178,8 +166,7 @@ export const diskCache = new DiskCache();
* @returns {Promise<string | undefined>} - Character card data * @returns {Promise<string | undefined>} - Character card data
*/ */
async function readCharacterData(inputFile, inputFormat = 'png') { async function readCharacterData(inputFile, inputFormat = 'png') {
const stat = fs.statSync(inputFile); const cacheKey = getCacheKey(inputFile);
const cacheKey = `${inputFile}-${stat.mtimeMs}`;
if (memoryCache.has(cacheKey)) { if (memoryCache.has(cacheKey)) {
return memoryCache.get(cacheKey); return memoryCache.get(cacheKey);
} }
@@ -220,7 +207,7 @@ async function writeCharacterData(inputFile, data, outputFile, request, crop = u
} }
} }
if (useDiskCache && !Buffer.isBuffer(inputFile)) { if (useDiskCache && !Buffer.isBuffer(inputFile)) {
diskCache.removalQueue.push({ item: inputFile, timestamp: Date.now() }); diskCache.syncQueue.add(request.user.profile.handle);
} }
/** /**
* Read the image, resize, and save it as a PNG into the buffer. * Read the image, resize, and save it as a PNG into the buffer.