Merge pull request #3732 from SillyTavern/disk-cache
Add disk cache for parsed character JSONs
This commit is contained in:
@@ -142,6 +142,8 @@ performance:
|
||||
lazyLoadCharacters: false
|
||||
# The maximum amount of memory that parsed character cards can use. Set to 0 to disable memory caching.
|
||||
memoryCacheCapacity: '100mb'
|
||||
# Enables disk caching for character cards. Improves performances with large card libraries.
|
||||
useDiskCache: true
|
||||
|
||||
# Allow secret keys exposure via API
|
||||
allowKeysExposure: false
|
||||
|
||||
@@ -66,6 +66,7 @@ import { init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.
|
||||
import { checkForNewContent } from './src/endpoints/content-manager.js';
|
||||
import { init as settingsInit } from './src/endpoints/settings.js';
|
||||
import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './src/server-startup.js';
|
||||
import { diskCache } from './src/endpoints/characters.js';
|
||||
|
||||
// Unrestrict console logs display limit
|
||||
util.inspect.defaultOptions.maxArrayLength = null;
|
||||
@@ -268,6 +269,7 @@ async function preSetupTasks() {
|
||||
const directories = await getUserDirectoriesList();
|
||||
await checkForNewContent(directories);
|
||||
await ensureThumbnailCache();
|
||||
await diskCache.verify(directories);
|
||||
cleanUploads();
|
||||
migrateAccessLog();
|
||||
|
||||
@@ -286,6 +288,7 @@ async function preSetupTasks() {
|
||||
if (typeof cleanupPlugins === 'function') {
|
||||
await cleanupPlugins();
|
||||
}
|
||||
diskCache.dispose();
|
||||
setWindowTitle(consoleTitle);
|
||||
process.exit();
|
||||
};
|
||||
|
||||
@@ -81,14 +81,14 @@ export const read = (image) => {
|
||||
* Parses a card image and returns the character metadata.
|
||||
* @param {string} cardUrl Path to the card image
|
||||
* @param {string} format File format
|
||||
* @returns {string} Character data
|
||||
* @returns {Promise<string>} Character data
|
||||
*/
|
||||
export const parse = (cardUrl, format) => {
|
||||
export const parse = async (cardUrl, format) => {
|
||||
let fileFormat = format === undefined ? 'png' : format;
|
||||
|
||||
switch (fileFormat) {
|
||||
case 'png': {
|
||||
const buffer = fs.readFileSync(cardUrl);
|
||||
const buffer = await fs.promises.readFile(cardUrl);
|
||||
return read(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
+143
-4
@@ -11,6 +11,7 @@ import yaml from 'yaml';
|
||||
import _ from 'lodash';
|
||||
import mime from 'mime-types';
|
||||
import jimp from 'jimp';
|
||||
import storage from 'node-persist';
|
||||
|
||||
import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';
|
||||
import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';
|
||||
@@ -20,6 +21,7 @@ import { parse, write } from '../character-card-parser.js';
|
||||
import { readWorldInfoFile } from './worldinfo.js';
|
||||
import { invalidateThumbnail } from './thumbnails.js';
|
||||
import { importRisuSprites } from './sprites.js';
|
||||
import { getUserDirectories } from '../users.js';
|
||||
const defaultAvatarPath = './public/img/ai4.png';
|
||||
|
||||
// With 100 MB limit it would take roughly 3000 characters to reach this limit
|
||||
@@ -29,6 +31,133 @@ const memoryCache = new MemoryLimitedMap(memoryCacheCapacity);
|
||||
const isAndroid = process.platform === 'android';
|
||||
// Use shallow character data for the character list
|
||||
const useShallowCharacters = !!getConfigValue('performance.lazyLoadCharacters', false, 'boolean');
|
||||
const useDiskCache = !!getConfigValue('performance.useDiskCache', true, 'boolean');
|
||||
|
||||
class DiskCache {
|
||||
/**
|
||||
* @type {string}
|
||||
* @readonly
|
||||
*/
|
||||
static DIRECTORY = 'characters';
|
||||
|
||||
/**
|
||||
* @type {number}
|
||||
* @readonly
|
||||
*/
|
||||
static SYNC_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
/** @type {import('node-persist').LocalStorage} */
|
||||
#instance;
|
||||
|
||||
/** @type {NodeJS.Timeout} */
|
||||
#syncInterval;
|
||||
|
||||
/**
|
||||
* Queue of user handles to sync.
|
||||
* @type {Set<string>}
|
||||
* @readonly
|
||||
*/
|
||||
syncQueue = new Set();
|
||||
|
||||
/**
|
||||
* Path to the cache directory.
|
||||
* @returns {string}
|
||||
*/
|
||||
get cachePath() {
|
||||
return path.join(globalThis.DATA_ROOT, '_cache', DiskCache.DIRECTORY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of hashed keys in the cache.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
get hashedKeys() {
|
||||
return fs.readdirSync(this.cachePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the synchronization queue.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async #syncCacheEntries() {
|
||||
try {
|
||||
if (!useDiskCache || this.syncQueue.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directories = [...this.syncQueue].map(entry => getUserDirectories(entry));
|
||||
this.syncQueue.clear();
|
||||
|
||||
await this.verify(directories);
|
||||
} catch (error) {
|
||||
console.error('Error while synchronizing cache entries:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the disk cache instance.
|
||||
* @returns {Promise<import('node-persist').LocalStorage>}
|
||||
*/
|
||||
async instance() {
|
||||
if (this.#instance) {
|
||||
return this.#instance;
|
||||
}
|
||||
|
||||
this.#instance = storage.create({ dir: this.cachePath, ttl: false });
|
||||
await this.#instance.init();
|
||||
this.#syncInterval = setInterval(this.#syncCacheEntries.bind(this), DiskCache.SYNC_INTERVAL);
|
||||
return this.#instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies disk cache size and prunes it if necessary.
|
||||
* @param {import('../users.js').UserDirectoryList[]} directoriesList List of user directories
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async verify(directoriesList) {
|
||||
if (!useDiskCache) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = await this.instance();
|
||||
const validKeys = new Set();
|
||||
for (const dir of directoriesList) {
|
||||
const files = fs.readdirSync(dir.characters, { withFileTypes: true });
|
||||
for (const file of files.filter(f => f.isFile() && path.extname(f.name) === '.png')) {
|
||||
const filePath = path.join(dir.characters, file.name);
|
||||
const cacheKey = getCacheKey(filePath);
|
||||
validKeys.add(path.parse(cache.getDatumPath(cacheKey)).base);
|
||||
}
|
||||
}
|
||||
for (const key of this.hashedKeys) {
|
||||
if (!validKeys.has(key)) {
|
||||
await cache.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.#syncInterval) {
|
||||
clearInterval(this.#syncInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -37,14 +166,22 @@ const useShallowCharacters = !!getConfigValue('performance.lazyLoadCharacters',
|
||||
* @returns {Promise<string | undefined>} - Character card data
|
||||
*/
|
||||
async function readCharacterData(inputFile, inputFormat = 'png') {
|
||||
const stat = fs.statSync(inputFile);
|
||||
const cacheKey = `${inputFile}-${stat.mtimeMs}`;
|
||||
const cacheKey = getCacheKey(inputFile);
|
||||
if (memoryCache.has(cacheKey)) {
|
||||
return memoryCache.get(cacheKey);
|
||||
}
|
||||
if (useDiskCache) {
|
||||
const cachedData = await diskCache.instance().then(i => i.getItem(cacheKey));
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
}
|
||||
|
||||
const result = parse(inputFile, inputFormat);
|
||||
const result = await parse(inputFile, inputFormat);
|
||||
!isAndroid && memoryCache.set(cacheKey, result);
|
||||
if (useDiskCache) {
|
||||
await diskCache.instance().then(i => i.setItem(cacheKey, result));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -69,7 +206,9 @@ async function writeCharacterData(inputFile, data, outputFile, request, crop = u
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (useDiskCache && !Buffer.isBuffer(inputFile)) {
|
||||
diskCache.syncQueue.add(request.user.profile.handle);
|
||||
}
|
||||
/**
|
||||
* Read the image, resize, and save it as a PNG into the buffer.
|
||||
* @returns {Promise<Buffer>} Image buffer
|
||||
|
||||
Reference in New Issue
Block a user