601 lines
17 KiB
JavaScript
601 lines
17 KiB
JavaScript
/**
|
|
* Performance Benchmark Suite for Parse Server
|
|
*
|
|
* This suite measures the performance of critical Parse Server operations
|
|
* using the Node.js Performance API. Results are output in a format
|
|
* compatible with github-action-benchmark.
|
|
*
|
|
* Run with: npm run benchmark
|
|
*/
|
|
|
|
/* eslint-disable no-console */
|
|
|
|
const core = require('@actions/core');
|
|
const Parse = require('parse/node');
|
|
const { performance, PerformanceObserver } = require('perf_hooks');
|
|
const { MongoClient } = require('mongodb');
|
|
const { wrapMongoDBWithLatency } = require('./MongoLatencyWrapper');
|
|
|
|
// Configuration
|
|
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/parse_benchmark_test';
|
|
const SERVER_URL = 'http://localhost:1337/parse';
|
|
const APP_ID = 'benchmark-app-id';
|
|
const MASTER_KEY = 'benchmark-master-key';
|
|
const ITERATIONS = process.env.BENCHMARK_ITERATIONS ? parseInt(process.env.BENCHMARK_ITERATIONS, 10) : undefined;
|
|
const LOG_ITERATIONS = false;
|
|
|
|
// Parse Server instance
|
|
let parseServer;
|
|
let mongoClient;
|
|
|
|
// Logging helpers
|
|
const logInfo = message => core.info(message);
|
|
const logError = message => core.error(message);
|
|
|
|
/**
|
|
* Initialize Parse Server for benchmarking
|
|
*/
|
|
async function initializeParseServer() {
|
|
const express = require('express');
|
|
const { default: ParseServer } = require('../lib/index.js');
|
|
|
|
const app = express();
|
|
|
|
parseServer = new ParseServer({
|
|
databaseURI: MONGODB_URI,
|
|
appId: APP_ID,
|
|
masterKey: MASTER_KEY,
|
|
serverURL: SERVER_URL,
|
|
silent: true,
|
|
allowClientClassCreation: true,
|
|
logLevel: 'error', // Minimal logging for performance
|
|
verbose: false,
|
|
});
|
|
|
|
app.use('/parse', parseServer.app);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const server = app.listen(1337, (err) => {
|
|
if (err) {
|
|
reject(new Error(`Failed to start server: ${err.message}`));
|
|
return;
|
|
}
|
|
Parse.initialize(APP_ID);
|
|
Parse.masterKey = MASTER_KEY;
|
|
Parse.serverURL = SERVER_URL;
|
|
resolve(server);
|
|
});
|
|
|
|
server.on('error', (err) => {
|
|
reject(new Error(`Server error: ${err.message}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clean up database between benchmarks
|
|
*/
|
|
async function cleanupDatabase() {
|
|
try {
|
|
if (!mongoClient) {
|
|
mongoClient = await MongoClient.connect(MONGODB_URI);
|
|
}
|
|
const db = mongoClient.db();
|
|
const collections = await db.listCollections().toArray();
|
|
|
|
for (const collection of collections) {
|
|
if (!collection.name.startsWith('system.')) {
|
|
await db.collection(collection.name).deleteMany({});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
throw new Error(`Failed to cleanup database: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset Parse SDK to use the default server
|
|
*/
|
|
function resetParseServer() {
|
|
Parse.serverURL = SERVER_URL;
|
|
}
|
|
|
|
/**
|
|
* Measure average time for an async operation over multiple iterations.
|
|
* @param {Object} options Measurement options.
|
|
* @param {string} options.name Name of the operation being measured.
|
|
* @param {Function} options.operation Async function to measure.
|
|
* @param {number} options.iterations Number of iterations to run; choose a value that is high
|
|
* enough to create reliable benchmark metrics with low variance but low enough to keep test
|
|
* duration reasonable around <=10 seconds.
|
|
* @param {boolean} [options.skipWarmup=false] Skip warmup phase.
|
|
* @param {number} [options.dbLatency] Artificial DB latency in milliseconds to apply during
|
|
* this benchmark.
|
|
*/
|
|
async function measureOperation({ name, operation, iterations, skipWarmup = false, dbLatency }) {
|
|
// Override iterations if global ITERATIONS is set
|
|
iterations = ITERATIONS || iterations;
|
|
|
|
// Determine warmup count (20% of iterations)
|
|
const warmupCount = skipWarmup ? 0 : Math.floor(iterations * 0.2);
|
|
const times = [];
|
|
|
|
// Apply artificial latency if specified
|
|
let unwrapLatency = null;
|
|
if (dbLatency !== undefined && dbLatency > 0) {
|
|
logInfo(`Applying ${dbLatency}ms artificial DB latency for this benchmark`);
|
|
unwrapLatency = wrapMongoDBWithLatency(dbLatency);
|
|
}
|
|
|
|
try {
|
|
if (warmupCount > 0) {
|
|
logInfo(`Starting warmup phase of ${warmupCount} iterations...`);
|
|
const warmupStart = performance.now();
|
|
for (let i = 0; i < warmupCount; i++) {
|
|
await operation();
|
|
}
|
|
logInfo(`Warmup took: ${(performance.now() - warmupStart).toFixed(2)}ms`);
|
|
}
|
|
|
|
// Measurement phase
|
|
logInfo(`Starting measurement phase of ${iterations} iterations...`);
|
|
const progressInterval = Math.ceil(iterations / 10); // Log every 10%
|
|
const measurementStart = performance.now();
|
|
|
|
for (let i = 0; i < iterations; i++) {
|
|
const start = performance.now();
|
|
await operation();
|
|
const end = performance.now();
|
|
const duration = end - start;
|
|
times.push(duration);
|
|
|
|
// Log progress every 10% or individual iterations if LOG_ITERATIONS is enabled
|
|
if (LOG_ITERATIONS) {
|
|
logInfo(`Iteration ${i + 1}: ${duration.toFixed(2)}ms`);
|
|
} else if ((i + 1) % progressInterval === 0 || i + 1 === iterations) {
|
|
const progress = Math.round(((i + 1) / iterations) * 100);
|
|
logInfo(`Progress: ${progress}%`);
|
|
}
|
|
}
|
|
|
|
logInfo(`Measurement took: ${(performance.now() - measurementStart).toFixed(2)}ms`);
|
|
|
|
// Sort times for percentile calculations
|
|
times.sort((a, b) => a - b);
|
|
|
|
// Filter outliers using Interquartile Range (IQR) method
|
|
const q1Index = Math.floor(times.length * 0.25);
|
|
const q3Index = Math.floor(times.length * 0.75);
|
|
const q1 = times[q1Index];
|
|
const q3 = times[q3Index];
|
|
const iqr = q3 - q1;
|
|
const lowerBound = q1 - 1.5 * iqr;
|
|
const upperBound = q3 + 1.5 * iqr;
|
|
|
|
const filtered = times.filter(t => t >= lowerBound && t <= upperBound);
|
|
|
|
// Calculate statistics on filtered data
|
|
const median = filtered[Math.floor(filtered.length * 0.5)];
|
|
const p95 = filtered[Math.floor(filtered.length * 0.95)];
|
|
const p99 = filtered[Math.floor(filtered.length * 0.99)];
|
|
const min = filtered[0];
|
|
const max = filtered[filtered.length - 1];
|
|
|
|
return {
|
|
name,
|
|
value: median, // Use median (p50) as primary metric for stability in CI
|
|
unit: 'ms',
|
|
range: `${min.toFixed(2)} - ${max.toFixed(2)}`,
|
|
extra: `p95: ${p95.toFixed(2)}ms, p99: ${p99.toFixed(2)}ms, n=${filtered.length}/${times.length}`,
|
|
};
|
|
} finally {
|
|
// Remove latency wrapper if it was applied
|
|
if (unwrapLatency) {
|
|
unwrapLatency();
|
|
logInfo('Removed artificial DB latency');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Benchmark: Object Create
|
|
*/
|
|
async function benchmarkObjectCreate(name) {
|
|
let counter = 0;
|
|
|
|
return measureOperation({
|
|
name,
|
|
iterations: 1_000,
|
|
operation: async () => {
|
|
const TestObject = Parse.Object.extend('BenchmarkTest');
|
|
const obj = new TestObject();
|
|
obj.set('testField', `test-value-${counter++}`);
|
|
obj.set('number', counter);
|
|
obj.set('boolean', true);
|
|
await obj.save();
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Benchmark: Object Read (by ID)
|
|
*/
|
|
async function benchmarkObjectRead(name) {
|
|
// Setup: Create test objects
|
|
const TestObject = Parse.Object.extend('BenchmarkTest');
|
|
const objects = [];
|
|
|
|
for (let i = 0; i < 1_000; i++) {
|
|
const obj = new TestObject();
|
|
obj.set('testField', `read-test-${i}`);
|
|
objects.push(obj);
|
|
}
|
|
|
|
await Parse.Object.saveAll(objects);
|
|
|
|
let counter = 0;
|
|
|
|
return measureOperation({
|
|
name,
|
|
iterations: 1_000,
|
|
operation: async () => {
|
|
const query = new Parse.Query('BenchmarkTest');
|
|
await query.get(objects[counter++ % objects.length].id);
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Benchmark: Object Update
|
|
*/
|
|
async function benchmarkObjectUpdate(name) {
|
|
// Setup: Create test objects
|
|
const TestObject = Parse.Object.extend('BenchmarkTest');
|
|
const objects = [];
|
|
|
|
for (let i = 0; i < 1_000; i++) {
|
|
const obj = new TestObject();
|
|
obj.set('testField', `update-test-${i}`);
|
|
obj.set('counter', 0);
|
|
objects.push(obj);
|
|
}
|
|
|
|
await Parse.Object.saveAll(objects);
|
|
|
|
let counter = 0;
|
|
|
|
return measureOperation({
|
|
name,
|
|
iterations: 1_000,
|
|
operation: async () => {
|
|
const obj = objects[counter++ % objects.length];
|
|
obj.increment('counter');
|
|
obj.set('lastUpdated', new Date());
|
|
await obj.save();
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Benchmark: Simple Query
|
|
*/
|
|
async function benchmarkSimpleQuery(name) {
|
|
// Setup: Create test data
|
|
const TestObject = Parse.Object.extend('BenchmarkTest');
|
|
const objects = [];
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
const obj = new TestObject();
|
|
obj.set('category', i % 10);
|
|
obj.set('value', i);
|
|
objects.push(obj);
|
|
}
|
|
|
|
await Parse.Object.saveAll(objects);
|
|
|
|
let counter = 0;
|
|
|
|
return measureOperation({
|
|
name,
|
|
iterations: 1_000,
|
|
operation: async () => {
|
|
const query = new Parse.Query('BenchmarkTest');
|
|
query.equalTo('category', counter++ % 10);
|
|
await query.find();
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Benchmark: Batch Save (saveAll)
|
|
*/
|
|
async function benchmarkBatchSave(name) {
|
|
const BATCH_SIZE = 10;
|
|
|
|
return measureOperation({
|
|
name,
|
|
iterations: 1_000,
|
|
operation: async () => {
|
|
const TestObject = Parse.Object.extend('BenchmarkTest');
|
|
const objects = [];
|
|
|
|
for (let i = 0; i < BATCH_SIZE; i++) {
|
|
const obj = new TestObject();
|
|
obj.set('batchField', `batch-${i}`);
|
|
obj.set('timestamp', new Date());
|
|
objects.push(obj);
|
|
}
|
|
|
|
await Parse.Object.saveAll(objects);
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Benchmark: User Signup
|
|
*/
|
|
async function benchmarkUserSignup(name) {
|
|
let counter = 0;
|
|
|
|
return measureOperation({
|
|
name,
|
|
iterations: 500,
|
|
operation: async () => {
|
|
counter++;
|
|
const user = new Parse.User();
|
|
user.set('username', `benchmark_user_${Date.now()}_${counter}`);
|
|
user.set('password', 'benchmark_password');
|
|
user.set('email', `benchmark${counter}@example.com`);
|
|
await user.signUp();
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Benchmark: User Login
|
|
*/
|
|
async function benchmarkUserLogin(name) {
|
|
// Setup: Create test users
|
|
const users = [];
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
const user = new Parse.User();
|
|
user.set('username', `benchmark_login_user_${i}`);
|
|
user.set('password', 'benchmark_password');
|
|
user.set('email', `login${i}@example.com`);
|
|
await user.signUp();
|
|
users.push({ username: user.get('username'), password: 'benchmark_password' });
|
|
await Parse.User.logOut();
|
|
}
|
|
|
|
let counter = 0;
|
|
|
|
return measureOperation({
|
|
name,
|
|
iterations: 500,
|
|
operation: async () => {
|
|
const userCreds = users[counter++ % users.length];
|
|
await Parse.User.logIn(userCreds.username, userCreds.password);
|
|
await Parse.User.logOut();
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Benchmark: Query with Include (Parallel Pointers)
|
|
* Tests the performance improvement when fetching multiple pointers at the same level.
|
|
*/
|
|
async function benchmarkQueryWithIncludeParallel(name) {
|
|
const PointerAClass = Parse.Object.extend('PointerA');
|
|
const PointerBClass = Parse.Object.extend('PointerB');
|
|
const PointerCClass = Parse.Object.extend('PointerC');
|
|
const RootClass = Parse.Object.extend('Root');
|
|
|
|
// Create pointer objects
|
|
const pointerAObjects = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const obj = new PointerAClass();
|
|
obj.set('name', `pointerA-${i}`);
|
|
pointerAObjects.push(obj);
|
|
}
|
|
await Parse.Object.saveAll(pointerAObjects);
|
|
|
|
const pointerBObjects = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const obj = new PointerBClass();
|
|
obj.set('name', `pointerB-${i}`);
|
|
pointerBObjects.push(obj);
|
|
}
|
|
await Parse.Object.saveAll(pointerBObjects);
|
|
|
|
const pointerCObjects = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const obj = new PointerCClass();
|
|
obj.set('name', `pointerC-${i}`);
|
|
pointerCObjects.push(obj);
|
|
}
|
|
await Parse.Object.saveAll(pointerCObjects);
|
|
|
|
// Create Root objects with multiple pointers at the same level
|
|
const rootObjects = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const obj = new RootClass();
|
|
obj.set('name', `root-${i}`);
|
|
obj.set('pointerA', pointerAObjects[i % pointerAObjects.length]);
|
|
obj.set('pointerB', pointerBObjects[i % pointerBObjects.length]);
|
|
obj.set('pointerC', pointerCObjects[i % pointerCObjects.length]);
|
|
rootObjects.push(obj);
|
|
}
|
|
await Parse.Object.saveAll(rootObjects);
|
|
|
|
return measureOperation({
|
|
name,
|
|
skipWarmup: true,
|
|
dbLatency: 100,
|
|
iterations: 100,
|
|
operation: async () => {
|
|
const query = new Parse.Query('Root');
|
|
// Include multiple pointers at the same level - should fetch in parallel
|
|
query.include(['pointerA', 'pointerB', 'pointerC']);
|
|
await query.find();
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Benchmark: Query with Include (Nested Pointers with Parallel Leaf Nodes)
|
|
* Tests the PR's optimization for parallel fetching at each nested level.
|
|
* Pattern: p1.p2.p3, p1.p2.p4, p1.p2.p5
|
|
* After fetching p2, we know the objectIds and can fetch p3, p4, p5 in parallel.
|
|
*/
|
|
async function benchmarkQueryWithIncludeNested(name) {
|
|
const Level3AClass = Parse.Object.extend('Level3A');
|
|
const Level3BClass = Parse.Object.extend('Level3B');
|
|
const Level3CClass = Parse.Object.extend('Level3C');
|
|
const Level2Class = Parse.Object.extend('Level2');
|
|
const Level1Class = Parse.Object.extend('Level1');
|
|
const RootClass = Parse.Object.extend('Root');
|
|
|
|
// Create Level3 objects (leaf nodes)
|
|
const level3AObjects = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const obj = new Level3AClass();
|
|
obj.set('name', `level3A-${i}`);
|
|
level3AObjects.push(obj);
|
|
}
|
|
await Parse.Object.saveAll(level3AObjects);
|
|
|
|
const level3BObjects = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const obj = new Level3BClass();
|
|
obj.set('name', `level3B-${i}`);
|
|
level3BObjects.push(obj);
|
|
}
|
|
await Parse.Object.saveAll(level3BObjects);
|
|
|
|
const level3CObjects = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const obj = new Level3CClass();
|
|
obj.set('name', `level3C-${i}`);
|
|
level3CObjects.push(obj);
|
|
}
|
|
await Parse.Object.saveAll(level3CObjects);
|
|
|
|
// Create Level2 objects pointing to multiple Level3 objects
|
|
const level2Objects = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const obj = new Level2Class();
|
|
obj.set('name', `level2-${i}`);
|
|
obj.set('level3A', level3AObjects[i % level3AObjects.length]);
|
|
obj.set('level3B', level3BObjects[i % level3BObjects.length]);
|
|
obj.set('level3C', level3CObjects[i % level3CObjects.length]);
|
|
level2Objects.push(obj);
|
|
}
|
|
await Parse.Object.saveAll(level2Objects);
|
|
|
|
// Create Level1 objects pointing to Level2
|
|
const level1Objects = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const obj = new Level1Class();
|
|
obj.set('name', `level1-${i}`);
|
|
obj.set('level2', level2Objects[i % level2Objects.length]);
|
|
level1Objects.push(obj);
|
|
}
|
|
await Parse.Object.saveAll(level1Objects);
|
|
|
|
// Create Root objects pointing to Level1
|
|
const rootObjects = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const obj = new RootClass();
|
|
obj.set('name', `root-${i}`);
|
|
obj.set('level1', level1Objects[i % level1Objects.length]);
|
|
rootObjects.push(obj);
|
|
}
|
|
await Parse.Object.saveAll(rootObjects);
|
|
|
|
return measureOperation({
|
|
name,
|
|
skipWarmup: true,
|
|
dbLatency: 100,
|
|
iterations: 100,
|
|
operation: async () => {
|
|
const query = new Parse.Query('Root');
|
|
// After fetching level1.level2, the PR should fetch level3A, level3B, level3C in parallel
|
|
query.include(['level1.level2.level3A', 'level1.level2.level3B', 'level1.level2.level3C']);
|
|
await query.find();
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Run all benchmarks
|
|
*/
|
|
async function runBenchmarks() {
|
|
logInfo('Starting Parse Server Performance Benchmarks...');
|
|
|
|
let server;
|
|
|
|
try {
|
|
// Initialize Parse Server
|
|
logInfo('Initializing Parse Server...');
|
|
server = await initializeParseServer();
|
|
|
|
// Wait for server to be ready
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
const results = [];
|
|
|
|
// Define all benchmarks to run
|
|
const benchmarks = [
|
|
{ name: 'Object.save (create)', fn: benchmarkObjectCreate },
|
|
{ name: 'Object.save (update)', fn: benchmarkObjectUpdate },
|
|
{ name: 'Object.saveAll (batch save)', fn: benchmarkBatchSave },
|
|
{ name: 'Query.get (by objectId)', fn: benchmarkObjectRead },
|
|
{ name: 'Query.find (simple query)', fn: benchmarkSimpleQuery },
|
|
{ name: 'User.signUp', fn: benchmarkUserSignup },
|
|
{ name: 'User.login', fn: benchmarkUserLogin },
|
|
{ name: 'Query.include (parallel pointers)', fn: benchmarkQueryWithIncludeParallel },
|
|
{ name: 'Query.include (nested pointers)', fn: benchmarkQueryWithIncludeNested },
|
|
];
|
|
|
|
// Run each benchmark with database cleanup
|
|
for (const benchmark of benchmarks) {
|
|
logInfo(`\nRunning benchmark '${benchmark.name}'...`);
|
|
resetParseServer();
|
|
await cleanupDatabase();
|
|
results.push(await benchmark.fn(benchmark.name));
|
|
}
|
|
|
|
// Output results in github-action-benchmark format (stdout)
|
|
logInfo(JSON.stringify(results, null, 2));
|
|
|
|
// Output summary to stderr for visibility
|
|
logInfo('Benchmarks completed successfully!');
|
|
logInfo('Summary:');
|
|
results.forEach(result => {
|
|
logInfo(` ${result.name}: ${result.value.toFixed(2)} ${result.unit} (${result.extra})`);
|
|
});
|
|
|
|
} catch (error) {
|
|
logError('Error running benchmarks:', error);
|
|
process.exit(1);
|
|
} finally {
|
|
// Cleanup
|
|
if (mongoClient) {
|
|
await mongoClient.close();
|
|
}
|
|
if (server) {
|
|
server.close();
|
|
}
|
|
// Give some time for cleanup
|
|
setTimeout(() => process.exit(0), 1000);
|
|
}
|
|
}
|
|
|
|
// Run benchmarks if executed directly
|
|
if (require.main === module) {
|
|
runBenchmarks();
|
|
}
|
|
|
|
module.exports = { runBenchmarks };
|