Files
windmill/benchmarks/main.ts
2023-10-25 00:17:38 +02:00

534 lines
14 KiB
TypeScript

/// <reference no-default-lib="true" />
/// <reference lib="deno.window" />
import { Command } from "https://deno.land/x/cliffy@v0.25.7/command/mod.ts";
import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import { Action } from "./action.ts";
import { UpgradeCommand } from "https://deno.land/x/cliffy@v0.25.7/command/upgrade/upgrade_command.ts";
import { DenoLandProvider } from "https://deno.land/x/cliffy@v0.25.7/command/upgrade/mod.ts";
import { VERSION, createBenchScript } from "./lib.ts";
export {
DenoLandProvider,
UpgradeCommand,
} from "https://deno.land/x/cliffy@v0.25.7/command/upgrade/mod.ts";
async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
requestBody: {
email: email,
password: password,
},
});
}
export async function main({
host,
workers: num_workers,
seconds,
email,
password,
token,
workspace,
metrics,
exportJson,
exportCsv,
exportHistograms,
exportSimple,
histogramBuckets,
maximumThroughput,
useFlows,
flowPattern,
scriptPattern,
zombieTimeout,
continous,
max,
custom,
}: {
host: string;
workers: number;
seconds: number;
email?: string;
password?: string;
token?: string;
workspace: string;
metrics: string;
exportJson?: string;
exportCsv?: string;
exportHistograms?: string[];
exportSimple?: string[];
histogramBuckets: string[];
maximumThroughput: number;
useFlows?: boolean;
flowPattern?: string;
scriptPattern?: string;
zombieTimeout: number;
continous?: boolean;
max?: number;
custom?: string;
}) {
windmill.setClient("", host);
const versionResp = await fetch(`${host}/api/version`);
console.log("Backend version: " + (await versionResp.text()));
const custom_content: Action | undefined = custom
? JSON.parse(await Deno.readTextFile(custom))
: undefined;
if (!Array.isArray(histogramBuckets)) {
histogramBuckets = [];
}
if (!Array.isArray(exportHistograms)) {
exportHistograms = [];
}
if (!Array.isArray(exportSimple)) {
exportSimple = [];
}
let metrics_worker: Worker | undefined = undefined;
if (!continous) {
if (exportJson || exportCsv) {
metrics_worker = new Worker(
new URL("./scraper.ts", import.meta.url).href,
{
type: "module",
}
);
metrics_worker.postMessage({
exportHistograms,
histogramBuckets,
exportSimple,
host: metrics,
});
}
}
console.log(
"Started with options",
JSON.stringify(
{
host,
num_workers,
seconds,
email,
workspace,
metrics,
exportJson,
exportCsv,
exportHistograms,
exportSimple,
maximumThroughput,
useFlows,
flowPattern,
scriptPattern,
zombieTimeout,
continous,
},
null,
4
)
);
const config = {
token: "",
server: host,
workspace_id: workspace,
};
let final_token: string;
if (!token) {
if (email && password) {
console.log("Logging in with email and password...");
final_token = await login(email, password);
console.log("Logged in!");
} else {
console.error("Token or email with password are required.");
return;
}
} else {
final_token = token;
}
console.log("Using token", final_token);
config.token = final_token;
windmill.setClient(final_token, host);
const per_worker_throughput = maximumThroughput / num_workers;
const max_per_worker = max ? max / num_workers : undefined;
const shared_config = {
server: host,
token: final_token,
workspace_id: config.workspace_id,
per_worker_throughput,
max_per_worker,
useFlows,
flowPattern,
scriptPattern,
continous,
custom: custom_content,
};
if (
!useFlows &&
(scriptPattern === undefined ||
["deno", "python", "go", "bash", "bun", "dedicated"].includes(
scriptPattern
))
) {
await createBenchScript(scriptPattern || "deno", workspace);
}
let workers: Worker[] = new Array(num_workers);
for (let i = 0; i < num_workers; i++) {
workers[i] = new Worker(new URL("./worker.ts", import.meta.url).href, {
type: "module",
});
}
let start: number | undefined = undefined;
const jobsSent = Array(num_workers).fill(0);
const enc = (s: string) => new TextEncoder().encode(s);
async function getQueueCount() {
return (
await (
await fetch(
config.server + "/api/w/" + config.workspace_id + "/jobs/queue/count",
{ headers: { ["Authorization"]: "Bearer " + config.token } }
)
).json()
).database_length;
}
const initial_queue_length = await getQueueCount();
console.log("Initial queue length:", initial_queue_length);
const updateState = setInterval(async () => {
const elapsed = start ? Math.ceil((Date.now() - start) / 1000) : 0;
const sum = jobsSent.reduce((a, b) => a + b, 0);
let queue_length = -1;
while (queue_length === -1) {
try {
queue_length = await getQueueCount();
} catch (e) {
console.log(
`queue count not reachable. waiting... `
);
await sleep(0.5);
continue;
}
}
await Deno.stdout.write(
enc(
`elapsed: ${elapsed}/${seconds} | jobs sent: ${JSON.stringify(
jobsSent
)} (sum: ${sum} thr: ${(sum / elapsed).toFixed(2)}) - processed (sum: ${
sum - queue_length
} thr: ${((sum - queue_length) / elapsed).toFixed(
2
)}) | queue: ${queue_length} \r`
)
);
}, 100);
workers.forEach((worker, i) => {
worker.addEventListener("message", (evt: MessageEvent<any>) => {
if (evt.data.type === "jobs_sent") {
jobsSent[i] = evt.data.jobs_sent;
}
});
worker.postMessage({ ...shared_config, i });
});
start = Date.now();
console.log("collecting samples...");
if (continous) {
while (true) {
await sleep(Infinity);
}
}
await sleep(seconds);
clearInterval(updateState);
let sum = jobsSent.reduce((a, b) => a + b, 0);
await Deno.stdout.write(
enc(" ".padStart(30) + `\rduration: ${seconds} | jobs sent: ${sum}\n`)
);
const shutdown_start = Date.now();
// let zombie_jobs = 0;
// let incorrect_results = 0;
// workers.forEach((worker, i) => {
// const l = (evt: MessageEvent<any>) => {
// if (evt.data.type === "zombie_jobs") {
// zombie_jobs += evt.data.zombie_jobs;
// incorrect_results += evt.data.incorrect_results;
// worker.removeEventListener("message", l);
// workers = workers.filter((w) => w != worker);
// jobsSent[i] = evt.data.jobs_sent;
// worker.terminate();
// }
// };
// worker.addEventListener("message", l);
// worker.postMessage(
// Number.isSafeInteger(zombieTimeout) ? zombieTimeout : 90000
// );
// });
workers.forEach((worker, i) => {
const l = (evt: MessageEvent<any>) => {
if (evt.data.type === "done") {
worker.removeEventListener("message", l);
workers = workers.filter((w) => w != worker);
jobsSent[i] = evt.data.jobs_sent;
worker.terminate();
}
};
worker.addEventListener("message", l);
worker.postMessage("done");
});
console.log("waiting for shutdown\n");
while (workers.length > 0) {
await sleep(0.1);
}
let queue_length = await getQueueCount();
const updateQueue = setInterval(async () => {
queue_length = (
await (
await fetch(
host + "/api/w/" + config.workspace_id + "/jobs/queue/count",
{ headers: { ["Authorization"]: "Bearer " + config.token } }
)
).json()
).database_length;
await Deno.stdout.write(enc(`queue length: ${queue_length}\r`));
}, 100);
while (queue_length > 0) {
await sleep(0.1);
}
clearInterval(updateQueue);
sum = jobsSent.reduce((a, b) => a + b, 0);
const tts = (Date.now() - shutdown_start) / 1000;
const time = seconds + tts;
console.log("\ntime to shutdown:", tts);
console.log("jobs:", sum);
console.log("time (s + tts):", time);
console.log("throughput /s (jobs/time):", sum / time);
// console.log("zombie jobs: ", zombie_jobs);
// console.log("incorrect results: ", incorrect_results);
console.log(
"queue length:",
(
await (
await fetch(
host + "/api/w/" + config.workspace_id + "/jobs/queue/count",
{ headers: { ["Authorization"]: "Bearer " + config.token } }
)
).json()
).database_length
);
if (metrics_worker) {
metrics_worker.postMessage("stop");
console.log("waiting for metrics");
const { columns, transfer_values } = await new Promise<{
columns: string[];
transfer_values: ArrayBufferLike[];
}>((resolve, _reject) => {
if (metrics_worker) {
metrics_worker.onmessage = (e) => {
resolve(e.data);
metrics_worker?.terminate();
};
}
});
const values = transfer_values.map((x) => new Float32Array(x));
if (exportJson) {
console.log("exporting mean & stdev to json");
const obj: any = {};
for (let i = 0; i < columns.length; i++) {
const name = columns[i]!;
const value = values[i]!;
const mean = value.reduce((acc, e) => acc + e, 0) / values.length;
const stdev = Math.sqrt(
value.reduce((acc, e) => acc + (e - mean) ** 2) / values.length
);
obj[name] = { mean, stdev };
}
await Deno.writeTextFile(exportJson, JSON.stringify(obj));
}
if (exportCsv) {
const f = await Deno.open(exportCsv, {
write: true,
create: true,
truncate: true,
});
const encoder = new TextEncoder();
const newline = new Uint8Array(1);
newline[0] = 0x0a;
await f.write(encoder.encode(columns.join(",")));
await f.write(newline);
for (let i = 0; i < values.length; i++) {
await f.write(encoder.encode(values[i].join(",")));
await f.write(newline);
}
f.close();
}
}
console.log("done");
return {
throughput: sum / time,
};
}
if (import.meta.main) {
await new Command()
.name("wmillbench")
.description("Run Benchmark to measure throughput of windmill.")
.version(VERSION)
.option("--host <url:string>", "The windmill host to benchmark.", {
default: "http://127.0.0.1:8000",
})
.option(
"--workers <workers:number>",
"The number of workers to run at once.",
{
default: 1,
}
)
.option(
"-s --seconds <seconds:number>",
"How long to run the benchmark for (in seconds).",
{
default: 30,
}
)
.option("--max <max:number>", "Maximum number of operations performed.")
.option("-e --email <email:string>", "The email to use to login.")
.option("-p --password <password:string>", "The password to use to login.")
.env(
"WM_TOKEN=<token:string>",
"The token to use when talking to the API server. Preferred over manual login."
)
.option(
"-t --token <token:string>",
"The token to use when talking to the API server. Preferred over manual login."
)
.env(
"WM_WORKSPACE=<workspace:string>",
"The workspace to spawn scripts from."
)
.option(
"-w --workspace <workspace:string>",
"The workspace to spawn scripts from.",
{ default: "admins" }
)
.option(
"-m --metrics <metrics:string>",
"The url to scrape metrics from.",
{
default: "http://localhost:8001/metrics",
}
)
.option(
"--export-json <export_json:string>",
"If set, exports will be into a JSON file."
)
.option(
"--export-csv <export_csv:string>",
"If set, exports will be into a csv file."
)
.option(
"--export-histograms <export_histograms:string[]>",
"Mark metrics (without label) that are reported as histograms to export."
)
.option(
"--export-simple <export_simple:string[]>",
"Mark metrics (without label) that are reported as simple values."
)
.option(
"--maximum-throughput <maximum_throughput:number>",
"Maximum number of jobs/flows to start in one second.",
{
default: Infinity,
}
)
.option("--use-flows", "Run flows instead of jobs.")
.option(
"--flow-pattern <pattern:string>",
"Use a different flow pattern among: 2steps, onebranch (Default 2steps)"
)
.option(
"--script-pattern <pattern:string>",
"Use a different script pattern among: deno, identity, python, go, bash, dedicated, bun (Default deno)"
)
.option("--custom <custom_path:string>", "Use custom actions during bench")
.option(
"--zombie-timeout <zombie_timeout:number>",
"The maximum time in ms to wait for jobs to complete.",
{
default: 90000,
}
)
.option(
"-c --continuous",
"Run the benchmark forever. This effectively disables metric collection & exports. No zombie jobs will be tracked."
)
.option(
"--histogram-buckets <histogram_buckets:string[]>",
"Define what buckets to collect from histograms.",
{
default: [
"+Inf",
"10",
"5",
"2.5",
"2.5",
"1",
"0.5",
"0.25",
"0.1",
"0.05",
"0.025",
"0.01",
"0.005",
],
}
)
.option("--hide-progress", "Hide worker progress logs")
.action(main)
.command(
"upgrade",
new UpgradeCommand({
main: "main.ts",
args: [
"--allow-net",
"--allow-read",
"--allow-write",
"--allow-env",
"--unstable",
],
provider: new DenoLandProvider({ name: "wmillbench" }),
})
)
.parse();
}