Add 100MB limit to parsed characters cache

52606616c4b7dac5f57d9e863543274c014bfdec

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

2 files changed, +185 -2Ignore whitespace
src/endpoints/characters.js+6 -2
@@ -14,7 +14,7 @@ import jimp from 'jimp';
1414
1515import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';
1616import { jsonParser, urlencodedParser } from '../express-common.js';
1717import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, LimitedMap } from '../util.js';
1818import { TavernCardValidator } from '../validator/TavernCardValidator.js';
1919import { parse, write } from '../character-card-parser.js';
2020import { readWorldInfoFile } from './worldinfo.js';
@@ -23,7 +23,8 @@ import { importRisuSprites } from './sprites.js';
2323const defaultAvatarPath = './public/img/ai4.png';
2424
2525// KV-store for parsed character data
26-const characterDataCache = new Map();
26+// 100 MB limit. Would take roughly 3000 characters to reach this limit
27+const characterDataCache = new LimitedMap(1024 * 1024 * 100);
2728// Some Android devices require tighter memory management
2829const isAndroid = process.platform === 'android';
2930
@@ -58,6 +59,9 @@ async function writeCharacterData(inputFile, data, outputFile, request, crop = u
5859 try {
5960 // Reset the cache
6061 for (const key of characterDataCache.keys()) {
62+ if (Buffer.isBuffer(inputFile)) {
63+ break;
64+ }
6165 if (key.startsWith(inputFile)) {
6266 characterDataCache.delete(key);
6367 break;
src/util.js+179 -0
@@ -670,3 +670,182 @@ export function isValidUrl(url) {
670670 return false;
671671 }
672672}
673+
674+/**
675+ * LimitedMap class that limits the memory usage of string values.
676+ */
677+export class LimitedMap {
678+ /**
679+ * Creates an instance of LimitedMap.
680+ * @param {number} maxMemoryInBytes - The maximum allowed memory in bytes for string values.
681+ */
682+ constructor(maxMemoryInBytes) {
683+ if (typeof maxMemoryInBytes !== 'number' || maxMemoryInBytes <= 0) {
684+ throw new Error('maxMemoryInBytes must be a positive number');
685+ }
686+ this.maxMemory = maxMemoryInBytes;
687+ this.currentMemory = 0;
688+ this.map = new Map();
689+ this.queue = [];
690+ }
691+
692+ /**
693+ * Estimates the memory usage of a string in bytes.
694+ * Assumes each character occupies 2 bytes (UTF-16).
695+ * @param {string} str
696+ * @returns {number}
697+ */
698+ static estimateStringSize(str) {
699+ return str.length * 2;
700+ }
701+
702+ /**
703+ * Adds or updates a key-value pair in the map.
704+ * If adding the new value exceeds the memory limit, evicts oldest entries.
705+ * @param {string} key
706+ * @param {string} value
707+ */
708+ set(key, value) {
709+ if (typeof key !== 'string' || typeof value !== 'string') {
710+ return;
711+ }
712+
713+ const newValueSize = LimitedMap.estimateStringSize(value);
714+
715+ // If the new value itself exceeds the max memory, reject it
716+ if (newValueSize > this.maxMemory) {
717+ return;
718+ }
719+
720+ // Check if the key already exists to adjust memory accordingly
721+ if (this.map.has(key)) {
722+ const oldValue = this.map.get(key);
723+ const oldValueSize = LimitedMap.estimateStringSize(oldValue);
724+ this.currentMemory -= oldValueSize;
725+ // Remove the key from its current position in the queue
726+ const index = this.queue.indexOf(key);
727+ if (index > -1) {
728+ this.queue.splice(index, 1);
729+ }
730+ }
731+
732+ // Evict oldest entries until there's enough space
733+ while (this.currentMemory + newValueSize > this.maxMemory && this.queue.length > 0) {
734+ const oldestKey = this.queue.shift();
735+ const oldestValue = this.map.get(oldestKey);
736+ const oldestValueSize = LimitedMap.estimateStringSize(oldestValue);
737+ this.map.delete(oldestKey);
738+ this.currentMemory -= oldestValueSize;
739+ }
740+
741+ // After eviction, check again if there's enough space
742+ if (this.currentMemory + newValueSize > this.maxMemory) {
743+ return;
744+ }
745+
746+ // Add the new key-value pair
747+ this.map.set(key, value);
748+ this.queue.push(key);
749+ this.currentMemory += newValueSize;
750+ }
751+
752+ /**
753+ * Retrieves the value associated with the given key.
754+ * @param {string} key
755+ * @returns {string | undefined}
756+ */
757+ get(key) {
758+ return this.map.get(key);
759+ }
760+
761+ /**
762+ * Checks if the map contains the given key.
763+ * @param {string} key
764+ * @returns {boolean}
765+ */
766+ has(key) {
767+ return this.map.has(key);
768+ }
769+
770+ /**
771+ * Deletes the key-value pair associated with the given key.
772+ * @param {string} key
773+ * @returns {boolean} - Returns true if the key was found and deleted, else false.
774+ */
775+ delete(key) {
776+ if (!this.map.has(key)) {
777+ return false;
778+ }
779+ const value = this.map.get(key);
780+ const valueSize = LimitedMap.estimateStringSize(value);
781+ this.map.delete(key);
782+ this.currentMemory -= valueSize;
783+
784+ // Remove the key from the queue
785+ const index = this.queue.indexOf(key);
786+ if (index > -1) {
787+ this.queue.splice(index, 1);
788+ }
789+
790+ return true;
791+ }
792+
793+ /**
794+ * Clears all entries from the map.
795+ */
796+ clear() {
797+ this.map.clear();
798+ this.queue = [];
799+ this.currentMemory = 0;
800+ }
801+
802+ /**
803+ * Returns the number of key-value pairs in the map.
804+ * @returns {number}
805+ */
806+ size() {
807+ return this.map.size;
808+ }
809+
810+ /**
811+ * Returns the current memory usage in bytes.
812+ * @returns {number}
813+ */
814+ totalMemory() {
815+ return this.currentMemory;
816+ }
817+
818+ /**
819+ * Returns an iterator over the keys in the map.
820+ * @returns {IterableIterator<string>}
821+ */
822+ keys() {
823+ return this.map.keys();
824+ }
825+
826+ /**
827+ * Returns an iterator over the values in the map.
828+ * @returns {IterableIterator<string>}
829+ */
830+ values() {
831+ return this.map.values();
832+ }
833+
834+ /**
835+ * Iterates over the map in insertion order.
836+ * @param {Function} callback - Function to execute for each element.
837+ */
838+ forEach(callback) {
839+ this.map.forEach((value, key) => {
840+ callback(value, key, this);
841+ });
842+ }
843+
844+ /**
845+ * Makes the LimitedMap iterable.
846+ * @returns {Iterator} - Iterator over [key, value] pairs.
847+ */
848+ [Symbol.iterator]() {
849+ return this.map[Symbol.iterator]();
850+ }
851+}