Skip to content

Commit

Permalink
add ttl timeout watcher (#44)
Browse files Browse the repository at this point in the history
* add ttl timeout watcher

* fix upper scope lint warning
  • Loading branch information
denbon05 authored Jul 7, 2024
1 parent bc63018 commit 701e3c7
Show file tree
Hide file tree
Showing 9 changed files with 125 additions and 43 deletions.
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ axios.get(url);
statusesToCache?: [200],
methodsToCache?: ['GET'],
excludeRoutes?: [],
ttlCheckIntervalMs?: 1000,
}
```

Expand Down
42 changes: 32 additions & 10 deletions __tests__/lcache.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { TLL_CHECK_INTERVAL_MS } from '@/constants';
import '@/types/fastify';
import type { RequestMethod } from '@/types/lcache';
import type { FastifyInstance } from 'fastify';
Expand Down Expand Up @@ -270,30 +269,53 @@ describe('Disabled lcache plugin', () => {
);
});

describe('Interval cleanup', () => {
describe('TTL', () => {
const TLL_CHECK_INTERVAL_MS = 1500;
const msToWait = TLL_CHECK_INTERVAL_MS * 3;
// convert to minutes for lcache usage
const ttlInMinutes = TLL_CHECK_INTERVAL_MS / 60000;
let app: FastifyInstance;

beforeEach(async () => {
app = await getApp({
ttlInMinutes,
});
beforeEach(() => {
// increase timeout of the tests
jest.setTimeout(msToWait * 4);
});

afterEach(async () => {
await app.close();
jest.clearAllTimers();
});

test('Cached data should be removed after ttl', async () => {
test('Cached data should be removed after ttl - timeout', async () => {
app = await getApp({
ttlInMinutes,
});

const key = 'someKey';
const value = 'someValue';

app.lcache.set(key, value);
// wait increased ttl time
await new Promise((resolve) => {
setTimeout(resolve, msToWait);
});

expect(app.lcache.get(key)).toBeUndefined();
expect(app.lcache.has(key)).toBeFalsy();
});

test('Cached data should be removed after ttl - interval', async () => {
app = await getApp({
ttlInMinutes,
ttlCheckIntervalMs: TLL_CHECK_INTERVAL_MS,
});

const key = 'someKey';
const value = 'someValue';

app.lcache.set(key, value);
// wait doubled ttl time
// wait increased ttl time
await new Promise((resolve) => {
setTimeout(resolve, TLL_CHECK_INTERVAL_MS * 2);
setTimeout(resolve, msToWait);
});

expect(app.lcache.get(key)).toBeUndefined();
Expand Down
1 change: 0 additions & 1 deletion lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,3 @@ import type { RequestMethod } from './types/lcache';
export const TTL_IN_MINUTES = 5;
export const STATUSES_TO_CACHE = [200];
export const METHODS_TO_CACHE: RequestMethod[] = ['GET'];
export const TLL_CHECK_INTERVAL_MS = 1000;
2 changes: 1 addition & 1 deletion lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const formatOptions = (opts: ICacheOptions): ICachePluginOptions => ({
ttl: getMilliseconds(opts.ttlInMinutes),
});

export const checkShouldBeCached = (
export const shouldDataBeCached = (
opts: ICachePluginOptions,
request: FastifyRequest,
statusCode: number
Expand Down
18 changes: 10 additions & 8 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,34 @@
import { checkShouldBeCached, formatOptions } from '@/helpers';
import * as constants from '@/constants';
import { formatOptions, shouldDataBeCached } from '@/helpers';
import MapStorage from '@/storage/Map';
import type { ICacheOptions } from '@/types/lcache';
import { buildCacheKey } from '@/utils';
import type { FastifyInstance, FastifyPluginCallback } from 'fastify';
import fp from 'fastify-plugin';
import * as constants from '@/constants';

const defaultOpts: ICacheOptions = {
ttlInMinutes: constants.TTL_IN_MINUTES,
statusesToCache: constants.STATUSES_TO_CACHE,
methodsToCache: constants.METHODS_TO_CACHE,
ttlCheckIntervalMs: constants.TLL_CHECK_INTERVAL_MS,
};

const cache: FastifyPluginCallback<ICacheOptions> = (
instance: FastifyInstance,
opts: ICacheOptions,
_next
next
) => {
const storageOpts = formatOptions({ ...defaultOpts, ...opts });
const pluginOpts = formatOptions({ ...defaultOpts, ...opts });

const storage = new MapStorage(storageOpts);
const storage = new MapStorage({
ttl: pluginOpts.ttl,
ttlCheckIntervalMs: pluginOpts.ttl,
});

instance.addHook('onSend', async (request, reply, payload) => {
const cacheKey = buildCacheKey(request);
const shouldValueBeCached =
!storage.has(cacheKey) &&
checkShouldBeCached(storageOpts, request, reply.statusCode) &&
shouldDataBeCached(pluginOpts, request, reply.statusCode) &&
!opts.disableCache;

if (shouldValueBeCached) {
Expand Down Expand Up @@ -56,7 +58,7 @@ const cache: FastifyPluginCallback<ICacheOptions> = (

instance.decorate('lcache', storage);

_next();
next();
};

const lcache = fp(cache, {
Expand Down
89 changes: 70 additions & 19 deletions lib/storage/Map.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,116 @@
import { TLL_CHECK_INTERVAL_MS } from '@/constants';
import type { IStorage, IStorageOptions, Src, SrcMeta } from '../types/Storage';
import type {
IStorage,
IStorageOptions,
Src,
SrcMeta,
StorageWatcherMethod,
} from '../types/Storage';

export default class Storage implements IStorage {
/** How often check the outdated cached data in ms */
private ttlCheckIntervalMs = TLL_CHECK_INTERVAL_MS;

private src: Src = new Map();

private options: IStorageOptions;

private srcMeta: SrcMeta = new Map();

private intervalId: ReturnType<typeof setInterval>;
private timeoutId: NodeJS.Timeout;

private watcherMethod: StorageWatcherMethod = 'timeout';

/** Is timeout watcher enabled */
private isWatcherEnabled = false;

/** @experimental Could be deprecated in the future releases */
private initCacheCleaner = () => {
this.intervalId = setInterval(() => {
this.timeoutId = setInterval(() => {
this.srcMeta.forEach(({ updatedAt }, key) => {
const isTtlOutdate = Date.now() - updatedAt > this.options.ttl;
if (isTtlOutdate) {
const isTTLOutdated = Date.now() - updatedAt > this.options.ttl;
if (isTTLOutdated) {
this.reset(key);
}
});
}, this.ttlCheckIntervalMs);
}, this.options.ttlCheckIntervalMs);
};

/** Watch cached data based on timeout range between cached values */
private watchOutdated = () => {
// initial values
let minDiff = Infinity;
// most recent outdated key
let recentOutdatedKey: string;
this.srcMeta.forEach(({ updatedAt }, key) => {
const diff = this.options.ttl - updatedAt;
if (diff < minDiff) {
minDiff = diff;
recentOutdatedKey = key;
}
});
this.timeoutId = setTimeout(
() => {
this.reset(recentOutdatedKey);

if (this.isWatcherEnabled) {
this.watchOutdated();
}
},
minDiff > 0 ? minDiff : 0
);
};

/**
* Init storage options
* @param {IStorageOptions} options Init storage options
*/
public constructor(options: IStorageOptions) {
this.options = { ...this.options, ...options };
this.initCacheCleaner();

if (typeof this.options.ttlCheckIntervalMs !== 'undefined') {
this.watcherMethod = 'interval';
// watch cached value by interval value provided by client
this.initCacheCleaner();
}
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
public get = (key: string): any => this.src.get(key);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
public set = (key: string, value: any): void => {
if (this.watcherMethod === 'timeout' && !this.isWatcherEnabled) {
// enable watcher of outdated data
this.watchOutdated();
}

this.src.set(key, value);
this.srcMeta.set(key, { updatedAt: Date.now() });
};

public has = (key: string): boolean => this.src.has(key);

/** Remove value by specified key or keys.
* @note Remove all cached values if argument not specified. */
public reset = (key?: string | string[]): void => {
if (typeof key === 'string') {
this.src.delete(key);
return;
}

if (Array.isArray(key)) {
this.srcMeta.delete(key);
} else if (Array.isArray(key)) {
key.forEach((k) => {
this.src.delete(k);
this.srcMeta.delete(k);
});
return;
} else {
// reset all cached data
this.src.clear();
this.srcMeta.clear();
}

this.src.clear();
if (this.srcMeta.size === 0) {
// there is not data to watch - mark watcher as disabled
this.isWatcherEnabled = false;
}
};

public destroy = (): void => {
clearInterval(this.intervalId);
this.reset(); // prune all data
clearTimeout(this.timeoutId);
};
}
3 changes: 3 additions & 0 deletions lib/types/Storage.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface IStorageOptions {
ttl?: number;
ttlCheckIntervalMs?: number;
}

export type StorageWatcherMethod = 'interval' | 'timeout';

export type Src = Map<string, any>;
export type SrcMeta = Map<string, { updatedAt: number }>;

Expand Down
10 changes: 8 additions & 2 deletions lib/types/lcache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,36 @@ export interface ICachePluginOptions extends IStorageOptions {
excludeRoutes?: Set<string>;
}

export interface ICacheOptions<> {
export interface ICacheOptions {
/** Specify is plugin should be disabled
* @default false
*/
disableCache?: boolean;

/** Time to live - remove cached data after a certain time specified in minutes
* @default 5
*/
ttlInMinutes?: number;

/** HTTP methods for which cache is applied
* @default ['GET']
*/
methodsToCache?: RequestMethod[];

/** Response statuses which should be cached
* @default [200]
*/
statusesToCache?: number[];

/** Routes which should be ignored by lcache plugin
* @default []
*/
excludeRoutes?: string[];

/** How often check the outdated cached data in ms
* @since v2.1.0
* @default 1000
* @experimental
* By default removes data based on the timeout between cached keys
*/
ttlCheckIntervalMs?: number;
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fastify-lcache",
"version": "2.1.0",
"version": "2.1.1",
"description": "Light cache plugin for fastify",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down

0 comments on commit 701e3c7

Please sign in to comment.