mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
ci: Add GC memory pressure performance test (#10094)
This commit is contained in:
@@ -195,6 +195,105 @@ async function measureOperation({ name, operation, iterations, skipWarmup = fals
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure GC pressure for an async operation over multiple iterations.
|
||||
* Tracks garbage collection duration per operation using PerformanceObserver.
|
||||
* Larger transient allocations (e.g., from unbounded cursor batch sizes) cause
|
||||
* more frequent and longer GC pauses, which this metric directly captures.
|
||||
* @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.
|
||||
* @param {boolean} [options.skipWarmup=false] Skip warmup phase.
|
||||
*/
|
||||
async function measureMemoryOperation({ name, operation, iterations, skipWarmup = false }) {
|
||||
const { PerformanceObserver } = require('node:perf_hooks');
|
||||
|
||||
// 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 gcDurations = [];
|
||||
|
||||
if (warmupCount > 0) {
|
||||
logInfo(`Starting warmup phase of ${warmupCount} iterations...`);
|
||||
for (let i = 0; i < warmupCount; i++) {
|
||||
await operation();
|
||||
}
|
||||
logInfo('Warmup complete.');
|
||||
}
|
||||
|
||||
// Measurement phase
|
||||
logInfo(`Starting measurement phase of ${iterations} iterations...`);
|
||||
const progressInterval = Math.ceil(iterations / 10);
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
// Force GC before each iteration to start from a clean state
|
||||
if (typeof global.gc === 'function') {
|
||||
global.gc();
|
||||
}
|
||||
|
||||
// Track GC events during this iteration; measure the longest single GC pause,
|
||||
// which reflects the production impact of large transient allocations
|
||||
let maxGcPause = 0;
|
||||
const obs = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.duration > maxGcPause) {
|
||||
maxGcPause = entry.duration;
|
||||
}
|
||||
}
|
||||
});
|
||||
obs.observe({ type: 'gc', buffered: false });
|
||||
|
||||
await operation();
|
||||
|
||||
// Flush any buffered entries before disconnecting to avoid data loss
|
||||
for (const entry of obs.takeRecords()) {
|
||||
if (entry.duration > maxGcPause) {
|
||||
maxGcPause = entry.duration;
|
||||
}
|
||||
}
|
||||
obs.disconnect();
|
||||
gcDurations.push(maxGcPause);
|
||||
|
||||
if (LOG_ITERATIONS) {
|
||||
logInfo(`Iteration ${i + 1}: ${maxGcPause.toFixed(2)} ms GC`);
|
||||
} else if ((i + 1) % progressInterval === 0 || i + 1 === iterations) {
|
||||
const progress = Math.round(((i + 1) / iterations) * 100);
|
||||
logInfo(`Progress: ${progress}%`);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort for percentile calculations
|
||||
gcDurations.sort((a, b) => a - b);
|
||||
|
||||
// Filter outliers using IQR method
|
||||
const q1Index = Math.floor(gcDurations.length * 0.25);
|
||||
const q3Index = Math.floor(gcDurations.length * 0.75);
|
||||
const q1 = gcDurations[q1Index];
|
||||
const q3 = gcDurations[q3Index];
|
||||
const iqr = q3 - q1;
|
||||
const lowerBound = q1 - 1.5 * iqr;
|
||||
const upperBound = q3 + 1.5 * iqr;
|
||||
|
||||
const filtered = gcDurations.filter(d => d >= lowerBound && d <= upperBound);
|
||||
|
||||
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,
|
||||
unit: 'ms',
|
||||
range: `${min.toFixed(2)} - ${max.toFixed(2)}`,
|
||||
extra: `p95: ${p95.toFixed(2)}ms, p99: ${p99.toFixed(2)}ms, n=${filtered.length}/${gcDurations.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Benchmark: Object Create
|
||||
*/
|
||||
@@ -525,6 +624,84 @@ async function benchmarkQueryWithIncludeNested(name) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benchmark: Large Result Set GC Pressure
|
||||
* Measures max GC pause when querying many large documents, which is affected
|
||||
* by MongoDB cursor batch size configuration. Without a batch size limit,
|
||||
* the driver processes larger data chunks between yield points, creating more
|
||||
* garbage that triggers longer GC pauses.
|
||||
*/
|
||||
async function benchmarkLargeResultMemory(name) {
|
||||
const TestObject = Parse.Object.extend('BenchmarkLargeResult');
|
||||
const TOTAL_OBJECTS = 3_000;
|
||||
const SAVE_BATCH_SIZE = 200;
|
||||
|
||||
// Seed data in batches; ~8 KB per document so 3,000 docs ≈ 24 MB total,
|
||||
// exceeding MongoDB's 16 MiB default batch limit to test cursor batching
|
||||
for (let i = 0; i < TOTAL_OBJECTS; i += SAVE_BATCH_SIZE) {
|
||||
const batch = [];
|
||||
for (let j = 0; j < SAVE_BATCH_SIZE && i + j < TOTAL_OBJECTS; j++) {
|
||||
const obj = new TestObject();
|
||||
obj.set('category', (i + j) % 10);
|
||||
obj.set('value', i + j);
|
||||
obj.set('data', `padding-${i + j}-${'x'.repeat(8000)}`);
|
||||
batch.push(obj);
|
||||
}
|
||||
await Parse.Object.saveAll(batch);
|
||||
}
|
||||
|
||||
return measureMemoryOperation({
|
||||
name,
|
||||
iterations: 100,
|
||||
operation: async () => {
|
||||
const query = new Parse.Query('BenchmarkLargeResult');
|
||||
query.limit(TOTAL_OBJECTS);
|
||||
await query.find({ useMasterKey: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benchmark: Concurrent Query GC Pressure
|
||||
* Measures max GC pause under concurrent load with large result sets.
|
||||
* Simulates production conditions where multiple clients query simultaneously,
|
||||
* compounding GC pressure from cursor batch sizes.
|
||||
*/
|
||||
async function benchmarkConcurrentQueryMemory(name) {
|
||||
const TestObject = Parse.Object.extend('BenchmarkConcurrentResult');
|
||||
const TOTAL_OBJECTS = 3_000;
|
||||
const SAVE_BATCH_SIZE = 200;
|
||||
const CONCURRENT_QUERIES = 10;
|
||||
|
||||
// Seed data in batches; ~8 KB per document so 3,000 docs ≈ 24 MB total,
|
||||
// exceeding MongoDB's 16 MiB default batch limit to test cursor batching
|
||||
for (let i = 0; i < TOTAL_OBJECTS; i += SAVE_BATCH_SIZE) {
|
||||
const batch = [];
|
||||
for (let j = 0; j < SAVE_BATCH_SIZE && i + j < TOTAL_OBJECTS; j++) {
|
||||
const obj = new TestObject();
|
||||
obj.set('category', (i + j) % 10);
|
||||
obj.set('value', i + j);
|
||||
obj.set('data', `padding-${i + j}-${'x'.repeat(8000)}`);
|
||||
batch.push(obj);
|
||||
}
|
||||
await Parse.Object.saveAll(batch);
|
||||
}
|
||||
|
||||
return measureMemoryOperation({
|
||||
name,
|
||||
iterations: 50,
|
||||
operation: async () => {
|
||||
const queries = [];
|
||||
for (let i = 0; i < CONCURRENT_QUERIES; i++) {
|
||||
const query = new Parse.Query('BenchmarkConcurrentResult');
|
||||
query.limit(TOTAL_OBJECTS);
|
||||
queries.push(query.find({ useMasterKey: true }));
|
||||
}
|
||||
await Promise.all(queries);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all benchmarks
|
||||
*/
|
||||
@@ -555,6 +732,8 @@ async function runBenchmarks() {
|
||||
{ name: 'User.login', fn: benchmarkUserLogin },
|
||||
{ name: 'Query.include (parallel pointers)', fn: benchmarkQueryWithIncludeParallel },
|
||||
{ name: 'Query.include (nested pointers)', fn: benchmarkQueryWithIncludeNested },
|
||||
{ name: 'Query.find (large result, GC pressure)', fn: benchmarkLargeResultMemory },
|
||||
{ name: 'Query.find (concurrent, GC pressure)', fn: benchmarkConcurrentQueryMemory },
|
||||
];
|
||||
|
||||
// Run each benchmark with database cleanup
|
||||
|
||||
+1
-1
@@ -137,7 +137,7 @@
|
||||
"postinstall": "node -p 'require(\"./postinstall.js\")()'",
|
||||
"madge:circular": "node_modules/.bin/madge ./src --circular",
|
||||
"benchmark": "cross-env MONGODB_VERSION=8.0.4 MONGODB_TOPOLOGY=standalone mongodb-runner exec -t standalone --version 8.0.4 -- --port 27017 -- npm run benchmark:only",
|
||||
"benchmark:only": "node benchmark/performance.js",
|
||||
"benchmark:only": "node --expose-gc benchmark/performance.js",
|
||||
"benchmark:quick": "cross-env BENCHMARK_ITERATIONS=10 npm run benchmark:only"
|
||||
},
|
||||
"types": "types/index.d.ts",
|
||||
|
||||
Reference in New Issue
Block a user