Skip to content

Commit

Permalink
Add ignoreFetchAbort and allowStaleOnFetchAbort
Browse files Browse the repository at this point in the history
Fix: #268
  • Loading branch information
isaacs committed Feb 22, 2023
1 parent 4218b2c commit cca36d5
Show file tree
Hide file tree
Showing 4 changed files with 285 additions and 14 deletions.
56 changes: 56 additions & 0 deletions README.md
Expand Up @@ -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
Expand Down
56 changes: 54 additions & 2 deletions index.d.ts
Expand Up @@ -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
*/
Expand All @@ -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}
Expand Down Expand Up @@ -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<K, V> {
allowStale?: boolean
Expand All @@ -632,6 +682,8 @@ declare namespace LRUCache {
noUpdateTTL?: boolean
noDeleteOnFetchRejection?: boolean
allowStaleOnFetchRejection?: boolean
ignoreFetchAbort?: boolean
allowStaleOnFetchAbort?: boolean
}

/**
Expand Down
63 changes: 51 additions & 12 deletions index.js
Expand Up @@ -168,6 +168,8 @@ class LRUCache {
noDeleteOnFetchRejection,
noDeleteOnStaleGet,
allowStaleOnFetchRejection,
allowStaleOnFetchAbort,
ignoreFetchAbort,
} = options

// deprecated options, don't trigger a warning for getting them if
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -854,6 +891,8 @@ class LRUCache {
noUpdateTTL,
noDeleteOnFetchRejection,
allowStaleOnFetchRejection,
allowStaleOnFetchAbort,
ignoreFetchAbort,
signal,
}

Expand Down
124 changes: 124 additions & 0 deletions test/fetch.ts
Expand Up @@ -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<number, number>({
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<number, number>({
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<number, number>({
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)
})

0 comments on commit cca36d5

Please sign in to comment.