Skip to content

Commit

Permalink
fetch: provide options and abort signal
Browse files Browse the repository at this point in the history
Fix: #216
  • Loading branch information
isaacs committed Mar 17, 2022
1 parent 243114d commit 787343d
Show file tree
Hide file tree
Showing 3 changed files with 171 additions and 33 deletions.
43 changes: 35 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,24 @@ If you put more stuff in it, then items will fall out.
Deprecated alias: `length`

* `fetchMethod` Function that is used to make background asynchronous
fetches. Called with `fetchMethod(key, staleValue)`. May return a
Promise.
fetches. Called with `fetchMethod(key, staleValue, { signal, options })`.
May return a Promise.

If `fetchMethod` is not provided, then `cache.fetch(key)` is equivalent
to `Promise.resolve(cache.get(key))`.

The `signal` object is an `AbortSignal`. If at any time,
`signal.aborted` is set to `true`, then that means that the fetch
should be abandoned. This may be passed along to async functions aware
of AbortController/AbortSignal behavior.

The `options` object is a union of the options that may be provided to
`set()` and `get()`. If they are modified, then that will result in
modifying the settings to `cache.set()` when the value is resolved.
For example, a DNS cache may update the TTL based on the value returned
from a remote DNS server by changing `options.ttl` in the
`fetchMethod`.

* `dispose` Function that is called on items when they are dropped
from the cache, as `this.dispose(value, key, reason)`.

Expand All @@ -175,6 +187,11 @@ If you put more stuff in it, then items will fall out.
* `delete` Item was removed by explicit `cache.delete(key)` or by
calling `cache.clear()`, which deletes everything.

The `dispose()` method is _not_ called for canceled calls to
`fetchMethod()`. If you wish to handle evictions, overwrites, and
deletes of in-flight asynchronous fetches, you must use the
`AbortSignal` provided.

Optional, must be a function.

* `disposeAfter` The same as `dispose`, but called _after_ the entry is
Expand All @@ -184,6 +201,11 @@ If you put more stuff in it, then items will fall out.
However, note that it is _very_ easy to inadvertently create infinite
recursion in this way.

The `disposeAfter()` method is _not_ called for canceled calls to
`fetchMethod()`. If you wish to handle evictions, overwrites, and
deletes of in-flight asynchronous fetches, you must use the
`AbortSignal` provided.

* `noDisposeOnSet` Set to `true` to suppress calling the `dispose()`
function if the entry key is still accessible within the cache.

Expand Down Expand Up @@ -337,24 +359,29 @@ If you put more stuff in it, then items will fall out.
`cache.set(key, undefined)`. Use `cache.has()` to determine whether a
key is present in the cache at all.

* `async fetch(key, { updateAgeOnGet, allowStale } = {}) => Promise`
* `async fetch(key, { updateAgeOnGet, allowStale, size, sizeCalculation, ttl, noDisposeOnSet } = {}) => Promise`

If the value is in the cache and not stale, then the returned Promise
resolves to the value.

If not in the cache, or beyond its TTL staleness, then
`fetchMethod(key, staleValue)` is called, and the value returned will
be added to the cache once resolved.
`fetchMethod(key, staleValue, options)` is called, and the value
returned will be added to the cache once resolved.

If called with `allowStale`, and an asynchronous fetch is currently in
progress to reload a stale value, then the former stale value will be
returned.

Multiple fetches for the same `key` will only call `fetchMethod` a
single time, and all will be resolved when the value is resolved.
single time, and all will be resolved when the value is resolved, even
if different options are used.

If `fetchMethod` is not specified, then this is effectively an alias
for `Promise.resolve(cache.get(key))`.

If `fetchMethod` is not specified, then this is an alias for
`Promise.resolve(cache.get(key))`.
When the fetch method resolves to a value, if the fetch has not been
aborted due to deletion, eviction, or being overwritten, then it is
added to the cache using the options provided.

* `peek(key, { allowStale } = {}) => value`

Expand Down
99 changes: 74 additions & 25 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
const perf = typeof performance === 'object' && performance &&
typeof performance.now === 'function' ? performance : Date

const hasAbortController = typeof AbortController !== undefined

/* istanbul ignore next - minimal backwards compatibility polyfill */
const AC = hasAbortController ? AbortController : Object.assign(
class AbortController {
constructor () { this.signal = new AC.AbortSignal }
abort () { this.signal.aborted = true }
},
{ AbortSignal: class AbortSignal { constructor () { this.aborted = false }}}
)

const warned = new Set()
const deprecatedOption = (opt, instead) => {
const code = `LRU_CACHE_OPTION_${opt}`
Expand Down Expand Up @@ -460,10 +471,14 @@ class LRUCache {
// update
const oldVal = this.valList[index]
if (v !== oldVal) {
if (!noDisposeOnSet) {
this.dispose(oldVal, k, 'set')
if (this.disposeAfter) {
this.disposed.push([oldVal, k, 'set'])
if (this.isBackgroundFetch(oldVal)) {
oldVal.__abortController.abort()
} else {
if (!noDisposeOnSet) {
this.dispose(oldVal, k, 'set')
if (this.disposeAfter) {
this.disposed.push([oldVal, k, 'set'])
}
}
}
this.removeItemSize(index)
Expand Down Expand Up @@ -512,9 +527,13 @@ class LRUCache {
const head = this.head
const k = this.keyList[head]
const v = this.valList[head]
this.dispose(v, k, 'evict')
if (this.disposeAfter) {
this.disposed.push([v, k, 'evict'])
if (this.isBackgroundFetch(v)) {
v.__abortController.abort()
} else {
this.dispose(v, k, 'evict')
if (this.disposeAfter) {
this.disposed.push([v, k, 'evict'])
}
}
this.removeItemSize(head)
this.head = this.next[head]
Expand All @@ -535,20 +554,26 @@ class LRUCache {
}
}

backgroundFetch (k, index) {
backgroundFetch (k, index, options) {
const v = index === undefined ? undefined : this.valList[index]
if (this.isBackgroundFetch(v)) {
return v
}
const p = Promise.resolve(this.fetchMethod(k, v)).then(v => {
if (this.keyMap.get(k) === index && p === this.valList[index]) {
this.set(k, v)
const ac = new AbortController()
const fetchOpts = {
signal: ac.signal,
options,
}
const p = Promise.resolve(this.fetchMethod(k, v, fetchOpts)).then(v => {
if (!ac.signal.aborted) {
this.set(k, v, fetchOpts.options)
}
return v
})
p.__abortController = ac
p.__staleWhileFetching = v
if (index === undefined) {
this.set(k, p)
this.set(k, p, fetchOpts.options)
index = this.keyMap.get(k)
} else {
this.valList[index] = p
Expand All @@ -561,17 +586,33 @@ class LRUCache {
Object.prototype.hasOwnProperty.call(p, '__staleWhileFetching')
}

// this takes the union of get() and set() opts, because it does both
async fetch (k, {
allowStale = this.allowStale,
updateAgeOnGet = this.updateAgeOnGet,
ttl = this.ttl,
noDisposeOnSet = this.noDisposeOnSet,
size = 0,
sizeCalculation = this.sizeCalculation,
noUpdateTTL = this.noUpdateTTL,
} = {}) {
if (!this.fetchMethod) {
return this.get(k, {allowStale, updateAgeOnGet})
}

const options = {
allowStale,
updateAgeOnGet,
ttl,
noDisposeOnSet,
size,
sizeCalculation,
noUpdateTTL,
}

let index = this.keyMap.get(k)
if (index === undefined) {
return this.backgroundFetch(k, index)
return this.backgroundFetch(k, index, options)
} else {
// in cache, maybe already fetching
const v = this.valList[index]
Expand All @@ -590,7 +631,7 @@ class LRUCache {

// ok, it is stale, and not already fetching
// refresh the cache.
const p = this.backgroundFetch(k, index)
const p = this.backgroundFetch(k, index, options)
return allowStale && p.__staleWhileFetching !== undefined
? p.__staleWhileFetching : p
}
Expand Down Expand Up @@ -667,9 +708,14 @@ class LRUCache {
this.clear()
} else {
this.removeItemSize(index)
this.dispose(this.valList[index], k, 'delete')
if (this.disposeAfter) {
this.disposed.push([this.valList[index], k, 'delete'])
const v = this.valList[index]
if (this.isBackgroundFetch(v)) {
v.__abortController.abort()
} else {
this.dispose(v, k, 'delete')
if (this.disposeAfter) {
this.disposed.push([v, k, 'delete'])
}
}
this.keyMap.delete(k)
this.keyList[index] = null
Expand All @@ -696,16 +742,19 @@ class LRUCache {
}

clear () {
if (this.dispose !== LRUCache.prototype.dispose) {
for (const index of this.rindexes({ allowStale: true })) {
this.dispose(this.valList[index], this.keyList[index], 'delete')
}
}
if (this.disposeAfter) {
for (const index of this.rindexes({ allowStale: true })) {
this.disposed.push([this.valList[index], this.keyList[index], 'delete'])
for (const index of this.rindexes({ allowStale: true })) {
const v = this.valList[index]
if (this.isBackgroundFetch(v)) {
v.__abortController.abort()
} else {
const k = this.keyList[index]
this.dispose(v, k, 'delete')
if (this.disposeAfter) {
this.disposed.push([v, k, 'delete'])
}
}
}

this.keyMap.clear()
this.valList.fill(null)
this.keyList.fill(null)
Expand Down
62 changes: 62 additions & 0 deletions test/fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,65 @@ t.test('fetch without fetch method', async t => {
c.fetch(1),
]), [0, 1])
})

t.test('fetch options, signal', async t => {
let aborted = false
const disposed = []
const disposedAfter = []
const c = new LRU({
max: 3,
ttl: 100,
fetchMethod: async (k, oldVal, { signal, options }) => {
// do something async
await new Promise(res => setImmediate(res))
if (signal.aborted) {
aborted = true
return
}
if (k === 2) {
options.ttl = 25
}
return (oldVal || 0) + 1
},
dispose: (v, k, reason) => {
disposed.push([v, k, reason])
},
disposeAfter: (v, k, reason) => {
disposedAfter.push([v, k, reason])
},
})

const v1 = c.fetch(2)
c.delete(2)
t.equal(await v1, undefined, 'no value returned, aborted by delete')
t.equal(aborted, true)
t.same(disposed, [], 'no disposals for aborted promises')
t.same(disposedAfter, [], 'no disposals for aborted promises')

aborted = false
const v2 = c.fetch(2)
c.set(2, 2)
t.equal(await v2, undefined, 'no value returned, aborted by set')
t.equal(aborted, true)
t.same(disposed, [], 'no disposals for aborted promises')
t.same(disposedAfter, [], 'no disposals for aborted promises')
c.delete(2)
disposed.length = 0
disposedAfter.length = 0

aborted = false
const v3 = c.fetch(2)
c.set(3, 3)
c.set(4, 4)
c.set(5, 5)
t.equal(await v3, undefined, 'no value returned, aborted by evict')
t.equal(aborted, true)
t.same(disposed, [], 'no disposals for aborted promises')
t.same(disposedAfter, [], 'no disposals for aborted promises')

aborted = false
const v4 = await c.fetch(6, { ttl: 1000 })
t.equal(c.getRemainingTTL(6), 1000, 'overridden ttl in fetch() opts')
const v5 = await c.fetch(2, { ttl: 1 })
t.equal(c.getRemainingTTL(2), 25, 'overridden ttl in fetchMethod')
})

0 comments on commit 787343d

Please sign in to comment.