Files
ipx/tests/load/media.js
rays bbdcbe213f 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>
2026-10-05 18:31:18 +00:00

49 lines
2.1 KiB
JavaScript

import http from 'k6/http';
import { check } from 'k6';
import { BASE, as, pick } from './lib.js';
// Listeners seeking: fifty at once asking for ranges of the same few episodes, as a player does
// on every seek and every few seconds of playback. Every range is checked against what the feed
// served, byte by byte at a sample of offsets, so a wrong offset fails, not only a wrong status.
export const options = {
scenarios: { seeking: { executor: 'constant-vus', vus: 50, duration: '45s' } },
thresholds: {
http_req_failed: ['rate==0'],
checks: ['rate==1'],
// About four times its p95 on 2026-10-05, 48ms on SQLite and 45 on Postgres: a range is a
// file read, little to vary.
'http_req_duration{name:range}': ['p(95)<200'],
},
};
// The generated file: ID3's ten bytes, then a byte its offset gives (run.js, mediaByte).
const SIZE = 2 * 1024 * 1024;
const byte = k => (k * 31 + 7) & 255;
export function setup() {
const r = http.get(`${BASE}/api/entries?filter=downloaded&limit=50`, as('listener', 'setup'));
const files = r.json().entries.flatMap(e => e.enclosures).filter(x => x.path).map(x => x.id);
if (!files.length) throw new Error('the listener has no downloaded files to seek through');
return { files };
}
export default function ({ files }) {
const start = 10 + Math.floor(Math.random() * (SIZE - 11));
const end = Math.min(SIZE - 1, start + Math.floor(Math.random() * 65536));
const r = http.get(`${BASE}/media/${pick(files)}`, {
headers: { 'X-Load-User': 'listener', Range: `bytes=${start}-${end}` },
responseType: 'binary',
tags: { name: 'range' },
});
const body = r.status === 206 ? new Uint8Array(r.body) : new Uint8Array(0);
check(r, {
'part of the file': r => r.status === 206,
'the range asked for': r => r.headers['Content-Range'] === `bytes ${start}-${end}/${SIZE}`,
'its bytes': () => {
if (body.length !== end - start + 1) return false;
for (const at of [0, body.length - 1, ...Array.from({ length: 30 }, () => Math.floor(Math.random() * body.length))])
if (body[at] !== byte(start + at)) return false;
return true;
},
});
}