Refactor DiskCache to use a synchronization queue and update cache key generation

a20c8978f9d7e8e0117bc383a73845cbbc82fe90

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

1 files changed, +41 -54Ignore whitespace
src/endpoints/characters.js+41 -54
@@ -21,6 +21,7 @@ import { parse, write } from '../character-card-parser.js';
21import { readWorldInfoFile } from './worldinfo.js';21import { readWorldInfoFile } from './worldinfo.js';
22import { invalidateThumbnail } from './thumbnails.js';22import { invalidateThumbnail } from './thumbnails.js';
23import { importRisuSprites } from './sprites.js';23import { importRisuSprites } from './sprites.js';
24import { getUserDirectories } from '../users.js';
24const defaultAvatarPath = './public/img/ai4.png';25const defaultAvatarPath = './public/img/ai4.png';
2526
26// 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
@@ -34,28 +35,29 @@ const useDiskCache = !!getConfigValue('performance.useDiskCache', true, 'boolean
3435
35class DiskCache {36class DiskCache {
36 /**37 /**
37 * @typedef {object} CacheRemovalQueueItem38 * @type {string}
38 * @property {string} item Path to the character file39 * @readonly
39 * @property {number} timestamp Timestamp of the last access
40 */40 */
41
42 /** @type {string} */
43 static DIRECTORY = 'characters';41 static DIRECTORY = 'characters';
4442
45 /** @type {number} */43 /**
46 static REMOVAL_INTERVAL = 5 * 60 * 1000;44 * @type {number}
45 * @readonly
46 */
47 static SYNC_INTERVAL = 5 * 60 * 1000;
4748
48 /** @type {import('node-persist').LocalStorage} */49 /** @type {import('node-persist').LocalStorage} */
49 #instance;50 #instance;
5051
51 /** @type {NodeJS.Timeout} */52 /** @type {NodeJS.Timeout} */
52 #removalInterval;53 #syncInterval;
5354
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();
5961
60 /**62 /**
61 * Path to the cache directory.63 * Path to the cache directory.
@@ -74,49 +76,21 @@ class DiskCache {
74 }76 }
7577
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 }
8887
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();
10090
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 }
12296
@@ -131,7 +105,7 @@ class DiskCache {
131105
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 }
137111
@@ -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 }
164138
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 {
172export const diskCache = new DiskCache();146export const diskCache = new DiskCache();
173147
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 */
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}
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 file164 * @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 data166 * @returns {Promise<string | undefined>} - Character card data
179 */167 */
180async function readCharacterData(inputFile, inputFormat = 'png') {168async 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.