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 -7Ignore whitespace
default/config.yaml+2 -0
@@ -142,6 +142,8 @@ performance:
142 lazyLoadCharacters: false142 lazyLoadCharacters: false
143 # The maximum amount of memory that parsed character cards can use. Set to 0 to disable memory caching.143 # The maximum amount of memory that parsed character cards can use. Set to 0 to disable memory caching.
144 memoryCacheCapacity: '100mb'144 memoryCacheCapacity: '100mb'
145 # Enables disk caching for character cards. Improves performances with large card libraries.
146 useDiskCache: true
145147
146# Allow secret keys exposure via API148# Allow secret keys exposure via API
147allowKeysExposure: false149allowKeysExposure: false
server.js+3 -0
@@ -66,6 +66,7 @@ import { init as statsInit, onExit as statsOnExit } from './src/endpoints/stats.
66import { checkForNewContent } from './src/endpoints/content-manager.js';66import { checkForNewContent } from './src/endpoints/content-manager.js';
67import { init as settingsInit } from './src/endpoints/settings.js';67import { init as settingsInit } from './src/endpoints/settings.js';
68import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './src/server-startup.js';68import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './src/server-startup.js';
69import { diskCache } from './src/endpoints/characters.js';
6970
70// Unrestrict console logs display limit71// Unrestrict console logs display limit
71util.inspect.defaultOptions.maxArrayLength = null;72util.inspect.defaultOptions.maxArrayLength = null;
@@ -268,6 +269,7 @@ async function preSetupTasks() {
268 const directories = await getUserDirectoriesList();269 const directories = await getUserDirectoriesList();
269 await checkForNewContent(directories);270 await checkForNewContent(directories);
270 await ensureThumbnailCache();271 await ensureThumbnailCache();
272 await diskCache.verify(directories);
271 cleanUploads();273 cleanUploads();
272 migrateAccessLog();274 migrateAccessLog();
273275
@@ -286,6 +288,7 @@ async function preSetupTasks() {
286 if (typeof cleanupPlugins === 'function') {288 if (typeof cleanupPlugins === 'function') {
287 await cleanupPlugins();289 await cleanupPlugins();
288 }290 }
291 diskCache.dispose();
289 setWindowTitle(consoleTitle);292 setWindowTitle(consoleTitle);
290 process.exit();293 process.exit();
291 };294 };
src/character-card-parser.js+3 -3
@@ -81,14 +81,14 @@ export const read = (image) => {
81 * Parses a card image and returns the character metadata.81 * Parses a card image and returns the character metadata.
82 * @param {string} cardUrl Path to the card image82 * @param {string} cardUrl Path to the card image
83 * @param {string} format File format83 * @param {string} format File format
84 * @returns {string} Character data84 * @returns {Promise<string>} Character data
85 */85 */
86export const parse = (cardUrl, format) => {86export const parse = async (cardUrl, format) => {
87 let fileFormat = format === undefined ? 'png' : format;87 let fileFormat = format === undefined ? 'png' : format;
8888
89 switch (fileFormat) {89 switch (fileFormat) {
90 case 'png': {90 case 'png': {
91 const buffer = fs.readFileSync(cardUrl);91 const buffer = await fs.promises.readFile(cardUrl);
92 return read(buffer);92 return read(buffer);
93 }93 }
94 }94 }
src/endpoints/characters.js+143 -4
@@ -11,6 +11,7 @@ import yaml from 'yaml';
11import _ from 'lodash';11import _ from 'lodash';
12import mime from 'mime-types';12import mime from 'mime-types';
13import jimp from 'jimp';13import jimp from 'jimp';
14import storage from 'node-persist';
1415
15import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';16import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';
16import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';17import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';
@@ -20,6 +21,7 @@ import { parse, write } from '../character-card-parser.js';
20import { readWorldInfoFile } from './worldinfo.js';21import { readWorldInfoFile } from './worldinfo.js';
21import { invalidateThumbnail } from './thumbnails.js';22import { invalidateThumbnail } from './thumbnails.js';
22import { importRisuSprites } from './sprites.js';23import { importRisuSprites } from './sprites.js';
24import { getUserDirectories } from '../users.js';
23const defaultAvatarPath = './public/img/ai4.png';25const defaultAvatarPath = './public/img/ai4.png';
2426
25// With 100 MB limit it would take roughly 3000 characters to reach this limit27// With 100 MB limit it would take roughly 3000 characters to reach this limit
@@ -29,6 +31,133 @@ const memoryCache = new MemoryLimitedMap(memoryCacheCapacity);
29const isAndroid = process.platform === 'android';31const isAndroid = process.platform === 'android';
30// Use shallow character data for the character list32// Use shallow character data for the character list
31const useShallowCharacters = !!getConfigValue('performance.lazyLoadCharacters', false, 'boolean');33const useShallowCharacters = !!getConfigValue('performance.lazyLoadCharacters', false, 'boolean');
34const useDiskCache = !!getConfigValue('performance.useDiskCache', true, 'boolean');
35
36class 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
146export 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 */
153function 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
33/**162/**
34 * Reads the character card from the specified image file.163 * Reads the character card from the specified image file.
@@ -37,14 +166,22 @@ const useShallowCharacters = !!getConfigValue('performance.lazyLoadCharacters',
37 * @returns {Promise<string | undefined>} - Character card data166 * @returns {Promise<string | undefined>} - Character card data
38 */167 */
39async function readCharacterData(inputFile, inputFormat = 'png') {168async function readCharacterData(inputFile, inputFormat = 'png') {
40 const stat = fs.statSync(inputFile);169 const cacheKey = getCacheKey(inputFile);
41 const cacheKey = `${inputFile}-${stat.mtimeMs}`;
42 if (memoryCache.has(cacheKey)) {170 if (memoryCache.has(cacheKey)) {
43 return memoryCache.get(cacheKey);171 return memoryCache.get(cacheKey);
44 }172 }
173 if (useDiskCache) {
174 const cachedData = await diskCache.instance().then(i => i.getItem(cacheKey));
175 if (cachedData) {
176 return cachedData;
177 }
178 }
45179
46 const result = parse(inputFile, inputFormat);180 const result = await parse(inputFile, inputFormat);
47 !isAndroid && memoryCache.set(cacheKey, result);181 !isAndroid && memoryCache.set(cacheKey, result);
182 if (useDiskCache) {
183 await diskCache.instance().then(i => i.setItem(cacheKey, result));
184 }
48 return result;185 return result;
49}186}
50187
@@ -69,7 +206,9 @@ async function writeCharacterData(inputFile, data, outputFile, request, crop = u
69 break;206 break;
70 }207 }
71 }208 }
72209 if (useDiskCache && !Buffer.isBuffer(inputFile)) {
210 diskCache.syncQueue.add(request.user.profile.handle);
211 }
73 /**212 /**
74 * Read the image, resize, and save it as a PNG into the buffer.213 * Read the image, resize, and save it as a PNG into the buffer.
75 * @returns {Promise<Buffer>} Image buffer214 * @returns {Promise<Buffer>} Image buffer