Skip to content

Commit

Permalink
Implement stale while revalidate (#45)
Browse files Browse the repository at this point in the history
* Implement stale while revalidate

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* Added docs

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* comments

Signed-off-by: Matteo Collina <hello@matteocollina.com>

* fixup

Signed-off-by: Matteo Collina <hello@matteocollina.com>

Signed-off-by: Matteo Collina <hello@matteocollina.com>
  • Loading branch information
mcollina authored Jan 18, 2023
1 parent 1892418 commit 4283c68
Show file tree
Hide file tree
Showing 8 changed files with 244 additions and 7 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Creates a new cache.
Options:

* `ttl`: the maximum time a cache entry can live, default `0`; if `0`, an element is removed from the cache as soon as the promise resolves.
* `stale`: the time after which the value is served from the cache after the ttl has expired.
* `onDedupe`: a function that is called every time it is defined is deduped.
* `onError`: a function that is called every time there is a cache error.
* `onHit`: a function that is called every time there is a hit in the cache.
Expand Down Expand Up @@ -93,6 +94,7 @@ in the cache. The cache key for `arg` is computed using [`safe-stable-stringify`
Options:

* `ttl`: a number or a function that returns a number of the maximum time a cache entry can live, default as defined in the cache; default is zero, so cache is disabled, the function will be only the deduped. The first argument of the function is the result of the original function.
* `stale`: the time after which the value is served from the cache after the ttl has expired.
* `serialize`: a function to convert the given argument into a serializable object (or string).
* `onDedupe`: a function that is called every time there is defined is deduped.
* `onError`: a function that is called every time there is a cache error.
Expand Down
32 changes: 26 additions & 6 deletions src/cache.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict'

const { kValues, kStorage, kStorages, kTTL, kOnDedupe, kOnError, kOnHit, kOnMiss } = require('./symbol')
const { kValues, kStorage, kStorages, kTTL, kOnDedupe, kOnError, kOnHit, kOnMiss, kStale } = require('./symbol')
const stringify = require('safe-stable-stringify')
const createStorage = require('./storage')

Expand Down Expand Up @@ -40,6 +40,11 @@ class Cache {
throw new Error('onMiss must be a function')
}

// ttl _may_ be a function to defer the ttl decision until later
if (typeof options.stale === 'number' && !(Math.floor(options.stale) === options.stale && options.stale >= 0)) {
throw new Error('stale must be an integer greater or equal to 0')
}

this[kValues] = {}

this[kStorage] = options.storage
Expand All @@ -51,6 +56,7 @@ class Cache {
this[kOnError] = options.onError || noop
this[kOnHit] = options.onHit || noop
this[kOnMiss] = options.onMiss || noop
this[kStale] = options.stale || 0
}

/**
Expand Down Expand Up @@ -108,12 +114,13 @@ class Cache {
}

const ttl = opts.ttl !== undefined ? opts.ttl : this[kTTL]
const stale = opts.stale !== undefined ? opts.stale : this[kStale]
const onDedupe = opts.onDedupe || this[kOnDedupe]
const onError = opts.onError || this[kOnError]
const onHit = opts.onHit || this[kOnHit]
const onMiss = opts.onMiss || this[kOnMiss]

const wrapper = new Wrapper(func, name, serialize, references, storage, ttl, onDedupe, onError, onHit, onMiss)
const wrapper = new Wrapper(func, name, serialize, references, storage, ttl, onDedupe, onError, onHit, onMiss, stale)

this[kValues][name] = wrapper
this[name] = wrapper.add.bind(wrapper)
Expand Down Expand Up @@ -185,8 +192,9 @@ class Wrapper {
* @param {function} onError
* @param {function} onHit
* @param {function} onMiss
* @param {stale} ttl
*/
constructor (func, name, serialize, references, storage, ttl, onDedupe, onError, onHit, onMiss) {
constructor (func, name, serialize, references, storage, ttl, onDedupe, onError, onHit, onMiss, stale) {
this.dedupes = new Map()
this.func = func
this.name = name
Expand All @@ -199,6 +207,7 @@ class Wrapper {
this.onError = onError
this.onHit = onHit
this.onMiss = onMiss
this.stale = stale
}

getKey (args) {
Expand Down Expand Up @@ -244,18 +253,29 @@ class Wrapper {

if (data !== undefined) {
this.onHit(key)
if (this.stale > 0) {
const remainingTTL = await this.storage.getTTL(storageKey)
if (remainingTTL <= this.stale) {
this._wrapFunction(storageKey, args, key).catch(noop)
}
}
return data
} else {
this.onMiss(key)
}

this.onMiss(key)
}

return this._wrapFunction(storageKey, args, key)
}

async _wrapFunction (storageKey, args, key) {
const result = await this.func(args, key)
const ttl = typeof this.ttl === 'function' ? this.ttl(result) : this.ttl
let ttl = typeof this.ttl === 'function' ? this.ttl(result) : this.ttl
if (ttl === undefined || ttl === null || (typeof ttl !== 'number' || !Number.isInteger(ttl))) {
this.onError(new Error('ttl must be an integer'))
return result
}
ttl += this.stale
if (ttl < 1) {
return result
}
Expand Down
20 changes: 20 additions & 0 deletions src/storage/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@ class StorageMemory extends StorageInterface {
}
}

/**
* retrieve the remaining TTL value by key
* @param {string} key
* @returns {undefined|*} undefined if key not found or expired
*/
getTTL (key) {
this.log.debug({ msg: 'acd/storage/memory.getTTL', key })

const entry = this.store.peek(key)
let ttl = 0
if (entry) {
ttl = entry.start + entry.ttl - now()
if (ttl < 0) {
ttl = 0
}
}

return ttl
}

/**
* set value by key
* @param {string} key
Expand Down
18 changes: 18 additions & 0 deletions src/storage/redis.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ class StorageRedis extends StorageInterface {
}
}

/**
* retrieve the remaining TTL value by key
* @param {string} key
* @returns {undefined|*} undefined if key not found or expired
*/
async getTTL (key) {
this.log.debug({ msg: 'acd/storage/memory.getTTL', key })

let pttl = await this.store.pttl(key)
if (pttl < 0) {
return 0
}

pttl = Math.ceil(pttl / 1000)

return pttl
}

/**
* set value by key
* @param {string} key
Expand Down
3 changes: 2 additions & 1 deletion src/symbol.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ const kOnDedupe = Symbol('kOnDedupe')
const kOnError = Symbol('kOnError')
const kOnHit = Symbol('kOnHit')
const kOnMiss = Symbol('kOnMiss')
const kStale = Symbol('kStale')

module.exports = { kValues, kStorage, kStorages, kTTL, kOnDedupe, kOnError, kOnHit, kOnMiss }
module.exports = { kValues, kStorage, kStorages, kTTL, kOnDedupe, kOnError, kOnHit, kOnMiss, kStale }
86 changes: 86 additions & 0 deletions test/stale.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
'use strict'

const t = require('tap')
const { promisify } = require('util')
const { Cache } = require('../src/cache')
const createStorage = require('../src/storage')

const sleep = promisify(setTimeout)

const { test } = t

t.jobs = 3

test('stale', async (t) => {
t.plan(11)

const storage = createStorage()

const cache = new Cache({
storage,
ttl: 1,
stale: 9
})

let toReturn = 42

cache.define('fetchSomething', async (query) => {
t.equal(query, 42)
return { k: toReturn }
})

t.same(await cache.fetchSomething(42), { k: 42 })
t.same(await cache.fetchSomething(42), { k: 42 })

t.equal(storage.getTTL('fetchSomething~42'), 10)
await sleep(2500)
t.equal(storage.getTTL('fetchSomething~42') < 10, true)

// This value will be revalidated
toReturn++
t.same(await cache.fetchSomething(42), { k: 42 })
t.equal(storage.getTTL('fetchSomething~42'), 10)

await sleep(500)

t.same(await cache.fetchSomething(42), { k: 43 })

t.same(await cache.fetchSomething(42), { k: 43 })
t.equal(storage.getTTL('fetchSomething~42'), 10)
})

test('global stale is a positive integer', async (t) => {
t.plan(1)

try {
// eslint-disable-next-line no-new
new Cache({ ttl: 42, stale: 3.14, storage: createStorage() })
} catch (err) {
t.equal(err.message, 'stale must be an integer greater or equal to 0')
}
})

test('stale as parameter', async (t) => {
t.plan(6)

const cache = new Cache({
storage: createStorage(),
ttl: 1
})

cache.define('fetchSomething', { stale: 9 }, async (query) => {
t.equal(query, 42)
return { k: query }
})

t.same(await cache.fetchSomething(42), { k: 42 })
t.same(await cache.fetchSomething(42), { k: 42 })

await sleep(2500)

t.same(await cache.fetchSomething(42), { k: 42 })

await sleep(500)

t.same(await cache.fetchSomething(42), { k: 42 })
})
50 changes: 50 additions & 0 deletions test/storage-memory.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,56 @@ test('storage memory', async (t) => {
})
})

test('getTTL', async (t) => {
test('should get the TTL of a previously key stored', async (t) => {
const storage = createStorage('memory')

storage.set('foo', 'bar', 100)

t.equal(storage.getTTL('foo'), 100)

await sleep(1000)

t.equal(storage.getTTL('foo'), 99)
})

test('should get the TTL of a a key without TTL', async (t) => {
const storage = createStorage('memory')

storage.set('foo', 'bar', 0)

t.equal(storage.getTTL('foo'), 0)
})

test('should get the TTL of a previously key stored', async (t) => {
const storage = createStorage('memory')

storage.set('foo', 'bar', 1)

t.equal(storage.getTTL('foo'), 1)

await sleep(1000)

t.equal(storage.getTTL('foo'), 0)
})

test('no key', async (t) => {
const storage = createStorage('memory')

t.equal(storage.getTTL('foo'), 0)
})

test('should get the TTL of a previously key stored', async (t) => {
const storage = createStorage('memory')

storage.set('foo', 'bar', 1)

await sleep(2000)

t.equal(storage.getTTL('foo'), 0)
})
})

test('set', async (t) => {
test('should set a value, with ttl', async (t) => {
const storage = createStorage('memory')
Expand Down
40 changes: 40 additions & 0 deletions test/storage-redis.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -977,4 +977,44 @@ test('storage redis', async (t) => {
})
})
})

test('getTTL', async (t) => {
test('should get the TTL of a previously key stored', async (t) => {
const storage = new StorageRedis({ client: redisClient, invalidation: false })

storage.set('foo', 'bar', 100)

t.equal(await storage.getTTL('foo'), 100)

await sleep(1000)

t.equal(await storage.getTTL('foo'), 99)
})

test('should get the TTL of a a key without TTL', async (t) => {
const storage = new StorageRedis({ client: redisClient, invalidation: false })

storage.set('foo', 'bar', 0)

t.equal(await storage.getTTL('foo'), 0)
})

test('should get the TTL of a previously key stored', async (t) => {
const storage = new StorageRedis({ client: redisClient, invalidation: false })

storage.set('foo', 'bar', 1)

t.equal(await storage.getTTL('foo'), 1)

await sleep(1000)

t.equal(await storage.getTTL('foo'), 0)
})

test('no key', async (t) => {
const storage = new StorageRedis({ client: redisClient, invalidation: false })

t.equal(await storage.getTTL('foo'), 0)
})
})
})

0 comments on commit 4283c68

Please sign in to comment.