diff --git a/.github/workflows/benchmarker.yml b/.github/workflows/benchmarker.yml
new file mode 100644
index 000000000..1a1de4da6
--- /dev/null
+++ b/.github/workflows/benchmarker.yml
@@ -0,0 +1,40 @@
+name: Node.js CI
+
+on: push
+
+jobs:
+ benchmark:
+ # TODO should we use the same container as circle & central?
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ node-version: [16.17.0]
+ services:
+ # see: https://docs.github.com/en/enterprise-server@3.5/actions/using-containerized-services/creating-postgresql-service-containers
+ postgres:
+ image: postgres:9.6
+ env:
+ POSTGRES_PASSWORD: odktest
+ ports:
+ - 5432:5432
+ # Set health checks to wait until postgres has started
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ steps:
+ - uses: actions/checkout@v3
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v3
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'npm'
+ - run: npm ci
+ - run: node lib/bin/create-docker-databases.js
+ - name: Benchmark
+ timeout-minutes: 10
+ run: ./benchmarker/scripts/ci-benchmark
+ - name: Backend Logs
+ if: always()
+ run: "! [[ -f ./backend.log ]] || cat ./backend.log"
diff --git a/benchmarker/.eslintrc.cjs b/benchmarker/.eslintrc.cjs
new file mode 100644
index 000000000..6daaf0715
--- /dev/null
+++ b/benchmarker/.eslintrc.cjs
@@ -0,0 +1,29 @@
+module.exports = {
+ env: {
+ commonjs: true,
+ es2021: true,
+ node: true,
+ },
+ extends: 'eslint:recommended',
+ parserOptions: {
+ ecmaVersion: "latest",
+ },
+ rules: {
+ 'indent': 'off',
+ 'key-spacing': 'off',
+ 'keyword-spacing': 'off',
+ 'no-console': 'off',
+ 'no-mixed-operators': 'off',
+ 'no-multi-spaces': 'off',
+ 'no-plusplus': 'off',
+ 'no-return-assign': 'off',
+ 'no-shadow': 'off',
+ 'no-undef-init': 'error',
+ 'no-unused-expressions': 'error',
+ 'no-use-before-define': [ 'error', { functions:false } ],
+ 'semi': [ 'error', 'always' ],
+ 'semi-style': 'off',
+ 'space-in-parens': 'off',
+ 'switch-colon-spacing': 'off',
+ },
+};
diff --git a/benchmarker/.gitignore b/benchmarker/.gitignore
new file mode 100644
index 000000000..e63b9859d
--- /dev/null
+++ b/benchmarker/.gitignore
@@ -0,0 +1,2 @@
+/node_modules/
+/logs/
diff --git a/benchmarker/250q-form.xml b/benchmarker/250q-form.xml
new file mode 100644
index 000000000..cfaaf77ad
--- /dev/null
+++ b/benchmarker/250q-form.xml
@@ -0,0 +1,1268 @@
+
+
+
+ 250 flat text questions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/benchmarker/250q-submission.template.xml b/benchmarker/250q-submission.template.xml
new file mode 100644
index 000000000..ae5969d24
--- /dev/null
+++ b/benchmarker/250q-submission.template.xml
@@ -0,0 +1,8 @@
+
+ uuid:{{uuid}}
+ {{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}
+ {{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}
+ {{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}
+ {{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}
+ {{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}{{randInt}}
+
diff --git a/benchmarker/index.js b/benchmarker/index.js
new file mode 100644
index 000000000..67d15aebb
--- /dev/null
+++ b/benchmarker/index.js
@@ -0,0 +1,315 @@
+// Copyright 2022 ODK Central Developers
+// See the NOTICE file at the top-level directory of this distribution and at
+// https://github.com/getodk/central-backend/blob/master/NOTICE.
+// This file is part of ODK Central. It is subject to the license terms in
+// the LICENSE file found in the top-level directory of this distribution and at
+// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central,
+// including this file, may be copied, modified, propagated, or distributed
+// except according to the terms contained in the LICENSE file.
+
+import fs from 'node:fs';
+import fetch, { fileFromSync } from 'node-fetch';
+import _ from 'lodash';
+import { v4 as uuid } from 'uuid';
+import { basename } from 'node:path';
+import { program } from 'commander';
+
+const _log = (...args) => console.log(`[${new Date().toISOString()}]`, '[benchmarker]', ...args);
+const log = (...args) => true && _log('INFO', ...args);
+log.debug = (...args) => false && _log('DEBUG', ...args);
+log.info = log;
+log.error = (...args) => true && _log('ERROR', ...args);
+log.report = (...args) => true && _log('REPORT', ...args);
+
+program
+ .option('-s, --server-url ', 'URL of ODK Central server', 'http://localhost:8989')
+ .option('-u, --user-email ', 'Email of central user', 'x@example.com')
+ .option('-P, --user-password ', 'Password of central user', 'secret')
+ .option('-f, --form-path ', 'Path to form file (XML, XLS, XLSX etc.)', './250q-form.xml')
+ .option('-L, --log-directory ', 'Log output directory (this should be an empty or non-existent directory)')
+ ;
+program.parse();
+const { serverUrl, userEmail, userPassword, formPath, logDirectory } = program.opts();
+
+const submissionTemplate = fs.readFileSync('./250q-submission.template.xml', { encoding:'utf8' }).trim();
+
+log(`Using form: ${formPath}`);
+log(`Connecting to ${serverUrl} with user ${userEmail}...`);
+
+const logPath = logDirectory || `./logs/${new Date().toISOString()}`;
+
+let bearerToken;
+
+benchmark();
+
+async function benchmark() {
+ log.info('Setting up...');
+
+ log.info('Creating log directory:', logPath, '...');
+ fs.mkdirSync(logPath, { recursive:true });
+
+ log.info('Creating session...');
+ const { token } = await apiPostJson('sessions', { email:userEmail, password:userPassword }, { Authorization:null });
+ bearerToken = token;
+
+ log.info('Creating project...');
+ const { id:projectId } = await apiPostJson('projects', { name:`benchmark-${new Date().toISOString().replace(/\..*/, '')}` });
+
+ log.info('Uploading form...');
+ const { xmlFormId:formId } = await apiPostFile(`projects/${projectId}/forms`, formPath);
+
+ log.info('Publishing form...');
+ await apiPost(`projects/${projectId}/forms/${formId}/draft/publish`);
+
+ log.info('Setup complete. Starting benchmarks...');
+
+ await doBenchmark('randomSubmission', 50, 1_000, 30_000, 100, n => randomSubmission(n, projectId, formId));
+
+ // TODO work out a more scientific sleep duration
+ const backgroundJobPause = 20_000;
+ log.info(`Sleeping ${durationForHumans(backgroundJobPause)} to allow central-backend to complete background jobs...`);
+ await new Promise(resolve => setTimeout(resolve, backgroundJobPause));
+ log.info('Woke up.');
+
+ await doBenchmark('exportZipWithDataAndMedia', 10, 3_000, 300_000, 0, n => exportZipWithDataAndMedia(n, projectId, formId));
+
+ log.info(`Check for extra logs at ${logPath}`);
+
+ log.info('Complete.');
+
+ // force exit in case some promise somewhere has failed to resolved (somehow required in Github Actions)
+ process.exit(0);
+}
+
+function doBenchmark(name, throughput, throughputPeriod, testDuration, minimumSuccessThreshold, fn) {
+ log.info('Starting benchmark:', name);
+ log.info(' throughput:', throughput, 'per period');
+ log.info(' throughputPeriod:', throughputPeriod, 'ms');
+ log.info(' testDuration:', durationForHumans(testDuration));
+ log.info('-------------------------------');
+ return new Promise((resolve, reject) => {
+ try {
+ const successes = [];
+ const sizes = [];
+ const fails = [];
+ const results = [];
+ const sleepyTime = +throughputPeriod / +throughput;
+
+ let iterationCount = 0;
+ let completedIterations = 0;
+ const iterate = async () => {
+ const n = iterationCount++;
+ const started = Date.now();
+ try {
+ const size = await fn(n);
+ const finished = Date.now();
+ const time = finished - started;
+ successes.push(time);
+ sizes.push(size);
+ results[n] = { success:true, started, finished, time, size };
+ } catch(err) {
+ fails.push(err.message);
+ results[n] = { success:false, started, finished:Date.now(), err:{ message:err.message, stack:err.stack } };
+ } finally {
+ ++completedIterations;
+ }
+ };
+
+ iterate();
+ const timerId = setInterval(iterate, sleepyTime);
+
+ setTimeout(async () => {
+ clearTimeout(timerId);
+
+ const maxDrainDuration = 120_000;
+ await new Promise(resolve => {
+ log.info(`Waiting up to ${durationForHumans(maxDrainDuration)} for test drainage...`);
+ const maxDrainTimeout = Date.now() + maxDrainDuration;
+ const drainPulse = 500;
+
+ checkDrain();
+
+ function checkDrain() {
+ log.debug('Checking drain status...');
+ if(Date.now() > maxDrainTimeout) {
+ log.info('Drain timeout exceeded.');
+ return resolve();
+ } else if(completedIterations >= iterationCount) {
+ log.info('All connections have completed.');
+ return resolve();
+ }
+ log.debug(`Drainage not complete. Still Waiting for ${iterationCount - results.length} connections. Sleeping for ${durationForHumans(drainPulse)}...`);
+ setTimeout(checkDrain, drainPulse);
+ }
+ });
+
+ fs.writeFileSync(`${logPath}/${name}.extras.log.json`, JSON.stringify(results, null, 2));
+
+ const successPercent = 100 * successes.length / iterationCount;
+
+ log.report('--------------------------');
+ log.report(' Test:', name);
+ log.report(' Test duration:', testDuration);
+ log.report(' Total requests:', iterationCount);
+ log.report('Success % required:', `${minimumSuccessThreshold}%`);
+ log.report(' Successes:', successes.length, `(${Math.floor(successPercent)}%)`);
+ log.report(' Throughput:', oneDp((1000 * successes.length / testDuration)), 'reqs/s');
+ log.report(' Failures:', fails.length);
+ log.report(' Response times:');
+ log.report(' mean:', durationForHumans(_.mean(successes)));
+ log.report(' min:', _.min(successes), 'ms');
+ log.report(' max:', _.max(successes), 'ms');
+ log.report(' Response sizes:');
+ log.report(' min:', _.min(sizes), 'b');
+ log.report(' max:', _.max(sizes), 'b');
+ if(fails.length) log.report(' Errors:');
+ [ ...new Set(fails) ].map(m => log.report(` * ${m.replace(/\n/g, '\\n')}`));
+ log.report('--------------------------');
+
+ if(_.min(sizes) !== _.max(sizes)) reportFatalError('VARIATION IN RESPONSE SIZES MAY INDICATE SERIOUS ERRORS SERVER-SIDE');
+
+ if(successPercent < minimumSuccessThreshold) reportFatalError('MINIMUM SUCCESS THRESHOLD WAS NOT MET');
+
+ if(fails.length) reportWarning('REQUEST FAILURES MAY AFFECT SUBSEQUENT BENCHMARKS');
+
+ resolve();
+ }, +testDuration);
+ } catch(err) {
+ reject(err);
+ }
+ });
+}
+
+function reportFatalError(message) {
+ reportWarning(message);
+ process.exit(1);
+}
+
+function reportWarning(message) {
+ log.report('!!!');
+ log.report('!!!');
+ log.report(`!!! ${message}!`);
+ log.report('!!!');
+ log.report('!!!');
+ log.report('--------------------------');
+}
+
+function apiPostFile(path, filePath) {
+ const mimeType = mimetypeFor(filePath);
+ const blob = fileFromSync(filePath, mimeType);
+ return apiPost(path, blob, { 'Content-Type':mimeType });
+}
+
+function apiPostJson(path, body, headers) {
+ return apiPost(path, JSON.stringify(body), { 'Content-Type':'application/json', ...headers });
+}
+
+function apiGetAndDump(prefix, n, path, headers) {
+ return fetchToFile(prefix, n, 'GET', path, undefined, headers);
+}
+
+function apiPostAndDump(prefix, n, path, body, headers) {
+ return fetchToFile(prefix, n, 'POST', path, body, headers);
+}
+
+async function fetchToFile(filenamePrefix, n, method, path, body, headers) {
+ const res = await apiFetch(method, path, body, headers);
+
+ return new Promise((resolve, reject) => {
+ try {
+ let bytes = 0;
+ res.body.on('data', data => bytes += data.length);
+ res.body.on('error', reject);
+
+ const file = fs.createWriteStream(`${logPath}/${filenamePrefix}.${n.toString().padStart(9, '0')}.dump`);
+ res.body.on('end', () => file.close(() => resolve(bytes)));
+
+ file.on('error', reject);
+
+ res.body.pipe(file);
+ } catch(err) {
+ console.log(err);
+ process.exit(99);
+ }
+ });
+}
+
+async function apiPost(path, body, headers) {
+ const res = await apiFetch('POST', path, body, headers);
+ return res.json();
+}
+
+async function apiFetch(method, path, body, headers) {
+ const url = `${serverUrl}/v1/${path}`;
+
+ const Authorization = bearerToken ? `Bearer ${bearerToken}` : `Basic ${base64(`${userEmail}:${userPassword}`)}`;
+
+ const res = await fetch(url, {
+ method,
+ body,
+ headers: { Authorization, ...headers },
+ });
+ log.debug(method, res.url, '->', res.status);
+ if(!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
+ return res;
+}
+
+function base64(s) {
+ return Buffer.from(s).toString('base64');
+}
+
+function mimetypeFor(f) {
+ const extension = fileExtensionFrom(f);
+ log.debug('fileExtensionFrom()', f, '->', extension);
+ switch(extension) {
+ case 'xls' : return 'application/vnd.ms-excel';
+ case 'xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+ case 'xml' : return 'application/xml';
+ default: throw new Error(`Unsure what mime type to use for: ${f}`);
+ }
+}
+
+function fileExtensionFrom(f) {
+ try {
+ return basename(f).match(/\.([^.]*)$/)[1];
+ } catch(err) {
+ throw new Error(`Could not get file extension from filename '${f}'!`);
+ }
+}
+
+function randomSubmission(n, projectId, formId) {
+ const headers = {
+ 'Content-Type': 'multipart/form-data; boundary=foo',
+ 'X-OpenRosa-Version': '1.0',
+ };
+
+ const body = `--foo\r
+Content-Disposition: form-data; name="xml_submission_file"; filename="submission.xml"\r
+Content-Type: application/xml\r
+\r
+${submissionTemplate
+ .replace(/{{uuid}}/g, () => uuid())
+ .replace(/{{randInt}}/g, randInt)
+}
+\r
+--foo--`;
+
+ return apiPostAndDump('randomSubmission', n, `projects/${projectId}/forms/${formId}/submissions`, body, headers);
+}
+
+function randInt() {
+ return Math.floor(Math.random() * 9999);
+}
+
+function exportZipWithDataAndMedia(n, projectId, formId) {
+ return apiGetAndDump('exportZipWithDataAndMedia', n, `projects/${projectId}/forms/${formId}/submissions.csv.zip?splitSelectMultiples=true&groupPaths=true&deletedFields=true`);
+}
+
+function durationForHumans(ms) {
+ if(ms > 1000) return oneDp((ms / 1000)) + 's';
+ else return oneDp( ms) + 'ms';
+}
+
+function oneDp(n) {
+ return Number(n.toFixed(1));
+}
diff --git a/benchmarker/package-lock.json b/benchmarker/package-lock.json
new file mode 100644
index 000000000..5ffe63398
--- /dev/null
+++ b/benchmarker/package-lock.json
@@ -0,0 +1,1480 @@
+{
+ "name": "benchmarker",
+ "version": "1.0.0",
+ "lockfileVersion": 2,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "benchmarker",
+ "version": "1.0.0",
+ "license": "SEE LICENSE IN ../LICENSE",
+ "dependencies": {
+ "commander": "^9.4.0",
+ "lodash": "^4.17.21",
+ "node-fetch": "^3.2.9",
+ "uuid": "^8.3.2"
+ },
+ "devDependencies": {
+ "eslint": "^8.20.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.3.2",
+ "globals": "^13.15.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array": {
+ "version": "0.9.5",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanwhocodes/object-schema": "^1.2.1",
+ "debug": "^4.1.1",
+ "minimatch": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=10.10.0"
+ }
+ },
+ "node_modules/@humanwhocodes/object-schema": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/acorn": {
+ "version": "8.8.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "9.4.0",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || >=14"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/data-uri-to-buffer": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.3.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.1.2"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "8.20.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint/eslintrc": "^1.3.0",
+ "@humanwhocodes/config-array": "^0.9.2",
+ "ajv": "^6.10.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "doctrine": "^3.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^7.1.1",
+ "eslint-utils": "^3.0.0",
+ "eslint-visitor-keys": "^3.3.0",
+ "espree": "^9.3.2",
+ "esquery": "^1.4.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "functional-red-black-tree": "^1.0.1",
+ "glob-parent": "^6.0.1",
+ "globals": "^13.15.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.0.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "js-yaml": "^4.1.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.1",
+ "regexpp": "^3.2.0",
+ "strip-ansi": "^6.0.1",
+ "strip-json-comments": "^3.1.0",
+ "text-table": "^0.2.0",
+ "v8-compile-cache": "^2.0.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "7.1.1",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/eslint-utils": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^2.0.0"
+ },
+ "engines": {
+ "node": "^10.0.0 || ^12.0.0 || >= 14.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mysticatea"
+ },
+ "peerDependencies": {
+ "eslint": ">=5"
+ }
+ },
+ "node_modules/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "3.3.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/espree": {
+ "version": "9.3.2",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.7.1",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fetch-blob": {
+ "version": "3.2.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "node-domexception": "^1.0.0",
+ "web-streams-polyfill": "^3.0.3"
+ },
+ "engines": {
+ "node": "^12.20 || >= 14.13"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "6.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^3.0.4"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "3.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.1.0",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.2.6",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/formdata-polyfill": {
+ "version": "4.0.10",
+ "license": "MIT",
+ "dependencies": {
+ "fetch-blob": "^3.1.2"
+ },
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/functional-red-black-tree": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "13.17.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.20.2"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "license": "MIT"
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "3.2.9",
+ "license": "MIT",
+ "dependencies": {
+ "data-uri-to-buffer": "^4.0.0",
+ "fetch-blob": "^3.1.4",
+ "formdata-polyfill": "^4.0.10"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/node-fetch"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.3"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/regexpp": {
+ "version": "3.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mysticatea"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/text-table": {
+ "version": "0.2.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.20.2",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "8.3.2",
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/v8-compile-cache": {
+ "version": "2.3.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/web-streams-polyfill": {
+ "version": "3.2.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "ISC"
+ }
+ },
+ "dependencies": {
+ "@eslint/eslintrc": {
+ "version": "1.3.0",
+ "dev": true,
+ "requires": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.3.2",
+ "globals": "^13.15.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ }
+ },
+ "@humanwhocodes/config-array": {
+ "version": "0.9.5",
+ "dev": true,
+ "requires": {
+ "@humanwhocodes/object-schema": "^1.2.1",
+ "debug": "^4.1.1",
+ "minimatch": "^3.0.4"
+ }
+ },
+ "@humanwhocodes/object-schema": {
+ "version": "1.2.1",
+ "dev": true
+ },
+ "acorn": {
+ "version": "8.8.0",
+ "dev": true
+ },
+ "acorn-jsx": {
+ "version": "5.3.2",
+ "dev": true,
+ "requires": {}
+ },
+ "ajv": {
+ "version": "6.12.6",
+ "dev": true,
+ "requires": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ }
+ },
+ "ansi-regex": {
+ "version": "5.0.1",
+ "dev": true
+ },
+ "ansi-styles": {
+ "version": "4.3.0",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "argparse": {
+ "version": "2.0.1",
+ "dev": true
+ },
+ "balanced-match": {
+ "version": "1.0.2",
+ "dev": true
+ },
+ "brace-expansion": {
+ "version": "1.1.11",
+ "dev": true,
+ "requires": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "callsites": {
+ "version": "3.1.0",
+ "dev": true
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "dev": true
+ },
+ "commander": {
+ "version": "9.4.0"
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "dev": true
+ },
+ "cross-spawn": {
+ "version": "7.0.3",
+ "dev": true,
+ "requires": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ }
+ },
+ "data-uri-to-buffer": {
+ "version": "4.0.0"
+ },
+ "debug": {
+ "version": "4.3.4",
+ "dev": true,
+ "requires": {
+ "ms": "2.1.2"
+ }
+ },
+ "deep-is": {
+ "version": "0.1.4",
+ "dev": true
+ },
+ "doctrine": {
+ "version": "3.0.0",
+ "dev": true,
+ "requires": {
+ "esutils": "^2.0.2"
+ }
+ },
+ "escape-string-regexp": {
+ "version": "4.0.0",
+ "dev": true
+ },
+ "eslint": {
+ "version": "8.20.0",
+ "dev": true,
+ "requires": {
+ "@eslint/eslintrc": "^1.3.0",
+ "@humanwhocodes/config-array": "^0.9.2",
+ "ajv": "^6.10.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "doctrine": "^3.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^7.1.1",
+ "eslint-utils": "^3.0.0",
+ "eslint-visitor-keys": "^3.3.0",
+ "espree": "^9.3.2",
+ "esquery": "^1.4.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "functional-red-black-tree": "^1.0.1",
+ "glob-parent": "^6.0.1",
+ "globals": "^13.15.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.0.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "js-yaml": "^4.1.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.1",
+ "regexpp": "^3.2.0",
+ "strip-ansi": "^6.0.1",
+ "strip-json-comments": "^3.1.0",
+ "text-table": "^0.2.0",
+ "v8-compile-cache": "^2.0.3"
+ }
+ },
+ "eslint-scope": {
+ "version": "7.1.1",
+ "dev": true,
+ "requires": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ }
+ },
+ "eslint-utils": {
+ "version": "3.0.0",
+ "dev": true,
+ "requires": {
+ "eslint-visitor-keys": "^2.0.0"
+ },
+ "dependencies": {
+ "eslint-visitor-keys": {
+ "version": "2.1.0",
+ "dev": true
+ }
+ }
+ },
+ "eslint-visitor-keys": {
+ "version": "3.3.0",
+ "dev": true
+ },
+ "espree": {
+ "version": "9.3.2",
+ "dev": true,
+ "requires": {
+ "acorn": "^8.7.1",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.3.0"
+ }
+ },
+ "esquery": {
+ "version": "1.4.0",
+ "dev": true,
+ "requires": {
+ "estraverse": "^5.1.0"
+ }
+ },
+ "esrecurse": {
+ "version": "4.3.0",
+ "dev": true,
+ "requires": {
+ "estraverse": "^5.2.0"
+ }
+ },
+ "estraverse": {
+ "version": "5.3.0",
+ "dev": true
+ },
+ "esutils": {
+ "version": "2.0.3",
+ "dev": true
+ },
+ "fast-deep-equal": {
+ "version": "3.1.3",
+ "dev": true
+ },
+ "fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "dev": true
+ },
+ "fast-levenshtein": {
+ "version": "2.0.6",
+ "dev": true
+ },
+ "fetch-blob": {
+ "version": "3.2.0",
+ "requires": {
+ "node-domexception": "^1.0.0",
+ "web-streams-polyfill": "^3.0.3"
+ }
+ },
+ "file-entry-cache": {
+ "version": "6.0.1",
+ "dev": true,
+ "requires": {
+ "flat-cache": "^3.0.4"
+ }
+ },
+ "flat-cache": {
+ "version": "3.0.4",
+ "dev": true,
+ "requires": {
+ "flatted": "^3.1.0",
+ "rimraf": "^3.0.2"
+ }
+ },
+ "flatted": {
+ "version": "3.2.6",
+ "dev": true
+ },
+ "formdata-polyfill": {
+ "version": "4.0.10",
+ "requires": {
+ "fetch-blob": "^3.1.2"
+ }
+ },
+ "fs.realpath": {
+ "version": "1.0.0",
+ "dev": true
+ },
+ "functional-red-black-tree": {
+ "version": "1.0.1",
+ "dev": true
+ },
+ "glob": {
+ "version": "7.2.3",
+ "dev": true,
+ "requires": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ }
+ },
+ "glob-parent": {
+ "version": "6.0.2",
+ "dev": true,
+ "requires": {
+ "is-glob": "^4.0.3"
+ }
+ },
+ "globals": {
+ "version": "13.17.0",
+ "dev": true,
+ "requires": {
+ "type-fest": "^0.20.2"
+ }
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "dev": true
+ },
+ "ignore": {
+ "version": "5.2.0",
+ "dev": true
+ },
+ "import-fresh": {
+ "version": "3.3.0",
+ "dev": true,
+ "requires": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ }
+ },
+ "imurmurhash": {
+ "version": "0.1.4",
+ "dev": true
+ },
+ "inflight": {
+ "version": "1.0.6",
+ "dev": true,
+ "requires": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "inherits": {
+ "version": "2.0.4",
+ "dev": true
+ },
+ "is-extglob": {
+ "version": "2.1.1",
+ "dev": true
+ },
+ "is-glob": {
+ "version": "4.0.3",
+ "dev": true,
+ "requires": {
+ "is-extglob": "^2.1.1"
+ }
+ },
+ "isexe": {
+ "version": "2.0.0",
+ "dev": true
+ },
+ "js-yaml": {
+ "version": "4.1.0",
+ "dev": true,
+ "requires": {
+ "argparse": "^2.0.1"
+ }
+ },
+ "json-schema-traverse": {
+ "version": "0.4.1",
+ "dev": true
+ },
+ "json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "dev": true
+ },
+ "levn": {
+ "version": "0.4.1",
+ "dev": true,
+ "requires": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ }
+ },
+ "lodash": {
+ "version": "4.17.21"
+ },
+ "lodash.merge": {
+ "version": "4.6.2",
+ "dev": true
+ },
+ "minimatch": {
+ "version": "3.1.2",
+ "dev": true,
+ "requires": {
+ "brace-expansion": "^1.1.7"
+ }
+ },
+ "ms": {
+ "version": "2.1.2",
+ "dev": true
+ },
+ "natural-compare": {
+ "version": "1.4.0",
+ "dev": true
+ },
+ "node-domexception": {
+ "version": "1.0.0"
+ },
+ "node-fetch": {
+ "version": "3.2.9",
+ "requires": {
+ "data-uri-to-buffer": "^4.0.0",
+ "fetch-blob": "^3.1.4",
+ "formdata-polyfill": "^4.0.10"
+ }
+ },
+ "once": {
+ "version": "1.4.0",
+ "dev": true,
+ "requires": {
+ "wrappy": "1"
+ }
+ },
+ "optionator": {
+ "version": "0.9.1",
+ "dev": true,
+ "requires": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.3"
+ }
+ },
+ "parent-module": {
+ "version": "1.0.1",
+ "dev": true,
+ "requires": {
+ "callsites": "^3.0.0"
+ }
+ },
+ "path-is-absolute": {
+ "version": "1.0.1",
+ "dev": true
+ },
+ "path-key": {
+ "version": "3.1.1",
+ "dev": true
+ },
+ "prelude-ls": {
+ "version": "1.2.1",
+ "dev": true
+ },
+ "punycode": {
+ "version": "2.1.1",
+ "dev": true
+ },
+ "regexpp": {
+ "version": "3.2.0",
+ "dev": true
+ },
+ "resolve-from": {
+ "version": "4.0.0",
+ "dev": true
+ },
+ "rimraf": {
+ "version": "3.0.2",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "shebang-command": {
+ "version": "2.0.0",
+ "dev": true,
+ "requires": {
+ "shebang-regex": "^3.0.0"
+ }
+ },
+ "shebang-regex": {
+ "version": "3.0.0",
+ "dev": true
+ },
+ "strip-ansi": {
+ "version": "6.0.1",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^5.0.1"
+ }
+ },
+ "strip-json-comments": {
+ "version": "3.1.1",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ },
+ "text-table": {
+ "version": "0.2.0",
+ "dev": true
+ },
+ "type-check": {
+ "version": "0.4.0",
+ "dev": true,
+ "requires": {
+ "prelude-ls": "^1.2.1"
+ }
+ },
+ "type-fest": {
+ "version": "0.20.2",
+ "dev": true
+ },
+ "uri-js": {
+ "version": "4.4.1",
+ "dev": true,
+ "requires": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "uuid": {
+ "version": "8.3.2"
+ },
+ "v8-compile-cache": {
+ "version": "2.3.0",
+ "dev": true
+ },
+ "web-streams-polyfill": {
+ "version": "3.2.1"
+ },
+ "which": {
+ "version": "2.0.2",
+ "dev": true,
+ "requires": {
+ "isexe": "^2.0.0"
+ }
+ },
+ "word-wrap": {
+ "version": "1.2.3",
+ "dev": true
+ },
+ "wrappy": {
+ "version": "1.0.2",
+ "dev": true
+ }
+ }
+}
diff --git a/benchmarker/package.json b/benchmarker/package.json
new file mode 100644
index 000000000..e8f7cf681
--- /dev/null
+++ b/benchmarker/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "benchmarker",
+ "version": "1.0.0",
+ "main": "index.js",
+ "type": "module",
+ "scripts": {
+ "start": "node index.js",
+ "test": "eslint ."
+ },
+ "dependencies": {
+ "commander": "^9.4.0",
+ "lodash": "^4.17.21",
+ "node-fetch": "^3.2.9",
+ "uuid": "^8.3.2"
+ },
+ "devDependencies": {
+ "eslint": "^8.20.0"
+ },
+ "license": "SEE LICENSE IN ../LICENSE"
+}
diff --git a/benchmarker/scripts/ci-benchmark b/benchmarker/scripts/ci-benchmark
new file mode 100755
index 000000000..0b2c9eb47
--- /dev/null
+++ b/benchmarker/scripts/ci-benchmark
@@ -0,0 +1,62 @@
+#!/bin/bash -eu
+set -o pipefail
+
+serverUrl="http://localhost:8383"
+userEmail="x@example.com"
+userPassword="secret1234"
+
+log() {
+ echo "[ci-benchmark] $*"
+}
+
+fail_job() {
+ log 'Job failed.'
+ exit 1
+}
+
+make base
+
+node ./lib/bin/cli.js user-create -u "$userEmail" -p "$userPassword"
+node ./lib/bin/cli.js user-promote -u "$userEmail"
+
+make run >backend.log 2>&1 &
+
+cd benchmarker
+npm ci
+
+log 'Waiting for backend to start...'
+timeout 30 bash -c "while ! curl -s -o /dev/null $serverUrl; do sleep 1; done"
+log 'Backend started!'
+
+node index.js -s "$serverUrl" -P "$userPassword" -L /tmp/benchmarker-logs
+
+if ! curl -s -o /dev/null "$serverUrl"; then
+ log 'Backend died.'
+ fail_job
+fi
+
+responseLog="$(mktemp)"
+requestBody='{"email":"'"$userEmail"'","password":"'"$userPassword"'"}'
+loginStatus="$(curl -s -o "$responseLog" -w '%{http_code}' \
+ --header 'Content-Type: application/json' --data "$requestBody" \
+ "$serverUrl/v1/sessions"
+)"
+if [[ "$loginStatus" = "200" ]]; then
+ log 'Backend survived; job should pass.'
+else
+ log 'Backend behaving badly:'
+ log "$(cat "$responseLog")"
+ fail_job
+fi
+
+log 'Checking open DB query count...'
+(cd .. && node lib/bin/check-open-db-queries.js)
+
+# TODO upload results to getodk.cloud for graphing. Include:
+#
+# * key metrics (for graphing against other branches)
+# * throughput
+# * average response time
+# * response time range (quickest, slowest)
+# * did export response sizes vary?
+# * individual response timings (to see if e.g. response times degrade over time)