Repository: mcollina/autocannon
Pull request #488 (2023-04-25 21:28:59)

Defer aggregating results until after the instance returns to allow multi-instance load tests
This update introduces the ability to coordinate a load test using multiple separate machines running autocannon against an API, whilst still being able to use the built-in result aggregation functionality, as if it were using connections and/or worker threads on a single machine.  It is a lightweight and backward-compatible.

To keep things simple in autocannon, the implementation of the coordination between machines is left as the responsibility of the consumers of this library, as there are undoubtedly many possibilities.


Patch:
diff --git a/README.md b/README.md
index 2f3e8d0..755b880 100644
--- a/README.md
+++ b/README.md
@@ -330,6 +330,7 @@ Start autocannon against the given target.
     * `excludeErrorStats`: A `Boolean` which allows you to disable tracking non-2xx code responses in latency and bytes per second calculations. _OPTIONAL_ default: `false`.
     * `expectBody`: A `String` representing the expected response body. Each request whose response body is not equal to `expectBody`is counted in `mismatches`. If enabled, mismatches count towards bailout. _OPTIONAL_
     * `tlsOptions`: An `Object` that is passed into `tls.connect` call ([Full list of options](https://nodejs.org/api/tls.html#tls_tls_connect_port_host_options_callback)). Note: this only applies if your URL is secure.
+    * `skipAggregateResult`: A `Boolean` which allows you to disable the aggregate result phase of an instance run. See [autocannon.aggregateResult](<#autocannon.aggregateResult(results[, opts])>)
 * `cb`: The callback which is called on completion of a benchmark. Takes the following params. _OPTIONAL_.
     * `err`: If there was an error encountered with the run.
     * `results`: The results of the run.
@@ -397,6 +398,22 @@ Print the result tables to the terminal, programmatically.
     * `renderResultsTable`: A truthy value to enable the rendering of the results table. default: `true`.
     * `renderLatencyTable`: A truthy value to enable the rendering of the latency table. default: `false`.
 
+### autocannon.aggregateResult(results[, opts])
+
+Aggregate the results of one or more autocannon instance runs, where the instances of autocannon have been run with the `skipAggregateResult` option.
+
+This is an advanced use case, where you might be running a load test using autocannon across multiple machines and therefore need to defer aggregating the results to a later time.
+
+* `results`: An array of autocannon instance results, where the instances have been run with the `skipAggregateResult` option set to true. _REQUIRED_.
+* `opts`: This is a subset of the options you would pass to the main autocannon API, so you could use the same options object as the one used to run the instances. See [autocannon](<#autocannon(opts[, cb])>) for full descriptions of the options. _REQUIRED_.
+    * `url`: _REQUIRED_
+    * `title`: _OPTIONAL_ default: `undefined`
+    * `socketPath`: _OPTIONAL_
+    * `connections`: _OPTIONAL_ default: `10`.
+    * `sampleInt`: _OPTIONAL_ default: `1`
+    * `pipelining`: _OPTIONAL_ default: `1`
+    * `workers`: _OPTIONAL_ default: `undefined`
+
 ### Autocannon events
 
 Because an autocannon instance is an `EventEmitter`, it emits several events. these are below:
diff --git a/autocannon.js b/autocannon.js
index 1f1bcfe..a4fb91e 100755
--- a/autocannon.js
+++ b/autocannon.js
@@ -18,6 +18,8 @@ const track = require('./lib/progressTracker')
 const generateSubArgAliases = require('./lib/subargAliases')
 const { checkURL, ofURL } = require('./lib/url')
 const { parseHAR } = require('./lib/parseHAR')
+const _aggregateResult = require('./lib/aggregateResult')
+const validateOpts = require('./lib/validate')
 
 if (typeof URL !== 'function') {
   console.error('autocannon requires the WHATWG URL API, but it is not available. Please upgrade to Node 6.13+.')
@@ -30,6 +32,19 @@ module.exports.track = track
 module.exports.start = start
 module.exports.printResult = printResult
 module.exports.parseArguments = parseArguments
+module.exports.aggregateResult = function aggregateResult (results, opts = {}) {
+  if (!Array.isArray(results)) {
+    throw new Error('"results" must be an array of results')
+  }
+
+  opts = validateOpts(opts, false)
+
+  if (opts instanceof Error) {
+    throw opts
+  }
+
+  return _aggregateResult(results, opts)
+}
 const alias = {
   connections: 'c',
   pipelining: 'p',
diff --git a/lib/aggregateResult.js b/lib/aggregateResult.js
index 0c04fa8..f8fe83d 100644
--- a/lib/aggregateResult.js
+++ b/lib/aggregateResult.js
@@ -1,9 +1,10 @@
 'use strict'
 
-const { decodeHist, histAsObj, addPercentiles } = require('./histUtil')
+const { decodeHist, getHistograms, histAsObj, addPercentiles } = require('./histUtil')
 
 function aggregateResult (results, opts, histograms) {
   results = Array.isArray(results) ? results : [results]
+  histograms = getHistograms(histograms)
 
   const aggregated = results.map(r => ({
     ...r,
diff --git a/lib/run.js b/lib/run.js
index 7111b7d..1331265 100644
--- a/lib/run.js
+++ b/lib/run.js
@@ -137,7 +137,7 @@ function run (opts, tracker, cb) {
 
       statusCodes.forEach((code, index) => { result[(index + 1) + 'xx'] = code })
 
-      const resultObj = isMainThread ? aggregateResult(result, opts, histograms) : result
+      const resultObj = isMainThread && !opts.skipAggregateResult ? aggregateResult(result, opts, histograms) : result
 
       if (opts.forever) {
         // we don't call callback when in forever mode, so this is the
diff --git a/samples/customise-individual-connection.js b/samples/customise-individual-connection.js
index b191719..6a89e03 100644
--- a/samples/customise-individual-connection.js
+++ b/samples/customise-individual-connection.js
@@ -7,6 +7,8 @@ const server = http.createServer(handle)
 
 server.listen(0, startBench)
 
+let connection = 0
+
 function handle (req, res) {
   res.end('hello world')
 }
@@ -21,8 +23,6 @@ function startBench () {
     setupClient
   }, finishedBench)
 
-  let connection = 0
-
   function setupClient (client) {
     client.setBody('connection number', connection++)
   }


Test patch:
diff --git a/test/aggregateResult.test.js b/test/aggregateResult.test.js
new file mode 100644
index 0000000..83cb1fc
--- /dev/null
+++ b/test/aggregateResult.test.js
@@ -0,0 +1,33 @@
+const { test } = require('tap')
+const { startServer } = require('./helper')
+const autocannon = require('../autocannon')
+const aggregateResult = autocannon.aggregateResult
+const server = startServer()
+const url = 'http://localhost:' + server.address().port
+
+test('exec separate autocannon instances with skipAggregateResult, then aggregateResult afterwards', async (t) => {
+  t.plan(2)
+
+  const opts = {
+    url,
+    connections: 1,
+    maxOverallRequests: 10,
+    skipAggregateResult: true
+  }
+
+  const results = await Promise.all([
+    autocannon(opts),
+    autocannon(opts)
+  ])
+
+  const aggregateResults = aggregateResult(results, opts)
+
+  t.equal(aggregateResults['2xx'], 20)
+  t.equal(aggregateResults.requests.total, 20)
+})
+
+test('aggregateResult must be passed opts with at least a URL or socketPath property', async (t) => {
+  t.plan(2)
+  t.throws(() => aggregateResult([]), 'url or socketPath option required')
+  t.throws(() => aggregateResult([], {}), 'url or socketPath option required')
+})