Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Data loader [WIP] #467

Open
wants to merge 18 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@splitsoftware/splitio",
"version": "10.15.0",
"version": "10.15.1-canary.0",
"description": "Split SDK",
"files": [
"README.md",
Expand Down
248 changes: 201 additions & 47 deletions src/__tests__/browserSuites/ready-from-cache.spec.js

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions src/__tests__/mocks/preloadedData.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export const splitDefinitions = {
p1__split: {
'name': 'p1__split',
'status': 'ACTIVE',
'conditions': []
},
p2__split: {
'name': 'p2__split',
'status': 'ACTIVE',
'conditions': []
},
p3__split: {
'name': 'p3__split',
'status': 'ACTIVE',
'conditions': []
},
};

export const splitSerializedDefinitions = (function () {
return Object.keys(splitDefinitions).reduce((acum, splitName) => {
acum[splitName] = JSON.stringify(splitDefinitions[splitName]);
return acum;
}, {});
}());

export const segmentsDefinitions = {
segment_1: {
'name': 'segment_1',
'added': ['nicolas@split.io'],
},
};

export const segmentsSerializedDefinitions = (function () {
return Object.keys(segmentsDefinitions).reduce((acum, segmentName) => {
acum[segmentName] = JSON.stringify(segmentsDefinitions[segmentName]);
return acum;
}, {});
}());

export const preloadedDataWithSegments = {
lastUpdated: Date.now(),
since: 1457552620999,
splitsData: splitSerializedDefinitions,
segmentsData: segmentsSerializedDefinitions
};
55 changes: 55 additions & 0 deletions src/storage/DataLoader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { DEFAULT_CACHE_EXPIRATION_IN_MILLIS } from './browser';

/**
* Factory of data loaders
*
* @TODO update comment
* @param {Object} preloadedData validated data following the format proposed in https://github.com/godaddy/split-javascript-data-loader
* and extended with a `mySegmentsData` property.
*/
export function dataLoaderFactory(preloadedData = {}) {

/**
* Storage-agnostic adaptation of `loadDataIntoLocalStorage` function
* (https://github.com/godaddy/split-javascript-data-loader/blob/master/src/load-data.js)
*
* @param {Object} storage storage for client-side
* @param {Object} userId main user key defined at the SDK config
*
* @TODO extend this function to load data on shared mySegments storages. Be specific when emitting SDK_READY_FROM_CACHE on shared clients. Maybe the serializer should provide the `useSegments` flag.
*/
return function loadData(storage, userId) {
// Do not load data if current preloadedData is empty
if (Object.keys(preloadedData).length === 0) return;

const { lastUpdated = -1, segmentsData = {}, since = -1, splitsData = {} } = preloadedData;

const storedSince = storage.splits.getChangeNumber();
const expirationTimestamp = Date.now() - DEFAULT_CACHE_EXPIRATION_IN_MILLIS;

// Do not load data if current localStorage data is more recent,
// or if its `lastUpdated` timestamp is older than the given `expirationTimestamp`,
if (storedSince > since || lastUpdated < expirationTimestamp) return;

// cleaning up the localStorage data, since some cached splits might need be part of the preloaded data
storage.splits.flush();
storage.splits.setChangeNumber(since);

// splitsData in an object where the property is the split name and the pertaining value is a stringified json of its data
Object.keys(splitsData).forEach(splitName => {
storage.splits.addSplit(splitName, splitsData[splitName]);
});

// add mySegments data
let mySegmentsData = preloadedData.mySegmentsData && preloadedData.mySegmentsData[userId];
if (!mySegmentsData) {
// segmentsData in an object where the property is the segment name and the pertaining value is a stringified object that contains the `added` array of userIds
mySegmentsData = Object.keys(segmentsData).filter(segmentName => {
const userIds = JSON.parse(segmentsData[segmentName]).added;
return Array.isArray(userIds) && userIds.indexOf(userId) > -1;
});
}
storage.segments.resetSegments(mySegmentsData);
};

}
5 changes: 3 additions & 2 deletions src/storage/SplitCache/InMemory.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,11 @@ class SplitCacheInMemory {
}

/**
* Check if the splits information is already stored in cache. In memory there is no cache to check.
* Check if the splits information is already stored in cache. The data can be preloaded and passed via the config.
*/
checkCache() {
return false;
// @TODO rollback if we decide not to emit SDK_READY_FROM_CACHE using InMemory storage
return this.getChangeNumber() > -1;
}
}

Expand Down
14 changes: 12 additions & 2 deletions src/storage/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ export const DEFAULT_CACHE_EXPIRATION_IN_MILLIS = 864000000; // 10 days
const BrowserStorageFactory = context => {
const settings = context.get(context.constants.SETTINGS);
const { storage } = settings;
let instance;

switch (storage.type) {
case STORAGE_MEMORY: {
const keys = new KeyBuilder(settings);

return {
instance = {
splits: new SplitCacheInMemory,
segments: new SegmentCacheInMemory(keys),
impressions: new ImpressionsCacheInMemory,
Expand Down Expand Up @@ -57,13 +58,14 @@ const BrowserStorageFactory = context => {
this.events.clear();
}
};
break;
}

case STORAGE_LOCALSTORAGE: {
const keys = new KeyBuilderLocalStorage(settings);
const expirationTimestamp = Date.now() - DEFAULT_CACHE_EXPIRATION_IN_MILLIS;

return {
instance = {
splits: new SplitCacheInLocalStorage(keys, expirationTimestamp, settings.sync.__splitFiltersValidation),
segments: new SegmentCacheInLocalStorage(keys),
impressions: new ImpressionsCacheInMemory,
Expand Down Expand Up @@ -99,12 +101,20 @@ const BrowserStorageFactory = context => {
this.events.clear();
}
};
break;
}

default:
throw new Error('Unsupported storage type');
}

// load precached data into storage
if (storage.dataLoader) {
const key = settings.core.key;
storage.dataLoader(instance, key);
}

return instance;
};

export default BrowserStorageFactory;
174 changes: 174 additions & 0 deletions src/utils/__tests__/inputValidation/preloadedData.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import tape from 'tape';
import sinon from 'sinon';
import proxyquire from 'proxyquire';
const proxyquireStrict = proxyquire.noCallThru();

const loggerMock = {
warn: sinon.stub(),
error: sinon.stub(),
};

function LogFactoryMock() {
return loggerMock;
}

// Import the module mocking the logger.
const validatePreloadedData = proxyquireStrict('../../inputValidation/preloadedData', {
'../logger': LogFactoryMock
}).validatePreloadedData;

const method = 'some_method';
const testCases = [
// valid inputs
{
input: { lastUpdated: 10, since: 10, splitsData: {} },
output: true,
warn: `${method}: preloadedData.splitsData doesn't contain split definitions.`
},
{
input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' } },
output: true
},
{
input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: { 'some_key': [] } },
output: true
},
{
input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: { 'some_key': [] } },
output: true
},
{
input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: {} },
output: true
},
{
input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: { some_key: [] } },
output: true
},
{
input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: { some_key: ['some_segment'] } },
output: true
},
{
input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, segmentsData: {} },
output: true
},
{
input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, segmentsData: { some_segment: 'SEGMENT DEFINITION' } },
output: true
},
{
input: { lastUpdated: 10, since: 10, splitsData: { 'some_split': 'SPLIT DEFINITION' }, mySegmentsData: { some_key: ['some_segment'], some_other_key: ['some_segment'] }, segmentsData: { some_segment: 'SEGMENT DEFINITION', some_other_segment: 'SEGMENT DEFINITION' } },
output: true
},
{
msg: 'should be true, even using objects for strings and numbers or having extra properties',
input: { ignoredProperty: 'IGNORED', lastUpdated: new Number(10), since: new Number(10), splitsData: { 'some_split': new String('SPLIT DEFINITION') }, mySegmentsData: { some_key: [new String('some_segment')] }, segmentsData: { some_segment: new String('SEGMENT DEFINITION') } },
output: true
},

// invalid inputs
{
msg: 'should be false if preloadedData is not an object',
input: undefined,
output: false,
error: `${method}: preloadedData must be an object.`
},
{
msg: 'should be false if preloadedData is not an object',
input: [],
output: false,
error: `${method}: preloadedData must be an object.`
},
{
msg: 'should be false if lastUpdated property is invalid',
input: { lastUpdated: undefined, since: 10, splitsData: {} },
output: false,
error: `${method}: preloadedData.lastUpdated must be a positive number.`
},
{
msg: 'should be false if lastUpdated property is invalid',
input: { lastUpdated: -1, since: 10, splitsData: {} },
output: false,
error: `${method}: preloadedData.lastUpdated must be a positive number.`
},
{
msg: 'should be false if since property is invalid',
input: { lastUpdated: 10, since: undefined, splitsData: {} },
output: false,
error: `${method}: preloadedData.since must be a positive number.`
},
{
msg: 'should be false if since property is invalid',
input: { lastUpdated: 10, since: -1, splitsData: {} },
output: false,
error: `${method}: preloadedData.since must be a positive number.`
},
{
msg: 'should be false if splitsData property is invalid',
input: { lastUpdated: 10, since: 10, splitsData: undefined },
output: false,
error: `${method}: preloadedData.splitsData must be a map of split names to their serialized definitions.`
},
{
msg: 'should be false if splitsData property is invalid',
input: { lastUpdated: 10, since: 10, splitsData: ['DEFINITION'] },
output: false,
error: `${method}: preloadedData.splitsData must be a map of split names to their serialized definitions.`
},
{
msg: 'should be false if splitsData property is invalid',
input: { lastUpdated: 10, since: 10, splitsData: { some_split: undefined } },
output: false,
error: `${method}: preloadedData.splitsData must be a map of split names to their serialized definitions.`
},
{
msg: 'should be false if mySegmentsData property is invalid',
input: { lastUpdated: 10, since: 10, splitsData: { some_split: 'DEFINITION' }, mySegmentsData: ['DEFINITION'] },
output: false,
error: `${method}: preloadedData.mySegmentsData must be a map of user keys to their list of segment names.`
},
{
msg: 'should be false if mySegmentsData property is invalid',
input: { lastUpdated: 10, since: 10, splitsData: { some_split: 'DEFINITION' }, mySegmentsData: { some_key: undefined } },
output: false,
error: `${method}: preloadedData.mySegmentsData must be a map of user keys to their list of segment names.`
},
{
msg: 'should be false if segmentsData property is invalid',
input: { lastUpdated: 10, since: 10, splitsData: { some_split: 'DEFINITION' }, segmentsData: ['DEFINITION'] },
output: false,
error: `${method}: preloadedData.segmentsData must be a map of segment names to their serialized definitions.`
},
{
msg: 'should be false if segmentsData property is invalid',
input: { lastUpdated: 10, since: 10, splitsData: { some_split: 'DEFINITION' }, segmentsData: { some_segment: undefined } },
output: false,
error: `${method}: preloadedData.segmentsData must be a map of segment names to their serialized definitions.`
}
];

tape('INPUT VALIDATION for preloadedData', assert => {

for (let i = 0; i < testCases.length; i++) {
const testCase = testCases[i];
assert.equal(validatePreloadedData(testCase.input, method), testCase.output, testCase.msg);

if (testCase.error) {
assert.ok(loggerMock.error.calledWithExactly(testCase.error), 'Should log the error for the invalid preloadedData.');
loggerMock.warn.resetHistory();
} else {
assert.true(loggerMock.error.notCalled, 'Should not log any error.');
}

if (testCase.warn) {
assert.ok(loggerMock.warn.calledWithExactly(testCase.warn), 'Should log the warning for the given preloadedData.');
loggerMock.warn.resetHistory();
} else {
assert.true(loggerMock.warn.notCalled, 'Should not log any warning.');
}
}

assert.end();

});
1 change: 1 addition & 0 deletions src/utils/inputValidation/apiKey.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { isString } from '../lang';
import logFactory from '../logger';
const log = logFactory('', {
// Errors on API key validation are important enough so that one day we might force logging them or throw an exception on startup.
displayAllErrors: true
});

Expand Down