From 447f6b855d7415441c099d3a3a7159742221ae78 Mon Sep 17 00:00:00 2001 From: Bill Date: Fri, 8 Nov 2019 12:56:25 -0800 Subject: [PATCH] feat: Add time aware support (#16) --- README.md | 12 + lib/cache.js | 156 +++ lib/index.js | 911 +++++++------ package.json | 5 +- tests/fixtures/time/time-test-configs.json | 1103 ++++++++++++++++ tests/fixtures/time/time-test-dimensions.json | 21 + tests/fixtures/time/time-test.json | 1122 +++++++++++++++++ .../touchdown-simple/configs/no-master.js | 25 + tests/lib/cache-test.js | 307 +++++ tests/lib/index.js | 710 +++++++++-- 10 files changed, 3789 insertions(+), 583 deletions(-) create mode 100644 lib/cache.js create mode 100644 tests/fixtures/time/time-test-configs.json create mode 100644 tests/fixtures/time/time-test-dimensions.json create mode 100644 tests/fixtures/time/time-test.json create mode 100644 tests/fixtures/touchdown-simple/configs/no-master.js create mode 100644 tests/lib/cache-test.js diff --git a/README.md b/README.md index f34d0ac..88f5797 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,18 @@ helper.readDimensions(function(err, dimensions) { YCB Config lets you read just the dimensions that are available for you to contextualize a request that's coming in. This can be an array of properties such as device type, language, feature bucket, or more. +## Scheduled Configs + +To support scheduled configs as described in [ycb](https://github.com/yahoo/ycb) ycb-config must be set to time aware mode via option flag and the time must be passed as a special dimension of the context when in this mode. +``` +let helper = new ConfigHelper({timeAware: true}); +let context = req.context; +context.time = Date.now(); //{device: 'mobile', time: 1573235678929} +helper.read(bundle, config, context, callback); +``` +The time value in the context should be a millisecond timestamp. To use a custom time dimension +it may specified asn an option:`new ConfigHelper({timeDimension: 'my-time-key'})`. + ## License This software is free to use under the Yahoo Inc. BSD license. See the [LICENSE file][] for license text and copyright information. diff --git a/lib/cache.js b/lib/cache.js new file mode 100644 index 0000000..cca994b --- /dev/null +++ b/lib/cache.js @@ -0,0 +1,156 @@ + +/*jslint nomen:true, anon:true, node:true, esversion:6 */ +'use strict'; + +/** + * Entry class used as map values and intrusive linked list nodes. + */ +class Entry { + constructor (key, value, setAt, expiresAt, groupId) { + this.next = null; + this.prev = null; + this.key = key; + this.value = value; + this.setAt = setAt; + this.expiresAt = expiresAt; + this.groupId = groupId; + } +} + +/** + * LRU cache. + * Supported options are {max: int} which will set the max capacity of the cache. + */ +class ConfigCache { + constructor(options) { + options = options || {}; + this.max = options.max; + if(!Number.isInteger(options.max) || options.max < 0) { + console.log('WARNING: no valid cache capacity given, defaulting to 100. %s', JSON.stringify(options)); + this.max = 100; + } + if(this.max === 0) { + this.get = this.set = this.getTimeAware = this.setTimeAware = function(){}; + } + this.size = 0; + this.map = new Map(); //key -> Entry + this.youngest = null; + this.oldest = null; + } + + /** + * Set a cache entry. + * @param {string} key Key mapping to this value. + * @param {*} value Value to be cached. + * @param {number} groupId Id of the entry's group, used to lazily invalidate subsets of the cache. + */ + set(key, value, groupId) { + this.setTimeAware(key, value, 0, 0, groupId); + } + + /** + * Set a time aware cache entry. + * @param {string} key Key mapping to this value. + * @param {*} value Value to be cached. + * @param {number} now Current time. + * @param {number} expiresAt Time at which entry will become stale. + * @param {number} groupId Id of the entry's group, used to lazily invalidate subsets of the cache. + */ + setTimeAware(key, value, now, expiresAt, groupId) { + var entry = this.map.get(key); + if(entry !== undefined) { + entry.value = value; + entry.setAt = now; + entry.expiresAt = expiresAt; + entry.groupId = groupId; + this._makeYoungest(entry); + return; + } + if(this.size === this.max) { + entry = this.oldest; + this.map.delete(entry.key); + entry.key = key; + entry.value = value; + entry.setAt = now; + entry.expiresAt = expiresAt; + entry.groupId = groupId; + this.map.set(key, entry); + this._makeYoungest(entry); + return; + } + entry = new Entry(key, value, now, expiresAt, groupId); + this.map.set(key, entry); + if(this.size === 0) { + this.youngest = entry; + this.oldest = entry; + this.size = 1; + return; + } + entry.next = this.youngest; + this.youngest.prev = entry; + this.youngest = entry; + this.size++; + } + + /** + * Get value from the cache. Will return undefined if entry is not in cache or is stale. + * @param {string} key Key to look up in cache. + * @param {number} groupId Group id to check if value is stale. + * @returns {*} + */ + get(key, groupId) { + var entry = this.map.get(key); + if(entry !== undefined) { + if(groupId !== entry.groupId) { + return undefined; //do not clean up stale entry as we know client code will set this key + } + this._makeYoungest(entry); + return entry.value; + } + return undefined; + } + + /** + * Get value from the cache with time awareness. Will return undefined if entry is not in cache or is stale. + * @param {string} key Key to look up in cache. + * @param {number} now Current time to check if value is stale. + * @param {number} groupId Group id to check if value is stale. + * @returns {*} + */ + getTimeAware(key, now, groupId) { + var entry = this.map.get(key); + if(entry !== undefined) { + if(groupId !== entry.groupId || now < entry.setAt || now >= entry.expiresAt){ + return undefined; //do not clean up stale entry as we know client code will set this key + } + this._makeYoungest(entry); + return entry.value; + } + return undefined; + } + + /** + * Move entry to the head of the list and set as youngest. + * @param {Entry} entry + * @private + */ + _makeYoungest(entry) { + if(entry === this.youngest) { + return; + } + var prev = entry.prev; + if(entry === this.oldest) { + prev.next = null; + this.oldest = prev; + } else { + prev.next = entry.next; + entry.next.prev = prev; + } + entry.prev = null; + this.youngest.prev = entry; + entry.next = this.youngest; + this.youngest = entry; + } +} + +module.exports = ConfigCache; \ No newline at end of file diff --git a/lib/index.js b/lib/index.js index 2bd4601..7ef0bd1 100644 --- a/lib/index.js +++ b/lib/index.js @@ -14,7 +14,7 @@ var libfs = require('fs'), libycb = require('ycb'), libjson5 = require('json5'), libyaml = require('yamljs'), - libcache = require('lru-cache'), + libcache = require('./cache'), deepFreeze = require('deep-freeze'), MESSAGES = { @@ -22,7 +22,8 @@ var libfs = require('fs'), 'unknown config': 'Unknown config "%s" in bundle "%s"', 'unknown cache data': 'Unknown cache data with config "%s" in bundle "%s"', 'missing dimensions': 'Failed to find a dimensions.json file', - 'parse error': 'Failed to parse "%s"\n%s' + 'parse error': 'Failed to parse "%s"\n%s', + 'missing time': 'No time dimension, %s, in context, %s, during time aware mode' }, DEFAULT_CACHE_OPTIONS = { max: 250 @@ -69,43 +70,12 @@ function contentsIsYCB(contents) { if (!section.settings) { return false; } - if (!Array.isArray(section.settings)) { - return false; - } } return true; } return false; } -/** - * Creates a cache key that will be one-to-one for each context object, - * based on their contents. JSON.stringify does not guarantee order for - * two objects that have the same contents, so we need to have this. - * @private - * @static - * @method getCacheKey - * @param {mixed} context The context object. - * @return {string} A JSON-parseable string that will be the same for all equivalent context objects. - */ - -function getCacheKey(context) { - var a = [], - KV_START = '"', - KV_END = '"', - JSON_START = '{', - JSON_END = '}', - key; - - for (key in context) { - a.push([KV_START + key + KV_END, - KV_START + context[key] + KV_END].join(':')); - } - - a.sort(); - return JSON_START + a.toString() + JSON_END; -} - /** * Create the YCB object. * @private @@ -118,25 +88,11 @@ function getCacheKey(context) { */ function makeYCB(config, dimensions, contents) { var ycbBundle, - ycb, - originalRead, - originalReadNoMerge; - + ycb; contents = contents || {}; ycbBundle = [{dimensions: dimensions}]; - // need to copy contents, since YCB messes with it - ycbBundle = ycbBundle.concat(clone(contents)); + ycbBundle = ycbBundle.concat(contents); ycb = new libycb.Ycb(ycbBundle); - - // monkey-patch to apply baseContext - originalRead = ycb.read; - ycb.read = function (context, options) { - return originalRead.call(ycb, config._mergeBaseContext(context), options); - }; - originalReadNoMerge = ycb.readNoMerge; - ycb.readNoMerge = function (context, options) { - return originalReadNoMerge.call(ycb, config._mergeBaseContext(context), options); - }; return ycb; } @@ -163,6 +119,15 @@ function makeFakeYCB(dimensions, contents) { }, readNoMerge: function () { return [contents]; + }, + readTimeAware: function () { + return contents; + }, + readNoMergeTimeAware: function () { + return [contents]; + }, + getCacheKey: function () { + return ''; } }; } @@ -187,476 +152,490 @@ function makeFakeYCB(dimensions, contents) { function Config(options) { this._options = options || {}; this._dimensionsPath = this._options.dimensionsPath; - this._configContents = {}; // fullpath: contents this._configPaths = {}; // bundle: config: fullpath - // cached data: + this._configContents = {}; // fullpath: contents this._configYCBs = {}; // fullpath: YCB object - this._configCache = {}; // bundle: config: context: config + this._pathCount = {}; // fullpath: number of configs using this path + //cache fields: + this._configIdCounter = 0; //incrementing counter assigned to config bundles to uniquely identify them for cache invalidation. + this._configIdMap = {}; //id = _configIdMap[bundleName][configName] + this._cache = new libcache(this._options.cache || DEFAULT_CACHE_OPTIONS); + //time fields: + this.timeAware = false; + this.timeDimension = 'time'; + this.expiresKey = libycb.expirationKey; + if(this._options.timeAware) { + this.timeAware = true; + } + if(this._options.timeDimension) { + this.timeAware = true; + this.timeDimension = this._options.timeDimension; + } } -Config.prototype = {}; +Config.prototype = { + + /** + * Registers a configuration file. + * @method addConfig + * @async + * @param {string} bundleName Name of the bundle to which this config file belongs. + * @param {string} configName Name of the config file. + * @param {string} fullPath Full filesystem path to the config file. + * @param {Function} [callback] Called once the config has been added to the helper. + * @param {Error|null} callback.err If an error occurred, then this parameter will + * contain the error. If the operation succeeded, then `err` will be null. + * @param {Object} callback.contents The contents of the config file, as a + * JavaScript object. + */ + addConfig: function (bundleName, configName, fullPath, callback) { + var self = this; + callback = callback || function(){}; + self._readConfigContents(fullPath, function (err, contents) { + if (err) { + return callback(err); + } + self.addConfigContents(bundleName, configName, fullPath, contents, callback); + }); + }, -/** - * Registers a configuration file. - * @method addConfig - * @async - * @param {string} bundleName Name of the bundle to which this config file belongs. - * @param {string} configName Name of the config file. - * @param {string} fullPath Full filesystem path to the config file. - * @param {Function} [callback] Called once the config has been added to the helper. - * @param {Error|null} callback.err If an error occurred, then this parameter will - * contain the error. If the operation succeeded, then `err` will be null. - * @param {Object} callback.contents The contents of the config file, as a - * JavaScript object. - */ -Config.prototype.addConfig = function (bundleName, configName, fullPath, callback) { - var self = this; - callback = callback || function() {}; - self._readConfigContents(fullPath, function (err, contents) { - if (err) { - return callback(err); + /** + * Registers a configuration file and its contents. + * @method addConfigContents + * @param {string} bundleName Name of the bundle to which this config file belongs. + * @param {string} configName Name of the config file. + * @param {string} fullPath Full filesystem path to the config file. + * @param {string|Object} contents The contents for the config file at the path. + * This will be parsed into an object (via JSON or YAML depending on the file extension) + * unless it is already an object. + * @param {Function} [callback] Called once the config has been added to the helper. + * @param {Error|null} callback.err If an error occurred, then this parameter will + * contain the error. If the operation succeeded, then `err` will be null. + * @param {Object} callback.contents The contents of the config file, as a + * JavaScript object. + */ + addConfigContents: function (bundleName, configName, fullPath, contents, callback) { + var self = this; + + contents = this._parseConfigContents(fullPath, contents); + + // register so that _readConfigContents() will use + self._configContents[fullPath] = contents; + + // deregister old config (if any) + self.deleteConfig(bundleName, configName, fullPath); + + if (!self._configPaths[bundleName]) { + self._configPaths[bundleName] = {}; } - self.addConfigContents(bundleName, configName, fullPath, contents, callback); - }); -}; - - -/** - * Registers a configuration file and its contents. - * @method addConfigContents - * @param {string} bundleName Name of the bundle to which this config file belongs. - * @param {string} configName Name of the config file. - * @param {string} fullPath Full filesystem path to the config file. - * @param {string|Object} contents The contents for the config file at the path. - * This will be parsed into an object (via JSON or YAML depending on the file extension) - * unless it is already an object. - * @param {Function} [callback] Called once the config has been added to the helper. - * @param {Error|null} callback.err If an error occurred, then this parameter will - * contain the error. If the operation succeeded, then `err` will be null. - * @param {Object} callback.contents The contents of the config file, as a - * JavaScript object. - */ -Config.prototype.addConfigContents = function (bundleName, configName, fullPath, contents, callback) { - var self = this; - - contents = this._parseConfigContents(fullPath, contents); + self._configPaths[bundleName][configName] = fullPath; - // register so that _readConfigContents() will use - self._configContents[fullPath] = contents; - - // deregister old config (if any) - self.deleteConfig(bundleName, configName, fullPath); + if (!self._pathCount[fullPath]) { + self._pathCount[fullPath] = 0; + } + self._pathCount[fullPath]++; - if (!self._configPaths[bundleName]) { - self._configPaths[bundleName] = {}; - } - self._configPaths[bundleName][configName] = fullPath; + //assign new config bundle a unique id by incrementing counter + if (!self._configIdMap[bundleName]) { + self._configIdMap[bundleName] = {}; + } + self._configIdCounter = (self._configIdCounter+1) % Number.MAX_SAFE_INTEGER; + self._configIdMap[bundleName][configName] = self._configIdCounter; - // keep path to dimensions file up-to-date - if ('dimensions' === configName && !self._options.dimensionsPath) { - if (self._options.dimensionsBundle) { - if (bundleName === self._options.dimensionsBundle) { - self._dimensionsPath = fullPath; - } - } else { - if (self._dimensionsPath) { - if (fullPath.length < self._dimensionsPath.length) { + // keep path to dimensions file up-to-date + if ('dimensions' === configName && !self._options.dimensionsPath) { + if (self._options.dimensionsBundle) { + if (bundleName === self._options.dimensionsBundle) { self._dimensionsPath = fullPath; } } else { - self._dimensionsPath = fullPath; + if (self._dimensionsPath) { + if (fullPath.length < self._dimensionsPath.length) { + self._dimensionsPath = fullPath; + } + } else { + self._dimensionsPath = fullPath; + } } } - } - if (callback) { - callback(null, contents); - } -}; + if (callback) { + callback(null, contents); + } + }, -/** - * Deregisters a configuration file. - * @method deleteConfig - * @param {string} bundleName Name of the bundle to which this config file belongs. - * @param {string} configName Name of the config file. - * @param {string} fullPath Full filesystem path to the config file. - * @return {undefined} Nothing appreciable is returned. - */ -Config.prototype.deleteConfig = function (bundleName, configName, fullPath) { - if (this._configPaths[bundleName]) { - this._configPaths[bundleName][configName] = undefined; - } -}; + /** + * Deregisters a configuration file. + * @method deleteConfig + * @param {string} bundleName Name of the bundle to which this config file belongs. + * @param {string} configName Name of the config file. + * @param {string} fullPath Full filesystem path to the config file. + * @return {undefined} Nothing appreciable is returned. + */ + deleteConfig: function (bundleName, configName, fullPath) { + var bundleMap = this._configPaths[bundleName]; + if(bundleMap) { + var path = bundleMap[configName]; + if(path) { + this._pathCount[path]--; + if(this._pathCount[path] === 0) { + delete this._configYCBs[path]; + delete this._configContents[path]; + delete this._pathCount[path]; + } + } + delete bundleMap[configName]; + delete this._configIdMap[bundleName][configName]; + if(Object.keys(bundleMap).length === 0) { + delete this._configPaths[bundleName]; + } + if(Object.keys(this._configIdMap[bundleName]).length === 0) { + delete this._configIdMap[bundleName]; + } + } + }, + /** + * Generates a cache key based on config and bundle names, a separator, and a context based key. + * Distinct separators can be used to distinguish distinct types of keys, e.g., merged and unmerged reads. + * + * @param {string} bundleName Name of bundle. + * @param {string} separator Separator string to join bundle and config names. + * @param {string} configName Name of config. + * @param {string} contextKey Key based on the context. + * @returns {string} cache key + * @private + */ + _getCacheKey: function (bundleName, separator, configName, contextKey) { + return bundleName + separator + configName + contextKey; + }, -/** - * Reads the contents of the named configuration file. - * This will auto-detect if the configuration file is YCB and read it in a context-sensitive way if so. - * - * This can possibly return the configuration object that is stored in a cache, so the caller should - * copy it if they intend to make a modifications. - * @method read - * @async - * @param {string} bundleName The bundle in which to find the configuration file. - * @param {string} configName Which configuration to read. - * @param {object} [context] The runtime context. - * @param {Function} callback - * @param {Error|null} callback.err If an error occurred, then this parameter will - * contain the error. If the operation succeeded, then `err` will be null. - * @param {Object} callback.config The merged configuration object, based on the - * provided context. - */ -Config.prototype.read = function (bundleName, configName, context, callback) { - var self = this; - self._getConfigCache(bundleName, configName, context, true, function (err, config) { - if (err) { - self._getYCB(bundleName, configName, function (err, ycb) { - if (err) { - callback(err); + + /** + * Reads the contents of the named configuration file. + * This will auto-detect if the configuration file is YCB and read it in a context-sensitive way if so. + * + * This can possibly return the configuration object that is stored in a cache, so the caller should + * copy it if they intend to make a modifications. + * @method read + * @async + * @param {string} bundleName The bundle in which to find the configuration file. + * @param {string} configName Which configuration to read. + * @param {object} [context] The runtime context. + * @param {Function} callback + * @param {Error|null} callback.err If an error occurred, then this parameter will + * contain the error. If the operation succeeded, then `err` will be null. + * @param {Object} callback.config The merged configuration object, based on the + * provided context. + */ + read: function (bundleName, configName, context, callback) { + var self = this; + self._getYCB(bundleName, configName, function (err, ycb) { + if (err) { + callback(err); + return; + } + if(self._options.baseContext) { + context = self._mergeBaseContext(context); + } + var key, config, groupId; + groupId = self._configIdMap[bundleName][configName]; + key = self._getCacheKey(bundleName, ':m:', configName, ycb.getCacheKey(context)); + if (self.timeAware) { + var now = context[self.timeDimension]; + if (now === undefined) { + callback(new Error(util.format(MESSAGES['missing time'], self.timeDimension, JSON.stringify(context)))); return; } + config = self._cache.getTimeAware(key, now, groupId); + if(config === undefined) { + config = ycb.readTimeAware(context, now, {cacheInfo: true}); + var expiresAt = config[self.expiresKey]; + if(expiresAt === undefined) { + expiresAt = Number.POSITIVE_INFINITY; + } + if(self._options.safeMode) { + config = deepFreeze(config); + } + self._cache.setTimeAware(key, config, now, expiresAt, groupId); + } + } else { + config = self._cache.get(key, groupId); + if(config === undefined) { + config = ycb.read(context, {}); + if(self._options.safeMode) { + config = deepFreeze(config); + } + self._cache.set(key, config, groupId); + } + } + callback(null, config); + }); + }, - config = ycb.read(context, {}); - return self._setConfigCache(bundleName, configName, context, config, true, callback); - }); - } else { - return callback(null, config); - } - }); -}; -/** - * Reads the contents of the named configuration file and returns the sections - * appropriate to the context. The sections are returned in priority order - * (most important first). - * - * If the file is not context sensitive then the list will contain a single section. - * - * This can possibly return the configuration object that is stored in a cache, so the caller should - * copy it if they intend to make a modifications. - * @method readNoMerge - * @async - * @param {string} bundleName The bundle in which to find the configuration file. - * @param {string} configName Which configuration to read. - * @param {object} [context] The runtime context. - * @return {Function} callback - * @param {Error|null} callback.err If an error occurred, then this parameter will - * contain the error. If the operation succeeded, then the `err` will be null. - * @param {Object} callback.contents The object containing the prioritized sections - * of the configuration file appropriate to the provided context. - */ -Config.prototype.readNoMerge = function (bundleName, configName, context, callback) { - var self = this; - self._getConfigCache(bundleName, configName, context, false, function (err, config) { - if (err) { - self._getYCB(bundleName, configName, function (err, ycb) { - if (err) { - return callback(err); + /** + * Reads the contents of the named configuration file and returns the sections + * appropriate to the context. The sections are returned in priority order + * (most important first). + * + * If the file is not context sensitive then the list will contain a single section. + * + * This can possibly return the configuration object that is stored in a cache, so the caller should + * copy it if they intend to make a modifications. + * @method readNoMerge + * @async + * @param {string} bundleName The bundle in which to find the configuration file. + * @param {string} configName Which configuration to read. + * @param {object} [context] The runtime context. + * @return {Function} callback + * @param {Error|null} callback.err If an error occurred, then this parameter will + * contain the error. If the operation succeeded, then the `err` will be null. + * @param {Object} callback.contents The object containing the prioritized sections + * of the configuration file appropriate to the provided context. + */ + readNoMerge: function (bundleName, configName, context, callback) { + var self = this; + self._getYCB(bundleName, configName, function (err, ycb) { + if (err) { + callback(err); + return; + } + if(self._options.baseContext) { + context = self._mergeBaseContext(context); + } + var key, config, groupId; + groupId = self._configIdMap[bundleName][configName]; + key = self._getCacheKey(bundleName, ':um:', configName, ycb.getCacheKey(context)); + if(self.timeAware) { + var now = context[self.timeDimension]; + if(now === undefined) { + callback(new Error(util.format(MESSAGES['missing time'], self.timeDimension, JSON.stringify(context)))); + return; + } + config = self._cache.getTimeAware(key, now, groupId); + if(config === undefined) { + config = config = ycb.readNoMergeTimeAware(context, now, {cacheInfo: true}); + var expiresAt = config.length > 0 ? config[0][self.expiresKey] : undefined; + if(expiresAt === undefined) { + expiresAt = Number.POSITIVE_INFINITY; + } + if(self._options.safeMode) { + config = deepFreeze(config); + } + self._cache.setTimeAware(key, config, now, expiresAt, groupId); + } + } else { + config = self._cache.get(key, groupId); + if(config === undefined) { + config = ycb.readNoMerge(context, {}); + if(self._options.safeMode) { + config = deepFreeze(config); + } + self._cache.set(key, config, groupId); } + } + callback(null, config); + }); + }, - config = ycb.readNoMerge(context, {}); - return self._setConfigCache(bundleName, configName, context, config, false, callback); - }); - } else { - return callback(null, config); + /** + * Reads the dimensions file for the application. + * + * If `options.dimensionsPath` is given to the constructor that'll be used. + * Otherwise, the dimensions file found in `options.dimensionsBundle` will be used. + * Otherwise, the dimensions file with the shortest path will be used. + * + * The returned dimensions object is shared, so it should not be modified. + * @method readDimensions + * @param {Function} callback + * @param {Error|null} callback.err If an error occurred, then this parameter will + * contain the error. If the operation succeeded, then `err` will be null. + * @param {array} callback.dimensions The returned dimensions array. + */ + readDimensions: function (callback) { + var self = this; + if (!self._dimensionsPath) { + return callback(new Error(MESSAGES['missing dimensions'])); + } + if (self._cachedDimensions) { + return callback(null, self._cachedDimensions); } - }); -}; - -/** - * Provides a method that should get and return a cached configuration object, - * given the bundle name, configuration name, context object, and whether or - * not the configuration was merged. - * - * The default implementation uses the LRU cache from `node-lru-cache`, and options - * to the cache can be passed through the Config constructor. - * - * This can be overridden if a custom caching method is provided. - * @method _getConfigCache - * @async - * @private - * @param {string} bundleName The bundle in which to find the configuration file. - * @param {string} configName Which configuration to read. - * @param {object} context The runtime context. - * @param {boolean} hasMerge Whether or not the configuration data will be merged. - * @param {Function} callback - * @param {Error|null} callback.err If an error occurred, then this parameter will - * contain the error. If the operation succeeded, then `err` will be null. - * @param {Object} callback.config The merged or unmerged cached configuration data. - */ -Config.prototype._getConfigCache = function (bundleName, configName, context, hasMerge, callback) { - var self = this, - bundlePath, - configPath, - mergePath, - mergeName = hasMerge ? 'merge' : 'no-merge', - config; - - bundlePath = self._configCache[bundleName]; - if (!bundlePath) { - return callback(new Error(util.format(MESSAGES['unknown bundle'], bundleName))); - } - configPath = bundlePath[configName]; - if (!configPath) { - return callback(new Error(util.format(MESSAGES['unknown config'], configName, bundleName))); - } + self._readConfigContents(self._dimensionsPath, function (err, body) { + if (err) { + return callback(err); + } + self._cachedDimensions = body[0].dimensions; + delete self._configContents[self._dimensionsPath]; // no longer need this copy of dimensions + return callback(null, self._cachedDimensions); + }); + }, - mergePath = configPath[mergeName]; - if (!mergePath) { - return callback(new Error(util.format(MESSAGES['unknown cache data'], configName, bundleName))); - } - config = mergePath.get(getCacheKey(context)); - if (config) { - return callback(null, config); - } else { - return callback(new Error(util.format(MESSAGES['unknown cache data'], configName, bundleName))); - } -}; + /** + * Provides a YCB object for the configuration file. + * This returns a YCB object even if the configuration file isn't a YCB file. + * @private + * @method _getYCB + * @async + * @param {string} bundleName The bundle in which to find the configuration file. + * @param {string} configName Which configuration to read. + * @param {object} [context] The runtime context. + * @param {Function} callback + * @param {Error|null} callback.err If an error occurred, then this parameter will + * contain the error. If the operation succeeded, then `err` will be null. + * @param {Object} callback.ycb The returned YCB object. + */ + _getYCB: function (bundleName, configName, callback) { + var self = this, + path, + contents, + dimensions, + ycb, + isYCB; + + if (!self._configPaths[bundleName]) { + return callback(new Error(util.format(MESSAGES['unknown bundle'], bundleName))); + } + path = self._configPaths[bundleName][configName]; + if (!path) { + return callback(new Error(util.format(MESSAGES['unknown config'], configName, bundleName))); + } -/** - * Provides a method that should set and return a cached configuration object, - * given the bundle name, configuration name, context object, configuration, - * and whether or not it was merged. - * - * The default implementation uses the LRU cache from `node-lru-cache`, and options - * to the cache can be passed through the Config constructor. - * - * This can be overridden if a custom caching method is provided. - * @method _setConfigCache - * @async - * @private - * @param {string} bundleName The bundle in which to find the configuration file. - * @param {string} configName Which configuration to read. - * @param {object} context The runtime context. - * @param {object} config The configuration data. - * @param {boolean} hasMerge Whether or not the configuration data will be merged. - * @param {Function} callback - * @param {Error|null} callback.err If an error occurred, then this parameter will - * contain the error. If the operation succeeded, then `err` will be null. - * @param {Object} callback.config The merged or unmerged cached configuration data. - */ -Config.prototype._setConfigCache = function (bundleName, configName, context, config, hasMerge, callback) { - var self = this, - LRU = libcache, - mergeName = hasMerge ? 'merge' : 'no-merge', - cacheOptions = self._options.cache || DEFAULT_CACHE_OPTIONS, - bundlePath, - configPath, - cache, - configClone, - configCache = self._configCache; - - bundlePath = configCache[bundleName] = (configCache[bundleName] || {}); - configPath = bundlePath[configName] = (bundlePath[configName] || {}); - cache = configPath[mergeName] = (configPath[mergeName] || new LRU(cacheOptions)); - - config = this._options.safeMode ? deepFreeze(config) : config; - - cache.set(getCacheKey(context), config); - - return callback(null, config); -}; + if (self._configYCBs[path]) { + return callback(null, self._configYCBs[path]); + } -/** - * Reads the dimensions file for the application. - * - * If `options.dimensionsPath` is given to the constructor that'll be used. - * Otherwise, the dimensions file found in `options.dimensionsBundle` will be used. - * Otherwise, the dimensions file with the shortest path will be used. - * - * The returned dimensions object is shared, so it should not be modified. - * @method readDimensions - * @param {Function} callback - * @param {Error|null} callback.err If an error occurred, then this parameter will - * contain the error. If the operation succeeded, then `err` will be null. - * @param {array} callback.dimensions The returned dimensions array. - */ -Config.prototype.readDimensions = function (callback) { - var self = this; - if (!self._dimensionsPath) { - return callback(new Error(MESSAGES['missing dimensions'])); - } - if (self._cachedDimensions) { - return callback(null, self._cachedDimensions); - } + self._readConfigContents(path, function (err, contents) { + if (err) { + return callback(err); + } - self._readConfigContents(self._dimensionsPath, function (err, body) { - if (err) { - return callback(err); - } + isYCB = contentsIsYCB(contents); - self._cachedDimensions = body[0].dimensions; - return callback(null, self._cachedDimensions); - }); -}; + if (isYCB) { + self.readDimensions(function (err, data) { + if (err) { + return callback(err); + } + dimensions = data; + ycb = self._makeYCBFromDimensions(path, dimensions, contents); + callback(null, ycb); + }); + } else { + ycb = self._makeYCBFromDimensions(path, dimensions, contents); + callback(null, ycb); + } + }); + }, -/** - * Provides a YCB object for the configuration file. - * This returns a YCB object even if the configuration file isn't a YCB file. - * @private - * @method _getYCB - * @async - * @param {string} bundleName The bundle in which to find the configuration file. - * @param {string} configName Which configuration to read. - * @param {object} [context] The runtime context. - * @param {Function} callback - * @param {Error|null} callback.err If an error occurred, then this parameter will - * contain the error. If the operation succeeded, then `err` will be null. - * @param {Object} callback.ycb The returned YCB object. - */ -Config.prototype._getYCB = function (bundleName, configName, callback) { - var self = this, - path, - contents, - dimensions, - ycb, - isYCB; - - if (!self._configPaths[bundleName]) { - return callback(new Error(util.format(MESSAGES['unknown bundle'], bundleName))); - } - path = self._configPaths[bundleName][configName]; - if (!path) { - return callback(new Error(util.format(MESSAGES['unknown config'], configName, bundleName))); - } + /** + * Determines whether to make a YCB or fake YCB object from + * a dimensions object. + * @private + * @method _makeYCBFromDimensions + */ - if (self._configYCBs[path]) { - return callback(null, self._configYCBs[path]); - } + _makeYCBFromDimensions: function (path, dimensions, contents) { + var ycb; - self._readConfigContents(path, function (err, contents) { - if (err) { - return callback(err); + if (dimensions) { + ycb = makeYCB(this, dimensions, contents); + } else { + ycb = makeFakeYCB(dimensions, contents); } + this._configYCBs[path] = ycb; + delete this._configContents[path]; // no longer need to keep a copy of the config + return ycb; + }, - isYCB = contentsIsYCB(contents); + /** + * Reads the contents of a configuration file. + * @private + * @method _readConfigContents + * @async + * @param {string} path Full path to the file. + * @param {Function} callback + * @param {Error|null} callback.err If an error occurred, then this parameter will + * contain the error. If the operation succeeded, then `err` will be null. + * @param {Object} callback.contents The returned contents of the configuration file. + */ + _readConfigContents: function (path, callback) { + var self = this, + ext = libpath.extname(path), + contents; + + if (this._configContents[path]) { + callback(null, this._configContents[path]); + return; + } - if (isYCB) { - self.readDimensions(function (err, data) { + // really try to do things async as much as possible + if ('.json' === ext || '.json5' === ext || '.yaml' === ext || '.yml' === ext) { + libfs.readFile(path, 'utf8', function (err, contents) { if (err) { return callback(err); } - - dimensions = data; - ycb = self._makeYCBFromDimensions(path, dimensions, contents); - callback(null, ycb); + self._parseConfigContents(path, contents, function (err, contents) { + return callback(err, contents); + }); }); } else { - ycb = self._makeYCBFromDimensions(path, dimensions, contents); - callback(null, ycb); - } - }); -}; - -/** - * Determines whether to make a YCB or fake YCB object from - * a dimensions object. - * @private - * @method _makeYCBFromDimensions - */ - - Config.prototype._makeYCBFromDimensions = function (path, dimensions, contents) { - var ycb; - - if (dimensions) { - ycb = makeYCB(this, dimensions, contents); - } else { - ycb = makeFakeYCB(dimensions, contents); - } - this._configYCBs[path] = ycb; - - return ycb; - }; - -/** - * Reads the contents of a configuration file. - * @private - * @method _readConfigContents - * @async - * @param {string} path Full path to the file. - * @param {Function} callback - * @param {Error|null} callback.err If an error occurred, then this parameter will - * contain the error. If the operation succeeded, then `err` will be null. - * @param {Object} callback.contents The returned contents of the configuration file. - */ -Config.prototype._readConfigContents = function (path, callback) { - var self = this, - ext = libpath.extname(path), - contents; - - if (this._configContents[path]) { - callback(null, this._configContents[path]); - return; - } - - // really try to do things async as much as possible - if ('.json' === ext || '.json5' === ext || '.yaml' === ext || '.yml' === ext) { - libfs.readFile(path, 'utf8', function (err, contents) { - if (err) { - return callback(err); + try { + contents = require(path); + } catch (e) { + return callback(new Error(util.format(MESSAGES['parse error'], path, e.message))); } - self._parseConfigContents(path, contents, function(err, contents) { - // TODO -- cache in _configContents? - return callback(err, contents); - }); - }); - } else { - try { - contents = require(path); - // TODO -- cache in _configContents? - } catch (e) { - return callback(new Error(util.format(MESSAGES['parse error'], path, e.message))); - } - return callback(null, contents); - } -}; + return callback(null, contents); + } + }, -Config.prototype._parseConfigContents = function (path, contents, callback) { - var ext, - error; - // Sometimes the contents are already parsed. - if ('object' !== typeof contents) { - ext = libpath.extname(path); - try { - if ('.json' === ext) { - contents = JSON.parse(contents); - } else if ('.json5' === ext) { - contents = libjson5.parse(contents); - } else if ('.js' === ext) { - contents = require(path); - } else { - contents = libyaml.parse(contents); - } - } catch (e) { - error = new Error(util.format(MESSAGES['parse error'], path, e.message)); - if (callback) { - return callback(error); - } else { - return error; + _parseConfigContents: function (path, contents, callback) { + var ext, + error; + // Sometimes the contents are already parsed. + if ('object' !== typeof contents) { + ext = libpath.extname(path); + try { + if ('.json' === ext) { + contents = JSON.parse(contents); + } else if ('.json5' === ext) { + contents = libjson5.parse(contents); + } else if ('.js' === ext) { + contents = require(path); + } else { + contents = libyaml.parse(contents); + } + } catch (e) { + error = new Error(util.format(MESSAGES['parse error'], path, e.message)); + if (callback) { + return callback(error); + } else { + return error; + } } } - } - return callback ? callback(null, contents) : contents; -}; + return callback ? callback(null, contents) : contents; + }, -/** - * Merges the base context under the runtime context. - * @private - * @method _mergeBaseContext - * @param {object} context The runtime context. - * @return {object} A new object with the context expanded with the base merged under. - */ -Config.prototype._mergeBaseContext = function (context) { - context = context || {}; - return mix(clone(context), this._options.baseContext); + /** + * Merges the base context under the runtime context. + * @private + * @method _mergeBaseContext + * @param {object} context The runtime context. + * @return {object} A new object with the context expanded with the base merged under. + */ + _mergeBaseContext: function (context) { + context = context || {}; + return mix(clone(context), this._options.baseContext); + } }; @@ -667,4 +646,4 @@ Config.test = { contentsIsYCB: contentsIsYCB, makeYCB: makeYCB, makeFakeYCB: makeFakeYCB -}; +}; \ No newline at end of file diff --git a/package.json b/package.json index 748c134..2afd006 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,9 @@ }, "homepage": "https://github.com/yahoo/ycb-config", "dependencies": { - "ycb": "^2.0.0", + "ycb": "^2.1.1", "json5": "~0.4.0", "yamljs": "^0.2.3", - "lru-cache": "^2.3.1", "deep-freeze": "~0.0.1" }, "devDependencies": { @@ -29,7 +28,7 @@ }, "scripts": { "cover": "./node_modules/istanbul/lib/cli.js cover -- ./node_modules/mocha/bin/_mocha tests/lib/*.js --reporter spec", - "test": "jshint lib/index.js tests/lib/index.js && _mocha tests/lib/index.js --reporter spec" + "test": "jshint lib/index.js lib/cache.js tests/lib/index.js tests/lib/cache-test.js && _mocha tests/lib/index.js tests/lib/cache-test.js --reporter spec" }, "license": "BSD", "repository": { diff --git a/tests/fixtures/time/time-test-configs.json b/tests/fixtures/time/time-test-configs.json new file mode 100644 index 0000000..02fc034 --- /dev/null +++ b/tests/fixtures/time/time-test-configs.json @@ -0,0 +1,1103 @@ +[ + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "end": "2019-08-19T20:26:12.247Z" + } + }, + "name": "config_0", + "intervals": { + "config_0": { + "end": 1566246372247 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:12.162Z", + "end": "2019-08-19T20:26:18.177Z" + } + }, + "name": "config_1", + "intervals": { + "config_1": { + "start": 1566246372162, + "end": 1566246378177 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:11.998Z", + "end": "2019-08-19T20:26:13.706Z" + } + }, + "name": "config_2", + "intervals": { + "config_2": { + "start": 1566246371998, + "end": 1566246373706 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:12.186Z", + "end": "2019-08-19T20:26:15.698Z" + } + }, + "name": "config_3", + "intervals": { + "config_3": { + "start": 1566246372186, + "end": 1566246375698 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:10.910Z", + "end": "2019-08-19T20:26:12.521Z" + } + }, + "name": "config_4", + "intervals": { + "config_4": { + "start": 1566246370910, + "end": 1566246372521 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:13.535Z", + "end": "2019-08-19T20:26:15.330Z" + } + }, + "name": "config_5", + "intervals": { + "config_5": { + "start": 1566246373535, + "end": 1566246375330 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:10.918Z", + "end": "2019-08-19T20:26:12.198Z" + } + }, + "name": "config_6", + "intervals": { + "config_6": { + "start": 1566246370918, + "end": 1566246372198 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "end": "2019-08-19T20:26:16.702Z" + } + }, + "name": "config_7", + "intervals": { + "config_7": { + "end": 1566246376702 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:16.316Z", + "end": "2019-08-19T20:26:19.804Z" + } + }, + "name": "config_8", + "intervals": { + "config_8": { + "start": 1566246376316, + "end": 1566246379804 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:10.607Z", + "end": "2019-08-19T20:26:10.817Z" + } + }, + "name": "config_9", + "intervals": { + "config_9": { + "start": 1566246370607, + "end": 1566246370817 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:12.020Z" + } + }, + "name": "config_10", + "intervals": { + "config_10": { + "start": 1566246372020 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:10.239Z", + "end": "2019-08-19T20:26:19.483Z" + } + }, + "name": "config_11", + "intervals": { + "config_11": { + "start": 1566246370239, + "end": 1566246379483 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:10.086Z", + "end": "2019-08-19T20:26:10.307Z" + } + }, + "name": "config_12", + "intervals": { + "config_12": { + "start": 1566246370086, + "end": 1566246370307 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:16.912Z" + } + }, + "name": "config_13", + "intervals": { + "config_13": { + "start": 1566246376912 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:13.364Z", + "end": "2019-08-19T20:26:13.388Z" + } + }, + "name": "config_14", + "intervals": { + "config_14": { + "start": 1566246373364, + "end": 1566246373388 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:12.985Z", + "end": "2019-08-19T20:26:16.035Z" + } + }, + "name": "config_15", + "intervals": { + "config_15": { + "start": 1566246372985, + "end": 1566246376035 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:12.913Z", + "end": "2019-08-19T20:26:16.893Z" + } + }, + "name": "config_16", + "intervals": { + "config_16": { + "start": 1566246372913, + "end": 1566246376893 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:15.609Z", + "end": "2019-08-19T20:26:18.796Z" + } + }, + "name": "config_17", + "intervals": { + "config_17": { + "start": 1566246375609, + "end": 1566246378796 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "end": "2019-08-19T20:26:10.480Z" + } + }, + "name": "config_18", + "intervals": { + "config_18": { + "end": 1566246370480 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:11.656Z", + "end": "2019-08-19T20:26:13.125Z" + } + }, + "name": "config_19", + "intervals": { + "config_19": { + "start": 1566246371656, + "end": 1566246373125 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:11.535Z", + "end": "2019-08-19T20:26:13.485Z" + } + }, + "name": "config_20", + "intervals": { + "config_20": { + "start": 1566246371535, + "end": 1566246373485 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:16.579Z", + "end": "2019-08-19T20:26:17.862Z" + } + }, + "name": "config_21", + "intervals": { + "config_21": { + "start": 1566246376579, + "end": 1566246377862 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:14.949Z", + "end": "2019-08-19T20:26:15.477Z" + } + }, + "name": "config_22", + "intervals": { + "config_22": { + "start": 1566246374949, + "end": 1566246375477 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:12.686Z", + "end": "2019-08-19T20:26:16.979Z" + } + }, + "name": "config_23", + "intervals": { + "config_23": { + "start": 1566246372686, + "end": 1566246376979 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:10.204Z", + "end": "2019-08-19T20:26:10.241Z" + } + }, + "name": "config_24", + "intervals": { + "config_24": { + "start": 1566246370204, + "end": 1566246370241 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:15.130Z", + "end": "2019-08-19T20:26:16.132Z" + } + }, + "name": "config_25", + "intervals": { + "config_25": { + "start": 1566246375130, + "end": 1566246376132 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:10.275Z", + "end": "2019-08-19T20:26:11.531Z" + } + }, + "name": "config_26", + "intervals": { + "config_26": { + "start": 1566246370275, + "end": 1566246371531 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:15.332Z", + "end": "2019-08-19T20:26:16.175Z" + } + }, + "name": "config_27", + "intervals": { + "config_27": { + "start": 1566246375332, + "end": 1566246376175 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:18.160Z", + "end": "2019-08-19T20:26:18.459Z" + } + }, + "name": "config_28", + "intervals": { + "config_28": { + "start": 1566246378160, + "end": 1566246378459 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "end": "2019-08-19T20:26:16.232Z" + } + }, + "name": "config_29", + "intervals": { + "config_29": { + "end": 1566246376232 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:10.935Z", + "end": "2019-08-19T20:26:12.079Z" + } + }, + "name": "config_30", + "intervals": { + "config_30": { + "start": 1566246370935, + "end": 1566246372079 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:10.535Z", + "end": "2019-08-19T20:26:14.902Z" + } + }, + "name": "config_31", + "intervals": { + "config_31": { + "start": 1566246370535, + "end": 1566246374902 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:10.504Z", + "end": "2019-08-19T20:26:11.168Z" + } + }, + "name": "config_32", + "intervals": { + "config_32": { + "start": 1566246370504, + "end": 1566246371168 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:10.451Z", + "end": "2019-08-19T20:26:12.556Z" + } + }, + "name": "config_33", + "intervals": { + "config_33": { + "start": 1566246370451, + "end": 1566246372556 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "end": "2019-08-19T20:26:12.074Z" + } + }, + "name": "config_34", + "intervals": { + "config_34": { + "end": 1566246372074 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:15.979Z", + "end": "2019-08-19T20:26:17.239Z" + } + }, + "name": "config_35", + "intervals": { + "config_35": { + "start": 1566246375979, + "end": 1566246377239 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:16.705Z" + } + }, + "name": "config_36", + "intervals": { + "config_36": { + "start": 1566246376705 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:10.488Z", + "end": "2019-08-19T20:26:10.558Z" + } + }, + "name": "config_37", + "intervals": { + "config_37": { + "start": 1566246370488, + "end": 1566246370558 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:12.666Z", + "end": "2019-08-19T20:26:14.697Z" + } + }, + "name": "config_38", + "intervals": { + "config_38": { + "start": 1566246372666, + "end": 1566246374697 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "end": "2019-08-19T20:26:11.724Z" + } + }, + "name": "config_39", + "intervals": { + "config_39": { + "end": 1566246371724 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:12.247Z", + "end": "2019-08-19T20:26:16.598Z" + } + }, + "name": "config_40", + "intervals": { + "config_40": { + "start": 1566246372247, + "end": 1566246376598 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:18.177Z", + "end": "2019-08-19T20:26:19.141Z" + } + }, + "name": "config_41", + "intervals": { + "config_41": { + "start": 1566246378177, + "end": 1566246379141 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:13.706Z", + "end": "2019-08-19T20:26:17.062Z" + } + }, + "name": "config_42", + "intervals": { + "config_42": { + "start": 1566246373706, + "end": 1566246377062 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:15.698Z", + "end": "2019-08-19T20:26:16.650Z" + } + }, + "name": "config_43", + "intervals": { + "config_43": { + "start": 1566246375698, + "end": 1566246376650 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:12.521Z", + "end": "2019-08-19T20:26:18.073Z" + } + }, + "name": "config_44", + "intervals": { + "config_44": { + "start": 1566246372521, + "end": 1566246378073 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:15.330Z", + "end": "2019-08-19T20:26:16.078Z" + } + }, + "name": "config_45", + "intervals": { + "config_45": { + "start": 1566246375330, + "end": 1566246376078 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:12.198Z", + "end": "2019-08-19T20:26:17.684Z" + } + }, + "name": "config_46", + "intervals": { + "config_46": { + "start": 1566246372198, + "end": 1566246377684 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:16.702Z", + "end": "2019-08-19T20:26:18.731Z" + } + }, + "name": "config_47", + "intervals": { + "config_47": { + "start": 1566246376702, + "end": 1566246378731 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:19.804Z", + "end": "2019-08-19T20:26:19.844Z" + } + }, + "name": "config_48", + "intervals": { + "config_48": { + "start": 1566246379804, + "end": 1566246379844 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:10.817Z", + "end": "2019-08-19T20:26:12.877Z" + } + }, + "name": "config_49", + "intervals": { + "config_49": { + "start": 1566246370817, + "end": 1566246372877 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:13.486Z", + "end": "2019-08-19T20:26:13.780Z" + } + }, + "name": "config_50", + "intervals": { + "config_50": { + "start": 1566246373486, + "end": 1566246373780 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:17.863Z", + "end": "2019-08-19T20:26:19.968Z" + } + }, + "name": "config_51", + "intervals": { + "config_51": { + "start": 1566246377863, + "end": 1566246379968 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:15.478Z", + "end": "2019-08-19T20:26:18.256Z" + } + }, + "name": "config_52", + "intervals": { + "config_52": { + "start": 1566246375478, + "end": 1566246378256 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:16.980Z", + "end": "2019-08-19T20:26:18.154Z" + } + }, + "name": "config_53", + "intervals": { + "config_53": { + "start": 1566246376980, + "end": 1566246378154 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:10.242Z", + "end": "2019-08-19T20:26:15.825Z" + } + }, + "name": "config_54", + "intervals": { + "config_54": { + "start": 1566246370242, + "end": 1566246375825 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:16.133Z", + "end": "2019-08-19T20:26:19.608Z" + } + }, + "name": "config_55", + "intervals": { + "config_55": { + "start": 1566246376133, + "end": 1566246379608 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:11.532Z", + "end": "2019-08-19T20:26:16.209Z" + } + }, + "name": "config_56", + "intervals": { + "config_56": { + "start": 1566246371532, + "end": 1566246376209 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:16.176Z", + "end": "2019-08-19T20:26:17.106Z" + } + }, + "name": "config_57", + "intervals": { + "config_57": { + "start": 1566246376176, + "end": 1566246377106 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:18.460Z", + "end": "2019-08-19T20:26:19.501Z" + } + }, + "name": "config_58", + "intervals": { + "config_58": { + "start": 1566246378460, + "end": 1566246379501 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:16.233Z", + "end": "2019-08-19T20:26:17.937Z" + } + }, + "name": "config_59", + "intervals": { + "config_59": { + "start": 1566246376233, + "end": 1566246377937 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": {} + }, + "name": "config_60", + "intervals": { + "config_60": {} + } + } +] \ No newline at end of file diff --git a/tests/fixtures/time/time-test-dimensions.json b/tests/fixtures/time/time-test-dimensions.json new file mode 100644 index 0000000..527f8ee --- /dev/null +++ b/tests/fixtures/time/time-test-dimensions.json @@ -0,0 +1,21 @@ +[ + { + "dimensions": [ + { + "environment": { + "testing": null, + "prod": null + } + }, + { + "device": { + "desktop": null, + "mobile": { + "table": null, + "smartphone": null + } + } + } + ] + } +] \ No newline at end of file diff --git a/tests/fixtures/time/time-test.json b/tests/fixtures/time/time-test.json new file mode 100644 index 0000000..fd2f859 --- /dev/null +++ b/tests/fixtures/time/time-test.json @@ -0,0 +1,1122 @@ +[ + { + "dimensions": [ + { + "environment": { + "testing": null, + "prod": null + } + }, + { + "device": { + "desktop": null, + "mobile": { + "table": null, + "smartphone": null + } + } + } + ] + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "end": "2019-08-19T20:26:12.247Z" + } + }, + "name": "config_0", + "intervals": { + "config_0": { + "end": 1566246372247 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:12.162Z", + "end": "2019-08-19T20:26:18.177Z" + } + }, + "name": "config_1", + "intervals": { + "config_1": { + "start": 1566246372162, + "end": 1566246378177 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:11.998Z", + "end": "2019-08-19T20:26:13.706Z" + } + }, + "name": "config_2", + "intervals": { + "config_2": { + "start": 1566246371998, + "end": 1566246373706 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:12.186Z", + "end": "2019-08-19T20:26:15.698Z" + } + }, + "name": "config_3", + "intervals": { + "config_3": { + "start": 1566246372186, + "end": 1566246375698 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:10.910Z", + "end": "2019-08-19T20:26:12.521Z" + } + }, + "name": "config_4", + "intervals": { + "config_4": { + "start": 1566246370910, + "end": 1566246372521 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:13.535Z", + "end": "2019-08-19T20:26:15.330Z" + } + }, + "name": "config_5", + "intervals": { + "config_5": { + "start": 1566246373535, + "end": 1566246375330 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:10.918Z", + "end": "2019-08-19T20:26:12.198Z" + } + }, + "name": "config_6", + "intervals": { + "config_6": { + "start": 1566246370918, + "end": 1566246372198 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "end": "2019-08-19T20:26:16.702Z" + } + }, + "name": "config_7", + "intervals": { + "config_7": { + "end": 1566246376702 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:16.316Z", + "end": "2019-08-19T20:26:19.804Z" + } + }, + "name": "config_8", + "intervals": { + "config_8": { + "start": 1566246376316, + "end": 1566246379804 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:10.607Z", + "end": "2019-08-19T20:26:10.817Z" + } + }, + "name": "config_9", + "intervals": { + "config_9": { + "start": 1566246370607, + "end": 1566246370817 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:12.020Z" + } + }, + "name": "config_10", + "intervals": { + "config_10": { + "start": 1566246372020 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:10.239Z", + "end": "2019-08-19T20:26:19.483Z" + } + }, + "name": "config_11", + "intervals": { + "config_11": { + "start": 1566246370239, + "end": 1566246379483 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:10.086Z", + "end": "2019-08-19T20:26:10.307Z" + } + }, + "name": "config_12", + "intervals": { + "config_12": { + "start": 1566246370086, + "end": 1566246370307 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:16.912Z" + } + }, + "name": "config_13", + "intervals": { + "config_13": { + "start": 1566246376912 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:13.364Z", + "end": "2019-08-19T20:26:13.388Z" + } + }, + "name": "config_14", + "intervals": { + "config_14": { + "start": 1566246373364, + "end": 1566246373388 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:12.985Z", + "end": "2019-08-19T20:26:16.035Z" + } + }, + "name": "config_15", + "intervals": { + "config_15": { + "start": 1566246372985, + "end": 1566246376035 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:12.913Z", + "end": "2019-08-19T20:26:16.893Z" + } + }, + "name": "config_16", + "intervals": { + "config_16": { + "start": 1566246372913, + "end": 1566246376893 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:15.609Z", + "end": "2019-08-19T20:26:18.796Z" + } + }, + "name": "config_17", + "intervals": { + "config_17": { + "start": 1566246375609, + "end": 1566246378796 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "end": "2019-08-19T20:26:10.480Z" + } + }, + "name": "config_18", + "intervals": { + "config_18": { + "end": 1566246370480 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:11.656Z", + "end": "2019-08-19T20:26:13.125Z" + } + }, + "name": "config_19", + "intervals": { + "config_19": { + "start": 1566246371656, + "end": 1566246373125 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:11.535Z", + "end": "2019-08-19T20:26:13.485Z" + } + }, + "name": "config_20", + "intervals": { + "config_20": { + "start": 1566246371535, + "end": 1566246373485 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:16.579Z", + "end": "2019-08-19T20:26:17.862Z" + } + }, + "name": "config_21", + "intervals": { + "config_21": { + "start": 1566246376579, + "end": 1566246377862 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:14.949Z", + "end": "2019-08-19T20:26:15.477Z" + } + }, + "name": "config_22", + "intervals": { + "config_22": { + "start": 1566246374949, + "end": 1566246375477 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:12.686Z", + "end": "2019-08-19T20:26:16.979Z" + } + }, + "name": "config_23", + "intervals": { + "config_23": { + "start": 1566246372686, + "end": 1566246376979 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:10.204Z", + "end": "2019-08-19T20:26:10.241Z" + } + }, + "name": "config_24", + "intervals": { + "config_24": { + "start": 1566246370204, + "end": 1566246370241 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:15.130Z", + "end": "2019-08-19T20:26:16.132Z" + } + }, + "name": "config_25", + "intervals": { + "config_25": { + "start": 1566246375130, + "end": 1566246376132 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:10.275Z", + "end": "2019-08-19T20:26:11.531Z" + } + }, + "name": "config_26", + "intervals": { + "config_26": { + "start": 1566246370275, + "end": 1566246371531 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:15.332Z", + "end": "2019-08-19T20:26:16.175Z" + } + }, + "name": "config_27", + "intervals": { + "config_27": { + "start": 1566246375332, + "end": 1566246376175 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:18.160Z", + "end": "2019-08-19T20:26:18.459Z" + } + }, + "name": "config_28", + "intervals": { + "config_28": { + "start": 1566246378160, + "end": 1566246378459 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "end": "2019-08-19T20:26:16.232Z" + } + }, + "name": "config_29", + "intervals": { + "config_29": { + "end": 1566246376232 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:10.935Z", + "end": "2019-08-19T20:26:12.079Z" + } + }, + "name": "config_30", + "intervals": { + "config_30": { + "start": 1566246370935, + "end": 1566246372079 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:10.535Z", + "end": "2019-08-19T20:26:14.902Z" + } + }, + "name": "config_31", + "intervals": { + "config_31": { + "start": 1566246370535, + "end": 1566246374902 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:10.504Z", + "end": "2019-08-19T20:26:11.168Z" + } + }, + "name": "config_32", + "intervals": { + "config_32": { + "start": 1566246370504, + "end": 1566246371168 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:10.451Z", + "end": "2019-08-19T20:26:12.556Z" + } + }, + "name": "config_33", + "intervals": { + "config_33": { + "start": 1566246370451, + "end": 1566246372556 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "end": "2019-08-19T20:26:12.074Z" + } + }, + "name": "config_34", + "intervals": { + "config_34": { + "end": 1566246372074 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:15.979Z", + "end": "2019-08-19T20:26:17.239Z" + } + }, + "name": "config_35", + "intervals": { + "config_35": { + "start": 1566246375979, + "end": 1566246377239 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:16.705Z" + } + }, + "name": "config_36", + "intervals": { + "config_36": { + "start": 1566246376705 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:10.488Z", + "end": "2019-08-19T20:26:10.558Z" + } + }, + "name": "config_37", + "intervals": { + "config_37": { + "start": 1566246370488, + "end": 1566246370558 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:12.666Z", + "end": "2019-08-19T20:26:14.697Z" + } + }, + "name": "config_38", + "intervals": { + "config_38": { + "start": 1566246372666, + "end": 1566246374697 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "end": "2019-08-19T20:26:11.724Z" + } + }, + "name": "config_39", + "intervals": { + "config_39": { + "end": 1566246371724 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:12.247Z", + "end": "2019-08-19T20:26:16.598Z" + } + }, + "name": "config_40", + "intervals": { + "config_40": { + "start": 1566246372247, + "end": 1566246376598 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:18.177Z", + "end": "2019-08-19T20:26:19.141Z" + } + }, + "name": "config_41", + "intervals": { + "config_41": { + "start": 1566246378177, + "end": 1566246379141 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:13.706Z", + "end": "2019-08-19T20:26:17.062Z" + } + }, + "name": "config_42", + "intervals": { + "config_42": { + "start": 1566246373706, + "end": 1566246377062 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:15.698Z", + "end": "2019-08-19T20:26:16.650Z" + } + }, + "name": "config_43", + "intervals": { + "config_43": { + "start": 1566246375698, + "end": 1566246376650 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:12.521Z", + "end": "2019-08-19T20:26:18.073Z" + } + }, + "name": "config_44", + "intervals": { + "config_44": { + "start": 1566246372521, + "end": 1566246378073 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:15.330Z", + "end": "2019-08-19T20:26:16.078Z" + } + }, + "name": "config_45", + "intervals": { + "config_45": { + "start": 1566246375330, + "end": 1566246376078 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:12.198Z", + "end": "2019-08-19T20:26:17.684Z" + } + }, + "name": "config_46", + "intervals": { + "config_46": { + "start": 1566246372198, + "end": 1566246377684 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:16.702Z", + "end": "2019-08-19T20:26:18.731Z" + } + }, + "name": "config_47", + "intervals": { + "config_47": { + "start": 1566246376702, + "end": 1566246378731 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:19.804Z", + "end": "2019-08-19T20:26:19.844Z" + } + }, + "name": "config_48", + "intervals": { + "config_48": { + "start": 1566246379804, + "end": 1566246379844 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:10.817Z", + "end": "2019-08-19T20:26:12.877Z" + } + }, + "name": "config_49", + "intervals": { + "config_49": { + "start": 1566246370817, + "end": 1566246372877 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:13.486Z", + "end": "2019-08-19T20:26:13.780Z" + } + }, + "name": "config_50", + "intervals": { + "config_50": { + "start": 1566246373486, + "end": 1566246373780 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:17.863Z", + "end": "2019-08-19T20:26:19.968Z" + } + }, + "name": "config_51", + "intervals": { + "config_51": { + "start": 1566246377863, + "end": 1566246379968 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:15.478Z", + "end": "2019-08-19T20:26:18.256Z" + } + }, + "name": "config_52", + "intervals": { + "config_52": { + "start": 1566246375478, + "end": 1566246378256 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:16.980Z", + "end": "2019-08-19T20:26:18.154Z" + } + }, + "name": "config_53", + "intervals": { + "config_53": { + "start": 1566246376980, + "end": 1566246378154 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:10.242Z", + "end": "2019-08-19T20:26:15.825Z" + } + }, + "name": "config_54", + "intervals": { + "config_54": { + "start": 1566246370242, + "end": 1566246375825 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:16.133Z", + "end": "2019-08-19T20:26:19.608Z" + } + }, + "name": "config_55", + "intervals": { + "config_55": { + "start": 1566246376133, + "end": 1566246379608 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": { + "start": "2019-08-19T20:26:11.532Z", + "end": "2019-08-19T20:26:16.209Z" + } + }, + "name": "config_56", + "intervals": { + "config_56": { + "start": 1566246371532, + "end": 1566246376209 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:16.176Z", + "end": "2019-08-19T20:26:17.106Z" + } + }, + "name": "config_57", + "intervals": { + "config_57": { + "start": 1566246376176, + "end": 1566246377106 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod", + "device:smartphone" + ], + "schedule": { + "start": "2019-08-19T20:26:18.460Z", + "end": "2019-08-19T20:26:19.501Z" + } + }, + "name": "config_58", + "intervals": { + "config_58": { + "start": 1566246378460, + "end": 1566246379501 + } + } + }, + { + "settings": { + "dimensions": [ + "master" + ], + "schedule": { + "start": "2019-08-19T20:26:16.233Z", + "end": "2019-08-19T20:26:17.937Z" + } + }, + "name": "config_59", + "intervals": { + "config_59": { + "start": 1566246376233, + "end": 1566246377937 + } + } + }, + { + "settings": { + "dimensions": [ + "environment:prod" + ], + "schedule": {} + }, + "name": "config_60", + "intervals": { + "config_60": {} + } + } +] \ No newline at end of file diff --git a/tests/fixtures/touchdown-simple/configs/no-master.js b/tests/fixtures/touchdown-simple/configs/no-master.js new file mode 100644 index 0000000..192039d --- /dev/null +++ b/tests/fixtures/touchdown-simple/configs/no-master.js @@ -0,0 +1,25 @@ + +module.exports = [ + { + settings: [ 'device:mobile' ], + selector: 'mobile' + }, + { + settings: { + dimensions: ["device:mobile"], + schedule: { + end: "2010-11-29T00:04:00Z" + } + }, + name: 'old' + }, + { + settings: { + dimensions: ["device:mobile"], + schedule: { + start: "2010-11-29T00:04:00Z" + } + }, + name: 'new' + } +]; diff --git a/tests/lib/cache-test.js b/tests/lib/cache-test.js new file mode 100644 index 0000000..605343e --- /dev/null +++ b/tests/lib/cache-test.js @@ -0,0 +1,307 @@ + +/*jslint nomen:true, anon:true, node:true, esversion:6 */ +/*globals describe, it */ +"use strict"; + + +var expect = require('chai').expect, + LRU = require('../../lib/cache'); + +// expect().to.deep.equal() cares about order of keys +// but very often we don't +function compareObjects(have, want) { + expect(typeof have).to.equal(typeof want); + if ('object' === typeof want) { + // order of keys doesn't matter + if (Object.keys(want).length) { + expect(have).to.have.keys(Object.keys(want)); + } + if (Object.keys(have).length) { + expect(want).to.have.keys(Object.keys(have)); + } + Object.keys(want).forEach(function (key) { + compareObjects(have[key], want[key]); + }); + } else { + expect(have).to.deep.equal(want); + } +} + +function assertEqual(a,b) { + expect(a).to.equal(b); +} + +var p = [91, 7, 20, 50]; +//public domain http://pracrand.sourceforge.net/license.txt +function prng() { + for(var i=0; i<4; i++) { + p[i] >>>= 0; + } + var x = p[0] + p[1] | 0; + p[0] = p[1] ^ (p[1] >>> 9); + p[1] = p[2] + 8*p[2] | 0; + p[2] = p[2] * 2097152 | p[2] >>> 11; + p[3]++; + p[3] |= 0; + x += p[3]; + x |= 0; + p[2] += x; + p[2] |= 0; + return (x >>> 0) / 4294967296; +} + +function getRandomInt(max) { + return Math.floor(prng() * max); +} + +//verify internal structure of the cache +function validateCacheStructure(cache) { + var i; + var refs = new Map(); + var current = cache.youngest; + assertEqual(cache.size >= 0, true); + assertEqual(cache.size <= cache.max, true); + assertEqual(cache.size === cache.map.size, true); + if(cache.size === 0) { + assertEqual(cache.youngest, null); + assertEqual(cache.oldest, null); + return; + } + refs.set(current, 1); + for(i=0; i current.next.value, true); + current = current.next; + } + validateCacheStructure(cache); + } + }); + it('entries should be ordered by age of set', function () { + var counter = 0; + var cache = new LRU({max: 40}); + for(var i=0; i<1000; i++) { + cache.set(getRandomInt(150), counter++, 1); + var current = cache.youngest; + while(current.next !== null) { + assertEqual(current.value > current.next.value, true); + current = current.next; + } + validateCacheStructure(cache); + } + }); + it('entries should be ordered by age of set and get', function () { + var counter = 0; + var cache = new LRU({max: 40}); + for(var i=0; i<1000; i++) { + var key = getRandomInt(150); + if(prng() > 0.5) { + if(cache.get(key, 1) !== undefined) { + cache.map.get(key).value = counter++; //manually update entries age value + } + } else { + cache.set(key, counter++, 1); + } + if(cache.size > 0) { + var current = cache.youngest; + while (current.next !== null) { + assertEqual(current.value > current.next.value, true); + current = current.next; + } + } + validateCacheStructure(cache); + } + }); + }); + + describe('staleness', function () { + it('should not return mismatched groups', function () { + var cache = new LRU({max: 20}); + cache.set('foo', 'bar', 1); + assertEqual(cache.get('foo', 2), undefined); + }); + it('should not return mismatched groups time aware', function () { + var cache = new LRU({max: 20}); + cache.setTimeAware('foo', 'bar', 10, 1000, 1); + assertEqual(cache.getTimeAware('foo', 500, 2), undefined); + }); + it('should not return expired keys', function () { + var cache = new LRU({max: 20}); + cache.setTimeAware('foo', 'bar', 10, 1000, 1); + assertEqual(cache.getTimeAware('foo', 2000, 1), undefined); + }); + it('should not return keys that expire at same time', function () { + var cache = new LRU({max: 20}); + cache.setTimeAware('foo', 'bar', 10, 1000, 1); + assertEqual(cache.getTimeAware('foo', 1000, 1), undefined); + }); + it('should not return keys set in the future', function () { + var cache = new LRU({max: 20}); + cache.setTimeAware('foo', 'bar', 10, 1000, 1); + assertEqual(cache.getTimeAware('foo', 5, 1), undefined); + }); + it('should return keys set at same time', function () { + var cache = new LRU({max: 20}); + cache.setTimeAware('foo', 'bar', 10, 1000, 1); + assertEqual(cache.getTimeAware('foo', 10, 1), 'bar'); + }); + it('should return keys expiring in one tick', function () { + var cache = new LRU({max: 20}); + cache.setTimeAware('foo', 'bar', 10, 1000, 1); + assertEqual(cache.getTimeAware('foo', 999, 1), 'bar'); + }); + }); + + describe('time', function () { + it('expire entries', function () { + var cache = new LRU({max: 40}); + var maxTime = 2000; + for(var i=0; i<1000; i++) { + var key = getRandomInt(150); + var now = getRandomInt(maxTime); + if (prng() > 0.5) { + var val = cache.getTimeAware(key, now, 1); + if (val !== undefined) { + assertEqual(val.set <= now, true); + assertEqual(val.expires > now, true); + } + } else { + var expiresAt = now + getRandomInt(maxTime-now); + cache.setTimeAware(key, {set:now, expires:expiresAt}, now, expiresAt, 1); + } + validateCacheStructure(cache); + } + }); + it('expire entries with realistic times', function () { + var cache = new LRU({max: 40}); + var maxTime = 1567586543000; + for(var i=0; i<1000; i++) { + var key = getRandomInt(150); + var now = getRandomInt(maxTime) + 1566586543000; + if (prng() > 0.5) { + var val = cache.getTimeAware(key, now, 1); + if (val !== undefined) { + assertEqual(val.set <= now, true); + assertEqual(val.expires > now, true); + } + } else { + var expiresAt = now + getRandomInt(maxTime-now); + cache.setTimeAware(key, {set:now, expires:expiresAt}, now, expiresAt, 1); + } + validateCacheStructure(cache); + } + }); + }); + + describe('internal structure', function () { + it('should be a mapped doubly linked list', function () { + var n = 10; + var cache = new LRU({max: n}); + for(var i=0; i time && interval.start < next) { + next = interval.start; + } + } + if(interval.end) { + valid = valid && interval.end >= time; + if(valid && interval.end < next) { + next = interval.end+1; + } + } + if(valid) { + applicable.push(interval.name); + } + } + next = next === Number.POSITIVE_INFINITY ? undefined : next; + return {configs: applicable, next: next}; + } + + it('scheduled configs should match timestamp', function (done) { + var bundle, ycb; + var path = libpath.join(__dirname, '..', 'fixtures' , 'time', 'time-test.json'); + var data = libfs.readFileSync(path, 'utf8'); + bundle = JSON.parse(data); + var intervals = []; + var minTime = Number.POSITIVE_INFINITY; + var maxTime = 0; + bundle.forEach(function(config) { + if(config.settings) { + var name = config.name; + var interval = config.intervals[name]; + if(interval.start || interval.end) { + if(interval.start && interval.start < minTime) { + minTime = interval.start; + } + if(interval.end && interval.end > maxTime) { + maxTime = interval.end; + } + interval = {start: interval.start, end: interval.end, name:name}; + intervals.push(interval); + } + } + }); + var config = new Config({'timeAware': true, cache: {max: 1000}}); + config.addConfig( + 'test', + 'dimensions', + libpath.join(__dirname, '..', 'fixtures' , 'time', 'time-test-dimensions.json'), + function() { + config.addConfig( + 'test', + 'configs', + libpath.join(__dirname, '..', 'fixtures' , 'time', 'time-test-configs.json'), + function(err) { + var context = {environment: 'prod', device: 'smartphone'}; + var times = []; + for(var t=minTime-2; t