artillery
Version:
Cloud-scale load testing. https://www.artillery.io
442 lines (372 loc) • 14.6 kB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const { SSMS } = require('@artilleryio/int-core').ssms;
const { loadPlugins, loadPluginsConfig } = require('./load-plugins');
const EventEmitter = require('eventemitter3');
const debug = require('debug')('core');
const p = require('util').promisify;
const _ = require('lodash');
const PlatformLocal = require('./platform/local');
const PlatformLambda = require('./platform/aws-lambda');
const PlatformAzureACI = require('./platform/az/aci');
async function createLauncher(script, payload, opts, launcherOpts) {
launcherOpts = launcherOpts || {
platform: 'local',
mode: 'distribute'
};
let l;
try {
l = new Launcher(script, payload, opts, launcherOpts);
} catch (err) {
console.log(err);
return null;
}
return l;
}
class Launcher {
constructor(script, payload, opts, launcherOpts) {
this.script = script;
this.payload = payload;
this.opts = opts;
this.exitedWorkersCount = 0;
this.workerMessageBuffer = [];
this.metricsByPeriod = {}; // individual intermediates by worker
this.finalReportsByWorker = {};
this.events = new EventEmitter();
this.pluginEvents = new EventEmitter();
this.pluginEventsLegacy = new EventEmitter();
this.launcherOpts = launcherOpts;
this.periodsReportedFor = [];
if (launcherOpts.platform === 'local') {
this.platform = new PlatformLocal(script, payload, opts, launcherOpts);
} else if (launcherOpts.platform === 'aws:lambda') {
this.platform = new PlatformLambda(script, payload, opts, launcherOpts);
} else if (launcherOpts.platform === 'az:aci') {
this.platform = new PlatformAzureACI(script, payload, opts, launcherOpts);
} else {
throw new Error('Unknown platform: ' + launcherOpts.platform);
}
this.phaseStartedEventsSeen = {};
this.phaseCompletedEventsSeen = {};
this.eventsByWorker = {};
return this;
}
async initWorkerEvents(workerEvents) {
workerEvents.on('workerError', (workerId, message) => {
const { id, error, level, aggregatable, logs } = message;
if (level !== 'warn') {
this.exitedWorkersCount++;
}
if (aggregatable) {
this.workerMessageBuffer.push(message);
} else {
global.artillery.log(`[${id}]: ${error.message}`);
if (logs) {
global.artillery.log(logs);
}
}
this.events.emit('workerError', message);
});
workerEvents.on('phaseStarted', (workerId, message) => {
// Note - we send only the first event for a phase, not all of them
if (
typeof this.phaseStartedEventsSeen[message.phase.index] === 'undefined'
) {
this.phaseStartedEventsSeen[message.phase.index] = Date.now();
const fullPhase = {
//get back original phase without any splitting for workers
...this.script.config.phases[message.phase.index],
index: message.phase.index,
id: message.phase.id,
startTime: this.phaseStartedEventsSeen[message.phase.index]
};
this.events.emit('phaseStarted', fullPhase);
this.pluginEvents.emit('phaseStarted', fullPhase);
this.pluginEventsLegacy.emit('phaseStarted', fullPhase);
global.artillery.globalEvents.emit('phaseStarted', fullPhase);
}
});
workerEvents.on('phaseCompleted', (workerId, message) => {
if (
typeof this.phaseCompletedEventsSeen[message.phase.index] ===
'undefined'
) {
this.phaseCompletedEventsSeen[message.phase.index] = Date.now();
const fullPhase = {
//get back original phase without any splitting for workers
...this.script.config.phases[message.phase.index],
id: message.phase.id,
index: message.phase.index,
startTime: this.phaseStartedEventsSeen[message.phase.index],
endTime: message.phase.endTime
};
this.events.emit('phaseCompleted', fullPhase);
this.pluginEvents.emit('phaseCompleted', fullPhase);
this.pluginEventsLegacy.emit('phaseCompleted', fullPhase);
global.artillery.globalEvents.emit('phaseCompleted', fullPhase);
}
});
// We are not going to receive stats events from workers
// which have zero arrivals for a phase. (This can only happen
// in "distribute" mode.)
workerEvents.on('stats', (workerId, message) => {
const workerStats = SSMS.deserializeMetrics(message.stats);
const period = workerStats.period;
if (typeof this.metricsByPeriod[period] === 'undefined') {
this.metricsByPeriod[period] = [];
}
// TODO: might want the full message here, with worker ID etc
this.metricsByPeriod[period].push(workerStats);
});
workerEvents.on('done', async (workerId, message) => {
this.exitedWorkersCount++;
this.finalReportsByWorker[workerId] = SSMS.deserializeMetrics(
message.report
);
});
workerEvents.on('log', async (workerId, message) => {
artillery.globalEvents.emit('log', ...message.args);
});
workerEvents.on('setSuggestedExitCode', (workerId, message) => {
artillery.suggestedExitCode = message.code;
});
}
async initPlugins() {
const plugins = await loadPlugins(
this.script.config.plugins,
this.script,
this.opts
);
//
// init plugins
//
for (const [name, result] of Object.entries(plugins)) {
if (result.isLoaded) {
if (result.version === 3) {
// TODO: load the plugin, subscribe to events
// global.artillery.plugins[name] = result.plugin;
} else {
// global.artillery.log(`WARNING: Legacy plugin detected: ${name}
// See https://artillery.io/docs/resources/core/v2.html for more details.`,
// 'warn');
// NOTE:
// We are giving v1 and v2 plugins a throw-away script
// object because we only care about the plugin setting
// up event handlers here. The plugins will be loaded
// properly in individual workers where they will have the
// opportunity to attach custom code, modify the script
// object etc.
// If we let a plugin access to the actual script object,
// and it happens to attach code to it (with a custom
// processor function for example) - spawning a worker
// will fail.
const dummyScript = JSON.parse(JSON.stringify(this.script));
dummyScript.config = {
...dummyScript.config,
// Load additional plugins configuration from the environment
plugins: loadPluginsConfig(this.script.config.plugins)
};
if (result.version === 1) {
result.plugin = new result.PluginExport(
dummyScript.config,
this.pluginEventsLegacy
);
global.artillery.plugins.push(result);
} else if (result.version === 2) {
if (result.PluginExport.LEGACY_METRICS_FORMAT === false) {
result.plugin = new result.PluginExport.Plugin(
dummyScript,
this.pluginEvents,
this.opts
);
} else {
result.plugin = new result.PluginExport.Plugin(
dummyScript,
this.pluginEventsLegacy,
this.opts
);
}
global.artillery.plugins.push(result);
} else {
// TODO: print warning
}
}
} else {
global.artillery.log(`WARNING: Could not load plugin: ${name}`, 'warn');
global.artillery.log(result.msg, 'warn');
// global.artillery.log(result.error, 'warn');
}
}
}
async handleAllWorkersFinished() {
const allWorkersDone =
this.exitedWorkersCount === this.platform.getDesiredWorkerCount();
if (allWorkersDone) {
clearInterval(this.i1);
clearInterval(this.i2);
// Flush messages from workers
await this.flushWorkerMessages(0);
await this.flushIntermediateMetrics(true);
const pds = Object.keys(this.finalReportsByWorker).map(
(k) => this.finalReportsByWorker[k]
);
const statsByPeriod = Object.values(SSMS.mergeBuckets(pds));
const stats = SSMS.pack(statsByPeriod);
stats.summaries = {};
for (const [name, value] of Object.entries(stats.histograms || {})) {
const summary = SSMS.summarizeHistogram(value);
stats.summaries[name] = summary;
}
clearInterval(this.workerExitWatcher);
// Relay event to workers
this.pluginEvents.emit('done', stats);
global.artillery.globalEvents.emit('done', stats);
this.pluginEventsLegacy.emit('done', SSMS.legacyReport(stats));
this.events.emit('done', stats);
}
}
async flushWorkerMessages(maxAge = 9000) {
// Collect messages older than maxAge msec and group by log message:
const now = Date.now();
const okToPrint = this.workerMessageBuffer.filter(
(m) => now - m.ts > maxAge
);
this.workerMessageBuffer = this.workerMessageBuffer.filter(
(m) => now - m.ts <= maxAge
);
const readyMessages = okToPrint.reduce((acc, message) => {
const { error } = message;
// TODO: Take event type and level into account
if (typeof acc[error.message] === 'undefined') {
acc[error.message] = [];
}
acc[error.message].push(message);
return acc;
}, {});
for (const [logMessage, messageObjects] of Object.entries(readyMessages)) {
if (messageObjects[0].error) {
global.artillery.log(
`[${messageObjects[0].id}] ${messageObjects[0].error.message}`,
messageObjects[0].level
);
} else {
// Expect a msg property:
global.artillery.log(
`[${messageObjects[0].id}] ${messageObjects[0].msg}`,
messageObjects[0].level
);
}
}
}
async flushIntermediateMetrics(flushAll = false) {
if (Object.keys(this.metricsByPeriod).length === 0) {
debug('No metrics received yet');
return;
}
// We always look at the earliest period available so that reports come in chronological order
const unreportedPeriods = Object.keys(this.metricsByPeriod)
.filter((x) => this.periodsReportedFor.indexOf(x) === -1)
.sort();
const earliestPeriodAvailable = unreportedPeriods[0];
// TODO: better name. One above is earliestNotAlreadyReported
const earliest = Object.keys(this.metricsByPeriod).sort()[0];
if (this.periodsReportedFor.indexOf(earliest) > -1) {
global.artillery.log(
'Warning: multiple batches of metrics for period',
earliest,
new Date(Number(earliest))
);
delete this.metricsByPeriod[earliest]; // FIXME: need to merge them in for the final report
}
// Dynamically adjust the duration we're willing to wait for. This matters on SQS where messages are received
// in batches of 10 and more workers => need to wait longer.
const MAX_WAIT_FOR_PERIOD_MS =
(Math.ceil(this.platform.getDesiredWorkerCount() / 10) * 3 + 30) * 1000;
debug({
now: Date.now(),
count: this.platform.getDesiredWorkerCount(),
earliestPeriodAvailable,
earliest,
MAX_WAIT_FOR_PERIOD_MS,
numReports: this.metricsByPeriod[earliestPeriodAvailable]?.length,
periodsReportedFor: this.periodsReportedFor,
metricsByPeriod: Object.keys(this.metricsByPeriod)
});
const allWorkersReportedForPeriod =
this.metricsByPeriod[earliestPeriodAvailable]?.length ===
this.platform.getDesiredWorkerCount();
const waitedLongEnough =
Date.now() - Number(earliestPeriodAvailable) > MAX_WAIT_FOR_PERIOD_MS;
if (flushAll) {
for (const period of unreportedPeriods) {
this.emitIntermediatesForPeriod(period);
}
} else if (
typeof earliestPeriodAvailable !== 'undefined' &&
(allWorkersReportedForPeriod || waitedLongEnough)
) {
this.emitIntermediatesForPeriod(earliestPeriodAvailable);
// TODO: autoscaling. Handle workers that drop off or join, and update count
} else {
debug('Waiting for more workerStats before emitting stats event');
}
}
emitIntermediatesForPeriod(period) {
debug(
'Report @',
new Date(Number(period)),
'made up of items:',
this.metricsByPeriod[String(period)].length
);
// TODO: Track how many workers provided metrics in the metrics report
// summarize histograms for console reporter:
const merged = SSMS.mergeBuckets(this.metricsByPeriod[String(period)]);
const stats = merged[String(period)];
stats.summaries = {};
for (const [name, value] of Object.entries(stats.histograms || {})) {
const summary = SSMS.summarizeHistogram(value);
stats.summaries[name] = summary;
}
delete this.metricsByPeriod[String(period)];
this.periodsReportedFor.push(period);
this.pluginEvents.emit('stats', stats);
global.artillery.globalEvents.emit('stats', stats);
this.pluginEventsLegacy.emit('stats', SSMS.legacyReport(stats));
this.events.emit('stats', stats);
}
async run() {
await this.initPlugins();
this.i1 = setInterval(async () => {
await this.flushWorkerMessages();
}, 1 * 1000).unref();
this.i2 = setInterval(async () => {
this.flushIntermediateMetrics();
}, 2 * 1000).unref();
this.workerExitWatcher = setInterval(async () => {
await this.handleAllWorkersFinished();
}, 2 * 1000);
await this.initWorkerEvents(this.platform.events);
await this.platform.startJob();
debug('workers running');
}
async shutdown() {
await this.platform.shutdown();
// TODO: flush worker messages, and intermediate stats
// Unload plugins
// TODO: v3 plugins
if (global.artillery && global.artillery.plugins) {
for (const o of global.artillery.plugins) {
if (o.plugin.cleanup) {
try {
await p(o.plugin.cleanup.bind(o.plugin))();
debug('plugin unloaded:', o.name);
} catch (cleanupErr) {
global.artillery.log(cleanupErr, 'error');
}
}
}
}
}
}
module.exports = createLauncher;