Skip to content

Commit

Permalink
feat: implement getTll from ttlFunction in constructor configuration (#6
Browse files Browse the repository at this point in the history
)
  • Loading branch information
icarolettieri authored Aug 30, 2023
1 parent a672b55 commit 55674c5
Show file tree
Hide file tree
Showing 4 changed files with 139 additions and 51 deletions.
47 changes: 37 additions & 10 deletions src/pacer.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,49 @@
import { Ttl } from './remembered-config';
import { RememberedConfig } from './remembered-config';
import Fifo = require('fast-fifo');
import { delay } from './delay';
import { performance } from 'perf_hooks';

export class Pacer<T> {
export class Pacer<TResponse = unknown, TKey = string> {
private purgeTask: PromiseLike<void> | undefined;
private toPurge = new Fifo<{
purgeTime: number;
payload: T;
callback?: (payload: T) => void;
payload: TKey;
callback?: (payload: TKey) => void;
}>();
private pace: (payload: T) => number;

constructor(pace: Ttl, private run: (payload: T) => any) {
this.pace = typeof pace === 'number' ? () => pace : pace;
constructor(
private config: RememberedConfig<TResponse, TKey>,
private run: (payload: TKey) => any,
) {}

private getTtl(
payload: TKey,
ttl: number | undefined,
response: TResponse,
): number {
if (ttl !== undefined) return ttl;

return typeof this.config.ttl === 'number'
? this.config.ttl
: this.config.ttl(payload, response);
}

schedulePurge(payload: T, callback?: (payload: T) => void) {
const purgeTime = Date.now() + this.pace(payload);
schedulePurge(
payload: TKey,
ttl: number | undefined,
response: TResponse,
callback?: (payload: TKey) => void,
) {
const now = performance.now();
ttl = this.getTtl(payload, ttl, response);

if (ttl === 0) {
this.run(payload);
return;
}

const purgeTime = now + ttl;

this.toPurge.push({ purgeTime, payload, callback });
if (!this.purgeTask) {
this.purgeTask = this.wait();
Expand All @@ -26,7 +53,7 @@ export class Pacer<T> {
private async wait(): Promise<void> {
const current = this.toPurge.shift();
if (current) {
const waiting = current.purgeTime - Date.now();
const waiting = current.purgeTime - performance.now();
if (waiting > 0) {
await delay(waiting);
}
Expand Down
13 changes: 10 additions & 3 deletions src/remembered-config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
export type Ttl = number | (<T>(request: T) => number);
export type TtlFunction<TResponse = unknown, TKey = string> = (
key: TKey,
response?: TResponse,
) => number;

export interface RememberedConfig {
ttl: Ttl;
export type Ttl<TResponse = unknown, TKey = string> =
| number
| TtlFunction<TResponse, TKey>;

export interface RememberedConfig<TResponse = unknown, TKey = string> {
ttl: Ttl<TResponse, TKey>;
/**
* Always keep a persistent last result for the cache when there is one, so the cache can be updated in the background
*/
Expand Down
80 changes: 43 additions & 37 deletions src/remembered.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,77 +2,79 @@ import { dontWait } from './dont-wait';
import { Pacer } from './pacer';
import { RememberedConfig } from './remembered-config';

const Empty = Symbol('Empty');

const defaultConfig = { ttl: 0 };
/**
* A class that help you remember previous calls for you functions, to avoid new calls while it is not forgotten
*/
export class Remembered {
private map = new Map<string, Promise<unknown>>();
private nonBlockingMap = new Map<string, unknown>();
private pacer: Pacer<string> | undefined;
export class Remembered<TResponse = unknown, TKey = string> {
private map = new Map<TKey, Promise<TResponse>>();
private nonBlockingMap = new Map<TKey, TResponse>();
private pacer: Pacer<TResponse, TKey> | undefined;
private removeImmediately: boolean;
private onReused?: (...args: any[]) => void;

constructor(private config: RememberedConfig = defaultConfig) {
constructor(
private config: RememberedConfig<TResponse, TKey> = defaultConfig,
) {
this.removeImmediately = !config.ttl;
this.onReused = config.onReused;
this.pacer = config.ttl
? new Pacer(config.ttl, (key: string) => this.map.delete(key))
: undefined;
this.pacer = new Pacer(config, (key: TKey) => this.map.delete(key));
}

/**
* Returns a remembered promise or the resulted promise from the callback
* @param key the remembering key, for remembering purposes
* @param callback the callback in case nothing is remember
* @param noCacheIf a optional condition that, when informed, the cache is not kept
* @param noCacheIf an optional condition that, when informed, the cache is not kept
* @param ttl an optional ttl that, when informed, replaces the ttl informed in the constructor configuration
* @returns the (now) remembered promise
*/
async get<T>(
key: string,
callback: () => PromiseLike<T>,
noCacheIf?: (result: T) => boolean,
async get<R extends TResponse>(
key: TKey,
callback: () => PromiseLike<R>,
noCacheIf?: (result: R) => boolean,
ttl?: number,
): Promise<T> {
): Promise<R> {
if (this.config.nonBlocking) {
if (this.nonBlockingMap.has(key)) {
dontWait(() => this.blockingGet(key, callback, noCacheIf, ttl));

return this.nonBlockingMap.get(key) as T;
return this.nonBlockingMap.get(key) as R;
}
}

return this.blockingGet(key, callback, noCacheIf, ttl);
}

getSync<T>(
key: string,
callback: () => PromiseLike<T>,
noCacheIf?: (result: T) => boolean,
getSync<R extends TResponse>(
key: TKey,
callback: () => PromiseLike<R>,
noCacheIf?: (result: R) => boolean,
ttl?: number,
): T | undefined {
): R | undefined {
if (!this.config.nonBlocking) {
throw new Error('getSync is only available for nonBlocking instances');
}
dontWait(() => this.blockingGet(key, callback, noCacheIf, ttl));

return this.nonBlockingMap.get(key) as T | undefined;
return this.nonBlockingMap.get(key) as R | undefined;
}

blockingGet<T>(
key: string,
callback: () => PromiseLike<T>,
noCacheIf?: (result: T) => boolean,
_ttl?: number,
): Promise<T> {
blockingGet<R extends TResponse>(
key: TKey,
callback: () => PromiseLike<R>,
noCacheIf?: (result: R) => boolean,
ttl?: number,
): Promise<R> {
const cached = this.map.get(key);
if (cached) {
this.onReused?.(key);
return cached as Promise<T>;
return cached as Promise<R>;
}
const value = this.loadValue(key, callback, noCacheIf);
const value = this.loadValue(key, callback, noCacheIf, ttl);
this.map.set(key, value);
this.pacer?.schedulePurge(key);
return value;
}

Expand All @@ -84,7 +86,7 @@ export class Remembered {
*/
wrap<T extends any[], K extends T, R extends Promise<any>>(
callback: (...args: T) => R,
getKey: (...args: K) => string,
getKey: (...args: K) => TKey,
noCacheIf?: (result: R extends Promise<infer TR> ? TR : never) => boolean,
): (...args: T) => R {
return (...args: T): R => {
Expand All @@ -93,17 +95,19 @@ export class Remembered {
};
}

clearCache(key: string): void | Promise<unknown> {
clearCache(key: TKey): void | Promise<unknown> {
this.map.delete(key);
}

private async loadValue<T>(
key: string,
load: () => PromiseLike<T>,
noCacheIf?: (result: T) => boolean,
private async loadValue<R extends TResponse>(
key: TKey,
load: () => PromiseLike<R>,
noCacheIf?: (result: R) => boolean,
ttl?: number,
) {
let result: R | typeof Empty = Empty;
try {
const result = await load();
result = await load();
if (noCacheIf?.(result)) {
this.map.delete(key);
} else if (this.config.nonBlocking) {
Expand All @@ -116,6 +120,8 @@ export class Remembered {
} finally {
if (this.removeImmediately) {
this.map.delete(key);
} else if (result !== Empty) {
this.pacer?.schedulePurge(key, ttl, result);
}
}
}
Expand Down
50 changes: 49 additions & 1 deletion test/unit/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Remembered } from '../../src';
import { Remembered, TtlFunction } from '../../src';
import { delay } from '../../src/delay';
import { expectCallsLike, getNames } from './setup';

Expand Down Expand Up @@ -32,6 +32,7 @@ describe(Remembered.name, () => {
expect(result3).toBe(2);
expect(result4).toBe(2);
});

it('should remember the last result until the ttl has passed when noCacheIf is informed and returns false', async () => {
let count = 0;
const getter = jest.fn().mockImplementation(async () => ++count);
Expand Down Expand Up @@ -161,6 +162,53 @@ describe(Remembered.name, () => {
await delay(1);
expectCallsLike(callback, [], []);
});

it('should persist in the remembered with ttl from request', async () => {
let count = 0;
const getter = jest.fn().mockImplementation(async () => ++count);
const key = 'key value';

const result1 = await target.get(key, getter, () => false, 30);
await delay(60);
const result2 = await target.get(key, getter, () => false, 30);

expectCallsLike(getter, [], []);
expect(result1).toBe(1);
expect(result2).toBe(2);
});

it('should persist in the remembered with tllFunction', async () => {
const ttlFunction: TtlFunction<number> = (
_key: string,
response?: number,
) => {
return response! * response! + 10;
};

const target0 = new Remembered({
ttl: ttlFunction,
});

let count = 0;
const getter = jest.fn().mockImplementation(async () => ++count);
const key = 'key value';

const result1 = await target0.get(key, getter);
await delay(8); // total ttl from result1 (11)
const result2 = await target0.get(key, getter);
await delay(10); // delay to clean value from result1

const result3 = await target0.get(key, getter);
await delay(12); // total ttl from result3 (14)
const result4 = await target0.get(key, getter);
await delay(10); // delay to clean value from result3

expectCallsLike(getter, [], []);
expect(result1).toBe(1); // first call getter
expect(result2).toBe(1); // value from cache
expect(result3).toBe(2); // second call getter
expect(result4).toBe(2); // value from cache
});
});

describe(methods.getSync, () => {
Expand Down

0 comments on commit 55674c5

Please sign in to comment.