test(live): F9's load test — two classes at once, every project in the action mode

60 people: 30 alone, 30 in 15 group projects of two, 45 projects of the
largest example. Each has the document and the room open, moves a pointer
every 2 s, pings every 6 s; in a group both move a device every 10 s. A
line a minute: the rooms' lag (M3's probe), a change's way to the
partner, what got lost, and /healthz.

Against an F a dashboard reconciles, M9_DASHBOARD/M9_SESSION/M9_USER make
the projects through the dashboard and sign everybody in as that person —
otherwise the next reconciliation ends every session (measured: all 120
sockets in the second minute).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 16:45:13 +00:00
co-authored by Claude Opus 5.5
parent f351caa824
commit a6e0da926a
+219
View File
@@ -0,0 +1,219 @@
// F9's load test on a live server F: two classes of 30 at once, every
// project in the action mode (plan F9: M3 under load). 30 people work
// alone, 30 in 15 group projects of two — 45 projects, each the largest
// example. Everybody has the document (/sync) and the room (/sim) open, as
// an editor has, moves a pointer (presence) every 2 s and pings across the
// network every 6 s; in a group both move a device every 10 s.
//
// Measured, a line a minute:
// - the room's lag: once a second per person, an action for a device that
// does not exist — answered at once between two ticks of the room's
// thread (M3's probe), so the time to the answer is how far behind it is;
// - the way of a change: a device one member moves, until the other's
// document has it (through F, both in this process: one clock);
// - what got lost: probes without an answer, sockets that closed;
// - what F says of itself (/healthz): documents, connections, rooms.
// Every project made is deleted at the end.
//
// Against an F a dashboard reconciles (test-filius with dev), sessions of
// people and projects the dashboard does not know are ended at its next run
// — measured on 11.10.2026: all 120 sockets at once in the second minute.
// With M9_DASHBOARD (its address), M9_SESSION (a session cookie
// "name=value") and M9_USER (that person's uuid) the projects are made
// through the dashboard as hers, and everybody signs in as her: the
// reconciliation keeps them, and F carries the same load.
//
// npx vite-node tools/live/m9.ts [minutes] [alone] [groups]
import { randomUUID } from 'node:crypto';
import * as Y from 'yjs';
import { moveDevice, ROOTS } from '@mdg-filius/doc';
import { BASE, cleanUp, joinDoc, openRoom, seededProject, signIn, type Room, type Shared } from './lib.js';
const EXAMPLE = 'java-examples/Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls';
const minutes = Number(process.argv[2] ?? 10);
const ALONE = Number(process.argv[3] ?? 30);
const GROUPS = Number(process.argv[4] ?? 15);
interface Person {
readonly name: string;
readonly shared: Shared;
readonly room: Room;
readonly timers: NodeJS.Timeout[];
}
const DASHBOARD = process.env.M9_DASHBOARD;
const SESSION = process.env.M9_SESSION;
const USER = process.env.M9_USER;
const EXAMPLE_NAME = 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE';
const dashboardProjects: string[] = [];
/** A project made through the dashboard, as M9_USER's, from the example. */
async function dashboardProject(index: number): Promise<string> {
const r = await fetch(`${DASHBOARD}/api/filius/projects`, {
method: 'POST',
headers: { cookie: SESSION!, 'content-type': 'application/json' },
body: JSON.stringify({ name: `Lastlauf ${index + 1}`, example: EXAMPLE_NAME }),
});
if (r.status !== 201) throw new Error(`dashboard made no project: ${r.status} ${await r.text()}`);
const project = ((await r.json()) as { uuid: string }).uuid;
dashboardProjects.push(project);
return project;
}
async function dashboardCleanUp(): Promise<void> {
for (const project of dashboardProjects.splice(0)) {
for (const [method, path] of [
['DELETE', ''],
['DELETE', '/purge'],
] as const)
await fetch(`${DASHBOARD}/api/filius/projects/${project}${path}`, { method, headers: { cookie: SESSION! } });
}
}
/** A device's id in the shared document, by its name. */
function deviceNamed(doc: Y.Doc, name: string): string {
const found = [...doc.getMap<Y.Map<unknown>>(ROOTS.nodes).entries()].find(([, node]) => node.get('name') === name);
if (found === undefined) throw new Error(`no device ${name}`);
return found[0];
}
const people: Person[] = [];
let probes: number[] = [];
let ways: number[] = [];
let lostProbes = 0;
let roomErrors = 0;
let closes: string[] = [];
const pending = new Map<string, number>();
let counter = 0;
/** The closings, counted by way and reason: a reconnecting provider closes again and again. */
const closedByReason = () =>
Object.entries(
closes.reduce<Record<string, number>>((counts, line) => {
const key = line.replace(/ [^:]+:/, ':');
counts[key] = (counts[key] ?? 0) + 1;
return counts;
}, {}),
)
.map(([reason, count]) => `${count}× ${reason}`)
.join('; ');
const quantile = (sorted: number[], q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? NaN;
const ms = (value: number) => (Number.isNaN(value) ? '–' : `${Math.round(value)} ms`);
const pause = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
/** One person in a project: document and room, pointer, pings and probes. */
async function join(project: string, name: string, starts: boolean): Promise<Person> {
const cookie = await signIn(project, { user: USER ?? randomUUID(), name });
const shared = await joinDoc(project, cookie);
const pingingDevice = deviceNamed(shared.doc, 'iPad 1');
const room = await openRoom(project, cookie);
room.onMessage = (message) => {
if (message.kind !== 'error' || typeof message.reqId !== 'string') return;
if (pending.has(message.reqId)) {
probes.push(performance.now() - pending.get(message.reqId)!);
pending.delete(message.reqId);
} else if (message.code !== 'unknownNode') roomErrors += 1;
};
void room.closed.then((reason) => closes.push(`/sim ${name}: ${reason}`));
shared.provider.on('close', ({ event }: { event: { code: number; reason: string } }) => closes.push(`/sync ${name}: ${event.code} ${event.reason}`));
if (starts) {
room.send({ kind: 'speed', value: 100 });
room.send({ kind: 'start' });
}
await room.waitFor((message) => message.kind === 'state' && message.mode === 'simulation', 30_000);
room.send({ kind: 'sub', topics: ['activity', `app:${pingingDevice}/terminal`] });
const awareness = shared.provider.awareness;
awareness.setLocalStateField('user', { name, color: '#3366cc' });
const timers: NodeJS.Timeout[] = [];
const point = () => awareness.setLocalStateField('pointer', { x: Math.random() * 1000, y: Math.random() * 700 });
const ping = () => room.send({ kind: 'input', node: pingingDevice, app: 'terminal', action: 'enter', args: { line: 'ping 120.130.140.80' }, reqId: `ping${counter++}` });
const probe = () => {
const reqId = `p${counter++}`;
pending.set(reqId, performance.now());
room.send({ kind: 'input', node: 'probe', app: 'terminal', action: 'enter', args: { line: '' }, reqId });
};
// Spread over the period, as people are: nobody clicks in step.
timers.push(setTimeout(() => timers.push(setInterval(point, 2000)), Math.random() * 2000));
timers.push(setTimeout(() => timers.push(setInterval(ping, 6000)), Math.random() * 6000));
timers.push(setTimeout(() => timers.push(setInterval(probe, 1000)), Math.random() * 1000));
const person = { name, shared, room, timers };
people.push(person);
return person;
}
/** In a group, each moves a device every 10 s; the partner's document is watched for it. */
function moveTogether(mover: Person, watcher: Person, device: string): void {
let sent = 0;
let x = 100;
watcher.shared.doc.on('update', () => {
if (sent === 0) return;
const node = watcher.shared.doc.getMap<Y.Map<unknown>>(ROOTS.nodes).get(device);
if (node?.get('x') === x) {
ways.push(performance.now() - sent);
sent = 0;
}
});
const move = () => {
x = 100 + Math.round(Math.random() * 600);
sent = performance.now();
moveDevice(mover.shared.doc, device, x, 200 + Math.round(Math.random() * 300), 'm9');
};
mover.timers.push(setTimeout(() => mover.timers.push(setInterval(move, 10_000)), Math.random() * 10_000));
}
try {
const started = performance.now();
console.log(`setting up: ${ALONE} alone, ${GROUPS} groups of two — ${ALONE + GROUPS} projects, ${ALONE + 2 * GROUPS} people`);
for (let index = 0; index < ALONE + GROUPS; index++) {
const project = DASHBOARD === undefined ? (await seededProject(EXAMPLE)).project : await dashboardProject(index);
if (index < ALONE) {
await join(project, `Allein ${index + 1}`, true);
} else {
const group = index - ALONE + 1;
const first = await join(project, `Gruppe ${group} A`, true);
const second = await join(project, `Gruppe ${group} B`, false);
moveTogether(first, second, deviceNamed(first.shared.doc, 'Heimrouter'));
moveTogether(second, first, deviceNamed(first.shared.doc, 'webserver.de (120.130.140.80)'));
}
if ((index + 1) % 5 === 0) console.log(` ${index + 1} projects, ${people.length} people (${((performance.now() - started) / 1000).toFixed(0)} s)`);
}
console.log(`all in, ${people.length} people, holding ${minutes} min`);
probes = [];
ways = [];
const all = { probes: [] as number[], ways: [] as number[] };
for (let minute = 1; minute <= minutes; minute++) {
await pause(60_000);
const sortedProbes = [...probes].sort((a, b) => a - b);
const sortedWays = [...ways].sort((a, b) => a - b);
const now = performance.now();
const overdue = [...pending.values()].filter((at) => now - at > 10_000).length;
lostProbes += overdue;
for (const [reqId, at] of pending) if (now - at > 10_000) pending.delete(reqId);
all.probes.push(...probes);
all.ways.push(...ways);
console.log(
`min ${minute}: room lag p50 ${ms(quantile(sortedProbes, 0.5))}, p95 ${ms(quantile(sortedProbes, 0.95))}, max ${ms(sortedProbes.at(-1) ?? NaN)} (${probes.length} probes, ${overdue} lost) · a change to the partner p50 ${ms(quantile(sortedWays, 0.5))}, p95 ${ms(quantile(sortedWays, 0.95))}, max ${ms(sortedWays.at(-1) ?? NaN)} (${ways.length}) · room errors ${roomErrors} · closed ${closes.length}`,
);
probes = [];
ways = [];
const health = await fetch(`${BASE}/healthz`)
.then((r) => r.json())
.catch((error: Error) => ({ error: error.message }));
console.log(` F: ${JSON.stringify(health)}`);
}
const sortedProbes = all.probes.sort((a, b) => a - b);
const sortedWays = all.ways.sort((a, b) => a - b);
console.log(
`whole run: room lag p50 ${ms(quantile(sortedProbes, 0.5))}, p95 ${ms(quantile(sortedProbes, 0.95))}, p99 ${ms(quantile(sortedProbes, 0.99))}, max ${ms(sortedProbes.at(-1) ?? NaN)} of ${sortedProbes.length}; lost ${lostProbes}; a change to the partner p50 ${ms(quantile(sortedWays, 0.5))}, p95 ${ms(quantile(sortedWays, 0.95))}, max ${ms(sortedWays.at(-1) ?? NaN)} of ${sortedWays.length}; room errors ${roomErrors}; closed: ${closes.length === 0 ? 'none' : closedByReason()}`,
);
} finally {
for (const person of people) {
for (const timer of person.timers) clearInterval(timer);
person.room.close();
person.shared.close();
}
await cleanUp();
await dashboardCleanUp();
}