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