Skip to content

Commit

Permalink
feat: memory storage cleanup
Browse files Browse the repository at this point in the history
  • Loading branch information
arthurfiorette committed Mar 19, 2023
1 parent 360ba57 commit 72de39c
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 33 deletions.
8 changes: 7 additions & 1 deletion docs/src/guide/storages.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,19 @@ option to clone the response before saving it. _Just like
[#136](https://github.com/arthurfiorette/axios-cache-interceptor/issues/163) and many
others._

For long running processes, you can avoid memory leaks by using playing with the
`cleanupInterval` option.

```ts
import Axios from 'axios';
import { setupCache, buildMemoryStorage } from 'axios-cache-interceptor';

setupCache(axios, {
// You don't need to to that, as it is the default option.
storage: buildMemoryStorage(/* cloneData default=*/ false)
storage: buildMemoryStorage(
/* cloneData default=*/ false,
/* cleanupInterval default=*/ 1000 * 60 * 60 /* 1 hour */
)
});
```

Expand Down
9 changes: 8 additions & 1 deletion src/storage/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ export function canStale(value: CachedStorageValue): boolean {
return true;
}

return value.state === 'cached' && value.staleTtl !== undefined && value.staleTtl > 0;
return (
value.state === 'cached' &&
value.staleTtl !== undefined &&
value.createdAt + value.ttl + value.staleTtl <= Date.now()
);
}

/**
Expand Down Expand Up @@ -97,11 +101,13 @@ export function buildStorage({ set, find, remove }: BuildStorage): AxiosStorage
return value;
}

// Handle cached values
if (value.state === 'cached') {
if (!isExpired(value)) {
return value;
}

// Tries to stale expired value
if (!canStale(value)) {
await remove(key, config);
return { state: 'empty' };
Expand All @@ -113,6 +119,7 @@ export function buildStorage({ set, find, remove }: BuildStorage): AxiosStorage
data: value.data,
ttl: value.staleTtl !== undefined ? value.staleTtl + value.ttl : undefined
};

await set(key, value, config);
}

Expand Down
41 changes: 39 additions & 2 deletions src/storage/memory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { buildStorage } from './build';
import { buildStorage, canStale, isExpired } from './build';
import type { AxiosStorage, StorageValue } from './types';

/**
Expand Down Expand Up @@ -32,8 +32,11 @@ declare const structuredClone: (<T>(value: T) => T) | undefined;
*
* @param {boolean} cloneData If the data returned by `find()` should be cloned to avoid
* mutating the original data outside the `set()` method.
*
* @param {number} cleanupInterval The interval in milliseconds to run a
* setInterval job of cleaning old entries. If false, the job will not be created. Defaults to 1 hour
*/
export function buildMemoryStorage(cloneData = false) {
export function buildMemoryStorage(cloneData = false, cleanupInterval = 1000 * 60 * 60) {
const storage = buildStorage({
set: (key, value) => {
storage.data[key] = value;
Expand Down Expand Up @@ -61,9 +64,43 @@ export function buildMemoryStorage(cloneData = false) {

storage.data = Object.create(null) as Record<string, StorageValue>;

// 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.
storage.cleaner = setInterval(() => {
const keys = Object.keys(storage.data);

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]!);

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);
}
}
}, cleanupInterval);

return storage;
}

export type MemoryStorage = AxiosStorage & {
data: Record<string, StorageValue>;
/** The job responsible to cleaning old entries */
cleaner: ReturnType<typeof setInterval>;
};
29 changes: 1 addition & 28 deletions test/interceptors/hydrate.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Header } from '../../src';
import { Header } from '../../src/header/headers';
import { mockAxios } from '../mocks/axios';
import { sleep } from '../utils';

Expand Down Expand Up @@ -40,33 +40,6 @@ describe('Hydrate works', () => {
expect(res2.cached).toBe(true);
});

it('hydrates when cache is stale', async () => {
const axios = mockAxios(
{},
{ [Header.CacheControl]: 'max-age=0, stale-while-revalidate=100' }
);
const id = 'some-unique-id';

const mock = jest.fn();

await axios.get('url', {
id,
cache: { hydrate: mock }
});

expect(mock).not.toHaveBeenCalled();

const cache = await axios.storage.get(id);
const res2 = await axios.get('url', {
id,
cache: { hydrate: mock }
});

expect(mock).toHaveBeenCalledTimes(1);
expect(res2.cached).toBe(false);
expect(mock).toHaveBeenCalledWith(cache);
});

it('hydrates when etag is set', async () => {
const axios = mockAxios(
{},
Expand Down
73 changes: 72 additions & 1 deletion test/storage/memory.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Header } from '../../src/header/headers';
import { buildMemoryStorage } from '../../src/storage/memory';
import type { CachedStorageValue } from '../../src/storage/types';
import { EMPTY_RESPONSE } from '../utils';
import { EMPTY_RESPONSE, sleep } from '../utils';
import { testStorage } from './storages';

describe('tests memory storage', () => {
Expand Down Expand Up @@ -36,4 +37,74 @@ describe('tests memory storage', () => {
expect(result2.state).toBe('cached');
expect(result2.data?.data).toBe('data');
});

it('tests cleanup function', async () => {
const storage = buildMemoryStorage(false, 500);

//@ts-expect-error - this is indeed wrongly behavior
await storage.set('empty', { state: 'empty' });
await storage.set('stale', {
state: 'stale',
ttl: 1000,
createdAt: Date.now() - 2000,
previous: 'stale',
data: {
status: 200,
statusText: '200 OK',
headers: { [Header.XAxiosCacheEtag]: 'ETAG-VALUE' }
}
});
await storage.set('expiredStale', {
state: 'stale',
ttl: 1000,
createdAt: Date.now() - 2000,
previous: 'stale',
data: {
status: 200,
statusText: '200 OK',
headers: { [Header.XAxiosCacheStaleIfError]: true }
}
});
await storage.set('loading', { previous: 'empty', state: 'loading' });
await storage.set('cached', {
data: {
status: 200,
statusText: '200 OK',
headers: {}
},
ttl: 5000,
createdAt: Date.now() - 500,
state: 'cached'
});
await storage.set('expiredCache', {
data: {
status: 200,
statusText: '200 OK',
headers: {}
},
ttl: 1000,
createdAt: Date.now() - 1500,
state: 'cached'
});

// Ensure that the values are still there
expect(storage.data['empty']).toMatchObject({ state: 'empty' });
expect(storage.data['stale']).toMatchObject({ state: 'stale' });
expect(storage.data['expiredStale']).toMatchObject({ state: 'stale' });
expect(storage.data['loading']).toMatchObject({ state: 'loading' });
expect(storage.data['cached']).toMatchObject({ state: 'cached' });
expect(storage.data['expiredCache']).toMatchObject({
state: 'cached'
});

// Waits for the cleanup function to run
await sleep(600);

await expect(storage.get('empty')).resolves.toMatchObject({ state: 'empty' });
await expect(storage.get('stale')).resolves.toMatchObject({ state: 'stale' });
await expect(storage.get('expiredStale')).resolves.toMatchObject({ state: 'empty' });
await expect(storage.get('loading')).resolves.toMatchObject({ state: 'loading' });
await expect(storage.get('cached')).resolves.toMatchObject({ state: 'cached' });
await expect(storage.get('expiredCache')).resolves.toMatchObject({ state: 'empty' });
});
});

0 comments on commit 72de39c

Please sign in to comment.