diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index b7c58db..fc23967 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -20,6 +20,8 @@ jobs: uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - name: Start Redis + uses: supercharge/redis-github-action@1.4.0 - name: npm install, build, and test run: | npm install diff --git a/README.md b/README.md index 221146f..16eedb1 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ --- -**HA-store** is a generic wrapper for your data queries, it features: +**HA-store** is a wrapper for your data queries, it features: - Smart TLRU cache for 'hot' information +- Supports mutliple caching levels - Request coalescing and batching (solves the [Thundering Herd problem](https://en.wikipedia.org/wiki/Thundering_herd_problem)) - Insightful stats and [events](#Monitoring-and-events) - Lightweight, configurable, battle-tested @@ -68,9 +69,8 @@ Name | Required | Default | Description --- | --- | --- | --- resolver | true | - | The method to wrap, and how to interpret the returned data. Uses the format `` delimiter | false | `[]` | The list of parameters that, when passed, generate unique results. Ex: 'language', 'view', 'fields', 'country'. These will generate different combinations of cache keys. -store | false | `null` | A custom store for the data, like [ha-store-redis](https://github.com/fed135/ha-redis-adapter). -cache | false |
{
  limit: 5000,
  ttl: 300000
}
| Caching options for the data - `limit` - the maximum number of records, and `ttl` - time to live for a record in milliseconds. -batch | false |
{
  delay: 50,
  limit: 100
}
| Batching options for the requests +cache | false |
{
  enabled: false,
  tiers: [
  {
    store: <instance of a store>,
    limit: 5000,
    ttl: 300000
  }
  ]
}
| A list of storage tiers for the data. The order indicates where to look first. It's recommended to keep an instance of an in-memory store, like `ha-store/stores/in-memory` as the first one, and then expend to external stores like [ha-store-redis](https://github.com/fed135/ha-redis-adapter). Caching options for the data - `limit` - the maximum number of records, and `ttl` - time to live for a record in milliseconds. +batch | false |
{
  enabled: false,
  delay: 50,
  limit: 100
}
| Batching options for the requests - `delay` is the amount of time to wait before sending the batch, `limit` is the maximum number of data items to send in a batch. *All options are in (ms) @@ -80,7 +80,8 @@ HA-store emits events to track cache hits, miss and outbound requests. Event | Format | Description --- | --- | --- -cacheHit | `` | When the requested item is present in the microcache, or is already being fetched. Prevents another request from being created. +localCacheHit | `` | When the requested item is present in the first listed local store (usually in-memory). +cacheHit | `` | When the requested item is present in a store. cacheMiss | `` | When the requested item not cached or coalesced and must be fetched. coalescedHit | `` | When a record query successfully hooks to the promise of the same record in transit. query | `` | When a batch of requests is about to be sent, gives the detail of the query and what triggered it. diff --git a/SECURITY.md b/SECURITY.md index 90bef43..b3826a8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,6 +5,7 @@ | Version | Supported | | ------- | ------------------ | +| 4.0.x | :white_check_mark: | | 3.1.x | :white_check_mark: | | 3.0.x | :white_check_mark: | | < 3.0 | :x: | diff --git a/package.json b/package.json index e21027a..c62ebfe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ha-store", - "version": "3.2.0", + "version": "4.0.0", "description": "Efficient data fetching", "main": "src/index.js", "scripts": { @@ -20,11 +20,7 @@ }, "keywords": [ "store", - "availability", - "optimize", - "throughput", "cache", - "service", "batch", "congestion", "tlru" @@ -35,9 +31,9 @@ "author": "frederic charette ", "license": "Apache-2.0", "devDependencies": { + "@ha-store/redis": "^4.0.1", "chai": "^4.3.0", - "eslint": "^8.18.0", - "ha-store-redis": "^2.0.1", + "eslint": "^8.20.0", "mocha": "^10.0.0", "sinon": "^14.0.0", "split2": "^4.1.0" @@ -49,6 +45,6 @@ ], "typings": "./src/index.d.ts", "dependencies": { - "lru-cache": "^7.10.0" + "lru-cache": "^7.13.0" } } diff --git a/src/buffer.js b/src/buffer.js index 0f5b51a..8b5495f 100644 --- a/src/buffer.js +++ b/src/buffer.js @@ -6,12 +6,10 @@ const BufferState = { COMPLETED: 2, }; -function queryBuffer(config, emitter, targetStore) { +function queryBufferConstructor(config, emitter, caches) { const buffers = []; let numCoalesced = 0; - let numCached = 0; - let numMisses = 0; class RequestBuffer { constructor(key, params) { @@ -26,7 +24,7 @@ function queryBuffer(config, emitter, targetStore) { } tick() { - const sizeLimit = config.batch?.limit || 1; + const sizeLimit = (config.batch.enabled && config.batch.limit) || 1; if (this.ids.length >= sizeLimit) { this.run('limit'); @@ -34,7 +32,7 @@ function queryBuffer(config, emitter, targetStore) { } if (this.timer === null) { - this.timer = setTimeout(this.run.bind(this, 'timeout'), config.batch?.delay || 0); + this.timer = setTimeout(this.run.bind(this, 'timeout'), config.batch.enabled && config.batch.delay || 0); } return this; @@ -59,45 +57,34 @@ function queryBuffer(config, emitter, targetStore) { this.state = BufferState.COMPLETED; emitter.emit('querySuccess', { key: this.contextKey, uid: this.uid, size: this.ids.length, params: this.params }); this.handle.resolve(entries); - if (config.cache !== null) targetStore.set(contextRecordKey(this.contextKey), this.ids, entries || {}); + if (config.cache.enabled) caches.set(contextRecordKey(this.contextKey), this.ids, entries || {}); buffers.splice(buffers.indexOf(this), 1); } } - function getHandles(key, ids, params, context, cacheResult) { - const handles = Array.from(new Array(ids.length)); - for (let i = 0; i < ids.length; i++) { - if (cacheResult[i] !== undefined) { - numCached++; - handles[i] = cacheResult[i]; - } - else { - const liveBuffer = buffers.find(buffer => buffer.contextKey === key && buffer.ids.includes(ids[i])); - if (liveBuffer) { - numCoalesced++; - handles[i] = liveBuffer.handle.promise.then(results => results[ids[i]]); - } - else { - numMisses++; - handles[i] = assignQuery(key, ids[i], params, context).handle.promise.then(results => results[ids[i]]); + function getHandles(key, ids, params, context) { + return caches.getMulti(contextRecordKey(key), ids.concat()) + .then((handles) => { + for (let i = 0; i < ids.length; i++) { + if (handles[i] === undefined) { + const liveBuffer = buffers.find(buffer => buffer.contextKey === key && buffer.ids.includes(ids[i])); + if (liveBuffer) { + numCoalesced++; + handles[i] = liveBuffer.handle.promise.then(results => results[ids[i]]); + } + else { + handles[i] = assignQuery(key, ids[i], params, context).handle.promise.then(results => results && results[ids[i]]); + } + } } - } - } - if (numCached > 0) { - emitter.emit('cacheHit', numCached); - numCached = 0; - } - if (numMisses > 0) { - emitter.emit('cacheMiss', numMisses); - numMisses = 0; - } - if (numCoalesced > 0) { - emitter.emit('coalescedHit', numCoalesced); - numCoalesced = 0; - } + if (numCoalesced > 0) { + emitter.track('coalescedHit', numCoalesced); + numCoalesced = 0; + } - return handles; + return handles; + }); } function assignQuery(key, id, params, context) { @@ -124,4 +111,4 @@ function queryBuffer(config, emitter, targetStore) { return { getHandles, size }; } -module.exports = queryBuffer; +module.exports = queryBufferConstructor; diff --git a/src/caches.js b/src/caches.js new file mode 100644 index 0000000..e5a0e69 --- /dev/null +++ b/src/caches.js @@ -0,0 +1,103 @@ +const {settleAndLog} = require('./utils'); + +function cachesConstructor(config, emitter) { + const caches = config.cache.enabled && config.cache.tiers.map(tier => tier.store(tier)) || []; + const local = caches.find(cache => cache.local); + const remotes = caches.filter(cache => !cache.local); + + function getLocal(key) { + return local && local.get(key); + } + + function getMultiLocal(recordKey, keys) { + return local && local.getMulti(recordKey, keys); + } + + function get(key) { + if (!config.cache.enabled) return undefined; + + const localValue = getLocal(key); + if (localValue !== undefined) { + emitter.track('localCacheHit', 1); + emitter.track('cacheHit', 1); + return localValue; + } + + return settleAndLog(remotes.map((remote) => remote.get(key))) + .then((remoteValues) => { + const responseValue = remoteValues.find(value => value !== undefined); + if (responseValue !== undefined) { + emitter.track('cacheHit', 1); + } + else { + emitter.track('cacheMiss', 1); + } + return remoteValues.find((response) => response !== undefined); + }); + } + + function getMulti(recordKey, keys) { + if (!config.cache.enabled) return Promise.resolve(Array.from(new Array(keys.length), () => undefined)); + + const localValues = getMultiLocal(recordKey, keys); + const foundLocally = localValues && localValues.filter(value => value !== undefined).length; + if (foundLocally) { + emitter.track('localCacheHit', foundLocally); + emitter.track('cacheHit', foundLocally); + } + if (foundLocally && foundLocally === keys.length) { + return Promise.resolve(localValues); + } + + return settleAndLog(remotes.map((remote) => remote.getMulti(recordKey, keys))) + .then((remoteValues) => { + const responseValues = Object.assign(...remoteValues, localValues).map((value) => (value === null || value === undefined) ? undefined : JSON.parse(value)); + const foundRemotely = remoteValues.filter((value) => value !== undefined); + const missingValues = responseValues.filter((value) => value === undefined); + emitter.track('cacheHit', foundRemotely.length); + emitter.track('cacheMiss', missingValues.length); + return responseValues; + }); + } + + function set(recordKey, keys, values) { + local && local.set(recordKey, keys, values); + return Promise.all(remotes.map((remote) => remote.set(recordKey, keys, values))).catch((err) => console.log('error writing', err)); + } + + function clear(key) { + local && local.clear(key); + remotes.forEach((remote) => remote.clear(key)); + return true; + } + + function size() { + if (!config.cache.enabled) { + return Promise.resolve({ + local: 0, + remote: 0, + status: 'disabled', + }); + } + + return Promise.resolve(remotes[0] && remotes[0].size()) + .then((remoteItems) => { + return { + local: local && local.size(), + remote: remoteItems || 0, + } + }); + } + + return { + get, + getLocal, + getMulti, + getMultiLocal, + set, + clear, + size, + }; +} + +module.exports = cachesConstructor; diff --git a/src/emitter.js b/src/emitter.js new file mode 100644 index 0000000..57b12a6 --- /dev/null +++ b/src/emitter.js @@ -0,0 +1,26 @@ +const EventEmitter = require('events').EventEmitter; + +class DeferredEmitter extends EventEmitter { + constructor() { + super(); + this._counters = { + localCacheHit: 0, + cacheHit: 0, + cacheMiss: 0, + coalescedHit: 0, + }; + + this._timer = setInterval(() => { + for (const type in this._counters) { + if (this._counters[type] !== 0) this.emit(type, this._counters[type]); + this._counters[type] = 0; + } + }, 1000); + } + + track(type, number) { + this._counters[type] += number; + } +} + +module.exports = DeferredEmitter; diff --git a/src/index.d.ts b/src/index.d.ts index 5af912b..33f53ed 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,5 +1,3 @@ -import { EventEmitter } from 'events' - type Params = { [key: string]: string } @@ -8,7 +6,7 @@ export interface HAExternalStore { get(key: string): Promise getMulti(recordKey: (contextKey: string) => string, keys: RequestIds): Promise set(recordKey: (contextKey: string) => string, keys: RequestIds, values: DataType): boolean - clear(key?: string): boolean + clear(key: '*' | string): boolean size(): number connection?: any } @@ -18,25 +16,41 @@ export interface HAStoreConfig { resolver(ids: string[], params?: Params, context?: Context): Promise<{ [id: string]: Response }> delimiter?: string[] cache?: { - limit?: number - ttl?: number + enabled: boolean + tiers: { + store: HAExternalStore + limit?: number + ttl?: number + } } batch?: { + enabled: boolean delay?: number limit?: number - }, - store?: HAExternalStore + } +} + +type QueryEvent = { + key: string + uid: string + size: number + params: any + error?: Error } -export interface HAStore extends EventEmitter { +export interface HAStore { get(id: string, params?: Params): Promise get(id: string, params?: Params, context?: Context): Promise getMany(id: string[], params?: Params): Promise<{status: string, value: Response}[]> getMany(id: string[], params?: Params, context?: Context): Promise<{status: string, value: Response}[]> set(items: { [id: string]: any }, ids: string[], params?: Params): boolean - clear(ids: string[], params?: Params): void + clear(ids: '*' | string | string[], params?: Params): void size(): { pendingBuffers: number, activeBuffers: number, records: number } getStorageKey(id: string, params?: Params): string + on(event: 'cacheHit' | 'cacheMiss' | 'localCacheHit' | 'coalescedHit', callback: (_: number) => any): void + on(event: 'query' | 'queryFailed' | 'querySuccess', callback: (_: QueryEvent) => any): void + once(event: 'cacheHit' | 'cacheMiss' | 'localCacheHit' | 'coalescedHit'): Promise + once(event: 'query' | 'queryFailed' | 'querySuccess'): Promise } -export default function batcher(config: HAStoreConfig, emitter?: EventEmitter): HAStore +export default function batcher(config: HAStoreConfig): HAStore diff --git a/src/index.js b/src/index.js index 8959279..0672575 100644 --- a/src/index.js +++ b/src/index.js @@ -1,32 +1,21 @@ -const queue = require('./buffer.js'); -const {contextKey, recordKey, contextRecordKey} = require('./utils.js'); -const EventEmitter = require('events').EventEmitter; +const queue = require('./buffer'); +const caches = require('./caches'); +const DeferredEmitter = require('./emitter'); +const {contextKey, recordKey, contextRecordKey} = require('./utils'); const {hydrateConfig} = require('./options'); -/* Local variable ------------------------------------------------------------*/ - -class HaStore extends EventEmitter { - constructor(initialConfig, emitter) { +class HaStore extends DeferredEmitter { + constructor(initialConfig) { super(); - if (typeof initialConfig.resolver !== 'function') { - throw new Error(`config.resolver [${initialConfig.resolver}] is not a function`); - } - - if (!emitter?.emit) { - throw new Error(`${emitter} is not an EventEmitter`); - } - - this.config = hydrateConfig(initialConfig); + this.config = Object.freeze(hydrateConfig(initialConfig)); - this.store = this.config.cache ? this.config.store(this.config) : null; - this.storeGetMulti = (key, ids) => this.store.getMulti(contextRecordKey(key), ids); - this.storeGet = (key, id) => ([ this.store.get(recordKey(key, id)) ]); + this._store = caches(this.config, this); - this.queue = queue( + this._queue = queue( this.config, this, - this.store + this._store ); } @@ -34,8 +23,7 @@ class HaStore extends EventEmitter { if (params === null) params = {}; const key = contextKey(this.config.delimiter, params); - return Promise.resolve(this.store && this.storeGet(key, id) || []) - .then((cacheResult) => this.queue.getHandles(key, [id], params, agg, cacheResult)) + return this._queue.getHandles(key, [id], params, agg) .then(handles => handles[0]); } @@ -43,8 +31,7 @@ class HaStore extends EventEmitter { if (params === null) params = {}; const key = contextKey(this.config.delimiter, params); - return Promise.resolve(this.store && this.storeGetMulti(key, ids) || []) - .then((cacheResult) => this.queue.getHandles(key, ids, params, agg, cacheResult)) + return this._queue.getHandles(key, ids, params, agg) .then((handles) => Promise.allSettled(handles) .then((outcomes) => ids.reduce((handles, id, index) => { handles[id] = outcomes[index]; @@ -55,23 +42,23 @@ class HaStore extends EventEmitter { set(items, ids, params = {}) { if (!Array.isArray(ids) || ids.length === 0) throw new Error('Missing required argument id list in batcher #set. '); const key = contextKey(this.config.delimiter, params); - return this.store.set(contextRecordKey(key), ids, items); + return this._store.set(contextRecordKey(key), ids, items); } clear(ids, params) { - if (this.store === null) return true; if (Array.isArray(ids)) { return ids.map(id => this.clear(id, params)); } - return this.store.clear(this.getStorageKey(ids, params)); + return this._store.clear(ids, params); } size() { - return { - ...this.queue.size(), - records: (this.store) ? this.store.size() : 0, - }; + return this._store.size() + .then(records => ({ + ...this._queue.size(), + records, + })); } getStorageKey(id, params) { @@ -79,10 +66,8 @@ class HaStore extends EventEmitter { } } -/* Exports -------------------------------------------------------------------*/ - -function make(initialConfig = {}, emitter = new EventEmitter()) { - return new HaStore(initialConfig, emitter); +function make(initialConfig = {}) { + return new HaStore(initialConfig); } module.exports = make; diff --git a/src/options.js b/src/options.js index b6706c0..9330948 100644 --- a/src/options.js +++ b/src/options.js @@ -1,44 +1,51 @@ -const store = require('./store.js'); +const inMemoryStore = require('./stores/in-memory'); -/* Local variables -----------------------------------------------------------*/ +const defaultCacheConfig = { + store: inMemoryStore, + limit: 5000, + ttl: 300000, +}; const defaultConfig = { batch: { + enabled: false, delay: 50, limit: 100, }, cache: { - limit: 5000, - ttl: 300000, + enabled: false, + tiers: [], }, }; -/* Methods -------------------------------------------------------------------*/ +function hydrateConfig(config = {}) { + if (typeof config.resolver !== 'function') { + throw new Error(`config.resolver [${config.resolver}] is not a function`); + } -function hydrateIfNotNull(baseConfig, defaultConfig) { - if (baseConfig === null) { - return null; + if (config.batch && config.batch.enabled == undefined) { + console.warn('Missing explicit `enabled` flag for ha-store batch config. Batching will not be enabled for this store.'); } - if (!baseConfig) { - return {...defaultConfig}; + if (config.cache && config.cache.enabled == undefined) { + console.warn('Missing explicit `enabled` flag for ha-store cache config. Caching will not be enabled for this store.'); } - return { - ...defaultConfig, - ...baseConfig, - }; -} + if (config.cache?.enabled) { + if (!config.cache?.tiers?.length) { + config.cache.tiers = [defaultCacheConfig]; + } + else { + config.cache.tiers = config.cache.tiers.map((store) =>({...defaultCacheConfig, ...store})); + } + } + else config.cache = defaultConfig.cache; -function hydrateConfig(config = {}) { return { ...config, - store: config.store || store, - batch: hydrateIfNotNull(config.batch, defaultConfig.batch), - cache: hydrateIfNotNull(config.cache, defaultConfig.cache), + batch: {...defaultConfig.batch, ...config.batch}, + cache: config.cache, }; } -/* Exports -------------------------------------------------------------------*/ - module.exports = {hydrateConfig}; diff --git a/src/store.js b/src/stores/in-memory.js similarity index 74% rename from src/store.js rename to src/stores/in-memory.js index bc6d564..b8f5692 100644 --- a/src/store.js +++ b/src/stores/in-memory.js @@ -1,11 +1,9 @@ const lru = require('lru-cache'); -/* Methods -------------------------------------------------------------------*/ - function localStore(config) { const store = new lru({ - max: config.cache.limit, - ttl: config.cache.ttl, + max: config.limit, + ttl: config.ttl, }); function get(key) { @@ -40,9 +38,11 @@ function localStore(config) { return store.size; } - return { get, getMulti, set, clear, size }; -} + function _debug() { + return store.dump(); + } -/* Exports -------------------------------------------------------------------*/ + return { get, getMulti, set, clear, size, local: true, _debug }; +} module.exports = localStore; diff --git a/src/utils.js b/src/utils.js index efad69c..5c04e7c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -15,6 +15,14 @@ function recordKey(context, id) { const contextRecordKey = key => id => recordKey(key, id); -/* Exports -------------------------------------------------------------------*/ +function settleAndLog(promises) { + return Promise.allSettled(promises).then((results) => { + const errors = results.filter((result) => result.status !== 'fulfilled').map((result) => result.reason); + if (errors.length > 0) { + console.error('Failed to get value from remote cache', errors); + } + return results.map((result) => result.value); + }); +} -module.exports = { deferred, contextKey, recordKey, contextRecordKey }; +module.exports = { deferred, contextKey, recordKey, contextRecordKey, settleAndLog }; diff --git a/tests/integration/batch.js b/tests/integration/batch.spec.js similarity index 91% rename from tests/integration/batch.js rename to tests/integration/batch.spec.js index c105567..6a4f610 100644 --- a/tests/integration/batch.js +++ b/tests/integration/batch.spec.js @@ -6,8 +6,8 @@ const expect = require('chai').expect; const sinon = require('sinon'); -const dao = require('./utils/dao.js'); -const store = require('../../src/index.js'); +const dao = require('./utils/dao'); +const store = require('../../src/index'); /* Tests ---------------------------------------------------------------------*/ @@ -24,6 +24,7 @@ describe('Batching', () => { testStore = store({ delimiter: ['language'], resolver: dao.getAssets, + batch: { enabled: true }, }); }); @@ -33,7 +34,7 @@ describe('Batching', () => { testStore.get('abc'), ]) .then((result) => { - expect(result).to.deep.equal([{ id: 'foo', language: undefined }, { id: 'abc', language: undefined }]); + expect(result).to.deep.equal([{ id: 'foo', language: null }, { id: 'abc', language: null }]); mockSource.expects('getAssets') .once() .withArgs(['foo', 'abc']); @@ -43,7 +44,7 @@ describe('Batching', () => { it('should batch multi calls', () => { return testStore.getMany(['abc', 'foo']) .then((result) => { - expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: undefined } }, foo: { status: 'fulfilled', value: { id: 'foo', language: undefined } } }); + expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: null } }, foo: { status: 'fulfilled', value: { id: 'foo', language: null } } }); mockSource.expects('getAssets') .once() .withArgs(['foo', 'abc']); @@ -56,7 +57,7 @@ describe('Batching', () => { testStore.get('abc'), ]) .then((result) => { - expect(result).to.deep.equal([{ bar: { status: 'fulfilled', value: { id: 'bar', language: undefined } }, foo: { status: 'fulfilled', value: { id: 'foo', language: undefined } } }, { id: 'abc', language: undefined }]); + expect(result).to.deep.equal([{ bar: { status: 'fulfilled', value: { id: 'bar', language: null } }, foo: { status: 'fulfilled', value: { id: 'foo', language: null } } }, { id: 'abc', language: null }]); mockSource.expects('getAssets') .once() .withArgs(['foo', 'bar', 'abc']); @@ -139,11 +140,11 @@ describe('Batching', () => { ]) .then((result) => { expect(result).to.deep.equal([{ - foo2: { status: 'fulfilled', value: { id: 'foo2', language: undefined } }, - bar2: { status: 'fulfilled', value: { id: 'bar2', language: undefined } }, - abc2: { status: 'fulfilled', value: { id: 'abc2', language: undefined } }, - def2: { status: 'fulfilled', value: { id: 'def2', language: undefined } }, - ghi2: { status: 'fulfilled', value: { id: 'ghi2', language: undefined } }, + foo2: { status: 'fulfilled', value: { id: 'foo2', language: null } }, + bar2: { status: 'fulfilled', value: { id: 'bar2', language: null } }, + abc2: { status: 'fulfilled', value: { id: 'abc2', language: null } }, + def2: { status: 'fulfilled', value: { id: 'def2', language: null } }, + ghi2: { status: 'fulfilled', value: { id: 'ghi2', language: null } }, },{ foo: { status: 'fulfilled', value: { id: 'foo', language: 'en' } }, bar: { status: 'fulfilled', value: { id: 'bar', language: 'en' } }, @@ -163,7 +164,7 @@ describe('Batching', () => { testStore.get('foo', null, '2345678901'), ]) .then((result) => { - expect(result).to.deep.equal([{ id: 'foo', language: undefined }, { id: 'foo', language: undefined }]); + expect(result).to.deep.equal([{ id: 'foo', language: null }, { id: 'foo', language: null }]); mockSource.expects('getAssets') .once() .withArgs(['abc'], null, ['1234567890', '2345678901']); @@ -171,13 +172,13 @@ describe('Batching', () => { }); it('should accumulate batch data, when batching is disabled', () => { - testStore.config.batch = null; + testStore.config.batch.enabled = false; return Promise.all([ testStore.get('foo'), testStore.get('abc', null, '1234567890'), ]) .then((result) => { - expect(result).to.deep.equal([{ id: 'foo', language: undefined }, { id: 'abc', language: undefined }]); + expect(result).to.deep.equal([{ id: 'foo', language: null }, { id: 'abc', language: null }]); mockSource.expects('getAssets') .once() .withArgs(['abc'], null, ['1234567890']); @@ -185,13 +186,13 @@ describe('Batching', () => { }); it('should support disabled batching', () => { - testStore.config.batch = null; + testStore.config.batch.enabled = false; return Promise.all([ testStore.get('foo'), testStore.get('abc'), ]) .then((result) => { - expect(result).to.deep.equal([{ id: 'foo', language: undefined }, { id: 'abc', language: undefined }]); + expect(result).to.deep.equal([{ id: 'foo', language: null }, { id: 'abc', language: null }]); mockSource.expects('getAssets') .once() .withArgs(['abc']); @@ -214,6 +215,7 @@ describe('Batching', () => { testStore = store({ delimiter: ['language'], resolver: dao.getEmptyGroup, + batch: { enabled: true }, }); }); @@ -279,7 +281,7 @@ describe('Batching', () => { }); it('should support disabled batching', () => { - testStore.config.batch = null; + testStore.config.batch.enabled = false; return Promise.all([ testStore.get('foo'), testStore.get('abc'), @@ -305,14 +307,14 @@ describe('Batching', () => { testStore = store({ delimiter: ['language'], resolver: dao.getPartialGroup, + batch: { limit: 6, delay: 1, enabled: true }, }); }); it('should return the valid results mixed calls', () => { - testStore.config.batch = { limit: 6, delay: 1 }; return testStore.getMany(['abc', 'foo', 'bar']) .then((result) => { - expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: undefined } }, foo: { status: 'fulfilled', value: undefined }, bar: { status: 'fulfilled', value: undefined } }); + expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: null } }, foo: { status: 'fulfilled', value: undefined }, bar: { status: 'fulfilled', value: undefined } }); mockSource.expects('getPartialGroup') .once() .withArgs(['foo', 'bar', 'abc']); @@ -332,6 +334,7 @@ describe('Batching', () => { testStore = store({ delimiter: ['language'], resolver: dao.getFailedRequest, + batch: { enabled: true }, }); }); @@ -354,7 +357,7 @@ describe('Batching', () => { }); it('should properly reject with disabled batching', () => { - testStore.config.batch = null; + testStore.config.batch.enabled = false; return testStore.get('abc') .then(null, (error) => { expect(error).to.deep.equal({ error: 'Something went wrong' }); @@ -377,6 +380,7 @@ describe('Batching', () => { testStore = store({ delimiter: ['language'], resolver: dao.getErroredRequest, + batch: { enabled: true }, }); }); @@ -406,7 +410,7 @@ describe('Batching', () => { }); it('should properly reject with disabled batching', () => { - testStore.config.batch = null; + testStore.config.batch.enabled = false; return testStore.get('abc') .then(null, (error) => { expect(error).to.be.instanceOf(Error).with.property('message', 'Something went wrong'); @@ -429,7 +433,7 @@ describe('Batching', () => { testStore = store({ delimiter: ['language'], resolver: dao.getFailOnFoo, - batch: { limit: 1 }, + batch: { limit: 1, enabled: true }, }); }); diff --git a/tests/integration/cache.js b/tests/integration/cache.spec.js similarity index 84% rename from tests/integration/cache.js rename to tests/integration/cache.spec.js index 74999c0..b32db73 100644 --- a/tests/integration/cache.js +++ b/tests/integration/cache.spec.js @@ -6,8 +6,8 @@ const expect = require('chai').expect; const sinon = require('sinon'); -const dao = require('./utils/dao.js'); -const store = require('../../src/index.js'); +const dao = require('./utils/dao'); +const store = require('../../src/index'); /* Tests ---------------------------------------------------------------------*/ @@ -31,7 +31,7 @@ describe('Caching', () => { testStore.get('foo'); return testStore.get('foo') .then((result) => { - expect(result).to.deep.equal({ id: 'foo', language: undefined }); + expect(result).to.deep.equal({ id: 'foo', language: null }); mockSource.expects('getAssets') .exactly(1) .withArgs(['foo', 'abc']); @@ -42,7 +42,7 @@ describe('Caching', () => { testStore.getMany(['abc', 'foo']) return testStore.getMany(['abc', 'foo']) .then((result) => { - expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: undefined } }, foo: { status: 'fulfilled', value: { id: 'foo', language: undefined } } }); + expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: null } }, foo: { status: 'fulfilled', value: { id: 'foo', language: null } } }); mockSource.expects('getAssets') .exactly(1) .withArgs(['foo', 'abc']); @@ -50,11 +50,11 @@ describe('Caching', () => { }); it('should cache single values without batching', () => { - testStore.config.batch = null; + testStore.config.batch.enabled = false; testStore.get('foo'); return testStore.get('foo') .then((result) => { - expect(result).to.deep.equal({ id: 'foo', language: undefined }); + expect(result).to.deep.equal({ id: 'foo', language: null }); mockSource.expects('getAssets') .exactly(1) .withArgs(['foo', 'abc']); @@ -62,11 +62,11 @@ describe('Caching', () => { }); it('should cache multi values without batching', () => { - testStore.config.batch = null; + testStore.config.batch.enabled = false; testStore.getMany(['abc', 'foo']) return testStore.getMany(['abc', 'foo']) .then((result) => { - expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: undefined } }, foo: { status: 'fulfilled', value: { id: 'foo', language: undefined } } }); + expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: null } }, foo: { status: 'fulfilled', value: { id: 'foo', language: null } } }); mockSource.expects('getAssets') .exactly(1) .withArgs(['foo', 'abc']); @@ -96,11 +96,11 @@ describe('Caching', () => { }); it('should support disabled caching after boot', () => { - testStore.config.cache = null; + testStore.config.cache.enabled = false; testStore.get('foo'); return testStore.get('foo') .then((result) => { - expect(result).to.deep.equal({ id: 'foo', language: undefined }); + expect(result).to.deep.equal({ id: 'foo', language: null }); mockSource.expects('getAssets') .once() .withArgs(['foo']); @@ -108,12 +108,12 @@ describe('Caching', () => { }); it('should support disabled caching and batching after boot', () => { - testStore.config.cache = null; - testStore.config.batch = null; + testStore.config.cache.enabled = false; + testStore.config.batch.enabled = false; testStore.get('foo'); return testStore.get('foo') .then((result) => { - expect(result).to.deep.equal({ id: 'foo', language: undefined }); + expect(result).to.deep.equal({ id: 'foo', language: null }); mockSource.expects('getAssets') .twice() .withArgs(['foo']); @@ -141,7 +141,7 @@ describe('Caching', () => { testStore.get('foo'); return testStore.get('foo') .then((result) => { - expect(result).to.deep.equal({ id: 'foo', language: undefined }); + expect(result).to.deep.equal({ id: 'foo', language: null }); mockSource.expects('getAssets') .exactly(1) .withArgs(['foo', 'abc']); @@ -152,7 +152,7 @@ describe('Caching', () => { testStore.getMany(['abc', 'foo']) return testStore.getMany(['abc', 'foo']) .then((result) => { - expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: undefined } }, foo: { status: 'fulfilled', value: { id: 'foo', language: undefined } } }); + expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: null } }, foo: { status: 'fulfilled', value: { id: 'foo', language: null } } }); mockSource.expects('getAssets') .exactly(1) .withArgs(['foo', 'abc']); @@ -180,7 +180,7 @@ describe('Caching', () => { testStore.get('foo'); return testStore.get('foo') .then((result) => { - expect(result).to.deep.equal({ id: 'foo', language: undefined }); + expect(result).to.deep.equal({ id: 'foo', language: null }); mockSource.expects('getAssets') .exactly(1) .withArgs(['foo', 'abc']); @@ -191,7 +191,7 @@ describe('Caching', () => { testStore.getMany(['abc', 'foo']) return testStore.getMany(['abc', 'foo']) .then((result) => { - expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: undefined } }, foo: { status: 'fulfilled', value: { id: 'foo', language: undefined } } }); + expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: null } }, foo: { status: 'fulfilled', value: { id: 'foo', language: null } } }); mockSource.expects('getAssets') .exactly(1) .withArgs(['foo', 'abc']); @@ -220,7 +220,7 @@ describe('Caching', () => { testStore.get('foo'); return testStore.get('foo') .then((result) => { - expect(result).to.deep.equal({ id: 'foo', language: undefined }); + expect(result).to.deep.equal({ id: 'foo', language: null }); mockSource.expects('getAssets') .exactly(1) .withArgs(['foo', 'abc']); @@ -231,7 +231,7 @@ describe('Caching', () => { testStore.getMany(['abc', 'foo']) return testStore.getMany(['abc', 'foo']) .then((result) => { - expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: undefined } }, foo: { status: 'fulfilled', value: { id: 'foo', language: undefined } } }); + expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: null } }, foo: { status: 'fulfilled', value: { id: 'foo', language: null } } }); mockSource.expects('getAssets') .exactly(1) .withArgs(['foo', 'abc']); @@ -276,7 +276,7 @@ describe('Caching', () => { }); it('should support disabled caching', () => { - testStore.config.batch = null; + testStore.config.batch.enabled = false; testStore.get('foo'); return testStore.get('abc') .then((result) => { @@ -298,6 +298,8 @@ describe('Caching', () => { beforeEach(() => { mockSource = sinon.mock(dao); testStore = store({ + batch: {enabled: true}, + cache: {enabled: true}, delimiter: ['language'], resolver: dao.getPartialGroup, }); @@ -306,19 +308,19 @@ describe('Caching', () => { it('should cache all the results on mixed responses', () => { return testStore.getMany(['abc', 'foo', 'bar']) .then((result) => { - expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: undefined } }, foo: { status: 'fulfilled', value: undefined }, bar: { status: 'fulfilled', value: undefined } }); + expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: null } }, foo: { status: 'fulfilled', value: undefined }, bar: { status: 'fulfilled', value: undefined } }); mockSource.expects('getPartialGroup') .once() .withArgs(['foo', 'bar', 'abc']); }); }); - it('should support disabled caching', () => { - testStore.config.batch = null; + it('should support disabled batching', () => { + testStore.config.batch.enabled = false; testStore.get('foo'); return testStore.get('abc') .then((result) => { - expect(result).to.deep.equal({ id: 'abc', language: undefined }); + expect(result).to.deep.equal({ id: 'abc', language: null }); mockSource.expects('getPartialGroup') .once() .withArgs(['abc']); @@ -360,7 +362,7 @@ describe('Caching', () => { }); it('should properly reject with disabled batching', () => { - testStore.config.batch = null; + testStore.config.batch.enabled = false; return testStore.get('abc') .then(null, (error) => { expect(error).to.deep.equal({ error: 'Something went wrong' }); @@ -387,7 +389,6 @@ describe('Caching', () => { }); it('should not cache on rejected requests', () => { - testStore.config.retry = { base: 1, steps: 1, limit: 1 }; return testStore.get('abc', { language: 'fr' }) .then(null, (error) => { expect(error).to.be.instanceOf(Error).with.property('message', 'Something went wrong'); @@ -397,8 +398,7 @@ describe('Caching', () => { }); it('should properly reject with disabled batching', () => { - testStore.config.retry = null; - testStore.config.batch = null; + testStore.config.batch.enabled = false; return testStore.get('abc') .then(null, (error) => { expect(error).to.be.instanceOf(Error).with.property('message', 'Something went wrong'); diff --git a/tests/integration/remote-cache.spec.js b/tests/integration/remote-cache.spec.js new file mode 100644 index 0000000..e9f67d9 --- /dev/null +++ b/tests/integration/remote-cache.spec.js @@ -0,0 +1,327 @@ +/** + * Remote Caching feature integration tests + */ + +/* Requires ------------------------------------------------------------------*/ + +const expect = require('chai').expect; +const sinon = require('sinon'); +const dao = require('./utils/dao'); +const {sleep} = require('./utils/testUtils'); +const store = require('../../src/index'); +const remote = require('@ha-store/redis'); +const local = require('../../src/stores/in-memory'); + +/* Tests ---------------------------------------------------------------------*/ + +describe('Remote Caching', () => { + describe('Happy remote-only responses', () => { + let testStore; + let mockSource; + afterEach(() => { + testStore = null; + mockSource.restore(); + }); + beforeEach(() => { + mockSource = sinon.mock(dao); + testStore = store({ + delimiter: ['language'], + resolver: dao.getAssets, + cache: { + enabled: true, + tiers: [ + {store: remote(Math.random().toString(36), '//0.0.0.0:6379')}, + ], + }, + }); + return testStore.clear('*'); + }); + + it('should cache single values', () => { + return testStore.get('foo') + .then(() => sleep(10)) + .then(() => testStore.get('foo') + .then((result) => { + expect(result).to.deep.equal({ id: 'foo', language: null }); + mockSource.expects('getAssets') + .exactly(1) + .withArgs(['foo', 'abc']); + })); + }); + + it('should cache multi values', async () => { + testStore.getMany(['abc', 'foo']) + await sleep(10); + return testStore.getMany(['abc', 'foo']) + .then((result) => { + expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: null } }, foo: { status: 'fulfilled', value: { id: 'foo', language: null } } }); + mockSource.expects('getAssets') + .exactly(1) + .withArgs(['foo', 'abc']); + }); + }); + + it('should cache single values without batching', async () => { + testStore.config.batch.enabled = false; + testStore.get('foo'); + await sleep(10); + return testStore.get('foo') + .then((result) => { + expect(result).to.deep.equal({ id: 'foo', language: null }); + mockSource.expects('getAssets') + .exactly(1) + .withArgs(['foo', 'abc']); + }); + }); + + it('should cache multi values without batching', async () => { + testStore.config.batch.enabled = false; + testStore.getMany(['abc', 'foo']) + await sleep(10); + return testStore.getMany(['abc', 'foo']) + .then((result) => { + expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: null } }, foo: { status: 'fulfilled', value: { id: 'foo', language: null } } }); + mockSource.expects('getAssets') + .exactly(1) + .withArgs(['foo', 'abc']); + }); + }); + + it('should cache single calls with params', async () => { + testStore.get('foo', { language: 'fr' }); + await sleep(10); + return testStore.get('foo', { language: 'fr' }) + .then((result) => { + expect(result).to.deep.equal({ id: 'foo', language: 'fr' }); + mockSource.expects('getAssets') + .exactly(1) + .withArgs(['foo'], { language: 'fr' }); + }); + }); + + it('should not return cached values forunique params mismatches', async () => { + testStore.get('foo', { language: 'fr' }); + await sleep(10); + return testStore.get('foo', { language: 'en' }) + .then((result) => { + expect(result).to.deep.equal({ id: 'foo' , language: 'en' }); + mockSource.expects('getAssets') + .once() + .withArgs(['foo'], { language: 'en' }); + }); + }); + + it('should support disabled caching after boot', async () => { + testStore.config.cache.enabled = false; + testStore.get('foo'); + await sleep(10); + return testStore.get('foo') + .then((result) => { + expect(result).to.deep.equal({ id: 'foo', language: null }); + mockSource.expects('getAssets') + .once() + .withArgs(['foo']); + }); + }); + + it('should support disabled caching and batching after boot', async () => { + testStore.config.cache.enabled = false; + testStore.config.batch.enabled = false; + testStore.get('foo'); + await sleep(10); + return testStore.get('foo') + .then((result) => { + expect(result).to.deep.equal({ id: 'foo', language: null }); + mockSource.expects('getAssets') + .twice() + .withArgs(['foo']); + }); + }); + }); + + describe('Happy hybrid-caching responses', () => { + let testStore; + let mockSource; + afterEach(() => { + testStore = null; + mockSource.restore(); + }); + beforeEach(async () => { + mockSource = sinon.mock(dao); + testStore = store({ + delimiter: ['language'], + resolver: dao.getAssets, + cache: { + enabled: true, + tiers: [ + {store: local}, + {store: remote(Math.random().toString(36), '//0.0.0.0:6379')}, + ], + }, + }); + await testStore.clear('*'); + }); + + it('remote cache should be populated', async () => { + await testStore.get('foo'); + await sleep(10); + return testStore.size() + .then((result) => { + return expect(result.records.remote).to.be.greaterThanOrEqual(1); + }); + }); + + it('should cache single values', async () => { + testStore.get('foo') + await sleep(10); + return testStore.get('foo') + .then((result) => { + expect(result).to.deep.equal({ id: 'foo', language: null }); + mockSource.expects('getAssets') + .exactly(1) + .withArgs(['foo', 'abc']); + }); + }); + + it('should cache multi values', async () => { + testStore.getMany(['abc', 'foo']) + await sleep(10); + return testStore.getMany(['abc', 'foo']) + .then((result) => { + expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: null } }, foo: { status: 'fulfilled', value: { id: 'foo', language: null } } }); + mockSource.expects('getAssets') + .exactly(1) + .withArgs(['foo', 'abc']); + }); + }); + + it('should cache single values without batching', async () => { + testStore.config.batch.enabled = false; + testStore.get('foo'); + await sleep(10); + return testStore.get('foo') + .then((result) => { + expect(result).to.deep.equal({ id: 'foo', language: null }); + mockSource.expects('getAssets') + .exactly(1) + .withArgs(['foo', 'abc']); + }); + }); + + it('should cache multi values without batching', async () => { + testStore.config.batch.enabled = false; + testStore.getMany(['abc', 'foo']) + await sleep(10); + return testStore.getMany(['abc', 'foo']) + .then((result) => { + expect(result).to.deep.equal({ abc: { status: 'fulfilled', value: { id: 'abc', language: null } }, foo: { status: 'fulfilled', value: { id: 'foo', language: null } } }); + mockSource.expects('getAssets') + .exactly(1) + .withArgs(['foo', 'abc']); + }); + }); + + it('should cache single calls with params', async () => { + testStore.get('foo', { language: 'fr' }); + await sleep(10); + return testStore.get('foo', { language: 'fr' }) + .then((result) => { + expect(result).to.deep.equal({ id: 'foo', language: 'fr' }); + mockSource.expects('getAssets') + .exactly(1) + .withArgs(['foo'], { language: 'fr' }); + }); + }); + + it('should not return cached values forunique params mismatches', async () => { + testStore.get('foo', { language: 'fr' }); + await sleep(10); + return testStore.get('foo', { language: 'en' }) + .then((result) => { + expect(result).to.deep.equal({ id: 'foo' , language: 'en' }); + mockSource.expects('getAssets') + .once() + .withArgs(['foo'], { language: 'en' }); + }); + }); + + it('should support disabled caching after boot', async () => { + testStore.config.cache.enabled = false; + testStore.get('foo'); + await sleep(10); + return testStore.get('foo') + .then((result) => { + expect(result).to.deep.equal({ id: 'foo', language: null }); + mockSource.expects('getAssets') + .once() + .withArgs(['foo']); + }); + }); + + it('should support disabled caching and batching after boot', async () => { + testStore.config.cache.enabled = false; + testStore.config.batch.enabled = false; + testStore.get('foo'); + await sleep(10); + return testStore.get('foo') + .then((result) => { + expect(result).to.deep.equal({ id: 'foo', language: null }); + mockSource.expects('getAssets') + .twice() + .withArgs(['foo']); + }); + }); + }); + + describe('Rejected remote requests', () => { + let testStore; + let mockSource; + afterEach(() => { + testStore = null; + mockSource.restore(); + }); + beforeEach(async () => { + mockSource = sinon.mock(dao); + testStore = store({ + delimiter: ['language'], + resolver: dao.getFailedRequest, + cache: { + enabled: true, + tiers: [ + {store: remote(Math.random().toString(36), '//0.0.0.0:6379')}, + ], + }, + }); + await testStore.clear('*'); + }); + + it('should not cache failed requests', () => { + return testStore.get('abc', { language: 'fr' }) + .then(null, (error) => { + expect(error).to.deep.equal({ error: 'Something went wrong' }); + mockSource.expects('getFailedRequest') + .once().withArgs(['abc'], { language: 'fr' }); + }); + }); + + it('should not cache failed multi requests', () => { + return testStore.getMany(['abc', 'foo'], { language: 'en' }) + .then(null, (error) => { + expect(error).to.deep.equal({ error: 'Something went wrong' }); + mockSource.expects('getFailedRequest') + .once().withArgs(['abc', 'foo'], { language: 'en' }); + }); + }); + + it('should properly reject with disabled batching', () => { + testStore.config.batch.enabled = false; + return testStore.get('abc') + .then(null, (error) => { + expect(error).to.deep.equal({ error: 'Something went wrong' }); + mockSource.expects('getFailedRequest') + .once() + .withArgs(['abc']); + }); + }); + }); +}); \ No newline at end of file diff --git a/tests/integration/utils/dao.js b/tests/integration/utils/dao.js index f15e69f..99f32a6 100644 --- a/tests/integration/utils/dao.js +++ b/tests/integration/utils/dao.js @@ -1,7 +1,7 @@ function getAssets(ids, { language }) { return new Promise((resolve) => { setTimeout(() => resolve(ids.reduce((acc, id) => { - acc[id] = { id, language }; + acc[id] = { id, language: language || null }; return acc; }, {})), (ids.length > 1) ? 130 : 100); }); @@ -15,7 +15,7 @@ function getEmptyGroup() { function getPartialGroup(ids, { language }) { return new Promise((resolve) => { - setTimeout(() => resolve({ [ids[0]]: { id: ids[0], language }}), 5); + setTimeout(() => resolve({ [ids[0]]: { id: ids[0], language: language || null }}), 5); }); } diff --git a/tests/integration/utils/testUtils.js b/tests/integration/utils/testUtils.js new file mode 100644 index 0000000..e1bdf23 --- /dev/null +++ b/tests/integration/utils/testUtils.js @@ -0,0 +1,7 @@ +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +module.exports = { + sleep, +}; diff --git a/tests/profiling/index.js b/tests/profiling/index.js index d5f51f6..821b16e 100644 --- a/tests/profiling/index.js +++ b/tests/profiling/index.js @@ -21,8 +21,9 @@ app.on('message', async (suite) => { console.log(` ${suite.completed} completed requests ${suite.cacheHits} cache hits + ${suite.localCacheHits} local cache hits ${suite.coalescedHit} coalesced hits - ${JSON.stringify(suite.size)} in memory + ${JSON.stringify(suite.size)} ${suite.timeouts} timed out avg response time ${(suite.sum / suite.completed).toFixed(3)} ${suite.batches} queries sent diff --git a/tests/profiling/settings.js b/tests/profiling/settings.js index c52ad17..0aab203 100644 --- a/tests/profiling/settings.js +++ b/tests/profiling/settings.js @@ -1,5 +1,4 @@ const {getAssets} = require('./dao.js'); -//const redisStore = require('ha-store-redis'); module.exports = { test: { @@ -7,11 +6,9 @@ module.exports = { }, setup: { resolver: getAssets, - // store: redisStore('footage-api-ha-cache', { host: '0.0.0.0', port: 6379 }), delimiter: ['language'], - cache: { limit: 5000, ttl: 300000 }, - batch: { delay: 10, limit: 50 }, - retry: { base: 1, step: 2 }, + cache: { enabled: true }, + batch: { enabled: true, delay: 10, limit: 50 }, }, assert: { completed: [300000, 300000], diff --git a/tests/profiling/worker.js b/tests/profiling/worker.js index a0ecc05..1477307 100644 --- a/tests/profiling/worker.js +++ b/tests/profiling/worker.js @@ -13,6 +13,7 @@ const crypto = require('crypto'); const suite = { completed: 0, cacheHits: 0, + localCacheHits: 0, coalescedHit: 0, sum: 0, timeouts: 0, @@ -49,6 +50,7 @@ function handleRequest(id, language) { store.on('query', batch => { suite.batches++; suite.avgBatchSize += batch.size; }); store.on('cacheHit', evt => { suite.cacheHits+=evt; }); +store.on('localCacheHit', evt => { suite.localCacheHits+=evt; }); store.on('coalescedHit', evt => { suite.coalescedHit+=evt; }); //End diff --git a/tests/unit/index.spec.js b/tests/unit/index.spec.js index feed455..e62eef1 100644 --- a/tests/unit/index.spec.js +++ b/tests/unit/index.spec.js @@ -3,20 +3,20 @@ */ /* Requires ------------------------------------------------------------------*/ -const {contextKey} = require('../../src/utils.js'); -const {noop} = require('./utils'); -const root = require('../../src/index.js'); +const {contextKey} = require('../../src/utils'); +const {noop} = require('./testUtils'); +const root = require('../../src/index'); const expect = require('chai').expect; const sinon = require('sinon'); /* Utils ---------------------------------------------------------------------*/ function checkForPublicProperties(store) { expect(store.get).to.not.be.undefined; + expect(store.getMany).to.not.be.undefined; expect(store.set).to.not.be.undefined; expect(store.clear).to.not.be.undefined; expect(store.getStorageKey).to.not.be.undefined; expect(store.config).to.not.be.undefined; - expect(store.queue).to.not.be.undefined; } /* Tests ---------------------------------------------------------------------*/ @@ -53,7 +53,7 @@ describe('index', () => { const test = root({ resolver: noop, delimiter: ['a', 'b', 'c'], - cache: {base: 2}, + cache: {enabled: true, tiers: [{ttl: 1000}]}, batch: {limit: 12}, }); checkForPublicProperties(test); @@ -67,7 +67,7 @@ describe('index', () => { describe('#get', () => { it('should handle single record queries', () => { const test = root({resolver: noop}); - const queueMock = sinon.mock(test.queue); + const queueMock = sinon.mock(test._queue); test.get('123abc'); queueMock.expects('getHandles').once().withArgs('', ['123abc'], undefined, undefined); }); @@ -75,14 +75,14 @@ describe('index', () => { it('should handle single record queries with params', () => { const test = root({resolver: noop}); const params = {foo: 'bar'}; - const queueMock = sinon.mock(test.queue); + const queueMock = sinon.mock(test._queue); test.get('123abc', params); queueMock.expects('getHandles').once().withArgs('foo="bar"', ['123abc'], { foo: 'bar' }, undefined); }); it('should handle multi record queries', () => { const test = root({resolver: noop}); - const queueMock = sinon.mock(test.queue); + const queueMock = sinon.mock(test._queue); test.get(['123abc', '456def', '789ghi']); queueMock.expects('getHandles').once().withArgs('', ['123abc', '456def', '789ghi'], undefined, undefined); }); @@ -90,7 +90,7 @@ describe('index', () => { it('should handle multi record queries with params', () => { const test = root({resolver: noop}); const params = {foo: 'bar'}; - const queueMock = sinon.mock(test.queue); + const queueMock = sinon.mock(test._queue); test.get(['123abc', '456def', '789ghi'], params); queueMock.expects('getHandles').once().withArgs('foo="bar"', ['123abc', '456def', '789ghi'], { foo: 'bar' }, undefined); }); @@ -100,7 +100,7 @@ describe('index', () => { it('should handle a collection of ids', () => { const test = root({resolver: noop}); const params = {foo: 'bar'}; - const storeMock = sinon.mock(test.store); + const storeMock = sinon.mock(test._store); test.set({foo123abc: 'test'}, ['123abc'], params); storeMock.expects('set') .once() @@ -117,7 +117,7 @@ describe('index', () => { describe('#clear', () => { it('should return clear value', () => { const test = root({resolver: noop}); - const storeMock = sinon.mock(test.store); + const storeMock = sinon.mock(test._store); test.clear('123abc'); storeMock.expects('clear').once().withArgs('123abc'); }); @@ -125,14 +125,14 @@ describe('index', () => { it('should return clear value with params', () => { const test = root({resolver: noop}); const params = {foo: 'bar'}; - const storeMock = sinon.mock(test.store); + const storeMock = sinon.mock(test._store); test.clear('123abc', params); storeMock.expects('clear').once().withArgs('foo=bar::123abc'); }); it('should handle multi record clear queries', () => { const test = root({resolver: noop}); - const storeMock = sinon.mock(test.store); + const storeMock = sinon.mock(test._store); test.clear(['123abc', '456def', '789ghi']); storeMock.expects('clear') .once().withArgs('123abc') @@ -146,7 +146,7 @@ describe('index', () => { delimiter: ['foo'], }); const params = {foo: 'bar'}; - const storeMock = sinon.mock(test.store); + const storeMock = sinon.mock(test._store); test.clear(['123abc', '456def', '789ghi'], params); storeMock.expects('clear') .once().withArgs('foo=bar::123abc') @@ -158,14 +158,37 @@ describe('index', () => { describe('#size', () => { it('should return size value', async () => { const test = root({resolver: noop}); - const queueMock = sinon.mock(test.queue); - const storeMock = sinon.mock(test.store); - test.get('123abc'); + const queueMock = sinon.mock(test._queue); + const storeMock = sinon.mock(test._store); + await test.get('123abc'); + const sizeValue = await test.size(); + expect(sizeValue).to.deep.equal({ + activeBuffers: 0, + pendingBuffers: 0, + records: { + local: 0, + remote: 0, + status: 'disabled', + }, + }); + queueMock.expects('size').once(); + storeMock.expects('size').once(); + }); + + it('should return size value and status if cache is disabled', async () => { + const test = root({resolver: noop, cache: null}); + const queueMock = sinon.mock(test._queue); + const storeMock = sinon.mock(test._store); + await test.get('123abc'); const sizeValue = await test.size(); expect(sizeValue).to.deep.equal({ activeBuffers: 0, pendingBuffers: 0, - records: 0, + records: { + local: 0, + remote: 0, + status: 'disabled', + }, }); queueMock.expects('size').once(); storeMock.expects('size').once(); diff --git a/tests/unit/options.spec.js b/tests/unit/options.spec.js deleted file mode 100644 index b367144..0000000 --- a/tests/unit/options.spec.js +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Utils unit tests - */ - -/* Requires ------------------------------------------------------------------*/ -const {hydrateConfig} = require('../../src/options'); -const batcher = require('../../src/index'); -const {noop} = require('./utils'); -const expect = require('chai').expect; -const store = require('../../src/store'); - -/* Tests ---------------------------------------------------------------------*/ - -describe('options', () => { - const defaultConfig = { - "batch": {"delay": 50, "limit": 100}, - "cache": {"limit": 5000, "ttl": 300000}, - "store": store, - }; - - describe('#basicParser', () => { - it('should produce a batcher with all the default config when called with true requirements', () => { - const test = batcher({ - resolver: noop, - delimiter: ['a', 'b', 'c'], - cache: true, - batch: true, - }); - - expect(test.config).to.deep.contain({ - cache: {limit: 5000, ttl: 300000}, - batch: {limit: 100, delay: 50}, - }); - }); - - it('should produce a batcher with all the merged config when called with custom requirements', () => { - const test = batcher({ - resolver: noop, - delimiter: ['a', 'b', 'c'], - cache: {limit: 2}, - batch: {limit: 12}, - }); - - expect(test.config).to.deep.contain({ - cache: {limit: 2, ttl: 300000}, - batch: {limit: 12, delay: 50}, - }); - }); - - it('should hydrate the configuration with default values if none are provided', () => { - const finalConfig = hydrateConfig({}); - - expect(defaultConfig).to.deep.equal(finalConfig, "Did you change the default configuration?"); - }); - - it('should hydrate a module\'s configuration if its base config is not `null`', () => { - const baseConfig = { - "batch": {}, - "cache": 1, - }; - - const finalConfig = hydrateConfig(baseConfig); - - expect(finalConfig.batch).to.not.be.undefined; - expect(finalConfig.cache).to.not.be.undefined; - - expect(finalConfig.batch).to.not.be.null; - expect(finalConfig.cache).to.not.be.null; - - expect(finalConfig.batch).to.deep.equal(defaultConfig.batch); - expect(finalConfig.cache).to.deep.equal(defaultConfig.cache); - }); - - it('should not hydrate a module\'s configuration if its base config is `null`', () => { - const baseConfig = { - "batch": null, - "cache": null, - }; - - const finalConfig = hydrateConfig(baseConfig); - expect(finalConfig.batch).to.be.null; - expect(finalConfig.cache).to.be.null; - }); - }); - -}); diff --git a/tests/unit/utils.js b/tests/unit/testUtils.js similarity index 100% rename from tests/unit/utils.js rename to tests/unit/testUtils.js