Merge pull request #3732 from SillyTavern/disk-cache Add disk cache for parsed character JSONs

5969bf399275e21caaaf54d370c266dc81e520cb

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
4 files changed, +151 -7Showing whitespace changes
default/config.yaml+2 -0
@@ -142,6 +142,8 @@ performance:
142142 lazyLoadCharacters: false
143143 # The maximum amount of memory that parsed character cards can use. Set to 0 to disable memory caching.
144144 memoryCacheCapacity: '100mb'
145+ # Enables disk caching for character cards. Improves performances with large card libraries.
146+ useDiskCache: true
145147
146148# Allow secret keys exposure via API
147149allowKeysExposure: false
server.js+3 -0
@@ -66,6 +66,7 @@ import { init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.
6666import { checkForNewContent } from './src/endpoints/content-manager.js';
6767import { init as settingsInit } from './src/endpoints/settings.js';
6868import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './src/server-startup.js';
69+import { diskCache } from './src/endpoints/characters.js';
6970
7071// Unrestrict console logs display limit
7172util.inspect.defaultOptions.maxArrayLength = null;
@@ -268,6 +269,7 @@ async function preSetupTasks() {
268269 const directories = await getUserDirectoriesList();
269270 await checkForNewContent(directories);
270271 await ensureThumbnailCache();
272+ await diskCache.verify(directories);
271273 cleanUploads();
272274 migrateAccessLog();
273275
@@ -286,6 +288,7 @@ async function preSetupTasks() {
286288 if (typeof cleanupPlugins === 'function') {
287289 await cleanupPlugins();
288290 }
291+ diskCache.dispose();
289292 setWindowTitle(consoleTitle);
290293 process.exit();
291294 };
src/character-card-parser.js+3 -3
@@ -81,14 +81,14 @@ export const read = (image) => {
8181 * Parses a card image and returns the character metadata.
8282 * @param {string} cardUrl Path to the card image
8383 * @param {string} format File format
8484 * @returns {Promise<string>} Character data
8585 */
8686export const parse = async (cardUrl, format) => {
8787 let fileFormat = format === undefined ? 'png' : format;
8888
8989 switch (fileFormat) {
9090 case 'png': {
9191 const buffer = await fs.readFileSyncpromises.readFile(cardUrl);
9292 return read(buffer);
9393 }
9494 }
src/endpoints/characters.js+143 -4
@@ -11,6 +11,7 @@ import yaml from 'yaml';
1111import _ from 'lodash';
1212import mime from 'mime-types';
1313import jimp from 'jimp';
14+import storage from 'node-persist';
1415
1516import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';
1617import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';
@@ -20,6 +21,7 @@ import { parse, write } from '../character-card-parser.js';
2021import { readWorldInfoFile } from './worldinfo.js';
2122import { invalidateThumbnail } from './thumbnails.js';
2223import { importRisuSprites } from './sprites.js';
24+import { getUserDirectories } from '../users.js';
2325const defaultAvatarPath = './public/img/ai4.png';
2426
2527// With 100 MB limit it would take roughly 3000 characters to reach this limit
@@ -29,6 +31,133 @@ const memoryCache = new MemoryLimitedMap(memoryCacheCapacity);
2931const isAndroid = process.platform === 'android';
3032// Use shallow character data for the character list
3133const useShallowCharacters = !!getConfigValue('performance.lazyLoadCharacters', false, 'boolean');
34+const useDiskCache = !!getConfigValue('performance.useDiskCache', true, 'boolean');
35+
36+class DiskCache {
37+ /**
38+ * @type {string}
39+ * @readonly
40+ */
41+ static DIRECTORY = 'characters';
42+
43+ /**
44+ * @type {number}
45+ * @readonly
46+ */
47+ static SYNC_INTERVAL = 5 * 60 * 1000;
48+
49+ /** @type {import('node-persist').LocalStorage} */
50+ #instance;
51+
52+ /** @type {NodeJS.Timeout} */
53+ #syncInterval;
54+
55+ /**
56+ * Queue of user handles to sync.
57+ * @type {Set<string>}
58+ * @readonly
59+ */
60+ syncQueue = new Set();
61+
62+ /**
63+ * Path to the cache directory.
64+ * @returns {string}
65+ */
66+ get cachePath() {
67+ return path.join(globalThis.DATA_ROOT, '_cache', DiskCache.DIRECTORY);
68+ }
69+
70+ /**
71+ * Returns the list of hashed keys in the cache.
72+ * @returns {string[]}
73+ */
74+ get hashedKeys() {
75+ return fs.readdirSync(this.cachePath);
76+ }
77+
78+ /**
79+ * Processes the synchronization queue.
80+ * @returns {Promise<void>}
81+ */
82+ async #syncCacheEntries() {
83+ try {
84+ if (!useDiskCache || this.syncQueue.size === 0) {
85+ return;
86+ }
87+
88+ const directories = [...this.syncQueue].map(entry => getUserDirectories(entry));
89+ this.syncQueue.clear();
90+
91+ await this.verify(directories);
92+ } catch (error) {
93+ console.error('Error while synchronizing cache entries:', error);
94+ }
95+ }
96+
97+ /**
98+ * Gets the disk cache instance.
99+ * @returns {Promise<import('node-persist').LocalStorage>}
100+ */
101+ async instance() {
102+ if (this.#instance) {
103+ return this.#instance;
104+ }
105+
106+ this.#instance = storage.create({ dir: this.cachePath, ttl: false });
107+ await this.#instance.init();
108+ this.#syncInterval = setInterval(this.#syncCacheEntries.bind(this), DiskCache.SYNC_INTERVAL);
109+ return this.#instance;
110+ }
111+
112+ /**
113+ * Verifies disk cache size and prunes it if necessary.
114+ * @param {import('../users.js').UserDirectoryList[]} directoriesList List of user directories
115+ * @returns {Promise<void>}
116+ */
117+ async verify(directoriesList) {
118+ if (!useDiskCache) {
119+ return;
120+ }
121+
122+ const cache = await this.instance();
123+ const validKeys = new Set();
124+ for (const dir of directoriesList) {
125+ const files = fs.readdirSync(dir.characters, { withFileTypes: true });
126+ for (const file of files.filter(f => f.isFile() && path.extname(f.name) === '.png')) {
127+ const filePath = path.join(dir.characters, file.name);
128+ const cacheKey = getCacheKey(filePath);
129+ validKeys.add(path.parse(cache.getDatumPath(cacheKey)).base);
130+ }
131+ }
132+ for (const key of this.hashedKeys) {
133+ if (!validKeys.has(key)) {
134+ await cache.removeItem(key);
135+ }
136+ }
137+ }
138+
139+ dispose() {
140+ if (this.#syncInterval) {
141+ clearInterval(this.#syncInterval);
142+ }
143+ }
144+}
145+
146+export const diskCache = new DiskCache();
147+
148+/**
149+ * Gets the cache key for the specified image file.
150+ * @param {string} inputFile - Path to the image file
151+ * @returns {string} - Cache key
152+ */
153+function getCacheKey(inputFile) {
154+ if (fs.existsSync(inputFile)) {
155+ const stat = fs.statSync(inputFile);
156+ return `${inputFile}-${stat.mtimeMs}`;
157+ }
158+
159+ return inputFile;
160+}
32161
33162/**
34163 * Reads the character card from the specified image file.
@@ -37,14 +166,22 @@ const useShallowCharacters = !!getConfigValue('performance.lazyLoadCharacters',
37166 * @returns {Promise<string | undefined>} - Character card data
38167 */
39168async function readCharacterData(inputFile, inputFormat = 'png') {
40169 const statcacheKey = fs.statSyncgetCacheKey(inputFile);
41- const cacheKey = `${inputFile}-${stat.mtimeMs}`;
42170 if (memoryCache.has(cacheKey)) {
43171 return memoryCache.get(cacheKey);
44172 }
173+ if (useDiskCache) {
174+ const cachedData = await diskCache.instance().then(i => i.getItem(cacheKey));
175+ if (cachedData) {
176+ return cachedData;
177+ }
178+ }
45179
46180 const result = await parse(inputFile, inputFormat);
47181 !isAndroid && memoryCache.set(cacheKey, result);
182+ if (useDiskCache) {
183+ await diskCache.instance().then(i => i.setItem(cacheKey, result));
184+ }
48185 return result;
49186}
50187
@@ -69,7 +206,9 @@ async function writeCharacterData(inputFile, data, outputFile, request, crop = u
69206 break;
70207 }
71208 }
72-
209+ if (useDiskCache && !Buffer.isBuffer(inputFile)) {
210+ diskCache.syncQueue.add(request.user.profile.handle);
211+ }
73212 /**
74213 * Read the image, resize, and save it as a PNG into the buffer.
75214 * @returns {Promise<Buffer>} Image buffer