Refactor DiskCache to use a synchronization queue and update cache key generation
| @@ -21,6 +21,7 @@ import { parse, write } from '../character-card-parser.js'; | |||
| 21 | import { readWorldInfoFile } from './worldinfo.js'; | 21 | import { readWorldInfoFile } from './worldinfo.js'; |
| 22 | import { invalidateThumbnail } from './thumbnails.js'; | 22 | import { invalidateThumbnail } from './thumbnails.js'; |
| 23 | import { importRisuSprites } from './sprites.js'; | 23 | import { importRisuSprites } from './sprites.js'; |
| 24 | import { getUserDirectories } from '../users.js'; | ||
| 24 | const defaultAvatarPath = './public/img/ai4.png'; | 25 | const defaultAvatarPath = './public/img/ai4.png'; |
| 25 | 26 | ||
| 26 | // With 100 MB limit it would take roughly 3000 characters to reach this limit | 27 | // 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 | |||
| 34 | 35 | ||
| 35 | class DiskCache { | 36 | class DiskCache { |
| 36 | /** | 37 | /** |
| 37 | * @typedef {object} CacheRemovalQueueItem | 38 | * @type {string} |
| 38 | * @property {string} item Path to the character file | 39 | * @readonly |
| 39 | * @property {number} timestamp Timestamp of the last access | ||
| 40 | */ | 40 | */ |
| 41 | |||
| 42 | /** @type {string} */ | ||
| 43 | static DIRECTORY = 'characters'; | 41 | static DIRECTORY = 'characters'; |
| 44 | 42 | ||
| 45 | /** @type {number} */ | 43 | /** |
| 46 | static REMOVAL_INTERVAL = 5 * 60 * 1000; | 44 | * @type {number} |
| 45 | * @readonly | ||
| 46 | */ | ||
| 47 | static SYNC_INTERVAL = 5 * 60 * 1000; | ||
| 47 | 48 | ||
| 48 | /** @type {import('node-persist').LocalStorage} */ | 49 | /** @type {import('node-persist').LocalStorage} */ |
| 49 | #instance; | 50 | #instance; |
| 50 | 51 | ||
| 51 | /** @type {NodeJS.Timeout} */ | 52 | /** @type {NodeJS.Timeout} */ |
| 52 | #removalInterval; | 53 | #syncInterval; |
| 53 | 54 | ||
| 54 | /** | 55 | /** |
| 55 | * Queue for removal of cache entries. | 56 | * Queue of user handles to sync. |
| 56 | * @type {CacheRemovalQueueItem[]} | 57 | * @type {Set<string>} |
| 58 | * @readonly | ||
| 57 | */ | 59 | */ |
| 58 | removalQueue = []; | 60 | syncQueue = new Set(); |
| 59 | 61 | ||
| 60 | /** | 62 | /** |
| 61 | * Path to the cache directory. | 63 | * Path to the cache directory. |
| @@ -74,49 +76,21 @@ class DiskCache { | |||
| 74 | } | 76 | } |
| 75 | 77 | ||
| 76 | /** | 78 | /** |
| 77 | * Processes the removal queue. | 79 | * Processes the synchronization queue. |
| 78 | * @returns {Promise<void>} | 80 | * @returns {Promise<void>} |
| 79 | */ | 81 | */ |
| 80 | async #removeCacheEntries() { | 82 | async #syncCacheEntries() { |
| 81 | // TODO: consider running this.verify() for a user instead as getting all cache keys | ||
| 82 | // is a heavy operation, since it requires to read all files in the disk cache. | ||
| 83 | |||
| 84 | try { | 83 | try { |
| 85 | if (!useDiskCache || this.removalQueue.length === 0) { | 84 | if (!useDiskCache || this.syncQueue.size === 0) { |
| 86 | return; | 85 | return; |
| 87 | } | 86 | } |
| 88 | 87 | ||
| 89 | /** @type {Map<string, number>} */ | 88 | const directories = [...this.syncQueue].map(entry => getUserDirectories(entry)); |
| 90 | const latestTimestamps = new Map(); | 89 | this.syncQueue.clear(); |
| 91 | for (const { item, timestamp } of this.removalQueue) { | ||
| 92 | if (!latestTimestamps.has(item) || timestamp > (latestTimestamps.get(item) ?? 0)) { | ||
| 93 | latestTimestamps.set(item, timestamp); | ||
| 94 | } | ||
| 95 | } | ||
| 96 | this.removalQueue.length = 0; | ||
| 97 | |||
| 98 | const cache = await this.instance(); | ||
| 99 | const keys = await cache.keys(); | ||
| 100 | 90 | ||
| 101 | for (const [item, timestamp] of latestTimestamps.entries()) { | 91 | await this.verify(directories); |
| 102 | const itemKeys = keys.filter(k => k.startsWith(item)); | ||
| 103 | if (!itemKeys.length) { | ||
| 104 | continue; | ||
| 105 | } | ||
| 106 | for (const key of itemKeys) { | ||
| 107 | const datumPath = cache.getDatumPath(key); | ||
| 108 | if (!fs.existsSync(datumPath)) { | ||
| 109 | continue; | ||
| 110 | } | ||
| 111 | const stat = fs.statSync(datumPath); | ||
| 112 | if (stat.mtimeMs > timestamp) { | ||
| 113 | continue; | ||
| 114 | } | ||
| 115 | await cache.removeItem(key); | ||
| 116 | } | ||
| 117 | } | ||
| 118 | } catch (error) { | 92 | } catch (error) { |
| 119 | console.error('Error while removing cache entries:', error); | 93 | console.error('Error while synchronizing cache entries:', error); |
| 120 | } | 94 | } |
| 121 | } | 95 | } |
| 122 | 96 | ||
| @@ -131,7 +105,7 @@ class DiskCache { | |||
| 131 | 105 | ||
| 132 | this.#instance = storage.create({ dir: this.cachePath, ttl: false }); | 106 | this.#instance = storage.create({ dir: this.cachePath, ttl: false }); |
| 133 | await this.#instance.init(); | 107 | await this.#instance.init(); |
| 134 | this.#removalInterval = setInterval(this.#removeCacheEntries.bind(this), DiskCache.REMOVAL_INTERVAL); | 108 | this.#syncInterval = setInterval(this.#syncCacheEntries.bind(this), DiskCache.SYNC_INTERVAL); |
| 135 | return this.#instance; | 109 | return this.#instance; |
| 136 | } | 110 | } |
| 137 | 111 | ||
| @@ -151,8 +125,8 @@ class DiskCache { | |||
| 151 | const files = fs.readdirSync(dir.characters, { withFileTypes: true }); | 125 | const files = fs.readdirSync(dir.characters, { withFileTypes: true }); |
| 152 | for (const file of files.filter(f => f.isFile() && path.extname(f.name) === '.png')) { | 126 | for (const file of files.filter(f => f.isFile() && path.extname(f.name) === '.png')) { |
| 153 | const filePath = path.join(dir.characters, file.name); | 127 | const filePath = path.join(dir.characters, file.name); |
| 154 | const stat = fs.statSync(filePath); | 128 | const cacheKey = getCacheKey(filePath); |
| 155 | validKeys.add(path.parse(cache.getDatumPath(`${filePath}-${stat.mtimeMs}`)).base); | 129 | validKeys.add(path.parse(cache.getDatumPath(cacheKey)).base); |
| 156 | } | 130 | } |
| 157 | } | 131 | } |
| 158 | for (const key of this.hashedKeys) { | 132 | for (const key of this.hashedKeys) { |
| @@ -163,8 +137,8 @@ class DiskCache { | |||
| 163 | } | 137 | } |
| 164 | 138 | ||
| 165 | dispose() { | 139 | dispose() { |
| 166 | if (this.#removalInterval) { | 140 | if (this.#syncInterval) { |
| 167 | clearInterval(this.#removalInterval); | 141 | clearInterval(this.#syncInterval); |
| 168 | } | 142 | } |
| 169 | } | 143 | } |
| 170 | } | 144 | } |
| @@ -172,14 +146,27 @@ class DiskCache { | |||
| 172 | export const diskCache = new DiskCache(); | 146 | export const diskCache = new DiskCache(); |
| 173 | 147 | ||
| 174 | /** | 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 | } | ||
| 161 | |||
| 162 | /** | ||
| 175 | * Reads the character card from the specified image file. | 163 | * Reads the character card from the specified image file. |
| 176 | * @param {string} inputFile - Path to the image file | 164 | * @param {string} inputFile - Path to the image file |
| 177 | * @param {string} inputFormat - 'png' | 165 | * @param {string} inputFormat - 'png' |
| 178 | * @returns {Promise<string | undefined>} - Character card data | 166 | * @returns {Promise<string | undefined>} - Character card data |
| 179 | */ | 167 | */ |
| 180 | async function readCharacterData(inputFile, inputFormat = 'png') { | 168 | async function readCharacterData(inputFile, inputFormat = 'png') { |
| 181 | const stat = fs.statSync(inputFile); | 169 | const cacheKey = getCacheKey(inputFile); |
| 182 | const cacheKey = `${inputFile}-${stat.mtimeMs}`; | ||
| 183 | if (memoryCache.has(cacheKey)) { | 170 | if (memoryCache.has(cacheKey)) { |
| 184 | return memoryCache.get(cacheKey); | 171 | return memoryCache.get(cacheKey); |
| 185 | } | 172 | } |
| @@ -220,7 +207,7 @@ async function writeCharacterData(inputFile, data, outputFile, request, crop = u | |||
| 220 | } | 207 | } |
| 221 | } | 208 | } |
| 222 | if (useDiskCache && !Buffer.isBuffer(inputFile)) { | 209 | if (useDiskCache && !Buffer.isBuffer(inputFile)) { |
| 223 | diskCache.removalQueue.push({ item: inputFile, timestamp: Date.now() }); | 210 | diskCache.syncQueue.add(request.user.profile.handle); |
| 224 | } | 211 | } |
| 225 | /** | 212 | /** |
| 226 | * 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. |