Add 100MB limit to parsed characters cache

52606616c4b7dac5f57d9e863543274c014bfdec

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

2 files changed, +185 -2Showing whitespace changes
src/endpoints/characters.js+6 -2
@@ -14,7 +14,7 @@ import jimp from 'jimp';
1414
15import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';15import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js';
16import { jsonParser, urlencodedParser } from '../express-common.js';16import { jsonParser, urlencodedParser } from '../express-common.js';
17import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer } from '../util.js';17import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, LimitedMap } from '../util.js';
18import { TavernCardValidator } from '../validator/TavernCardValidator.js';18import { TavernCardValidator } from '../validator/TavernCardValidator.js';
19import { parse, write } from '../character-card-parser.js';19import { parse, write } from '../character-card-parser.js';
20import { readWorldInfoFile } from './worldinfo.js';20import { readWorldInfoFile } from './worldinfo.js';
@@ -23,7 +23,8 @@ import { importRisuSprites } from './sprites.js';
23const defaultAvatarPath = './public/img/ai4.png';23const defaultAvatarPath = './public/img/ai4.png';
2424
25// KV-store for parsed character data25// KV-store for parsed character data
26const characterDataCache = new Map();26// 100 MB limit. Would take roughly 3000 characters to reach this limit
27const characterDataCache = new LimitedMap(1024 * 1024 * 100);
27// Some Android devices require tighter memory management28// Some Android devices require tighter memory management
28const isAndroid = process.platform === 'android';29const isAndroid = process.platform === 'android';
2930
@@ -58,6 +59,9 @@ async function writeCharacterData(inputFile, data, outputFile, request, crop = u
58 try {59 try {
59 // Reset the cache60 // Reset the cache
60 for (const key of characterDataCache.keys()) {61 for (const key of characterDataCache.keys()) {
62 if (Buffer.isBuffer(inputFile)) {
63 break;
64 }
61 if (key.startsWith(inputFile)) {65 if (key.startsWith(inputFile)) {
62 characterDataCache.delete(key);66 characterDataCache.delete(key);
63 break;67 break;
src/util.js+179 -0
@@ -670,3 +670,182 @@ export function isValidUrl(url) {
670 return false;670 return false;
671 }671 }
672}672}
673
674/**
675 * LimitedMap class that limits the memory usage of string values.
676 */
677export 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}