diff --git a/README.md b/README.md index 8d3b72e9..64029f68 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,62 @@ This may be set in calls to `fetch()`, or defaulted on the constructor, or overridden by modifying the options object in the `fetchMethod`. +### `allowStaleOnFetchAbort` + +Set to true to return a stale value from the cache when the +`AbortSignal` passed to the `fetchMethod` dispatches an `'abort'` +event, whether user-triggered, or due to internal cache behavior. + +Unless `ignoreFetchAbort` is also set, the underlying +`fetchMethod` will still be considered canceled, and its return +value will be ignored and not cached. + +### `ignoreFetchAbort` + +Set to true to ignore the `abort` event emitted by the +`AbortSignal` object passed to `fetchMethod`, and still cache the +resulting resolution value, as long as it is not `undefined`. + +When used on its own, this means aborted `fetch()` calls are not +immediately resolved or rejected when they are aborted, and +instead take the full time to await. + +When used with `allowStaleOnFetchAbort`, aborted `fetch()` calls +will resolve immediately to their stale cached value or +`undefined`, and will continue to process and eventually update +the cache when they resolve, as long as the resulting value is +not `undefined`, thus supporting a "return stale on timeout while +refreshing" mechanism by passing `AbortSignal.timeout(n)` as the +signal. + +For example: + +```js +const c = new LRUCache({ + ttl: 100, + ignoreFetchAbort: true, + allowStaleOnFetchAbort: true, + fetchMethod: async (key, oldValue, { signal }) => { + // note: do NOT pass the signal to fetch()! + // let's say this fetch can take a long time. + const res = await fetch(`https://slow-backend-server/${key}`) + return await res.json() + }, +}) + +// this will return the stale value after 100ms, while still +// updating in the background for next time. +const val = await c.fetch('key', { signal: AbortSignal.timeout(100) }) +``` + +**Note**: regardless of this setting, an `abort` event _is still +emitted on the `AbortSignal` object_, so may result in invalid +results when passed to other underlying APIs that use +AbortSignals. + +This may be overridden on the `fetch()` call or in the +`fetchMethod` itself. + ### `dispose` Function that is called on items when they are dropped from the diff --git a/index.d.ts b/index.d.ts index 47c02bae..0820d0b7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -521,6 +521,8 @@ declare namespace LRUCache { * Set to true to suppress the deletion of stale data when a * {@link fetchMethod} throws an error or returns a rejected promise * + * This may be overridden in the {@link fetchMethod}. + * * @default false * @since 7.10.0 */ @@ -533,11 +535,59 @@ declare namespace LRUCache { * ONLY be returned in the case that the fetch fails, not any other * times. * + * This may be overridden in the {@link fetchMethod}. + * * @default false * @since 7.16.0 */ allowStaleOnFetchRejection?: boolean + /** + * + * Set to true to ignore the `abort` event emitted by the `AbortSignal` + * object passed to {@link fetchMethod}, and still cache the + * resulting resolution value, as long as it is not `undefined`. + * + * When used on its own, this means aborted {@link fetch} calls are not + * immediately resolved or rejected when they are aborted, and instead take + * the full time to await. + * + * When used with {@link allowStaleOnFetchAbort}, aborted {@link fetch} + * calls will resolve immediately to their stale cached value or + * `undefined`, and will continue to process and eventually update the + * cache when they resolve, as long as the resulting value is not + * `undefined`, thus supporting a "return stale on timeout while + * refreshing" mechanism by passing `AbortSignal.timeout(n)` as the signal. + * + * **Note**: regardless of this setting, an `abort` event _is still emitted + * on the `AbortSignal` object_, so may result in invalid results when + * passed to other underlying APIs that use AbortSignals. + * + * This may be overridden in the {@link fetchMethod} or the call to + * {@link fetch}. + * + * @default false + * @since 7.17.0 + */ + ignoreFetchAbort?: boolean + + /** + * Set to true to return a stale value from the cache when the + * `AbortSignal` passed to the {@link fetchMethod} dispatches an `'abort'` + * event, whether user-triggered, or due to internal cache behavior. + * + * Unless {@link ignoreFetchAbort} is also set, the underlying + * {@link fetchMethod} will still be considered canceled, and its return + * value will be ignored and not cached. + * + * This may be overridden in the {@link fetchMethod} or the call to + * {@link fetch}. + * + * @default false + * @since 7.17.0 + */ + allowStaleOnFetchAbort?: boolean + /** * Set to any value in the constructor or {@link fetch} options to * pass arbitrary data to the {@link fetchMethod} in the {@link context} @@ -618,8 +668,8 @@ declare namespace LRUCache { * * May be mutated by the {@link fetchMethod} to affect the behavior of the * resulting {@link set} operation on resolution, or in the case of - * {@link noDeleteOnFetchRejection} and {@link allowStaleOnFetchRejection}, - * the handling of failure. + * {@link noDeleteOnFetchRejection}, {@link ignoreFetchAbort}, and + * {@link allowStaleOnFetchRejection}, the handling of failure. */ interface FetcherFetchOptions { allowStale?: boolean @@ -632,6 +682,8 @@ declare namespace LRUCache { noUpdateTTL?: boolean noDeleteOnFetchRejection?: boolean allowStaleOnFetchRejection?: boolean + ignoreFetchAbort?: boolean + allowStaleOnFetchAbort?: boolean } /** diff --git a/index.js b/index.js index 082fd10e..8a4c2300 100644 --- a/index.js +++ b/index.js @@ -168,6 +168,8 @@ class LRUCache { noDeleteOnFetchRejection, noDeleteOnStaleGet, allowStaleOnFetchRejection, + allowStaleOnFetchAbort, + ignoreFetchAbort, } = options // deprecated options, don't trigger a warning for getting them if @@ -238,6 +240,8 @@ class LRUCache { this.noUpdateTTL = !!noUpdateTTL this.noDeleteOnFetchRejection = !!noDeleteOnFetchRejection this.allowStaleOnFetchRejection = !!allowStaleOnFetchRejection + this.allowStaleOnFetchAbort = !!allowStaleOnFetchAbort + this.ignoreFetchAbort = !!ignoreFetchAbort // NB: maxEntrySize is set to maxSize if it's set if (this.maxEntrySize !== 0) { @@ -752,39 +756,70 @@ class LRUCache { options, context, } - const cb = v => { - if (!ac.signal.aborted) { - this.set(k, v, fetchOpts.options) - return v - } else { + const cb = (v, updateCache = false) => { + const { aborted } = ac.signal + const ignoreAbort = options.ignoreFetchAbort && v !== undefined + if (aborted && !ignoreAbort && !updateCache) { return eb(ac.signal.reason) } + // either we didn't abort, and are still here, or we did, and ignored + if (this.valList[index] === p) { + if (v === undefined) { + if (p.__staleWhileFetching) { + this.valList[index] = p.__staleWhileFetching + } else { + this.delete(k) + } + } else { + this.set(k, v, fetchOpts.options) + } + } + return v } const eb = er => { + const { aborted } = ac.signal + const allowStaleAborted = + aborted && options.allowStaleOnFetchAbort + const allowStale = + allowStaleAborted || options.allowStaleOnFetchRejection + const noDelete = allowStale || options.noDeleteOnFetchRejection if (this.valList[index] === p) { // if we allow stale on fetch rejections, then we need to ensure that // the stale value is not removed from the cache when the fetch fails. - const noDelete = - options.noDeleteOnFetchRejection || - options.allowStaleOnFetchRejection const del = !noDelete || p.__staleWhileFetching === undefined if (del) { this.delete(k) - } else { + } else if (!allowStaleAborted) { // still replace the *promise* with the stale value, // since we are done with the promise at this point. + // leave it untouched if we're still waiting for an + // aborted background fetch that hasn't yet returned. this.valList[index] = p.__staleWhileFetching } } - if (options.allowStaleOnFetchRejection) { + if (allowStale) { return p.__staleWhileFetching } else if (p.__returned === p) { throw er } } const pcall = (res, rej) => { - ac.signal.addEventListener('abort', () => res()) - this.fetchMethod(k, v, fetchOpts).then(res, rej) + this.fetchMethod(k, v, fetchOpts).then(v => res(v), rej) + // ignored, we go until we finish, regardless. + // defer check until we are actually aborting, + // so fetchMethod can override. + ac.signal.addEventListener('abort', () => { + if ( + !options.ignoreFetchAbort || + options.allowStaleOnFetchAbort + ) { + res() + // when it eventually resolves, update the cache. + if (options.allowStaleOnFetchAbort) { + res = v => cb(v, true) + } + } + }) } const p = new Promise(pcall).then(cb, eb) p.__abortController = ac @@ -830,6 +865,8 @@ class LRUCache { // fetch exclusive options noDeleteOnFetchRejection = this.noDeleteOnFetchRejection, allowStaleOnFetchRejection = this.allowStaleOnFetchRejection, + ignoreFetchAbort = this.ignoreFetchAbort, + allowStaleOnFetchAbort = this.allowStaleOnFetchAbort, fetchContext = this.fetchContext, forceRefresh = false, signal, @@ -854,6 +891,8 @@ class LRUCache { noUpdateTTL, noDeleteOnFetchRejection, allowStaleOnFetchRejection, + allowStaleOnFetchAbort, + ignoreFetchAbort, signal, } diff --git a/test/fetch.ts b/test/fetch.ts index 93eac791..72ef0d9f 100644 --- a/test/fetch.ts +++ b/test/fetch.ts @@ -659,3 +659,127 @@ t.test('send a signal', async t => { t.equal(ac.signal.reason, er) t.equal(c.get(1), undefined) }) + +t.test('abort, but then keep on fetching anyway', async t => { + let aborted: Error | undefined = undefined + let resolved: boolean = false + let returnUndefined: boolean = false + const cache = new LRU({ + max: 10, + ignoreFetchAbort: true, + fetchMethod: async (k, _, { signal, options }) => { + t.equal(options.ignoreFetchAbort, true, 'aborts ignored') + signal.addEventListener('abort', () => { + aborted = signal.reason + }) + return new Promise(res => + setTimeout(() => { + resolved = true + res(returnUndefined ? undefined : k) + }, 100) + ) + }, + }) + const ac = new AbortController() + const p = cache.fetch(1, { signal: ac.signal }) + const er = new Error('ignored abort signal') + ac.abort(er) + clock.advance(100) + t.equal(await p, 1) + + t.equal(resolved, true, 'aborted, but resolved anyway') + t.equal(aborted, er) + t.equal(ac.signal.reason, er) + t.equal(cache.get(1), 1) + + const p2 = cache.fetch(2) + t.equal(cache.get(2), undefined) + cache.delete(2) + t.equal(cache.get(2), undefined) + clock.advance(100) + t.equal(await p2, 2) + t.equal(cache.get(2), undefined) + + // if aborted for cause, we don't save the fetched value + const p3 = cache.fetch(3) + t.equal(cache.get(3), undefined) + cache.set(3, 33) + t.equal(cache.get(3), 33) + clock.advance(100) + t.equal(await p3, 3) + t.equal(cache.get(3), 33) + + const e = expose(cache) + returnUndefined = true + const before = e.valList.slice() + const p4 = cache.fetch(4) + clock.advance(100) + t.equal(await p4, undefined) + t.same(e.valList, before, 'did not update values with undefined') +}) + +t.test('allowStaleOnFetchAbort', async t => { + const c = new LRUCache({ + ttl: 10, + max: 10, + allowStaleOnFetchAbort: true, + fetchMethod: async (k, _, { signal }) => { + return new Promise(res => { + const t = setTimeout(() => res(k), 100) + signal.addEventListener('abort', () => clearTimeout(t)) + }) + }, + }) + c.set(1, 10) + clock.advance(100) + const ac = new AbortController() + const p = c.fetch(1, { signal: ac.signal }) + ac.abort(new Error('gimme the stale value')) + t.equal(await p, 10) + t.equal(c.get(1, { allowStale: true }), 10) +}) + +t.test('background update on timeout, return stale', async t => { + let returnUndefined = false + const c = new LRUCache({ + ttl: 10, + max: 10, + ignoreFetchAbort: true, + allowStaleOnFetchAbort: true, + fetchMethod: async k => { + return new Promise(res => { + setTimeout(() => { + res(returnUndefined ? undefined : k) + }, 100) + }) + }, + }) + c.set(1, 10) + clock.advance(100) + const ac = new AbortController() + const p = c.fetch(1, { signal: ac.signal }) + const e = expose(c) + t.match(e.valList[0], { __staleWhileFetching: 10 }) + ac.abort(new Error('gimme the stale value')) + t.equal(await p, 10) + t.equal(c.get(1, { allowStale: true }), 10) + clock.advance(200) + await new Promise(res => setImmediate(res)) + t.equal(e.valList[0], 1, 'got updated value later') + + c.set(1, 99) + clock.advance(100) + returnUndefined = true + const ac2 = new AbortController() + const p2 = c.fetch(1, { signal: ac2.signal }) + await new Promise(res => setImmediate(res)) + t.match(e.valList[0], { __staleWhileFetching: 99 }) + ac2.abort(new Error('gimme stale 99')) + t.equal(await p2, 99) + t.match(e.valList[0], { __staleWhileFetching: 99 }) + t.equal(c.get(1, { allowStale: true }), 99) + t.match(e.valList[0], { __staleWhileFetching: 99 }) + clock.advance(200) + await new Promise(res => setImmediate(res)) + t.equal(e.valList[0], 99) +})