Skip to content

Commit

Permalink
Merge pull request #17 from simone-sanfratello/feat/invalidation-wild…
Browse files Browse the repository at this point in the history
…card

feat: invalidation with wildcard
  • Loading branch information
Simone Sanfratello committed Feb 25, 2022
2 parents 16464b6 + e5fd042 commit 1517d6f
Show file tree
Hide file tree
Showing 12 changed files with 723 additions and 270 deletions.
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,47 @@ Options:
Clear the cache. If `name` is specified, all the cache entries from the function defined with that name are cleared.
If `arg` is specified, only the elements cached with the given `name` and `arg` are cleared.

### `cache.invalidateAll(references, [storage])`

`cache.invalidateAll` perform invalidation over the whole storage; if `storage` is not specified - using the same `name` as the defined function, invalidation is made over the default storage.

`references` can be:

* a single reference
* an array of references (without wildcard)
* a matching reference with wildcard, same logic for `memory` and `redis`

Exmple

```js
const cache = createCache({ ttl: 60 })

cache.define('fetchUser', {
references: (args, key, result) => result ? [`user:${result.id}`] : null
}, (id) => database.find({ table: 'users', where: { id }}))

cache.define('fetchCountries', {
storage: { type: 'memory', size: 256 },
references: (args, key, result) => [`countries`]
}, (id) => database.find({ table: 'countries' }))

// ...

// invalidate all users from default storage
cache.invalidateAll('user:*')

// invalidate user 1 from default storage
cache.invalidateAll('user:1')

// invalidate user 1 and user 2 from default storage
cache.invalidateAll(['user:1', 'user:2'])

// note "fetchCountries" uses a different storage
cache.invalidateAll('countries', 'fetchCountries')
```

See below how invalidation and references work.

## Invalidation

Along with `time to live` invalidation of the cache entries, we can use invalidation by keys.
Expand Down
25 changes: 21 additions & 4 deletions src/cache.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict'

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

Expand Down Expand Up @@ -40,7 +40,11 @@ class Cache {
}

this[kValues] = {}

this[kStorage] = options.storage
this[kStorages] = new Map()
this[kStorages].set('_default', options.storage)

this[kTTL] = options.ttl || 0
this[kOnDedupe] = options.onDedupe || noop
this[kOnError] = options.onError || noop
Expand Down Expand Up @@ -88,7 +92,14 @@ class Cache {
throw new TypeError('references must be a function')
}

const storage = opts.storage ? createStorage(opts.storage.type, opts.options) : this[kStorage]
let storage
if (opts.storage) {
storage = createStorage(opts.storage.type, opts.options)
this[kStorages].set(name, storage)
} else {
storage = this[kStorage]
}

const ttl = opts.ttl || this[kTTL]
const onDedupe = opts.onDedupe || this[kOnDedupe]
const onError = opts.onError || this[kOnError]
Expand Down Expand Up @@ -143,10 +154,16 @@ class Cache {
throw new Error(`${name} is not defined in the cache`)
}

// TODO validate references?

return this[kValues][name].invalidate(references)
}

async invalidateAll(references, storage = '_default') {
if (!this[kStorages].has(storage)) {
throw new Error(`${storage} storage is not defined in the cache`)
}
const s = this[kStorages].get(storage)
await s.invalidate(references)
}
}

class Wrapper {
Expand Down
59 changes: 54 additions & 5 deletions src/storage/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
const LRUCache = require('mnemonist/lru-cache')
const nullLogger = require('abstract-logging')
const StorageInterface = require('./interface')
const { findMatchingIndexes, findNotMatching, bsearchIndex } = require('../util')
const { findMatchingIndexes, findNotMatching, bsearchIndex, wildcardMatch } = require('../util')

const DEFAULT_CACHE_SIZE = 1024

Expand Down Expand Up @@ -241,7 +241,7 @@ class StorageMemory extends StorageInterface {
}

/**
* @param {string[]} references
* @param {string|string[]} references
* @returns {string[]} removed keys
*/
invalidate (references) {
Expand All @@ -252,31 +252,80 @@ class StorageMemory extends StorageInterface {

this.log.debug({ msg: 'acd/storage/memory.invalidate', references })

if (Array.isArray(references)) {
return this._invalidateReferences(references)
}
return this._invalidateReference(references)
}

/**
* @param {string[]} references
* @returns {string[]} removed keys
*/
_invalidateReferences(references) {
const removed = []
for (let i = 0; i < references.length; i++) {
const reference = references[i]
const keys = this.referencesKeys.get(reference)
this.log.debug({ msg: 'acd/storage/memory.invalidate, remove keys on reference', reference, keys })
this.log.debug({ msg: 'acd/storage/memory._invalidateReferences, remove keys on reference', reference, keys })
if (!keys) {
continue
}

for (let j = 0; j < keys.length; j++) {
const key = keys[j]
this.log.debug({ msg: 'acd/storage/memory.invalidate, remove key on reference', reference, key })
this.log.debug({ msg: 'acd/storage/memory._invalidateReferences, remove key on reference', reference, key })
// istanbul ignore next
if (this._removeKey(key)) {
removed.push(key)
}
}

this.log.debug({ msg: 'acd/storage/memory.invalidate, remove references of', reference, keys })
this.log.debug({ msg: 'acd/storage/memory._invalidateReferences, remove references of', reference, keys })
this._removeReferences([...keys])
}

return removed
}

/**
* @param {string} reference
* @returns {string[]} removed keys
*/
_invalidateReference(reference) {
if (reference.includes('*')) {
const references = []
for (const key of this.referencesKeys.keys()) {
if (wildcardMatch(reference, key)) {
references.push(key)
}
}
return this._invalidateReferences(references)
}

const keys = this.referencesKeys.get(reference)
const removed = []
this.log.debug({ msg: 'acd/storage/memory._invalidateReference, remove keys on reference', reference, keys })

if (!keys) {
return removed
}

for (let j = 0; j < keys.length; j++) {
const key = keys[j]
this.log.debug({ msg: 'acd/storage/memory._invalidateReference, remove key on reference', reference, key })
// istanbul ignore next
if (this._removeKey(key)) {
removed.push(key)
}
}

this.log.debug({ msg: 'acd/storage/memory._invalidateReference, remove references of', reference, keys })
this._removeReferences([...keys])

return removed
}

/**
* remove all entries if name is not provided
* remove entries where key starts with name if provided
Expand Down
85 changes: 63 additions & 22 deletions src/storage/redis.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ class StorageRedis extends StorageInterface {
* @param {?string[]} references
*/
async set (key, value, ttl, references) {
// TODO can keys contains * or other special chars?
// TODO validate keys, can't contain * or other special chars
this.log.debug({ msg: 'acd/storage/redis.set key', key, value, ttl, references })

ttl = Number(ttl)
Expand Down Expand Up @@ -160,7 +160,7 @@ class StorageRedis extends StorageInterface {
}

/**
* @param {string[]} references
* @param {string|string[]} references
* @returns {string[]} removed keys
*/
async invalidate (references) {
Expand All @@ -172,33 +172,74 @@ class StorageRedis extends StorageInterface {
this.log.debug({ msg: 'acd/storage/redis.invalidate', references })

try {
const reads = references.map(reference => ['smembers', this.getReferenceKeyLabel(reference)])
const keys = await this.store.pipeline(reads).exec()

this.log.debug({ msg: 'acd/storage/redis.invalidate keys', keys })

const writes = []
const removed = []
for (let i = 0; i < keys.length; i++) {
const key0 = keys[i][1]
this.log.debug({ msg: 'acd/storage/redis.invalidate got keys to be invalidated', keys: key0 })
for (let j = 0; j < key0.length; j++) {
const key1 = key0[j]
this.log.debug({ msg: 'acd/storage/redis.del key' + key1 })
removed.push(key1)
writes.push(['del', key1])
}
if (Array.isArray(references)) {
return await this._invalidateReferences(references)
}

await this.store.pipeline(writes).exec()
await this.clearReferences(removed)
return removed
return await this._invalidateReference(references)
} catch (err) {
this.log.error({ msg: 'acd/storage/redis.invalidate error', err, references })
return []
}
}

/**
* @param {string[]} references
* @param {[bool=true]} mapReferences
* @returns {string[]} removed keys
*/
async _invalidateReferences(references, mapReferences = true) {
const reads = references.map(reference => ['smembers', mapReferences ? this.getReferenceKeyLabel(reference) : reference])
const keys = await this.store.pipeline(reads).exec()

this.log.debug({ msg: 'acd/storage/redis._invalidateReferences keys', keys })

const writes = []
const removed = []
for (let i = 0; i < keys.length; i++) {
const key0 = keys[i][1]
this.log.debug({ msg: 'acd/storage/redis._invalidateReferences got keys to be invalidated', keys: key0 })
for (let j = 0; j < key0.length; j++) {
const key1 = key0[j]
this.log.debug({ msg: 'acd/storage/redis._invalidateReferences del key' + key1 })
removed.push(key1)
writes.push(['del', key1])
}
}

await this.store.pipeline(writes).exec()
await this.clearReferences(removed)
return removed
}

/**
* @param {string} reference
* @returns {string[]} removed keys
*/
async _invalidateReference(reference) {
let keys
if (reference.includes('*')) {
const references = await this.store.keys(this.getReferenceKeyLabel(reference))
return this._invalidateReferences(references, false)
} else {
keys = await this.store.smembers(this.getReferenceKeyLabel(reference))
}

this.log.debug({ msg: 'acd/storage/redis._invalidateReference keys', keys })

const writes = []
const removed = []
for (let i = 0; i < keys.length; i++) {
const key0 = keys[i]
this.log.debug({ msg: 'acd/storage/redis._invalidateReference del key' + key0 })
removed.push(key0)
writes.push(['del', key0])
}

await this.store.pipeline(writes).exec()
await this.clearReferences(removed)
return removed
}

/**
* @param {string} name
*/
Expand Down
3 changes: 2 additions & 1 deletion src/symbol.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

const kValues = Symbol('values')
const kStorage = Symbol('kStorage')
const kStorages = Symbol('kStorages')
const kTTL = Symbol('kTTL')
const kOnDedupe = Symbol('kOnDedupe')
const kOnError = Symbol('kOnError')
const kOnHit = Symbol('kOnHit')
const kOnMiss = Symbol('kOnMiss')

module.exports = { kValues, kStorage, kTTL, kOnDedupe, kOnError, kOnHit, kOnMiss }
module.exports = { kValues, kStorage, kStorages, kTTL, kOnDedupe, kOnError, kOnHit, kOnMiss }
34 changes: 34 additions & 0 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,44 @@ function randomSubset (array, size) {

return result
}

/**
* @param {!string} value substring to search in content, supporting wildcard
* @param {!string} content string to search in
* @return {boolean} true if value is in content
* @example wildcardMatch("1*5", "12345") > true
* @example wildcardMatch("1*6", "12345") > false
*/
function wildcardMatch(value, content) {
if (value === '*') return true
if (value.length === content.length && value === content) return true

let i = 0, j = 0
while (i < value.length && j < content.length) {
if (value[i] === content[j]) {
i++
j++
continue
}
if (value[i] === '*') {
if (value[i + 1] === content[j]) {
i++
continue
}
j++
continue
}
return false
}

return i >= value.length - 1
}

module.exports = {
findNotMatching,
findMatchingIndexes,
bsearchIndex,
wildcardMatch,

randomSubset
}
Loading

0 comments on commit 1517d6f

Please sign in to comment.