From 80040f86fe124b1f8b8868017b04dc412ea0c5f6 Mon Sep 17 00:00:00 2001 From: Adam Fanello Date: Thu, 19 Sep 2024 14:47:31 -0700 Subject: [PATCH] Issue #146 ExpiringValue caches failures - New default behavior: If the factory function rejects/throws, the value is returned but immediately expired. The next call to `get()` will retry. - New option on the constructor to choose to cache errors instead - the old behavior. - Major version bump because of default behavior change. --- docs/types/expiring-value.d.ts | 10 +- expiring-value/lib/expiring-value.test.ts | 45 +++++++- expiring-value/lib/expiring-value.ts | 24 +++- expiring-value/package-lock.json | 135 +++++++++++++++------- expiring-value/package.json | 8 +- 5 files changed, 169 insertions(+), 53 deletions(-) diff --git a/docs/types/expiring-value.d.ts b/docs/types/expiring-value.d.ts index 3ad2881..3da351e 100644 --- a/docs/types/expiring-value.d.ts +++ b/docs/types/expiring-value.d.ts @@ -5,6 +5,7 @@ export declare class ExpiringValue { private factoryFn; private ttl; + private options; /** Cached value */ private value; /** Epoch millisecond time of when the current value expires */ @@ -14,8 +15,13 @@ export declare class ExpiringValue { * * @param factoryFn factory to lazy-load the value * @param ttl milliseconds the value is good for, after which it is reloaded. + * @param options optional options to change behavior + * @param options.cacheError set to true to cache for TTL a Promise rejection from factoryFn. + * By default, a rejection is not cached and factoryFn will be retried upon the next call. */ - constructor(factoryFn: (() => Promise), ttl: number); + constructor(factoryFn: (() => Promise), ttl: number, options?: { + cacheError: boolean; + }); /** * Get value; lazy-load from factory if not yet loaded or if expired. */ @@ -29,4 +35,6 @@ export declare class ExpiringValue { * Is the value expired (or not set) */ isExpired(): boolean; + /** Reset the value expiration to TTL past now */ + private extendExpiration; } diff --git a/expiring-value/lib/expiring-value.test.ts b/expiring-value/lib/expiring-value.test.ts index 4661e08..5aa7095 100644 --- a/expiring-value/lib/expiring-value.test.ts +++ b/expiring-value/lib/expiring-value.test.ts @@ -1,8 +1,8 @@ -import {ExpiringValue} from "./expiring-value"; import * as MockDate from "mockdate"; +import {ExpiringValue} from "./expiring-value"; -describe('ExpiringValue',() => { +describe('ExpiringValue', () => { const baseDate = Date.now(); beforeEach(() => { @@ -18,7 +18,7 @@ describe('ExpiringValue',() => { // Initialize let sut = new ExpiringValue(() => Promise.resolve(factoryValue), 1000); - expect(sut['value']).toBeFalsy(); + expect(sut['value']).toBeUndefined(); expect(sut['expiration']).toEqual(0); // First GET - lazy created @@ -52,7 +52,7 @@ describe('ExpiringValue',() => { // Clear content.. sut.clear(); - expect(sut['value']).toBeFalsy(); + expect(sut['value']).toBeUndefined(); expect(sut['expiration']).toEqual(0); // Fourth GET - factory called again @@ -64,4 +64,41 @@ describe('ExpiringValue',() => { await expect(sut['value']).resolves.toBe('world!'); expect(sut['expiration']).toEqual(baseDate + 1000); }); + + test("doesn't cache failure", async () => { + let factoryResponse: Promise = Promise.reject(new Error()); + + // Initialize + let sut = new ExpiringValue(() => factoryResponse, 1000); + expect(sut['value']).toBeUndefined(); + expect(sut['expiration']).toEqual(0); + + // First GET - rejects - still expired + await expect(sut.get()).rejects.toThrow(); + expect(sut['expiration']).toEqual(0); + + // Second GET - calls again, success this time + factoryResponse = Promise.resolve("yay"); + const v2 = await sut.get(); + expect(v2).toBe('yay'); + await expect(sut['value']).resolves.toBe('yay'); + expect(sut['expiration']).toEqual(baseDate + 1000); + }); + + test("does cache failure when option selected", async () => { + let factoryResponse: Promise = Promise.reject(new Error()); + + // Initialize + let sut = new ExpiringValue(() => factoryResponse, 1000,{cacheError: true}); + expect(sut['value']).toBeUndefined(); + expect(sut['expiration']).toEqual(0); + + // First GET - rejects - still expired + await expect(sut.get()).rejects.toThrow(); + expect(sut['expiration']).toEqual(baseDate + 1000); + + // Second GET - calls again, uses cached failure + factoryResponse = Promise.resolve("yay"); + await expect(sut.get()).rejects.toThrow(); + }); }); diff --git a/expiring-value/lib/expiring-value.ts b/expiring-value/lib/expiring-value.ts index 31aabeb..88c98ea 100644 --- a/expiring-value/lib/expiring-value.ts +++ b/expiring-value/lib/expiring-value.ts @@ -4,7 +4,7 @@ */ export class ExpiringValue { /** Cached value */ - private value: Promise|undefined; + private value: Promise | undefined; /** Epoch millisecond time of when the current value expires */ private expiration: number = 0; @@ -14,8 +14,15 @@ export class ExpiringValue { * * @param factoryFn factory to lazy-load the value * @param ttl milliseconds the value is good for, after which it is reloaded. + * @param options optional options to change behavior + * @param options.cacheError set to true to cache for TTL a Promise rejection from factoryFn. + * By default, a rejection is not cached and factoryFn will be retried upon the next call. */ - constructor(private factoryFn: (() => Promise), private ttl: number) { + constructor( + private factoryFn: (() => Promise), + private ttl: number, + private options = {cacheError: false} + ) { } /** @@ -24,7 +31,13 @@ export class ExpiringValue { get(): Promise { if (this.isExpired()) { this.value = this.factoryFn(); - this.expiration = Date.now() + this.ttl; + + if (this.options.cacheError) { + this.extendExpiration(); + } else { + // Update expiration, only upon success + this.value.then(() => this.extendExpiration()); + } } return this.value!; @@ -45,4 +58,9 @@ export class ExpiringValue { isExpired(): boolean { return Date.now() > this.expiration; } + + /** Reset the value expiration to TTL past now */ + private extendExpiration(): void { + this.expiration = Date.now() + this.ttl; + } } diff --git a/expiring-value/package-lock.json b/expiring-value/package-lock.json index 1a3c09b..61f3da5 100644 --- a/expiring-value/package-lock.json +++ b/expiring-value/package-lock.json @@ -1,19 +1,19 @@ { "name": "@sailplane/expiring-value", - "version": "3.0.1", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sailplane/expiring-value", - "version": "3.0.1", + "version": "4.0.0", "license": "Apache-2.0", "devDependencies": { - "@types/jest": "^29.5.12", + "@types/jest": "^29.5.13", "@types/node": "^18.19.17", "jest": "^29.7.0", "mockdate": "^3.0.5", - "ts-jest": "^29.1.2", + "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "typescript": "4.9.x" @@ -1190,9 +1190,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.12", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz", - "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==", + "version": "29.5.13", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.13.tgz", + "integrity": "sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==", "dev": true, "dependencies": { "expect": "^29.0.0", @@ -1536,6 +1536,12 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1999,6 +2005,21 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.677", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.677.tgz", @@ -2210,6 +2231,36 @@ "bser": "2.1.1" } }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -2587,6 +2638,24 @@ "node": ">=8" } }, + "node_modules/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "dev": true, + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -4156,28 +4225,30 @@ } }, "node_modules/ts-jest": { - "version": "29.1.2", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.2.tgz", - "integrity": "sha512-br6GJoH/WUX4pu7FbZXuWGKGNDuU7b8Uj77g/Sp7puZV6EXzuByl6JrECvm0MzVzSTkSHWTihsXt+5XYER5b+g==", + "version": "29.2.5", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz", + "integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==", "dev": true, "dependencies": { - "bs-logger": "0.x", - "fast-json-stable-stringify": "2.x", + "bs-logger": "^0.2.6", + "ejs": "^3.1.10", + "fast-json-stable-stringify": "^2.1.0", "jest-util": "^29.0.0", "json5": "^2.2.3", - "lodash.memoize": "4.x", - "make-error": "1.x", - "semver": "^7.5.3", - "yargs-parser": "^21.0.1" + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.6.3", + "yargs-parser": "^21.1.1" }, "bin": { "ts-jest": "cli.js" }, "engines": { - "node": "^16.10.0 || ^18.0.0 || >=20.0.0" + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0", "@jest/types": "^29.0.0", "babel-jest": "^29.0.0", "jest": "^29.0.0", @@ -4187,6 +4258,9 @@ "@babel/core": { "optional": true }, + "@jest/transform": { + "optional": true + }, "@jest/types": { "optional": true }, @@ -4198,26 +4272,11 @@ } } }, - "node_modules/ts-jest/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/ts-jest/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -4225,12 +4284,6 @@ "node": ">=10" } }, - "node_modules/ts-jest/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/ts-loader": { "version": "9.5.1", "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.1.tgz", diff --git a/expiring-value/package.json b/expiring-value/package.json index 4f97d43..13a5a36 100644 --- a/expiring-value/package.json +++ b/expiring-value/package.json @@ -1,6 +1,6 @@ { "name": "@sailplane/expiring-value", - "version": "3.0.1", + "version": "4.0.0", "description": "Container for a value that is instantiated on-demand (lazy-loaded via factory) and cached for a limited time.", "keywords": [ "factory", @@ -10,7 +10,7 @@ ], "scripts": { "build": "tsc && npm link && cp dist/expiring-value.d.ts ../docs/types/", - "test": "jest", + "test": "NODE_OPTIONS=--unhandled-rejections=none jest", "test:watch": "jest --watch", "clean:publish": "rm -r dist; npm run build && npm publish --access public" }, @@ -25,11 +25,11 @@ "Adam Fanello " ], "devDependencies": { - "@types/jest": "^29.5.12", + "@types/jest": "^29.5.13", "@types/node": "^18.19.17", "jest": "^29.7.0", "mockdate": "^3.0.5", - "ts-jest": "^29.1.2", + "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "typescript": "4.9.x"