Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions

name: Node.js CI

on:
push:
branches: ['main', 'master']
pull_request:
branches: ['main', 'master']

jobs:
build:
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [14.x, 16.x, 18.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/

steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- run: yarn install
- run: yarn run coverage
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "omnesiac",
"version": "0.0.7-unpublished",
"version": "1.0.0",
"description": "Mutually-Exclusive, Asynchronous Memoization",
"main": "dist/index.js",
"types": "dist/types/index.d.ts",
Expand Down
19 changes: 11 additions & 8 deletions src/OmnesiacCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import should = require('should');
import OmnesiacCache from './OmnesiacCache';
import * as sinon from 'sinon';

function wait(ms: number, result?: unknown): Promise<unknown> {
return new Promise((resolve) => {
setTimeout(() => resolve(result), ms);
describe('OmnesiacCache', () => {
let clock: sinon.SinonFakeTimers;

before(() => {
clock = sinon.useFakeTimers();
});
}

describe('OmnesiacCache', () => {
after(() => {
clock.restore();
});
describe('set()', () => {
it('should cache a value, and remove after TTL has expired', async () => {
const cache = new OmnesiacCache();
Expand All @@ -19,11 +22,11 @@ describe('OmnesiacCache', () => {
cache.set(key, { ttl });
cache.set(otherKey, { ttl: 0 });

await wait(ttl / 2);
await clock.tickAsync(ttl / 2);
cache.get(key).should.be.an.Object().with.property('ttl').eql(ttl);

removeSpy.calledOnceWith(key).should.be.false();
await wait(ttl / 2 + 1);
await clock.tickAsync(ttl / 2);
removeSpy.calledOnceWith(key).should.be.true();

should(cache.get(key)).not.be.ok();
Expand All @@ -39,7 +42,7 @@ describe('OmnesiacCache', () => {
cache.set(key, { ttl });
cache.set(otherKey, { ttl: 0 });

await wait(50);
await clock.tickAsync(50);

cache.get(key).should.be.an.Object().with.property('ttl').eql(ttl);

Expand Down
1 change: 1 addition & 0 deletions src/OmnesiacCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface OmnesiacResult<T> {
ttl?: number;
inFlight?: boolean;
result?: T;
error?: Error | false;
}

export default class OmnesiacCache<T> {
Expand Down
198 changes: 151 additions & 47 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,63 @@
import should = require('should');
import Omnesiac = require('./index');
import * as sinon from 'sinon';

function wait(ms: number, result?: unknown): Promise<unknown> {
return new Promise((resolve) => {
setTimeout(() => resolve(result), ms);
import { before } from 'mocha';

function wait(ms: number, result?: unknown, error?: string): Promise<unknown> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (error) reject(new Error(error));
resolve(result);
}, ms);
});
}

interface AsyncTestResult {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result?: any;
time: number;
seq: number;
}

class Counter {
private count = 0;
get next(): number {
return ++this.count;
}
reset() {
this.count = 0;
}
}

describe('Omnesiac', () => {
let clock: sinon.SinonFakeTimers;
const counter = new Counter();

before(() => {
clock = sinon.useFakeTimers();
});

after(() => {
clock.restore();
counter.reset();
});

describe('blocking = false', () => {
it('should only process one request at a time, concurrent requests should not be blocked by in-flight', async () => {
const fn = sinon.spy(wait);
const omnesized = Omnesiac(fn, { blocking: false });

const wrapper = async (): Promise<AsyncTestResult> => {
const result = await omnesized('key', 50, 'waited for 50');
return { result, time: Date.now() };
const wrapper = async (val: unknown = 'Unspecified Value'): Promise<AsyncTestResult> => {
const result = await omnesized('key', 50, val);
return { result, seq: counter.next };
};

const [{ result: result1, time: time1 }, { result: result2, time: time2 }, { result: result3, time: time3 }] =
await Promise.all([wrapper(), wrapper(), wrapper()]);
const resultPromises = Promise.all([wrapper(), wrapper(), wrapper()]);
await clock.runAllAsync();
const [{ result: result1, seq: seq1 }, { result: result2, seq: seq2 }, { result: result3, seq: seq3 }] =
await resultPromises;

time1.should.be.a.Number().greaterThan(time2);
time1.should.be.a.Number().greaterThan(time3);
seq1.should.be.a.Number().greaterThan(seq2);
seq1.should.be.a.Number().greaterThan(seq3);

should(result1).be.ok();
should(result2).not.be.ok();
Expand All @@ -38,50 +66,76 @@ describe('Omnesiac', () => {
fn.calledOnce.should.be.true;
});
});

describe('blocking = true', () => {
it('should only process one request at a time, concurrent requests should block during in-flight and return result', async () => {
const fn = sinon.spy(wait);
const omnesized = Omnesiac(fn, { blocking: true });

let counter = 1;
const wrapper = async (): Promise<AsyncTestResult> => {
const result = await omnesized('key', 100, counter++);
return { result, time: Date.now() };
const wrapper = async (val: unknown = 'Unspecified Value'): Promise<AsyncTestResult> => {
const result = await omnesized('key', 100, val);
return { result, seq: counter.next };
};

const [{ result: result1, time: time1 }, { result: result2, time: time2 }, { result: result3, time: time3 }] =
await Promise.all([wrapper(), wrapper(), wrapper()]);

time1.should.be.a.Number().lessThanOrEqual(time2);
time1.should.be.a.Number().lessThanOrEqual(time3);

should(result1).be.ok();
should(result2).be.ok();
should(result3).be.ok();
const resultPromises = Promise.all([wrapper(1), wrapper(2), wrapper(3)]);
await clock.runAllAsync();
const [{ result: result1, seq: seq1 }, { result: result2, seq: seq2 }, { result: result3, seq: seq3 }] =
await resultPromises;

result1.should.be.a.Number().eql(1);
result2.should.be.a.Number().eql(result1);
result3.should.be.a.Number().eql(result1);

seq1.should.be.a.Number().lessThan(seq2);
seq2.should.be.a.Number().lessThan(seq3);

fn.calledOnce.should.be.true;
});
describe('Exceptions', () => {
it('should throw an exception', async () => {
const fn = sinon.spy(wait);
const omnesized = Omnesiac(fn, { blocking: true });

const wrapper = async (val: unknown = 'Unspecified Value', error?: string): Promise<AsyncTestResult> => {
const result = await omnesized('key', 100, val, error);
return { result, seq: counter.next };
};

const pOne = wrapper(1, 'You Been Errored');
const pTwo = wrapper(2);
const pThree = wrapper(3);
clock.runAll();
await pOne.should.be.rejectedWith('You Been Errored');
await clock.runAllAsync();
await pTwo.should.not.be.rejected();
await pThree.should.not.be.rejected();
const { result: result2, seq: seq2 } = await pTwo;
const { result: result3, seq: seq3 } = await pThree;

result2.should.be.a.Number().eql(2);
result3.should.be.a.Number().eql(result2);

seq2.should.be.a.Number().greaterThan(1);
seq2.should.be.a.Number().lessThan(seq3);

fn.calledOnce.should.be.true;
});
});
});

describe('ttl = ?', () => {
it('should memoize the results of the function until the ttl has expired', async () => {
const fn = sinon.spy(wait);
const omnesized = Omnesiac(fn, { blocking: true, ttl: 75 });
const omnesized = Omnesiac(fn, { blocking: true, ttl: 200 });

let counter = 1;
const wrapper = async (): Promise<AsyncTestResult> => {
const result = await omnesized('key', 50, counter++);
return { result, time: Date.now() };
const wrapper = async (val: unknown = 'Unspecified Value'): Promise<AsyncTestResult> => {
const result = await omnesized('key', 100, val);
return { result, seq: counter.next };
};

const [{ result: result1, time: time1 }, { result: result2, time: time2 }, { result: result3, time: time3 }] =
await Promise.all([wrapper(), wrapper(), wrapper()]);

time1.should.be.a.Number().lessThanOrEqual(time2);
time1.should.be.a.Number().lessThanOrEqual(time3);
let resultPromises = Promise.all([wrapper(1), wrapper(2), wrapper(3)]);
await clock.tickAsync(100);
const [{ result: result1 }, { result: result2 }, { result: result3 }] = await resultPromises;

should(result1).be.ok();
should(result2).be.ok();
Expand All @@ -93,26 +147,76 @@ describe('Omnesiac', () => {

fn.callCount.should.be.a.Number().eql(1);

await wait(100);

const [{ result: result4, time: time4 }, { result: result5, time: time5 }, { result: result6, time: time6 }] =
await Promise.all([wrapper(), wrapper(), wrapper()]);
await clock.tickAsync(50);

time4.should.be.a.Number().greaterThan(time1);
time4.should.be.a.Number().greaterThan(time2);
time4.should.be.a.Number().greaterThan(time3);
time4.should.be.a.Number().lessThanOrEqual(time5);
time4.should.be.a.Number().lessThanOrEqual(time6);
resultPromises = Promise.all([wrapper(4), wrapper(5), wrapper(6)]);
await clock.tickAsync(50);
const [{ result: result4 }, { result: result5 }, { result: result6 }] = await resultPromises;

should(result4).be.ok();
should(result5).be.ok();
should(result6).be.ok();

result4.should.be.a.Number().eql(4);
result5.should.be.a.Number().eql(result4);
result6.should.be.a.Number().eql(result4);
result4.should.be.a.Number().eql(result1);
result5.should.be.a.Number().eql(result1);
result6.should.be.a.Number().eql(result1);

fn.callCount.should.be.a.Number().eql(1);

await clock.tickAsync(100);

resultPromises = Promise.all([wrapper(7), wrapper(8), wrapper(9)]);
await clock.runAllAsync();
const [{ result: result7 }, { result: result8 }, { result: result9 }] = await resultPromises;

should(result7).be.ok();
should(result8).be.ok();
should(result9).be.ok();

result7.should.be.a.Number().eql(7);
result8.should.be.a.Number().eql(result7);
result9.should.be.a.Number().eql(result7);

fn.callCount.should.be.a.Number().eql(2);
});
it('should cache forever if ttl = 0 (default)', async () => {
const fn = sinon.spy(wait);
const omnesized = Omnesiac(fn, { blocking: true });

const wrapper = async (val: unknown = 'Unspecified Value'): Promise<AsyncTestResult> => {
const result = await omnesized('key', 100, val);
return { result, seq: counter.next };
};

let resultPromises = Promise.all([wrapper(1), wrapper(2), wrapper(3)]);
await clock.tickAsync(100);
const [{ result: result1 }, { result: result2 }, { result: result3 }] = await resultPromises;

should(result1).be.ok();
should(result2).be.ok();
should(result3).be.ok();

result1.should.be.a.Number().eql(1);
result2.should.be.a.Number().eql(result1);
result3.should.be.a.Number().eql(result1);

fn.callCount.should.be.a.Number().eql(1);

await clock.tickAsync(1e9);

resultPromises = Promise.all([wrapper(4), wrapper(5), wrapper(6)]);
await clock.runAllAsync();
const [{ result: result4 }, { result: result5 }, { result: result6 }] = await resultPromises;

should(result4).be.ok();
should(result5).be.ok();
should(result6).be.ok();

result4.should.be.a.Number().eql(result1);
result5.should.be.a.Number().eql(result1);
result6.should.be.a.Number().eql(result1);

fn.callCount.should.be.a.Number().eql(1);
});
});
});
19 changes: 13 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,23 @@ export = function Omnesiac<T extends (...args: any[]) => any>(

return async function (key: string, ...args: Parameters<T>): Promise<ReturnType<T> | void> {
const val = cache.get(key);
if (!val) {
cache.set(key, { inFlight: true });
const retVal = await fn(...args);
cache.set(key, { ttl, inFlight: false, result: retVal });
return retVal;
} else if (val.inFlight) {
if (val?.inFlight && blocking) {
while (val.inFlight && blocking) {
await wait(pollFrequencyMs);
}
}

if (!val || val.error) {
cache.set(key, { inFlight: true, error: false });
try {
const retVal = await fn(...args);
cache.set(key, { ttl, inFlight: false, result: retVal, error: false });
return retVal;
} catch (err) {
cache.set(key, { ttl: 0, inFlight: false, error: err as Error });
throw err;
}
}
return val.result;
};
};