Files
vdm/tools/mockd/src/synth.ts
T
samiandClaude Sonnet 5 f60070c420 mockd: add --tasks N — a plausible large synthetic table for GUI's DoD
GUI's M1 definition of done is "10 000 synthetic rows scroll at 60 fps with
flat memory over 10 minutes (mockd --tasks 10000)". This flag was missing from
the four unhappy-path flags that did land; the brief's own flag list omitted
it, which is corrected here too.

--tasks seeds a plausible population rather than N copies of one row: varied
state, size (log-uniform 50 KB - 20 GB), category, queue position and
description, drawn from the same category.list / queue.list fixtures the rest
of mockd already serves so a synthetic task can never name a category or
queue those methods don't also return. State distribution is roughly
55% complete / 8% failed / 4% cancelled / 6% paused / 2% retry_wait / 25%
queued, using the new TaskErrorCode taxonomy for failures.

"Progress advances across the whole set, not a handful of live rows" ruled
out the obvious cheap answer. A bounded, rotating pool of concurrently-active
downloads (--active-cap, default 24) is fed continuously from each queue's
FIFO — with the rest of that queue's queuePosition renumbered on every
promotion, as a real scheduler would — and a small fraction of active tasks
hit a transient failure and cycle through retry_wait before rejoining, so the
pool keeps rotating through new rows for the whole run instead of draining
once. Verified over a 10000-task, 60-second run: 61.5 MB RSS flat, and the
active pool's membership meaningfully different after 60s.

tick() only ever walks the active pool plus due retry-wait entries, never the
full task list, so its cost stays flat regardless of --tasks. A manual
download.add is still admitted immediately regardless of --active-cap — a
human driving the GUI by hand must never wait behind synthetic load.

Fixed a latent double-push while building this: any task 'connecting' at the
top of a tick was pushed to the progress batch once for the transition and
again at the loop's unconditional final push, inflating event.task.progress
payloads with a duplicate entry for that taskId. It predates this change (the
original tick() had the same shape) but only became visible once several
tasks are legitimately 'connecting' in the same tick, which --active-cap's
continuous promotion now does routinely.

--seed makes a run reproducible, which matters when a GUI bug only shows up
at a particular row.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
2026-09-10 00:01:44 +04:00

278 lines
11 KiB
TypeScript

/**
* `--tasks N`: a plausible, large synthetic task list.
*
* GUI's M1 definition of done is "10 000 synthetic rows scroll at 60 fps with flat memory
* over 10 minutes (`mockd --tasks 10000`)". That is a claim about a table full of rows that
* look like a real download manager's history, not 10 000 copies of one row: varied
* states, sizes, speeds, categories and queue positions, drawn from the same category and
* queue fixtures the rest of mockd already serves so nothing here can drift from them.
*
* "Progress advances across the whole set, not a handful of live rows" rules out the
* obvious cheap answer — seed a handful of tasks as 'downloading' forever and leave
* thousands static. Instead a bounded, rotating pool of concurrently-active downloads
* (state.ts's `activeCap`) is fed continuously from the queued population and from
* transient retries, so over a ten-minute run hundreds of distinct rows have shown live
* progress by the time it ends — while at any single instant the active count stays
* realistic, exactly as a real daemon would run it.
*/
import { randomUUID } from 'node:crypto';
import type { Category, Queue, TaskState, TaskSummary } from '../../../extension/src/shared/protocol/types.js';
import { TASK_ERROR_CODE_VALUES, type TaskErrorCode } from '../../../extension/src/shared/protocol/types.js';
/** Deterministic PRNG (mulberry32) so a given --seed reproduces the same table — useful
* when a GUI bug only shows up at a particular row and needs to be reproduced exactly. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
type Rng = () => number;
function pick<T>(rng: Rng, items: readonly T[]): T {
return items[Math.floor(rng() * items.length)] as T;
}
function int(rng: Rng, min: number, max: number): number {
return Math.floor(min + rng() * (max - min + 1));
}
/** Log-uniform: real download sizes span 50 KB README files to 20 GB disk images, and a
* plain uniform draw would make everything look mid-sized. */
function logUniform(rng: Rng, min: number, max: number): number {
const lo = Math.log(min);
const hi = Math.log(max);
return Math.round(Math.exp(lo + rng() * (hi - lo)));
}
const ADJECTIVES = ['annual', 'final', 'draft', 'archived', 'backup', 'shared', 'personal',
'weekly', 'monthly', 'legacy', 'updated', 'source', 'compiled', 'signed', 'raw', 'edited'];
const NOUNS = ['report', 'photos', 'session', 'build', 'release', 'dataset', 'presentation',
'recording', 'mixdown', 'clip', 'manual', 'invoice', 'archive', 'snapshot', 'export', 'project'];
function synthFilename(rng: Rng, ext: string): string {
const stem = `${pick(rng, ADJECTIVES)}-${pick(rng, NOUNS)}-${int(rng, 1, 9999)}`;
return `${stem}.${ext}`;
}
function synthHost(rng: Rng): string {
const hosts = ['cdn.example.org', 'files.example.net', 'mirror.example.com',
'downloads.example.io', 'assets.example.dev', 'releases.example.org'];
return pick(rng, hosts);
}
/** Retryable per B1's taxonomy (`docs/adr/0010`) — matches `x-carriesHttpStatus` roughly:
* network hiccups and 5xx are the plausible transient ones a synthetic run should cycle
* through; auth/4xx/local-fs errors are terminal and go straight to 'failed'. */
const RETRYABLE_CODES: readonly TaskErrorCode[] =
['resolve_failed', 'connect_failed', 'connection_reset', 'timeout', 'http_server_error'];
const TERMINAL_CODES: readonly TaskErrorCode[] =
TASK_ERROR_CODE_VALUES.filter((c) => !RETRYABLE_CODES.includes(c) && c !== 'canceled');
export function randomTaskErrorCode(rng: Rng, retryable: boolean): TaskErrorCode {
return pick(rng, retryable ? RETRYABLE_CODES : TERMINAL_CODES);
}
export function taskErrorFor(rng: Rng, code: TaskErrorCode, retryable: boolean): TaskSummary['error'] {
const httpCoded: readonly TaskErrorCode[] =
['http_client_error', 'http_server_error', 'auth_required', 'forbidden', 'not_found',
'range_not_satisfiable', 'gone'];
return {
code,
message: `synthetic ${code} for load testing`,
httpStatus: httpCoded.includes(code) ? pick(rng, [403, 404, 410, 429, 500, 502, 503]) : null,
retryable,
cause: null,
attempt: int(rng, 1, 4),
nextRetryAt: null,
};
}
export interface SynthOptions {
readonly count: number;
readonly seed: number;
readonly categories: readonly Category[];
readonly queues: readonly Queue[];
readonly baseSpeedBps: number;
}
export interface SynthResult {
readonly tasks: TaskSummary[];
/** taskIds seeded directly into 'downloading', in creation order. */
readonly initiallyActive: string[];
/** taskId -> the ordered position it holds in its queue (1-based), for tasks seeded
* 'queued'. Bulk-seeding needs this to rebuild state.ts's per-queue FIFOs without
* re-deriving order from creation time. */
readonly queuedOrder: Map<string, string[]>; // queueId -> taskIds, front to back
/** taskId -> a per-task speed multiplier, so active downloads don't all move in lockstep. */
readonly speedFactor: Map<string, number>;
}
// Weights need not sum to any particular total; they are normalised below. Kept as a flat
// list (not a Record) so the generator can walk it in one pass while assigning position
// within each state's own counter (queuePosition, retry delay, etc).
const STATE_WEIGHTS: ReadonlyArray<readonly [TaskState, number]> = [
['complete', 55],
['failed', 8],
['cancelled', 4],
['paused', 6],
['retry_wait', 2],
['queued', 25],
];
export function generateSyntheticTasks(opts: SynthOptions): SynthResult {
const rng = mulberry32(opts.seed);
const tasks: TaskSummary[] = [];
const initiallyActive: string[] = [];
const queuedOrder = new Map<string, string[]>();
const speedFactor = new Map<string, number>();
const categories = opts.categories.length > 0 ? opts.categories : FALLBACK_CATEGORIES;
const queues = opts.queues.length > 0 ? opts.queues : FALLBACK_QUEUES;
for (const q of queues) queuedOrder.set(q.queueId, []);
// Reserve a slice of the population to start already 'downloading', mid-transfer, so a
// client that connects the instant mockd starts sees live rows immediately rather than
// waiting for the first promotion from the queue.
const activeSeed = Math.min(opts.count, DEFAULT_ACTIVE_CAP);
const totalWeight = STATE_WEIGHTS.reduce((sum, [, w]) => sum + w, 0);
const remaining = Math.max(0, opts.count - activeSeed);
// How many of the `remaining` tasks get each state, largest remainder method so the
// rounding doesn't silently drop or duplicate a task at small N.
const quotas = new Map<TaskState, number>();
let assigned = 0;
const remainders: Array<[TaskState, number]> = [];
for (const [state, weight] of STATE_WEIGHTS) {
const exact = (remaining * weight) / totalWeight;
const floor = Math.floor(exact);
quotas.set(state, floor);
assigned += floor;
remainders.push([state, exact - floor]);
}
remainders.sort((a, b) => b[1] - a[1]);
for (let i = 0; i < remaining - assigned; i += 1) {
const state = remainders[i % remainders.length]![0];
quotas.set(state, (quotas.get(state) ?? 0) + 1);
}
const now = Date.now();
const spawn = (state: TaskState): TaskSummary => {
const category = pick(rng, categories);
const ext = pick(rng, category.extensions.length > 0 ? category.extensions : ['bin']);
const filename = synthFilename(rng, ext);
const sizeBytes = logUniform(rng, 50_000, 20_000_000_000);
const resumable = rng() > 0.08;
const segments = resumable ? pick(rng, [1, 2, 4, 4, 8, 8, 8, 16]) : 1;
// Spread creation times over the past 30 days so the table doesn't show one instant.
const createdAt = new Date(now - int(rng, 0, 30 * 24 * 3600) * 1000).toISOString();
const task: TaskSummary = {
taskId: randomUUID(),
filename,
saveDir: category.saveDir,
url: `https://${synthHost(rng)}/${category.categoryId}/${filename}`,
effectiveUrl: null,
sizeBytes,
downloadedBytes: 0,
state,
speedBps: 0,
etaSeconds: null,
resumable,
segments,
categoryId: category.categoryId,
queueId: null,
queuePosition: null,
description: rng() > 0.85 ? pick(rng, NOUNS) : null,
createdAt,
lastTryAt: null,
completedAt: null,
error: null,
};
speedFactor.set(task.taskId, 0.3 + rng() * 1.7);
return task;
};
for (const [state, quota] of quotas) {
for (let i = 0; i < quota; i += 1) {
const task = spawn(state);
switch (state) {
case 'complete': {
task.downloadedBytes = task.sizeBytes ?? 0;
task.lastTryAt = task.createdAt;
task.completedAt = new Date(
new Date(task.createdAt).getTime() + int(rng, 5, 3600) * 1000,
).toISOString();
break;
}
case 'failed': {
const code = randomTaskErrorCode(rng, false);
task.error = taskErrorFor(rng, code, false);
task.downloadedBytes = Math.floor((task.sizeBytes ?? 0) * rng() * 0.6);
task.lastTryAt = task.createdAt;
break;
}
case 'cancelled': {
task.downloadedBytes = Math.floor((task.sizeBytes ?? 0) * rng() * 0.4);
task.lastTryAt = task.createdAt;
break;
}
case 'paused': {
task.downloadedBytes = Math.floor((task.sizeBytes ?? 0) * rng());
task.lastTryAt = task.createdAt;
break;
}
case 'retry_wait': {
const code = randomTaskErrorCode(rng, true);
task.error = taskErrorFor(rng, code, true);
task.downloadedBytes = Math.floor((task.sizeBytes ?? 0) * rng() * 0.5);
task.lastTryAt = task.createdAt;
break;
}
case 'queued': {
// ~80/20 split across the first two queues, matching Main/Sync in the fixtures;
// falls back to the single available queue if fewer than two exist.
const queue = queues.length > 1 && rng() < 0.8 ? queues[0]! : pick(rng, queues);
task.queueId = queue.queueId;
const order = queuedOrder.get(queue.queueId)!;
order.push(task.taskId);
task.queuePosition = order.length;
break;
}
default:
break;
}
tasks.push(task);
}
}
// The reserved active slice: mid-transfer, non-zero progress, so it reads as "already
// running" rather than "just started" the moment mockd comes up.
for (let i = 0; i < activeSeed; i += 1) {
const task = spawn('downloading');
task.downloadedBytes = Math.floor((task.sizeBytes ?? 0) * rng() * 0.7);
task.lastTryAt = task.createdAt;
tasks.push(task);
initiallyActive.push(task.taskId);
}
return { tasks, initiallyActive, queuedOrder, speedFactor };
}
export const DEFAULT_ACTIVE_CAP = 24;
const FALLBACK_CATEGORIES: readonly Category[] = [
{ categoryId: 'compressed', name: 'Compressed', saveDir: '/home/sami/Downloads/Compressed',
extensions: ['zip', 'tar', 'gz'], mimeTypes: [], builtin: true, sortOrder: 0 },
];
const FALLBACK_QUEUES: readonly Queue[] = [
{ queueId: 'main', name: 'Main Queue', state: 'running', maxConcurrent: 3,
taskIds: [], schedule: null, onComplete: 'nothing' },
];