From ebd400bfa417144c0ecb65a59e5d72daec0f65a4 Mon Sep 17 00:00:00 2001 From: arthurfiorette Date: Thu, 25 May 2023 13:14:11 -0300 Subject: [PATCH] feat: max entries on memory storage #539 --- docs/src/guide/storages.md | 5 ++- src/storage/memory.ts | 81 +++++++++++++++++++++++++------------ test/storage/memory.test.ts | 81 +++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 28 deletions(-) diff --git a/docs/src/guide/storages.md b/docs/src/guide/storages.md index e6f168f..22c4752 100644 --- a/docs/src/guide/storages.md +++ b/docs/src/guide/storages.md @@ -33,7 +33,7 @@ option to clone the response before saving it. _Just like others._ For long running processes, you can avoid memory leaks by using playing with the -`cleanupInterval` option. +`cleanupInterval` option. And can reduce memory usage with `maxEntries`. ```ts import Axios from 'axios'; @@ -43,7 +43,8 @@ setupCache(axios, { // You don't need to to that, as it is the default option. storage: buildMemoryStorage( /* cloneData default=*/ false, - /* cleanupInterval default=*/ false + /* cleanupInterval default=*/ false, + /* maxEntries default=*/ false ) }); ``` diff --git a/src/storage/memory.ts b/src/storage/memory.ts index c67c2e8..327aa10 100644 --- a/src/storage/memory.ts +++ b/src/storage/memory.ts @@ -35,13 +35,38 @@ declare const structuredClone: ((value: T) => T) | undefined; * * @param {number | false} cleanupInterval The interval in milliseconds to run a * setInterval job of cleaning old entries. If false, the job will not be created. Disabled is default + * + * @param {number | false} maxEntries The maximum number of entries to keep in the storage. Its hard to + * determine the size of the entries, so a smart FIFO order is used to determine eviction. If false, + * no check will be done and you may grow up memory usage. Disabled is default */ export function buildMemoryStorage( cloneData = false, - cleanupInterval: number | false = false + cleanupInterval: number | false = false, + maxEntries: number | false = false ) { const storage = buildStorage({ set: (key, value) => { + if (maxEntries) { + let keys = Object.keys(storage.data); + + // Tries to cleanup first + if (keys.length >= maxEntries) { + storage.cleanup(); + + // Recalculates the keys + keys = Object.keys(storage.data); + + // Keeps deleting until there's space + while (keys.length >= maxEntries) { + // There's always at least one key here, otherwise it would not be + // in the loop. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + delete storage.data[keys.shift()!]; + } + } + } + storage.data[key] = value; }, @@ -70,35 +95,37 @@ export function buildMemoryStorage( // When this program gets running for more than the specified interval, there's a good // chance of it being a long-running process or at least have a lot of entries. Therefore, // "faster" loop is more important than code readability. - if (cleanupInterval) { - storage.cleaner = setInterval(() => { - const keys = Object.keys(storage.data); + storage.cleanup = () => { + const keys = Object.keys(storage.data); - let i = -1, - value: StorageValue, - key: string; + let i = -1, + value: StorageValue, + key: string; - // Looping forward, as older entries are more likely to be expired - // than newer ones. - while (++i < keys.length) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (key = keys[i]!), (value = storage.data[key]!); + // Looping forward, as older entries are more likely to be expired + // than newer ones. + while (++i < keys.length) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (key = keys[i]!), (value = storage.data[key]!); - if (value.state === 'empty') { - // this storage returns void. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - storage.remove(key); - continue; - } - - // If the value is expired and can't be stale, remove it - if (value.state === 'cached' && isExpired(value) && !canStale(value)) { - // this storage returns void. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - storage.remove(key); - } + if (value.state === 'empty') { + // this storage returns void. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + storage.remove(key); + continue; } - }, cleanupInterval); + + // If the value is expired and can't be stale, remove it + if (value.state === 'cached' && isExpired(value) && !canStale(value)) { + // this storage returns void. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + storage.remove(key); + } + } + }; + + if (cleanupInterval) { + storage.cleaner = setInterval(storage.cleanup, cleanupInterval); } return storage; @@ -108,4 +135,6 @@ export type MemoryStorage = AxiosStorage & { data: Record; /** The job responsible to cleaning old entries */ cleaner: ReturnType; + /** Tries to remove any invalid entry from the memory */ + cleanup: () => void; }; diff --git a/test/storage/memory.test.ts b/test/storage/memory.test.ts index 6707a97..0a3446a 100644 --- a/test/storage/memory.test.ts +++ b/test/storage/memory.test.ts @@ -110,4 +110,85 @@ describe('tests memory storage', () => { // Clears handle clearTimeout(storage.cleaner); }); + + it('tests maxEntries without cleanup', async () => { + const storage = buildMemoryStorage(false, false, 2); + + await storage.set('key', { + state: 'cached', + createdAt: Date.now(), + ttl: 1000 * 60 * 5, // 5 Minutes + data: { ...EMPTY_RESPONSE, data: 'data' } + }); + + await storage.set('key2', { + state: 'cached', + createdAt: Date.now(), + ttl: 1000 * 60 * 5, // 5 Minutes + data: { ...EMPTY_RESPONSE, data: 'data' } + }); + + expect(Object.keys(storage.data)).toHaveLength(2); + expect(storage.data['key']).toBeDefined(); + expect(storage.data['key2']).toBeDefined(); + expect(storage.data['key3']).toBeUndefined(); + + await storage.set('key3', { + state: 'cached', + createdAt: Date.now(), + ttl: 1000 * 60 * 5, // 5 Minutes + data: { ...EMPTY_RESPONSE, data: 'data' } + }); + + expect(Object.keys(storage.data)).toHaveLength(2); + + expect(storage.data['key']).toBeUndefined(); + expect(storage.data['key2']).toBeDefined(); + expect(storage.data['key3']).toBeDefined(); + }); + + it('tests maxEntries with cleanup', async () => { + const storage = buildMemoryStorage(false, false, 3); + + await storage.set('exp', { + state: 'cached', + createdAt: Date.now() - 1000, + ttl: 500, + data: { ...EMPTY_RESPONSE, data: 'data' } + }); + + await storage.set('not exp', { + state: 'cached', + createdAt: Date.now(), + ttl: 1000 * 60 * 5, // 5 Minutes + data: { ...EMPTY_RESPONSE, data: 'data' } + }); + + await storage.set('exp2', { + state: 'cached', + createdAt: Date.now() - 1000, + ttl: 500, + data: { ...EMPTY_RESPONSE, data: 'data' } + }); + + expect(Object.keys(storage.data)).toHaveLength(3); + expect(storage.data['exp']).toBeDefined(); + expect(storage.data['not exp']).toBeDefined(); + expect(storage.data['exp2']).toBeDefined(); + expect(storage.data['key']).toBeUndefined(); + + await storage.set('key', { + state: 'cached', + createdAt: Date.now(), + ttl: 1000 * 60 * 5, // 5 Minutes + data: { ...EMPTY_RESPONSE, data: 'data' } + }); + + expect(Object.keys(storage.data)).toHaveLength(2); + + expect(storage.data['exp']).toBeUndefined(); + expect(storage.data['exp2']).toBeUndefined(); + expect(storage.data['not exp']).toBeDefined(); + expect(storage.data['key']).toBeDefined(); + }); });