The browser tests drive one person against a handful of feeds, one request at a time, and cannot
show what production's load does. node tests/load/run.js builds a release binary, serves 1,500
generated feeds from itself, starts a daemon of its own under /tmp/ipx-load, seeds a hundred
listeners with thirty feeds each and four downloaded files, and runs four k6 scripts, with
`ipx status` -- the healthcheck -- run every second and failing the run at its 5s timeout:
browse 25 people at once: feed list, All Subscriptions, a feed, a search, the Directory,
a feed's page in it
listening 100 players saving positions every second and marking items read while scans write;
each reads its own state back, which must be as it left it
media 50 listeners seeking: every range checked byte for byte against the served file
signin a flood of wrong passwords while others browse, and the gap between refusing a
known name and an unknown one
Each budget is about twice what it measures now. Getting here found #135 (SQLite waiting for the
disk after every write, a first scan of 1,500 feeds estimated at 50 minutes, now 27s), #136
(SQLite's single connection, left open), #137 and #138 (fixed in the commit before this). k6 is
installed from its own signed apt repository by /src/install.sh, outside this repository.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
48 lines
2.1 KiB
JavaScript
48 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: 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;
|
|
},
|
|
});
|
|
}
|