Run the load tests on Postgres too, as production runs (#139)
node tests/load/run.js --postgres puts the scratch daemon in IPX_TEST_DATABASE_URL, the database the Rust tests already use on Postgres, ipodderx_test on the databases project's server, in a schema of its own, ipx_load, dropped and made again each run as /tmp/ipx-load is wiped. The URL is in /src/.envrc, taken from ipx.env. It is production's server and role, so the harness refuses a database whose name does not end in _test, and hands psql the password in its environment, where no other process on Tower can read it. psql comes from /src/install.sh. After seeding it runs ANALYZE: a schema filled seconds before has no planner statistics until autovacuum gets there, which it did partway through a test, and the search's p95 swung between 40ms and 166ms from run to run. Analyzed first, three runs gave 37-38ms. Each budget now has a value for each database, about twice what it measures on Tower: Postgres answers the feed list at p95 54ms to SQLite's 242, having no one connection to queue for (#136). Running it found #140. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
// database and to the healthcheck. The browser suite drives one person against a handful of
|
||||
// feeds, one request at a time.
|
||||
//
|
||||
// node tests/load/run.js [browse|listening|media|signin ...] default: all of them
|
||||
// node tests/load/run.js [--postgres] [browse|listening|media|signin ...] default: all four
|
||||
//
|
||||
// It builds a release binary (debug Argon2 alone takes a second a sign-in, which would measure
|
||||
// the build, not ipx), serves generated feeds from this process, starts a daemon on its own
|
||||
@@ -11,10 +11,12 @@
|
||||
// While each runs, `ipx status` -- the Docker healthcheck -- is run every second, and the run
|
||||
// fails if any answer takes as long as the healthcheck's 5s timeout.
|
||||
//
|
||||
// The daemon is on SQLite unless --postgres, which puts it in IPX_TEST_DATABASE_URL, the
|
||||
// database the Rust tests use on Postgres too (ipodderx_test on the server production's is on, in
|
||||
// /src/.envrc), in a schema of its own, ipx_load, made afresh each run (#139). SQLite's numbers
|
||||
// carry its one connection (#136); Postgres's are what production would see.
|
||||
// IPX_LOAD_FEEDS sets the catalogue's size (1500, near production's), IPX_LOAD_USERS the
|
||||
// listeners (100), IPX_LOAD_LOG=1 shows the daemon's log. The daemon is on SQLite, not Postgres
|
||||
// as production is: ponytail: Postgres needs a database of its own emptied before each run,
|
||||
// which this cannot do yet (#139).
|
||||
// listeners (100), IPX_LOAD_LOG=1 shows the daemon's log.
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -31,6 +33,9 @@ const ITEMS = 20;
|
||||
const BASE = `http://127.0.0.1:${WEB}`;
|
||||
const bin = path.join(repo, 'target/release/ipx');
|
||||
const SCRIPTS = ['browse', 'listening', 'media', 'signin'];
|
||||
const POSTGRES = process.argv.includes('--postgres');
|
||||
const PG_URL = process.env.IPX_TEST_DATABASE_URL || '';
|
||||
const PG_SCHEMA = 'ipx_load';
|
||||
const env = {
|
||||
...process.env,
|
||||
IPX_CONFIG: `${root}/config/config.toml`,
|
||||
@@ -38,7 +43,10 @@ const env = {
|
||||
IPX_LOG: 'ipx=info',
|
||||
IPX_LOG_FORMAT: 'json',
|
||||
// Nothing of the test reaches production's database or traces, or a paid API.
|
||||
IPX_DATABASE_URL: '',
|
||||
// In its own schema, as Db::memory puts each Rust test, and with notices off, as url_for does.
|
||||
IPX_DATABASE_URL: POSTGRES
|
||||
? `${PG_URL}${PG_URL.includes('?') ? '&' : '?'}options=-c%20search_path%3D${PG_SCHEMA}%20-c%20client_min_messages%3Dwarning`
|
||||
: '',
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
||||
TYPESAFE_KEY: '',
|
||||
};
|
||||
@@ -93,6 +101,26 @@ const feedUrl = n => `http://127.0.0.1:${GEN}/feed/${n}.xml`;
|
||||
|
||||
// ---- the daemon -----------------------------------------------------------------------------
|
||||
|
||||
/// The load tests' schema dropped and made again, as /tmp/ipx-load is wiped. Only in a database
|
||||
/// made for tests: the URL is production's server and role, and one slip of a name would
|
||||
/// otherwise point this at production's database.
|
||||
function freshPostgres() {
|
||||
const u = PG_URL && new URL(PG_URL);
|
||||
if (!u || !/_test$/.test(u.pathname)) {
|
||||
throw new Error('--postgres needs IPX_TEST_DATABASE_URL, a database whose name ends in _test (. /src/.envrc)');
|
||||
}
|
||||
psql(`DROP SCHEMA IF EXISTS ${PG_SCHEMA} CASCADE`, `CREATE SCHEMA ${PG_SCHEMA}`);
|
||||
}
|
||||
|
||||
/// Statements run in the test database, given by its URL in psql's environment, not its
|
||||
/// arguments, where every process on Tower could read the password.
|
||||
function psql(...sql) {
|
||||
const u = new URL(PG_URL);
|
||||
const env = { ...process.env, PGHOST: u.hostname, PGPORT: u.port || '5432', PGUSER: decodeURIComponent(u.username),
|
||||
PGPASSWORD: decodeURIComponent(u.password), PGDATABASE: u.pathname.slice(1), PGOPTIONS: `-c search_path=${PG_SCHEMA}` };
|
||||
execFileSync('psql', ['-q', '-v', 'ON_ERROR_STOP=1', ...sql.flatMap(s => ['-c', s])], { env, stdio: ['ignore', 'ignore', 'inherit'] });
|
||||
}
|
||||
|
||||
function writeConfig() {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
for (const d of ['config', 'data', 'downloads']) fs.mkdirSync(path.join(root, d), { recursive: true });
|
||||
@@ -195,13 +223,14 @@ function watchHealth() {
|
||||
function k6(script) {
|
||||
return new Promise(resolve => {
|
||||
const run = spawn('k6', ['run', '--quiet', '-e', `BASE=${BASE}`, '-e', `USERS=${USERS}`, '-e', `FEEDS=${FEEDS}`,
|
||||
'-e', `DB=${POSTGRES ? 'postgres' : 'sqlite'}`,
|
||||
path.join(__dirname, `${script}.js`)], { stdio: 'inherit' });
|
||||
run.on('exit', code => resolve(code));
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const only = process.argv.slice(2);
|
||||
const only = process.argv.slice(2).filter(a => a !== '--postgres');
|
||||
for (const s of only) if (!SCRIPTS.includes(s)) { console.error(`no load test called ${s}: ${SCRIPTS.join(', ')}`); process.exit(2); }
|
||||
if (spawnSync('k6', ['version']).error) { console.error('k6 is not installed: see install.sh'); process.exit(2); }
|
||||
console.log('building the release binary...');
|
||||
@@ -209,6 +238,8 @@ function k6(script) {
|
||||
|
||||
const feeds = serveFeeds();
|
||||
writeConfig();
|
||||
if (POSTGRES) freshPostgres();
|
||||
console.log(`on ${POSTGRES ? 'Postgres' : 'SQLite'}`);
|
||||
// Its log kept out of this run's output, which is k6's; IPX_LOAD_LOG=1 shows it.
|
||||
const daemon = spawn(bin, ['daemon'], { env, stdio: ['ignore', process.env.IPX_LOAD_LOG ? 'inherit' : 'ignore', 'pipe'] });
|
||||
daemon.stderr.setEncoding('utf8');
|
||||
@@ -221,6 +252,10 @@ function k6(script) {
|
||||
const first = await waitForLog(daemon, ev => ev.ev === 'scan_done' && ev.feeds > 0, 600_000);
|
||||
console.log(`first scan: ${first.feeds} feeds in ${((Date.now() - t) / 1000).toFixed(1)}s`);
|
||||
await seed();
|
||||
// The planner's statistics, as production's long-standing tables have them. A schema filled
|
||||
// seconds ago has none until autovacuum gets to it, which it did partway through a test, and
|
||||
// the search's p95 swung from 40ms to 166ms between runs with the plan it changed.
|
||||
if (POSTGRES) psql('ANALYZE');
|
||||
// The log is drained from here on, or the pipe fills and the daemon blocks writing to it.
|
||||
daemon.stderr.resume();
|
||||
for (const script of only.length ? only : SCRIPTS) {
|
||||
|
||||
Reference in New Issue
Block a user