Adds schema caching capabilities (5s by default) (#2286)

* Adds schema caching capabilities (off by default)

* Use InMemoryCacheAdapter

* Uses proper adapter to generate a cache

* Fix bugs when running disabled cache

* nits

* nits

* Use options object instead of boolean

* Imrpove concurrency of loadSchema

* Adds testing with SCHEMA_CACHE_ON

* Use CacheController instead of generator

- Makes caching SchemaCache use a generated prefix
- Makes clearing the SchemaCache clear only the cached schema keys
- Enable cache by default (ttl 5s)
This commit is contained in:
Florent Vilmart
2016-07-23 06:23:59 +02:00
committed by Drew
parent 66c4b98b55
commit 09bd9e3b2c
14 changed files with 205 additions and 47 deletions

View File

@@ -3,6 +3,8 @@
// mount is the URL for the root of the API; includes http, domain, etc.
import AppCache from './cache';
import SchemaCache from './Controllers/SchemaCache';
import DatabaseController from './Controllers/DatabaseController';
function removeTrailingSlash(str) {
if (!str) {
@@ -32,7 +34,14 @@ export class Config {
this.fileKey = cacheInfo.fileKey;
this.facebookAppIds = cacheInfo.facebookAppIds;
this.allowClientClassCreation = cacheInfo.allowClientClassCreation;
this.database = cacheInfo.databaseController;
// Create a new DatabaseController per request
if (cacheInfo.databaseController) {
const schemaCache = new SchemaCache(cacheInfo.cacheController, cacheInfo.schemaCacheTTL);
this.database = new DatabaseController(cacheInfo.databaseController.adapter, schemaCache);
}
this.schemaCacheTTL = cacheInfo.schemaCacheTTL;
this.serverURL = cacheInfo.serverURL;
this.publicServerURL = removeTrailingSlash(cacheInfo.publicServerURL);

View File

@@ -13,9 +13,10 @@ function joinKeys(...keys) {
* eg "Role" or "Session"
*/
export class SubCache {
constructor(prefix, cacheController) {
constructor(prefix, cacheController, ttl) {
this.prefix = prefix;
this.cache = cacheController;
this.ttl = ttl;
}
get(key) {

View File

@@ -7,7 +7,8 @@ import _ from 'lodash';
var mongodb = require('mongodb');
var Parse = require('parse/node').Parse;
var SchemaController = require('../Controllers/SchemaController');
var SchemaController = require('./SchemaController');
const deepcopy = require('deepcopy');
function addWriteACL(query, acl) {
@@ -80,9 +81,9 @@ const validateQuery = query => {
});
}
function DatabaseController(adapter) {
function DatabaseController(adapter, schemaCache) {
this.adapter = adapter;
this.schemaCache = schemaCache;
// We don't want a mutable this.schema, because then you could have
// one request that uses different schemas for different parts of
// it. Instead, use loadSchema to get a schema.
@@ -107,9 +108,9 @@ DatabaseController.prototype.validateClassName = function(className) {
};
// Returns a promise for a schemaController.
DatabaseController.prototype.loadSchema = function() {
DatabaseController.prototype.loadSchema = function(options = {clearCache: false}) {
if (!this.schemaPromise) {
this.schemaPromise = SchemaController.load(this.adapter);
this.schemaPromise = SchemaController.load(this.adapter, this.schemaCache, options);
this.schemaPromise.then(() => delete this.schemaPromise,
() => delete this.schemaPromise);
}
@@ -805,8 +806,8 @@ const untransformObjectACL = ({_rperm, _wperm, ...output}) => {
}
DatabaseController.prototype.deleteSchema = function(className) {
return this.loadSchema()
.then(schemaController => schemaController.getOneSchema(className))
return this.loadSchema(true)
.then(schemaController => schemaController.getOneSchema(className, true))
.catch(error => {
if (error === undefined) {
return { fields: {} };

View File

@@ -0,0 +1,68 @@
const MAIN_SCHEMA = "__MAIN_SCHEMA";
const SCHEMA_CACHE_PREFIX = "__SCHEMA";
const ALL_KEYS = "__ALL_KEYS";
import { randomString } from '../cryptoUtils';
export default class SchemaCache {
cache: Object;
constructor(cacheController, ttl = 30) {
this.ttl = ttl;
if (typeof ttl == 'string') {
this.ttl = parseInt(ttl);
}
this.cache = cacheController;
this.prefix = SCHEMA_CACHE_PREFIX+randomString(20);
}
put(key, value) {
return this.cache.get(this.prefix+ALL_KEYS).then((allKeys) => {
allKeys = allKeys || {};
allKeys[key] = true;
return Promise.all([this.cache.put(this.prefix+ALL_KEYS, allKeys, this.ttl), this.cache.put(key, value, this.ttl)]);
});
}
getAllClasses() {
if (!this.ttl) {
return Promise.resolve(null);
}
return this.cache.get(this.prefix+MAIN_SCHEMA);
}
setAllClasses(schema) {
if (!this.ttl) {
return Promise.resolve(null);
}
return this.put(this.prefix+MAIN_SCHEMA, schema);
}
setOneSchema(className, schema) {
if (!this.ttl) {
return Promise.resolve(null);
}
return this.put(this.prefix+className, schema);
}
getOneSchema(className) {
if (!this.ttl) {
return Promise.resolve(null);
}
return this.cache.get(this.prefix+className);
}
clear() {
// That clears all caches...
let promise = Promise.resolve();
return this.cache.get(this.prefix+ALL_KEYS).then((allKeys) => {
if (!allKeys) {
return;
}
let promises = Object.keys(allKeys).map((key) => {
return this.cache.del(key);
});
return Promise.all(promises);
});
}
}

View File

@@ -273,19 +273,25 @@ class SchemaController {
data;
perms;
constructor(databaseAdapter) {
constructor(databaseAdapter, schemaCache) {
this._dbAdapter = databaseAdapter;
this._cache = schemaCache;
// this.data[className][fieldName] tells you the type of that field, in mongo format
this.data = {};
// this.perms[className][operation] tells you the acl-style permissions
this.perms = {};
}
reloadData() {
reloadData(options = {clearCache: false}) {
if (options.clearCache) {
this._cache.clear();
}
if (this.reloadDataPromise && !options.clearCache) {
return this.reloadDataPromise;
}
this.data = {};
this.perms = {};
return this.getAllClasses()
this.reloadDataPromise = this.getAllClasses(options)
.then(allSchemas => {
allSchemas.forEach(schema => {
this.data[schema.className] = injectDefaultSchema(schema).fields;
@@ -300,20 +306,51 @@ class SchemaController {
classLevelPermissions: {}
});
});
delete this.reloadDataPromise;
}, (err) => {
delete this.reloadDataPromise;
throw err;
});
return this.reloadDataPromise;
}
getAllClasses(options = {clearCache: false}) {
if (options.clearCache) {
this._cache.clear();
}
return this._cache.getAllClasses().then((allClasses) => {
if (allClasses && allClasses.length && !options.clearCache) {
return Promise.resolve(allClasses);
}
return this._dbAdapter.getAllClasses()
.then(allSchemas => allSchemas.map(injectDefaultSchema))
.then(allSchemas => {
return this._cache.setAllClasses(allSchemas).then(() => {
return allSchemas;
});
})
});
}
getAllClasses() {
return this._dbAdapter.getAllClasses()
.then(allSchemas => allSchemas.map(injectDefaultSchema));
}
getOneSchema(className, allowVolatileClasses = false) {
if (allowVolatileClasses && volatileClasses.indexOf(className) > -1) {
return Promise.resolve(this.data[className]);
getOneSchema(className, allowVolatileClasses = false, options = {clearCache: false}) {
if (options.clearCache) {
this._cache.clear();
}
return this._dbAdapter.getClass(className)
.then(injectDefaultSchema)
if (allowVolatileClasses && volatileClasses.indexOf(className) > -1) {
return Promise.resolve(this.data[className]);
}
return this._cache.getOneSchema(className).then((cached) => {
if (cached && !options.clearCache) {
return Promise.resolve(cached);
}
return this._dbAdapter.getClass(className)
.then(injectDefaultSchema)
.then((result) => {
return this._cache.setOneSchema(className, result).then(() => {
return result;
})
});
});
}
// Create a new class that includes the three default fields.
@@ -331,6 +368,10 @@ class SchemaController {
return this._dbAdapter.createClass(className, convertSchemaToAdapterSchema({ fields, classLevelPermissions, className }))
.then(convertAdapterSchemaToParseSchema)
.then((res) => {
this._cache.clear();
return res;
})
.catch(error => {
if (error && error.code === Parse.Error.DUPLICATE_VALUE) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`);
@@ -376,7 +417,7 @@ class SchemaController {
});
return Promise.all(deletePromises) // Delete Everything
.then(() => this.reloadData()) // Reload our Schema, so we have all the new values
.then(() => this.reloadData({ clearCache: true })) // Reload our Schema, so we have all the new values
.then(() => {
let promises = insertedFields.map(fieldName => {
const type = submittedFields[fieldName];
@@ -410,13 +451,13 @@ class SchemaController {
// We don't have this class. Update the schema
return this.addClassIfNotExists(className)
// The schema update succeeded. Reload the schema
.then(() => this.reloadData())
.then(() => this.reloadData({ clearCache: true }))
.catch(error => {
// The schema update failed. This can be okay - it might
// have failed because there's a race condition and a different
// client is making the exact same schema update that we want.
// So just reload the schema.
return this.reloadData();
return this.reloadData({ clearCache: true });
})
.then(() => {
// Ensure that the schema now validates
@@ -486,7 +527,7 @@ class SchemaController {
}
validateCLP(perms, newSchema);
return this._dbAdapter.setClassLevelPermissions(className, perms)
.then(() => this.reloadData());
.then(() => this.reloadData({ clearCache: true }));
}
// Returns a promise that resolves successfully to the new schema
@@ -521,23 +562,26 @@ class SchemaController {
`schema mismatch for ${className}.${fieldName}; expected ${expectedType.type || expectedType} but got ${type.type}`
);
}
return this;
}
return this._dbAdapter.addFieldIfNotExists(className, fieldName, type).then(() => {
// The update succeeded. Reload the schema
return this.reloadData();
return this.reloadData({ clearCache: true });
}, error => {
//TODO: introspect the error and only reload if the error is one for which is makes sense to reload
// The update failed. This can be okay - it might have been a race
// condition where another client updated the schema in the same
// way that we wanted to. So, just reload the schema
return this.reloadData();
return this.reloadData({ clearCache: true });
}).then(error => {
// Ensure that the schema now validates
if (!dbTypeMatchesObjectType(this.getExpectedType(className, fieldName), type)) {
throw new Parse.Error(Parse.Error.INVALID_JSON, `Could not add field ${fieldName}`);
}
// Remove the cached schema
this._cache.clear();
return this;
});
});
@@ -562,7 +606,7 @@ class SchemaController {
throw new Parse.Error(136, `field ${fieldName} cannot be changed`);
}
return this.getOneSchema(className)
return this.getOneSchema(className, false, {clearCache: true})
.catch(error => {
if (error === undefined) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`);
@@ -579,8 +623,9 @@ class SchemaController {
return database.adapter.deleteFields(className, schema, [fieldName])
.then(() => database.adapter.deleteClass(`_Join:${fieldName}:${className}`));
}
return database.adapter.deleteFields(className, schema, [fieldName]);
}).then(() => {
this._cache.clear();
});
}
@@ -711,9 +756,9 @@ class SchemaController {
}
// Returns a promise for a new Schema.
const load = dbAdapter => {
let schema = new SchemaController(dbAdapter);
return schema.reloadData().then(() => schema);
const load = (dbAdapter, schemaCache, options) => {
let schema = new SchemaController(dbAdapter, schemaCache);
return schema.reloadData(options).then(() => schema);
}
// Builds a new schema (in schema API response format) out of an

View File

@@ -55,6 +55,7 @@ import { UsersRouter } from './Routers/UsersRouter';
import { PurgeRouter } from './Routers/PurgeRouter';
import DatabaseController from './Controllers/DatabaseController';
import SchemaCache from './Controllers/SchemaCache';
const SchemaController = require('./Controllers/SchemaController');
import ParsePushAdapter from 'parse-server-push-adapter';
import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter';
@@ -139,6 +140,7 @@ class ParseServer {
expireInactiveSessions = true,
verbose = false,
revokeSessionOnPasswordReset = true,
schemaCacheTTL = 5, // cache for 5s
__indexBuildCompletionCallbackForTests = () => {},
}) {
// Initialize the node client SDK automatically
@@ -197,7 +199,7 @@ class ParseServer {
const userController = new UserController(emailControllerAdapter, appId, { verifyUserEmails });
const liveQueryController = new LiveQueryController(liveQuery);
const cacheController = new CacheController(cacheControllerAdapter, appId);
const databaseController = new DatabaseController(databaseAdapter);
const databaseController = new DatabaseController(databaseAdapter, new SchemaCache(cacheController, schemaCacheTTL));
const hooksController = new HooksController(appId, databaseController, webhookKey);
const analyticsController = new AnalyticsController(analyticsControllerAdapter);
@@ -254,6 +256,7 @@ class ParseServer {
jsonLogs,
revokeSessionOnPasswordReset,
databaseController,
schemaCacheTTL
});
// To maintain compatibility. TODO: Remove in some version that breaks backwards compatability

View File

@@ -15,15 +15,15 @@ function classNameMismatchResponse(bodyClass, pathClass) {
}
function getAllSchemas(req) {
return req.config.database.loadSchema()
.then(schemaController => schemaController.getAllClasses())
return req.config.database.loadSchema({ clearCache: true})
.then(schemaController => schemaController.getAllClasses(true))
.then(schemas => ({ response: { results: schemas } }));
}
function getOneSchema(req) {
const className = req.params.className;
return req.config.database.loadSchema()
.then(schemaController => schemaController.getOneSchema(className))
return req.config.database.loadSchema({ clearCache: true})
.then(schemaController => schemaController.getOneSchema(className, true))
.then(schema => ({ response: schema }))
.catch(error => {
if (error === undefined) {
@@ -46,7 +46,7 @@ function createSchema(req) {
throw new Parse.Error(135, `POST ${req.path} needs a class name.`);
}
return req.config.database.loadSchema()
return req.config.database.loadSchema({ clearCache: true})
.then(schema => schema.addClassIfNotExists(className, req.body.fields, req.body.classLevelPermissions))
.then(schema => ({ response: schema }));
}
@@ -59,7 +59,7 @@ function modifySchema(req) {
let submittedFields = req.body.fields || {};
let className = req.params.className;
return req.config.database.loadSchema()
return req.config.database.loadSchema({ clearCache: true})
.then(schema => schema.updateClass(className, submittedFields, req.body.classLevelPermissions, req.config.database))
.then(result => ({response: result}));
}

View File

@@ -197,5 +197,10 @@ export default {
env: "PARSE_SERVER_REVOKE_SESSION_ON_PASSWORD_RESET",
help: "When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions.",
action: booleanParser
},
"schemaCacheTTL": {
env: "PARSE_SERVER_SCHEMA_CACHE_TTL",
help: "The TTL for caching the schema for optimizing read/write operations. You should put a long TTL when your DB is in production. default to 0; disabled.",
action: numberParser("schemaCacheTTL"),
}
};