Compare commits

...

3 Commits

Author SHA1 Message Date
Ruben Fiszel
adbf4720ae docs: add CONTEXT.md for benchmark framework continuation
Comprehensive handoff document covering architecture, decisions,
gotchas, and potential improvements for the competitor benchmark suite.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:07:35 +00:00
Ruben Fiszel
97c81650b3 feat: add competitor benchmark framework for WAC performance comparison
Benchmark Windmill's workflow-as-code against 6 competitors:
Temporal, Inngest, Restate, Kestra, Prefect, and Airflow.

Each platform runs an equivalent 3-step sequential workflow with
inline step execution. Measures cold start, single-execution latency
(median + P95), throughput, and per-step overhead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 21:21:58 +00:00
Ruben Fiszel
480d9acd83 feat: add --main flag to write_latest_ee_ref.sh to point to latest EE main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:04:04 +00:00
46 changed files with 5452 additions and 3 deletions

View File

@@ -0,0 +1,74 @@
name: Competitor Benchmarks
on:
schedule:
- cron: "0 6 * * 1" # Weekly on Monday at 6 AM UTC
workflow_dispatch:
jobs:
benchmark_competitors:
runs-on: ubicloud-standard-8
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Pre-pull competitor Docker images
run: |
docker pull postgres:16 &
docker pull ghcr.io/windmill-labs/windmill:main &
docker pull temporalio/auto-setup:latest &
docker pull inngest/inngest:latest &
docker pull docker.io/restatedev/restate:latest &
docker pull kestra/kestra:latest &
wait
- name: Run competitor benchmarks
timeout-minutes: 60
run: |
cd benchmarks/competitors
deno run -A competitor_suite.ts \
-c competitor_suite_config.json \
--output-dir ./results
- name: Generate comparison graphs
run: |
cd benchmarks/competitors
deno run -A competitor_graphs.ts \
-c competitor_graphs_config.json \
--results-dir ./results \
--output-dir ./results
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: competitor_benchmarks
path: |
benchmarks/competitors/results/*.json
benchmarks/competitors/results/*.svg
commit_results:
runs-on: ubicloud
needs: benchmark_competitors
steps:
- uses: actions/checkout@v4
with:
ref: benchmarks
- uses: actions/download-artifact@v4
with:
name: competitor_benchmarks
path: competitors/
- name: Push changes
run: |
git add .
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git commit -m "Update competitor benchmarks" || exit 0
git push

View File

@@ -15,11 +15,17 @@ else
echo "Directory not found"; exit 1
fi
# Get the current commit hash
commit_hash=$(git rev-parse HEAD)
# If --main is passed, fetch and use latest main
if [ "$1" = "--main" ]; then
git fetch origin main
commit_hash=$(git rev-parse origin/main)
else
# Get the current commit hash
commit_hash=$(git rev-parse HEAD)
fi
# Navigate back to the original directory
cd - || exit
cd - > /dev/null || exit
# Write the commit hash to ./ee-repo-ref.txt
echo -n "$commit_hash" > ./ee-repo-ref.txt

2
benchmarks/competitors/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
results/

View File

@@ -0,0 +1,178 @@
# Competitor Benchmark Framework — Context for Continuation
## Goal
Reproducible performance benchmarks comparing Windmill's workflow-as-code (WAC) against competitors, for SEO comparison pages. All platforms run an equivalent **3-step sequential workflow** with inline step execution (no subprocess/child job dispatch). This isolates orchestration overhead from actual compute.
## Current Competitors (7)
| Competitor | Version Tested | Adapter | Workflow Type |
|---|---|---|---|
| **Windmill** | CE v1.673.0 | `windmill/adapter.ts` | `step()` from `windmill-client` (inline, no child jobs) |
| **Temporal** | auto-setup:latest | `temporal/adapter.ts` | `proxyActivities()` with 3 sequential activities |
| **Inngest** | inngest:latest | `inngest/adapter.ts` | `step.run()` × 3 via Express app |
| **Restate** | restate:latest | `restate/adapter.ts` | `ctx.run()` × 3 via Node.js endpoint |
| **Kestra** | 1.3.7 | `kestra/adapter.ts` | `io.kestra.plugin.core.debug.Return` × 3 |
| **Prefect** | 3.6.25 | `prefect/adapter.ts` | `@task` × 3 with `.serve()` runner |
| **Airflow** | 2.10.5 | `airflow/adapter.ts` | `PythonOperator` × 3 with LocalExecutor |
## Latest Results (local dev machine, 2026-04-03)
| Competitor | Cold Start | Median Latency | P95 Latency | Throughput | Step Overhead | Workers |
|---|---|---|---|---|---|---|
| Restate | 15ms | 4.1ms | 5.0ms | 2,057/s | 1.4ms | event loop |
| **Windmill** | **106ms** | **103ms** | **105ms** | **152/s** | **34ms** | 10 |
| Temporal | 166ms | 119ms | 283ms | 39/s | 40ms | 10 |
| Kestra | 410ms | 177ms | 326ms | 68/s | 59ms | JVM threads |
| Inngest | 313ms | 260ms | 261ms | 90/s | 87ms | 10 |
| Airflow | 3,858ms | 3,792ms | 4,277ms | 7.4/s | 1,264ms | LocalExecutor |
| Prefect | 2,764ms | 9,679ms | 12,631ms | 1.3/s | 3,226ms | .serve() |
## Architecture
```
benchmarks/competitors/
├── types.ts # CompetitorAdapter interface + BenchmarkResult types
├── competitor_harness.ts # Main orchestrator: for each competitor → setup → deploy → test → teardown
├── competitor_suite.ts # CLI wrapper (cliffy): --competitors, --latency-samples, --throughput-batch, --output-dir
├── competitor_graphs.ts # SVG bar chart generation (D3 + JSDOM)
├── competitor_suite_config.json
├── competitor_graphs_config.json
├── lib/
│ ├── docker.ts # composeUp(), composeDown(), waitForHealth(), composeLogs()
│ ├── timing.ts # measureLatency(), warmup(), collectLatencySamples(), computeStats()
│ └── results.ts # saveResult(), saveSummary(), getCpuCount(), getMachineId()
├── .gitignore # excludes node_modules/ and results/
├── {competitor}/
│ ├── adapter.ts # implements CompetitorAdapter
│ ├── docker-compose.yml # isolated container stack
│ ├── workflow.* # equivalent workflow definition
│ └── Dockerfile.* # (some competitors need custom app/worker images)
└── results/ # (gitignored) JSON output from benchmark runs
```
## CompetitorAdapter Interface
```typescript
interface CompetitorAdapter {
readonly name: string;
readonly composeFile: string;
setup(): Promise<void>; // docker compose up + health wait
deployWorkflow(): Promise<void>; // register/create the 3-step workflow
triggerOne(): Promise<{ latencyMs: number; result: unknown }>;
triggerBatch(n: number): Promise<{ totalMs: number; results: unknown[] }>;
teardown(): Promise<void>; // docker compose down -v
getVersion(): Promise<string>;
}
```
## Harness Flow (per competitor)
1. `setup()``docker compose up -d` + wait for health endpoint
2. `deployWorkflow()` — register the 3-step workflow via REST API / SQL / CLI
3. **Cold start**`triggerOne()` immediately after deploy
4. **Warmup**`triggerOne()` × N, discard results
5. **Single latency**`triggerOne()` × N, collect `performance.now()` timings → compute median, P95, mean, stdev
6. **Throughput**`triggerBatch(N)` (concurrent `Promise.all`), measure total wall-clock time
7. **Step overhead** — median_latency / 3 (trivial steps, so overhead ≈ latency)
8. `teardown()``docker compose down -v`
9. Save JSON results
## How to Run
```bash
cd benchmarks/competitors
# Single competitor
deno run -A competitor_suite.ts --competitors windmill --latency-samples 50 --throughput-batch 100 --output-dir ./results
# All competitors
deno run -A competitor_suite.ts --latency-samples 100 --throughput-batch 200 --warmup-count 10 --output-dir ./results
# Generate SVG graphs
deno run -A --allow-import competitor_graphs.ts -c competitor_graphs_config.json --results-dir ./results
```
## Key Decisions & Gotchas
### Windmill
- Uses `step()` (inline), NOT `task()` (child jobs). Other competitors don't create separate jobs per step either, so this is the fair comparison.
- `WORKER_GROUP=main` + `NUM_WORKERS=10` + `I_ACK_NUM_WORKERS_IS_UNSAFE=1` + `SLEEP_QUEUE=50`. Without `SLEEP_QUEUE=50`, the queue poll interval scales to `50ms × NUM_WORKERS / 2 = 250ms`, killing latency. See `backend/windmill-worker/src/worker.rs:318-329`.
- `WORKER_TAGS=bun,flow,dependency` — must include `bun` since WAC scripts deploy as bun language.
- Cannot use `WORKER_GROUP=native` or `NATIVE_MODE=true` because native mode forces `NUM_WORKERS=8` and doesn't support the `bun` tag needed for WAC scripts.
### Temporal
- Needs a **separate worker container** (Dockerfile.worker) because the Temporal TypeScript SDK has native gRPC bindings that don't work in Deno.
- The adapter uses `node -e` subprocess calls to run Temporal client operations (connect, start workflow, get result).
- `npm install` in `temporal/` is needed before running (the adapter does this in `setup()`).
- `maxConcurrentWorkflowTaskExecution: 10` and `maxConcurrentActivityTaskExecution: 10` in worker.ts to match Windmill's 10 workers.
- Cold start is high (~6s) because `temporalio/auto-setup` does DB schema migration on first boot. Not representative of steady-state.
### Inngest
- The dev server's event run status API (`/v1/events/{id}/runs`) **caches responses for 15 seconds**. Must add `?ts=${Date.now()}` cache-buster to polling requests, otherwise each step appears to take 5 seconds.
- `--queue-workers 10` caps concurrency to match other competitors.
- `--tick 10` (10ms) for fast queue polling, `--poll-interval 1` for fast app sync.
- The Express app needs `express.json()` middleware or Inngest SDK returns 500 "Missing body".
- `--no-discovery` is set because auto-discovery is unreliable in Docker networks.
### Restate
- Extremely fast (4ms median) because it runs workflow steps **in-process** in the app's Node.js event loop. No queue hop, no network round-trip between steps. This is a fundamental architectural difference from Windmill/Temporal/Inngest.
- Uses `send` + `attach` pattern: POST `/benchmark/{id}/run/send` then GET `/restate/workflow/benchmark/{id}/attach`.
- The app must be registered with the Restate admin API: POST `http://admin:9070/deployments` with `{"uri": "http://app:9080"}`.
- Ports remapped to 8085/9075 to avoid conflict with port 8080 (often in use).
### Kestra
- Kestra 1.3.x has **mandatory basic auth** that cannot be disabled via config. The `/api/v1/flows` endpoint always returns 401.
- Workaround: deploy the flow via **direct SQL insertion** into Kestra's Postgres `flows` table. Key format is `main_{namespace}_{id}_{revision}`. The `value` JSONB must include `tenantId: "main"`, `source`, `deleted: false`, etc. (see existing tutorial flows for format).
- Trigger via the **webhook endpoint** (`/api/v1/executions/webhook/{namespace}/{flowId}/{key}`) which is public.
- Poll execution status via SQL: `SELECT value->>'state' FROM executions WHERE key = '{executionId}'`.
- Uses `io.kestra.plugin.core.debug.Return` tasks (not shell tasks) to avoid subprocess overhead.
### Prefect
- Very slow (~9.7s/workflow) because `.serve()` mode spawns a new subprocess per flow run. Prefect is designed for data pipeline tasks, not lightweight orchestration.
- The worker container runs `python flow.py` which calls `.serve(name="benchmark-deployment")` — this both registers the deployment and runs a worker loop.
- Deployment ID must be fetched via `GET /api/deployments/name/{flow_name}/{deployment_name}` before triggering.
- No auth required on self-hosted Prefect OSS server.
### Airflow
- Slow (~3.8s/workflow) due to scheduler overhead and PythonOperator subprocess execution.
- DAG file is volume-mounted to `./dags/`. Scheduler detects it after `DAG_DIR_LIST_INTERVAL` (set to 5s).
- DAGs are paused by default — must PATCH `/api/v1/dags/{dag_id}` with `{"is_paused": false}`.
- All API calls require Basic Auth: `Authorization: Basic YWRtaW46YWRtaW4=` (admin:admin).
- `airflow-init` container runs DB migration + user creation, then exits. Webserver and scheduler depend on it via `service_completed_successfully`.
- Webserver port remapped to 8090 to avoid conflict with 8080.
### Docker / Infrastructure
- All custom app/worker images must be **pre-built** before running (`docker build -t {name} -f Dockerfile ...`). Docker Compose's `build:` directive times out in some environments.
- Compose files use pre-built image references (e.g., `image: temporal-benchmark-worker`) not `build:` blocks.
- Docker Compose `--wait` flag was removed from `composeUp()` because it caused timeout failures. Each adapter handles its own readiness checking via `waitForHealth()` or custom polling.
- Competitors run sequentially with full `docker compose down -v` between runs to avoid resource contention and port conflicts.
- **Disk space**: Running all 7 competitors needs ~15GB for Docker images. Kestra (3.3GB) and Airflow (1.5GB) are the largest. Use `docker system prune -af` between runs if tight on space.
### Graphs
- `competitor_graphs.ts` generates SVG bar charts using D3 + JSDOM (same stack as existing `benchmarks/graph.ts`).
- Reads from `results/competitor_comparison_benchmark.json` (flat summary format).
- Config in `competitor_graphs_config.json` defines 5 charts: cold start, median latency, P95, throughput, step overhead.
- D3 callback params need explicit `any` types to pass Deno type checking.
## CI
`.github/workflows/benchmark-competitors.yml`:
- Runs weekly (Monday 6AM UTC) + manual dispatch
- `ubicloud-standard-8` runner
- Pre-pulls all Docker images in parallel
- Sequential competitor runs
- Results committed to `benchmarks` branch
## Potential Improvements
1. **Dagster adapter** — Another popular Python DAG-based orchestrator (competitor to Airflow/Prefect)
2. **Hatchet adapter** — Rising Postgres-backed task orchestrator (YC W24)
3. **Multi-worker scaling test** — Run with 1, 4, 8, 16 workers to show scaling curves
4. **Windmill `task()` benchmark** — Add a separate test using `task()` (child jobs) for users who need per-step isolation
5. **Payload size test** — Steps that pass non-trivial data (1KB, 100KB, 1MB) to measure serialization overhead
6. **Error/retry test** — Steps that fail and retry to measure recovery overhead
7. **Long-running workflow test** — 100+ steps to measure checkpoint overhead at scale
8. **Graph improvements** — Add error bars, include version numbers in chart labels
9. **Prefect worker pool mode** — Test with `Process` work pool instead of `.serve()` for potentially better throughput
10. **Airflow CeleryExecutor** — Test with Celery + Redis for parallel task execution instead of LocalExecutor

View File

@@ -0,0 +1,85 @@
# Competitor Benchmarks
Performance benchmarks comparing Windmill's workflow-as-code against Temporal, Inngest, Restate, and Kestra.
## What's Measured
All platforms run the **same logical workflow**: 3 sequential steps, each returning a trivial integer. This isolates orchestration overhead from actual compute.
| Metric | Description |
|--------|-------------|
| **Cold start** | First execution latency after fresh deploy |
| **Single latency** | Median + P95 of individual end-to-end execution times |
| **Throughput** | Concurrent workflow completions per second |
| **Step overhead** | Per-step orchestration overhead (median_latency / 3) |
## Quick Start
```bash
# Install Deno
curl -fsSL https://deno.land/install.sh | sh
# Run all competitors (requires Docker)
deno run -A competitor_suite.ts
# Run specific competitors
deno run -A competitor_suite.ts --competitors windmill,temporal
# Custom parameters
deno run -A competitor_suite.ts \
--competitors windmill,inngest,restate \
--latency-samples 100 \
--throughput-batch 200 \
--output-dir ./results
# Generate graphs from results
deno run -A competitor_graphs.ts \
-c competitor_graphs_config.json \
--results-dir ./results
```
## Prerequisites
- [Deno](https://deno.land/) v2+
- [Docker](https://www.docker.com/) with Docker Compose v2
- [Node.js](https://nodejs.org/) 20+ (for Temporal client subprocess)
- ~8GB RAM (competitors run sequentially, one at a time)
## Workflow Equivalence
Each platform implements the same 3-step sequential workflow:
| Platform | Implementation |
|----------|---------------|
| **Windmill** | `task()` + `workflow()` from `windmill-client` |
| **Temporal** | `proxyActivities()` with 3 sequential activity calls |
| **Inngest** | `inngest.createFunction()` with 3 `step.run()` calls |
| **Restate** | `restate.workflow()` with 3 `ctx.run()` calls |
| **Kestra** | YAML flow with 3 `io.kestra.plugin.core.debug.Return` tasks |
## Architecture
```
competitor_suite.ts CLI entrypoint
└── competitor_harness.ts Orchestrator (setup → deploy → test → teardown)
├── lib/docker.ts Docker Compose lifecycle
├── lib/timing.ts Latency/throughput measurement
├── lib/results.ts JSON output + statistics
└── {competitor}/
├── adapter.ts CompetitorAdapter implementation
├── docker-compose.yml Container stack
└── workflow.* Workflow definition
```
Each competitor gets its own Docker Compose stack. Competitors run sequentially with full teardown between runs to avoid resource contention.
## Output
Results are written to `./results/` as JSON:
- `{competitor}_competitor_benchmark.json` — Per-competitor detailed results
- `competitor_comparison_benchmark.json` — Flat summary for graphing
- `competitor_*.svg` — Bar chart visualizations
## CI
The GitHub Actions workflow (`.github/workflows/benchmark-competitors.yml`) runs weekly on Monday. Results are committed to the `benchmarks` branch.

View File

@@ -0,0 +1,142 @@
/**
* Airflow CompetitorAdapter.
*
* Uses Airflow 2.x REST API:
* - GET /api/v1/dags/{dag_id} to check DAG is loaded
* - PATCH /api/v1/dags/{dag_id} to unpause
* - POST /api/v1/dags/{dag_id}/dagRuns to trigger
* - GET /api/v1/dags/{dag_id}/dagRuns/{run_id} to poll status
*
* All requests require Basic Auth (admin:admin).
*/
import type { CompetitorAdapter } from "../types.ts";
import { composeUp, composeDown, waitForHealth } from "../lib/docker.ts";
import { dirname, fromFileUrl, resolve } from "https://deno.land/std@0.224.0/path/mod.ts";
const SELF_DIR = dirname(fromFileUrl(import.meta.url));
const AIRFLOW_URL = "http://127.0.0.1:8090/api/v1";
const AUTH_HEADER = "Basic " + btoa("admin:admin");
const DAG_ID = "benchmark_3step";
function headers(): Record<string, string> {
return {
Authorization: AUTH_HEADER,
"Content-Type": "application/json",
};
}
async function waitForDag(timeoutMs = 120000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const resp = await fetch(`${AIRFLOW_URL}/dags/${DAG_ID}`, {
headers: headers(),
});
if (resp.ok) {
await resp.body?.cancel();
return;
}
await resp.body?.cancel();
} catch (_) {
// not ready
}
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error("Airflow DAG did not appear in time");
}
async function unpauseDag(): Promise<void> {
const resp = await fetch(`${AIRFLOW_URL}/dags/${DAG_ID}`, {
method: "PATCH",
headers: headers(),
body: JSON.stringify({ is_paused: false }),
});
if (!resp.ok) throw new Error(`Unpause failed: ${resp.status} ${await resp.text()}`);
await resp.body?.cancel();
}
async function triggerDagRun(): Promise<string> {
const resp = await fetch(`${AIRFLOW_URL}/dags/${DAG_ID}/dagRuns`, {
method: "POST",
headers: headers(),
body: JSON.stringify({ conf: {} }),
});
if (!resp.ok) throw new Error(`Trigger failed: ${resp.status} ${await resp.text()}`);
const body = await resp.json();
return body.dag_run_id;
}
async function waitForDagRun(runId: string, timeoutMs = 120000): Promise<unknown> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const resp = await fetch(
`${AIRFLOW_URL}/dags/${DAG_ID}/dagRuns/${encodeURIComponent(runId)}`,
{ headers: headers() },
);
if (resp.ok) {
const body = await resp.json();
if (body.state === "success") return body;
if (body.state === "failed") throw new Error(`Airflow DAG run failed: ${JSON.stringify(body)}`);
} else {
await resp.body?.cancel();
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`Airflow DAG run ${runId} did not complete within ${timeoutMs}ms`);
}
async function triggerAndWait(): Promise<unknown> {
const runId = await triggerDagRun();
return await waitForDagRun(runId);
}
export const airflowAdapter: CompetitorAdapter = {
name: "airflow",
composeFile: resolve(SELF_DIR, "docker-compose.yml"),
async setup() {
await composeUp(this.composeFile);
// Airflow takes a while to initialize (DB migration + scheduler startup)
await waitForHealth(`${AIRFLOW_URL}/health`, {
maxRetries: 60,
intervalMs: 3000,
});
},
async deployWorkflow() {
// DAG file is volume-mounted. Wait for scheduler to parse it.
await waitForDag();
await unpauseDag();
await new Promise((r) => setTimeout(r, 2000));
},
async triggerOne() {
const start = performance.now();
const result = await triggerAndWait();
const latencyMs = performance.now() - start;
return { latencyMs, result };
},
async triggerBatch(n: number) {
const start = performance.now();
const promises = Array.from({ length: n }, () => triggerAndWait());
const results = await Promise.all(promises);
const totalMs = performance.now() - start;
return { totalMs, results };
},
async teardown() {
await composeDown(this.composeFile);
},
async getVersion() {
try {
const resp = await fetch(`${AIRFLOW_URL}/version`, { headers: headers() });
const body = await resp.json();
return body.version ?? "unknown";
} catch (_) {
return "unknown";
}
},
};

View File

@@ -0,0 +1,29 @@
from airflow import DAG
from airflow.operators.python import PythonOperator
from datetime import datetime
def step_a():
return 1
def step_b():
return 2
def step_c():
return 3
with DAG(
dag_id="benchmark_3step",
start_date=datetime(2024, 1, 1),
schedule=None,
catchup=False,
max_active_runs=200,
) as dag:
t1 = PythonOperator(task_id="step_a", python_callable=step_a)
t2 = PythonOperator(task_id="step_b", python_callable=step_b)
t3 = PythonOperator(task_id="step_c", python_callable=step_c)
t1 >> t2 >> t3

View File

@@ -0,0 +1,57 @@
x-airflow-common: &airflow-common
image: apache/airflow:2.10.5
environment: &airflow-common-env
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:airflow@postgres:5432/airflow
AIRFLOW__CORE__FERNET_KEY: ""
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
AIRFLOW__API__AUTH_BACKENDS: "airflow.api.auth.backend.basic_auth"
AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL: "5"
AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL: "0"
AIRFLOW__CORE__PARALLELISM: "32"
AIRFLOW__CORE__MAX_ACTIVE_TASKS_PER_DAG: "32"
volumes:
- ./dags:/opt/airflow/dags
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: airflow
POSTGRES_PASSWORD: airflow
POSTGRES_DB: airflow
ports:
- "5436:5432"
airflow-init:
<<: *airflow-common
entrypoint: /bin/bash
command:
- -c
- |
airflow db migrate &&
airflow users create \
--username admin \
--password admin \
--firstname Admin \
--lastname User \
--role Admin \
--email admin@example.com
depends_on:
- postgres
webserver:
<<: *airflow-common
command: airflow webserver --port 8090
ports:
- "8090:8090"
depends_on:
airflow-init:
condition: service_completed_successfully
scheduler:
<<: *airflow-common
command: airflow scheduler
depends_on:
airflow-init:
condition: service_completed_successfully

View File

@@ -0,0 +1,229 @@
/**
* Generate SVG bar charts from competitor benchmark results.
*
* Usage:
* deno run -A competitor_graphs.ts -c competitor_graphs_config.json --results-dir ./results
*/
import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
import { JSDOM } from "https://jspm.dev/jsdom@22";
import { Command } from "https://deno.land/x/cliffy@v0.25.7/command/mod.ts";
interface GraphConfig {
graph_title: string;
metric: string;
unit: string;
lower_is_better: boolean;
filename: string;
}
interface SummaryEntry {
competitor: string;
metric: string;
value: number;
ts: number;
}
const COLORS: Record<string, string> = {
windmill: "#3b82f6",
temporal: "#8b5cf6",
inngest: "#f59e0b",
restate: "#10b981",
kestra: "#ef4444",
};
function drawBarChart(
data: { name: string; value: number }[],
title: string,
unit: string,
lowerIsBetter: boolean,
): string {
const context = { jsdom: new JSDOM("") };
const { document } = context.jsdom.window;
const body = d3.select(document).select("body");
const width = 500;
const height = 280;
const marginTop = 40;
const marginRight = 30;
const marginBottom = 50;
const marginLeft = 80;
let svg = body
.append("svg")
.attr("xmlns", "http://www.w3.org/2000/svg")
.attr("width", width + marginLeft + marginRight)
.attr("height", height + marginTop + marginBottom);
svg.append("rect").attr("width", "100%").attr("height", "100%").attr("fill", "white");
svg = svg
.append("g")
.attr("transform", `translate(${marginLeft},${marginTop})`);
// X scale (competitors)
const x = d3
.scaleBand()
.domain(data.map((d) => d.name))
.range([0, width])
.padding(0.3);
// Y scale (metric values)
// deno-lint-ignore no-explicit-any
const maxVal = d3.max(data, (d: any) => d.value) ?? 1;
const y = d3
.scaleLinear()
.domain([0, maxVal * 1.2])
.nice()
.range([height, 0]);
// Axes
svg
.append("g")
.attr("transform", `translate(0,${height})`)
.call(d3.axisBottom(x))
.selectAll("text")
.attr("style", "font-size: 12px; font-weight: bold");
svg.append("g").call(d3.axisLeft(y).ticks(6));
// Y axis label
svg
.append("text")
.attr("text-anchor", "middle")
.attr("style", "font-size: 12px")
.attr("transform", "rotate(-90)")
.attr("y", -marginLeft + 20)
.attr("x", -height / 2)
.text(`[${unit}]`);
// Title
svg
.append("text")
.attr("text-anchor", "middle")
.attr("style", "font-size: 14px; font-weight: bold")
.attr("y", -15)
.attr("x", width / 2)
.text(title);
// Subtitle
svg
.append("text")
.attr("text-anchor", "middle")
.attr("style", "font-size: 10px; fill: #666")
.attr("y", -2)
.attr("x", width / 2)
.text(lowerIsBetter ? "(lower is better)" : "(higher is better)");
// Bars
svg
.selectAll(".bar")
.data(data)
.join("rect")
.attr("class", "bar")
.attr("x", (d: any) => x(d.name)!)
.attr("y", (d: any) => y(d.value))
.attr("width", x.bandwidth())
.attr("height", (d: any) => height - y(d.value))
.attr("fill", (d: any) => COLORS[d.name] ?? "#999")
.attr("rx", 3);
// Value labels on top of bars
svg
.selectAll(".label")
.data(data)
.join("text")
.attr("class", "label")
.attr("text-anchor", "middle")
.attr("style", "font-size: 11px; font-weight: bold")
.attr("x", (d: any) => x(d.name)! + x.bandwidth() / 2)
.attr("y", (d: any) => y(d.value) - 5)
.text((d: any) => `${d.value.toFixed(1)}`);
return body.node().innerHTML;
}
async function main({
configPath,
resultsDir,
outputDir,
}: {
configPath: string;
resultsDir: string;
outputDir: string;
}) {
const configs: GraphConfig[] = JSON.parse(
await Deno.readTextFile(configPath),
);
// Load the summary file
let summary: SummaryEntry[];
try {
summary = JSON.parse(
await Deno.readTextFile(`${resultsDir}/competitor_comparison_benchmark.json`),
);
} catch (e) {
console.error(`Failed to load summary from ${resultsDir}:`, e);
Deno.exit(1);
}
try {
await Deno.mkdir(outputDir, { recursive: true });
} catch (_) {
// already exists
}
for (const config of configs) {
const entries = summary.filter((e) => e.metric === config.metric);
if (entries.length === 0) {
console.warn(`No data for metric: ${config.metric}, skipping`);
continue;
}
// Use the latest entry per competitor
const latest = new Map<string, SummaryEntry>();
for (const e of entries) {
const existing = latest.get(e.competitor);
if (!existing || e.ts > existing.ts) {
latest.set(e.competitor, e);
}
}
const data = Array.from(latest.values()).map((e) => ({
name: e.competitor,
value: e.value,
}));
// Sort: best first (lower-is-better → ascending, higher-is-better → descending)
data.sort((a, b) =>
config.lower_is_better ? a.value - b.value : b.value - a.value,
);
const svgContent = drawBarChart(
data,
config.graph_title,
config.unit,
config.lower_is_better,
);
const filepath = `${outputDir}/${config.filename}`;
await Deno.writeTextFile(filepath, svgContent);
console.log(`Generated: ${filepath}`);
}
}
await new Command()
.name("competitor-graphs")
.description("Generate SVG bar charts from competitor benchmark results.")
.version("1.0.0")
.option("-c --config-path <path:string>", "Path to graph config JSON", {
required: true,
})
.option("--results-dir <path:string>", "Directory containing result JSON files", {
default: "./results",
})
.option("--output-dir <path:string>", "Directory to write SVG files", {
default: "./results",
})
.action(main)
.parse();

View File

@@ -0,0 +1,37 @@
[
{
"graph_title": "Cold Start Latency (3-step sequential workflow)",
"metric": "cold_start_ms",
"unit": "ms",
"lower_is_better": true,
"filename": "competitor_cold_start.svg"
},
{
"graph_title": "Single Execution Latency — Median (3-step sequential)",
"metric": "single_latency_median_ms",
"unit": "ms",
"lower_is_better": true,
"filename": "competitor_latency_median.svg"
},
{
"graph_title": "Single Execution Latency — P95 (3-step sequential)",
"metric": "single_latency_p95_ms",
"unit": "ms",
"lower_is_better": true,
"filename": "competitor_latency_p95.svg"
},
{
"graph_title": "Throughput (concurrent 3-step workflows)",
"metric": "throughput_per_second",
"unit": "workflows/s",
"lower_is_better": false,
"filename": "competitor_throughput.svg"
},
{
"graph_title": "Per-Step Orchestration Overhead",
"metric": "step_overhead_ms",
"unit": "ms",
"lower_is_better": true,
"filename": "competitor_step_overhead.svg"
}
]

View File

@@ -0,0 +1,174 @@
/**
* Main benchmark orchestrator.
*
* Runs cold start, single latency, throughput, and step overhead tests
* against one or more competitor adapters sequentially.
*/
import type { CompetitorAdapter, BenchmarkResult } from "./types.ts";
import {
warmup,
collectLatencySamples,
measureThroughput,
computeStats,
} from "./lib/timing.ts";
import { saveResult, saveSummary, getCpuCount, getMachineId } from "./lib/results.ts";
import { composeLogs } from "./lib/docker.ts";
export interface HarnessOptions {
latencySamples: number;
throughputBatch: number;
warmupCount: number;
outputDir: string;
}
const DEFAULTS: HarnessOptions = {
latencySamples: 50,
throughputBatch: 100,
warmupCount: 5,
outputDir: ".",
};
export async function runBenchmark(
adapter: CompetitorAdapter,
opts: Partial<HarnessOptions> = {},
): Promise<BenchmarkResult> {
const options = { ...DEFAULTS, ...opts };
const machine = await getMachineId();
const numCpus = getCpuCount();
console.log(`\n${"=".repeat(60)}`);
console.log(`Benchmarking: ${adapter.name}`);
console.log(`${"=".repeat(60)}`);
// 1. Setup
console.log(`[${adapter.name}] Setting up containers...`);
try {
await adapter.setup();
} catch (e) {
console.error(`[${adapter.name}] Setup failed, dumping logs...`);
try {
console.error(await composeLogs(adapter.composeFile));
} catch (_) {
// ignore
}
throw e;
}
let version = "unknown";
try {
version = await adapter.getVersion();
} catch (_) {
// non-critical
}
console.log(`[${adapter.name}] Version: ${version}`);
// 2. Deploy workflow
console.log(`[${adapter.name}] Deploying workflow...`);
await adapter.deployWorkflow();
// 3. Cold start
console.log(`[${adapter.name}] Measuring cold start...`);
const coldStartResult = await adapter.triggerOne();
const coldStartMs = coldStartResult.latencyMs;
console.log(`[${adapter.name}] Cold start: ${coldStartMs.toFixed(1)}ms`);
// 4. Warmup
console.log(`[${adapter.name}] Warming up (${options.warmupCount} runs)...`);
await warmup(adapter, options.warmupCount);
// 5. Single execution latency
console.log(
`[${adapter.name}] Collecting ${options.latencySamples} latency samples...`,
);
const samples = await collectLatencySamples(adapter, options.latencySamples);
const latencyStats = computeStats(samples);
console.log(
`[${adapter.name}] Latency: median=${latencyStats.median_ms}ms p95=${latencyStats.p95_ms}ms mean=${latencyStats.mean_ms}ms`,
);
// 6. Throughput
console.log(
`[${adapter.name}] Measuring throughput (batch of ${options.throughputBatch})...`,
);
const { totalMs, perSecond } = await measureThroughput(
adapter,
options.throughputBatch,
);
console.log(
`[${adapter.name}] Throughput: ${perSecond.toFixed(2)} workflows/s (${totalMs.toFixed(0)}ms total)`,
);
// 7. Step overhead
const stepOverheadMs = Math.round((latencyStats.median_ms / 3) * 100) / 100;
console.log(`[${adapter.name}] Step overhead: ${stepOverheadMs}ms/step`);
// 8. Teardown
console.log(`[${adapter.name}] Tearing down...`);
await adapter.teardown();
// 9. Build result
const result: BenchmarkResult = {
competitor: adapter.name,
timestamp: Date.now(),
environment: {
machine,
competitor_version: version,
num_cpus: numCpus,
},
cold_start: { latency_ms: coldStartMs },
single_latency: latencyStats,
throughput: {
batch_size: options.throughputBatch,
total_ms: totalMs,
per_second: perSecond,
},
step_overhead: { per_step_ms: stepOverheadMs },
};
await saveResult(result, options.outputDir);
return result;
}
export async function runAllBenchmarks(
adapters: CompetitorAdapter[],
opts: Partial<HarnessOptions> = {},
): Promise<BenchmarkResult[]> {
const results: BenchmarkResult[] = [];
for (const adapter of adapters) {
try {
const result = await runBenchmark(adapter, opts);
results.push(result);
} catch (e) {
console.error(`\n[${adapter.name}] BENCHMARK FAILED:`, e);
// Continue with next competitor
try {
await adapter.teardown();
} catch (_) {
// best effort cleanup
}
}
}
if (results.length > 0) {
const outputDir = opts.outputDir ?? ".";
await saveSummary(results, outputDir);
}
// Print summary table
console.log(`\n${"=".repeat(60)}`);
console.log("RESULTS SUMMARY");
console.log(`${"=".repeat(60)}`);
console.log(
`${"Competitor".padEnd(15)} ${"Cold Start".padEnd(12)} ${"Median".padEnd(12)} ${"P95".padEnd(12)} ${"Throughput".padEnd(15)} ${"Step OH".padEnd(10)}`,
);
console.log("-".repeat(76));
for (const r of results) {
console.log(
`${r.competitor.padEnd(15)} ${(r.cold_start.latency_ms.toFixed(1) + "ms").padEnd(12)} ${(r.single_latency.median_ms.toFixed(1) + "ms").padEnd(12)} ${(r.single_latency.p95_ms.toFixed(1) + "ms").padEnd(12)} ${(r.throughput.per_second.toFixed(2) + "/s").padEnd(15)} ${(r.step_overhead.per_step_ms.toFixed(1) + "ms").padEnd(10)}`,
);
}
return results;
}

View File

@@ -0,0 +1,120 @@
/**
* CLI wrapper for the competitor benchmark harness.
*
* Usage:
* deno run -A competitor_suite.ts
* deno run -A competitor_suite.ts --competitors windmill,temporal
* deno run -A competitor_suite.ts --latency-samples 100 --throughput-batch 200
*/
import { Command } from "https://deno.land/x/cliffy@v0.25.7/command/mod.ts";
import { runAllBenchmarks } from "./competitor_harness.ts";
import type { CompetitorAdapter, SuiteConfig } from "./types.ts";
// Import all adapters
import { windmillAdapter } from "./windmill/adapter.ts";
import { temporalAdapter } from "./temporal/adapter.ts";
import { inngestAdapter } from "./inngest/adapter.ts";
import { restateAdapter } from "./restate/adapter.ts";
import { kestraAdapter } from "./kestra/adapter.ts";
import { prefectAdapter } from "./prefect/adapter.ts";
import { airflowAdapter } from "./airflow/adapter.ts";
const ALL_ADAPTERS: Record<string, CompetitorAdapter> = {
windmill: windmillAdapter,
temporal: temporalAdapter,
inngest: inngestAdapter,
restate: restateAdapter,
kestra: kestraAdapter,
prefect: prefectAdapter,
airflow: airflowAdapter,
};
async function main({
competitors,
configPath,
latencySamples,
throughputBatch,
warmupCount,
outputDir,
}: {
competitors?: string;
configPath?: string;
latencySamples?: number;
throughputBatch?: number;
warmupCount?: number;
outputDir: string;
}) {
let config: Partial<SuiteConfig> = {};
// Load config file if provided
if (configPath) {
config = JSON.parse(await Deno.readTextFile(configPath));
}
// CLI flags override config file
const competitorNames = competitors
? competitors.split(",").map((c) => c.trim())
: config.competitors ?? Object.keys(ALL_ADAPTERS);
const adapters: CompetitorAdapter[] = [];
for (const name of competitorNames) {
const adapter = ALL_ADAPTERS[name];
if (!adapter) {
console.error(`Unknown competitor: ${name}. Available: ${Object.keys(ALL_ADAPTERS).join(", ")}`);
Deno.exit(1);
}
adapters.push(adapter);
}
console.log(`Competitors: ${adapters.map((a) => a.name).join(", ")}`);
console.log(`Output directory: ${outputDir}`);
// Ensure output directory exists
try {
await Deno.mkdir(outputDir, { recursive: true });
} catch (_) {
// already exists
}
await runAllBenchmarks(adapters, {
latencySamples: latencySamples ?? config.latency_samples ?? 50,
throughputBatch: throughputBatch ?? config.throughput_batch ?? 100,
warmupCount: warmupCount ?? config.warmup_count ?? 5,
outputDir,
});
}
await new Command()
.name("competitor-bench")
.description(
"Run workflow-as-code performance benchmarks against competitor platforms.",
)
.version("1.0.0")
.option(
"--competitors <list:string>",
"Comma-separated competitor names (default: all)",
)
.option(
"-c --config-path <path:string>",
"Path to suite config JSON",
)
.option(
"--latency-samples <n:number>",
"Number of single-execution latency samples",
)
.option(
"--throughput-batch <n:number>",
"Number of concurrent executions for throughput test",
)
.option(
"--warmup-count <n:number>",
"Number of warmup executions before latency test",
)
.option(
"--output-dir <path:string>",
"Directory to write result JSON files",
{ default: "./results" },
)
.action(main)
.parse();

View File

@@ -0,0 +1,6 @@
{
"competitors": ["windmill", "temporal", "inngest", "restate", "kestra"],
"latency_samples": 50,
"throughput_batch": 100,
"warmup_count": 5
}

View File

@@ -0,0 +1,6 @@
FROM node:20-slim
WORKDIR /app
COPY package.json ./
RUN npm install
COPY workflow.ts serve.ts ./
CMD ["npx", "tsx", "serve.ts"]

View File

@@ -0,0 +1,149 @@
/**
* Inngest CompetitorAdapter.
*
* Uses Inngest Dev Server HTTP APIs:
* - POST /e/{eventKey} to send events (trigger functions)
* - GET /v1/events/{eventId}/runs to poll for function completion
*/
import type { CompetitorAdapter } from "../types.ts";
import { composeUp, composeDown, waitForHealth } from "../lib/docker.ts";
import { dirname, fromFileUrl, resolve } from "https://deno.land/std@0.224.0/path/mod.ts";
const SELF_DIR = dirname(fromFileUrl(import.meta.url));
const INNGEST_URL = "http://127.0.0.1:8288";
const EVENT_KEY = "bench-event-key";
async function sendEvent(): Promise<string> {
const resp = await fetch(`${INNGEST_URL}/e/${EVENT_KEY}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "benchmark/run",
data: {},
}),
});
if (!resp.ok) throw new Error(`Event send failed: ${resp.status} ${await resp.text()}`);
const body = await resp.json();
// Dev server returns { ids: [eventId], status: 200 }
return body.ids?.[0] ?? body.internal_id ?? "";
}
async function waitForRun(
eventId: string,
timeoutMs = 30000,
): Promise<unknown> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const resp = await fetch(
`${INNGEST_URL}/v1/events/${eventId}/runs?ts=${Date.now()}`,
{ headers: { "Cache-Control": "no-cache" } },
);
if (resp.ok) {
const body = await resp.json();
const runs = body.data ?? body;
if (Array.isArray(runs) && runs.length > 0) {
const run = runs[0];
if (run.status === "Completed" || run.status === "completed") {
return run.output;
}
if (run.status === "Failed" || run.status === "failed") {
throw new Error(`Inngest run failed: ${JSON.stringify(run)}`);
}
}
} else {
await resp.body?.cancel();
}
} catch (e) {
if (e instanceof Error && e.message.startsWith("Inngest run failed")) throw e;
// connection error, retry
}
await new Promise((r) => setTimeout(r, 50));
}
throw new Error(`Inngest run did not complete within ${timeoutMs}ms`);
}
async function triggerAndWait(): Promise<unknown> {
const eventId = await sendEvent();
return await waitForRun(eventId);
}
export const inngestAdapter: CompetitorAdapter = {
name: "inngest",
composeFile: resolve(SELF_DIR, "docker-compose.yml"),
async setup() {
await composeUp(this.composeFile);
await waitForHealth(`${INNGEST_URL}/health`, { maxRetries: 30 });
await waitForHealth("http://127.0.0.1:3010/health", { maxRetries: 30 });
// Give the dev server time to discover and sync the app functions
await new Promise((r) => setTimeout(r, 3000));
},
async deployWorkflow() {
// Trigger a sync by hitting the app's inngest endpoint from the dev server
try {
await fetch(`${INNGEST_URL}/v0/gql`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `mutation { syncNewApp(appURL: "http://app:3000/api/inngest") { app { id } } }`,
}),
});
} catch (_) {
// Best effort
}
// Wait until the function is actually registered by sending a test event
// and checking that a run appears
for (let i = 0; i < 20; i++) {
try {
const testEventId = await sendEvent();
await new Promise((r) => setTimeout(r, 2000));
const resp = await fetch(`${INNGEST_URL}/v1/events/${testEventId}/runs`);
if (resp.ok) {
const body = await resp.json();
const runs = body.data ?? body;
if (Array.isArray(runs) && runs.length > 0) {
// Function is registered and running/completed. Wait for completion.
await waitForRun(testEventId, 15000).catch(() => {});
return;
}
}
} catch (_) {
// not ready yet
}
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error("Inngest function did not register after retries");
},
async triggerOne() {
const start = performance.now();
const result = await triggerAndWait();
const latencyMs = performance.now() - start;
return { latencyMs, result };
},
async triggerBatch(n: number) {
const start = performance.now();
const promises = Array.from({ length: n }, () => triggerAndWait());
const results = await Promise.all(promises);
const totalMs = performance.now() - start;
return { totalMs, results };
},
async teardown() {
await composeDown(this.composeFile);
},
async getVersion() {
try {
const resp = await fetch(`${INNGEST_URL}/health`);
return (await resp.text()).trim();
} catch (_) {
return "unknown";
}
},
};

View File

@@ -0,0 +1,21 @@
services:
inngest:
image: inngest/inngest:latest
command: ["inngest", "dev", "-u", "http://app:3000/api/inngest", "--no-discovery", "--tick", "10", "--retry-interval", "0", "--poll-interval", "1", "--queue-workers", "10"]
ports:
- "8288:8288"
environment:
- INNGEST_EVENT_KEY=bench-event-key
- INNGEST_SIGNING_KEY=signkey-bench-000000000000000000000000000000000000000000000000
app:
image: inngest-benchmark-app
depends_on:
- inngest
environment:
- INNGEST_DEV=1
- INNGEST_EVENT_KEY=bench-event-key
- INNGEST_SIGNING_KEY=signkey-bench-000000000000000000000000000000000000000000000000
- INNGEST_BASE_URL=http://inngest:8288
ports:
- "3010:3000"

View File

@@ -0,0 +1,13 @@
{
"name": "inngest-benchmark",
"private": true,
"dependencies": {
"inngest": "^3.0.0",
"express": "^4.21.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
},
"devDependencies": {
"@types/express": "^4.17.21"
}
}

View File

@@ -0,0 +1,19 @@
import express from "express";
import { serve } from "inngest/express";
import { inngest, benchmarkFn } from "./workflow";
const app = express();
// Inngest needs the raw body for signature verification
app.use(express.json());
app.use(
"/api/inngest",
serve({ client: inngest, functions: [benchmarkFn] }),
);
app.get("/health", (_req, res) => res.send("ok"));
app.listen(3000, () => {
console.log("Inngest app server listening on port 3000");
});

View File

@@ -0,0 +1,14 @@
import { Inngest } from "inngest";
export const inngest = new Inngest({ id: "benchmark" });
export const benchmarkFn = inngest.createFunction(
{ id: "benchmark-3step" },
{ event: "benchmark/run" },
async ({ step }) => {
const a = await step.run("step-a", async () => 1);
const b = await step.run("step-b", async () => 2);
const c = await step.run("step-c", async () => 3);
return { a, b, c };
},
);

View File

@@ -0,0 +1,185 @@
/**
* Kestra CompetitorAdapter.
*
* Kestra 1.3+ has mandatory basic auth that requires UI-based initial setup.
* To work around this, we:
* - Deploy flows via direct SQL insertion into the Kestra Postgres database
* - Trigger executions via the webhook trigger which can be configured with a key
* - Poll execution status via SQL queries
*
* For execution triggering, we use the internal queue mechanism:
* insert execution records directly and let the Kestra worker pick them up.
*
* Alternative simpler approach: use `docker exec` to run flows via the kestra CLI.
*/
import type { CompetitorAdapter } from "../types.ts";
import { composeUp, composeDown, waitForHealth } from "../lib/docker.ts";
import { dirname, fromFileUrl, resolve } from "https://deno.land/std@0.224.0/path/mod.ts";
const SELF_DIR = dirname(fromFileUrl(import.meta.url));
const KESTRA_URL = "http://127.0.0.1:8081";
const KESTRA_PG = "postgresql://kestra:kestra@127.0.0.1:5434/kestra";
async function dockerExec(container: string, ...args: string[]): Promise<string> {
const cmd = new Deno.Command("docker", {
args: ["exec", container, ...args],
stdout: "piped",
stderr: "piped",
});
const output = await cmd.output();
const stdout = new TextDecoder().decode(output.stdout);
if (output.code !== 0) {
const stderr = new TextDecoder().decode(output.stderr);
throw new Error(`docker exec failed: ${stderr}`);
}
return stdout.trim();
}
async function psql(query: string): Promise<string> {
return await dockerExec(
"kestra-postgres-1",
"psql", "-U", "kestra", "-d", "kestra", "-t", "-A", "-c", query,
);
}
async function deployFlowViaSQL(): Promise<void> {
const flowYaml = await Deno.readTextFile(resolve(SELF_DIR, "workflow.yml"));
const now = new Date().toISOString();
const flowJson = JSON.stringify({
id: "benchmark-3step",
namespace: "benchmark",
tenantId: "main",
revision: 1,
deleted: false,
disabled: false,
updated: now,
source: flowYaml,
tasks: [
{ id: "step_a", type: "io.kestra.plugin.core.debug.Return", format: "1" },
{ id: "step_b", type: "io.kestra.plugin.core.debug.Return", format: "2" },
{ id: "step_c", type: "io.kestra.plugin.core.debug.Return", format: "3" },
],
triggers: [
{ id: "benchmark_webhook", type: "io.kestra.plugin.core.trigger.Webhook", key: "benchmark-key" },
],
});
const escapedJson = flowJson.replace(/'/g, "''");
const escapedYaml = flowYaml.replace(/'/g, "''");
// Delete existing flow if any
await psql(`DELETE FROM flows WHERE id = 'benchmark-3step' AND namespace = 'benchmark';`).catch(() => {});
// Insert the flow with the correct key format: tenantId_namespace_id_revision
await psql(
`INSERT INTO flows (key, value, source_code) VALUES ('main_benchmark_benchmark-3step_1', '${escapedJson}'::jsonb, '${escapedYaml}');`,
);
// Also insert into the queues table to notify Kestra of the new flow
const queueMsg = JSON.stringify({
type: "io.kestra.core.models.flows.Flow",
key: "main_benchmark_benchmark-3step_1",
});
const escapedQueue = queueMsg.replace(/'/g, "''");
await psql(
`INSERT INTO queues (type, key, value, consumers, updated) VALUES ('io.kestra.core.models.flows.Flow', 'main_benchmark_benchmark-3step_1', '${escapedQueue}'::jsonb, '{}', NOW());`,
).catch(() => {});
}
async function triggerViaWebhook(): Promise<{ executionId: string }> {
const resp = await fetch(
`${KESTRA_URL}/api/v1/executions/webhook/benchmark/benchmark-3step/benchmark-key`,
{ method: "POST" },
);
if (!resp.ok) {
throw new Error(`Kestra webhook trigger failed: ${resp.status} ${await resp.text()}`);
}
const body = await resp.json();
return { executionId: body.id };
}
async function waitForExecution(executionId: string, timeoutMs = 30000): Promise<unknown> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const result = await psql(
`SELECT value->>'state' FROM executions WHERE key = '${executionId}';`,
);
const lines = result.split("\n").filter(Boolean);
if (lines.length > 0) {
try {
const state = JSON.parse(lines[0]);
if (state.current === "SUCCESS") return state;
if (state.current === "FAILED" || state.current === "KILLED") {
throw new Error(`Kestra execution failed: ${lines[0]}`);
}
} catch (e) {
if (e instanceof Error && e.message.startsWith("Kestra execution failed")) throw e;
// Not valid JSON state yet, maybe "SUCCESS" as raw string
if (lines[0].includes("SUCCESS")) return { current: "SUCCESS" };
if (lines[0].includes("FAILED")) throw new Error(`Kestra execution failed: ${lines[0]}`);
}
}
} catch (e) {
if (e instanceof Error && e.message.startsWith("Kestra execution failed")) throw e;
}
await new Promise((r) => setTimeout(r, 100));
}
throw new Error(`Kestra execution ${executionId} did not complete within ${timeoutMs}ms`);
}
async function triggerAndWait(): Promise<unknown> {
const { executionId } = await triggerViaWebhook();
return await waitForExecution(executionId);
}
export const kestraAdapter: CompetitorAdapter = {
name: "kestra",
composeFile: resolve(SELF_DIR, "docker-compose.yml"),
async setup() {
await composeUp(this.composeFile);
// Wait for Kestra to be ready (configs endpoint is public)
await waitForHealth(`${KESTRA_URL}/api/v1/configs`, {
maxRetries: 40,
intervalMs: 3000,
});
// Extra wait for Kestra to finish internal initialization
await new Promise((r) => setTimeout(r, 5000));
},
async deployWorkflow() {
await deployFlowViaSQL();
// Wait for Kestra to pick up the flow from the database
await new Promise((r) => setTimeout(r, 3000));
},
async triggerOne() {
const start = performance.now();
const result = await triggerAndWait();
const latencyMs = performance.now() - start;
return { latencyMs, result };
},
async triggerBatch(n: number) {
const start = performance.now();
const promises = Array.from({ length: n }, () => triggerAndWait());
const results = await Promise.all(promises);
const totalMs = performance.now() - start;
return { totalMs, results };
},
async teardown() {
await composeDown(this.composeFile);
},
async getVersion() {
try {
const resp = await fetch(`${KESTRA_URL}/api/v1/configs`);
const body = await resp.json();
return body.version ?? "unknown";
} catch (_) {
return "unknown";
}
},
};

View File

@@ -0,0 +1,41 @@
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: kestra
POSTGRES_PASSWORD: kestra
POSTGRES_DB: kestra
ports:
- "5434:5432"
kestra:
image: kestra/kestra:latest
depends_on:
- postgres
command: server standalone
environment:
KESTRA_CONFIGURATION: |
datasources:
postgres:
url: jdbc:postgresql://postgres:5432/kestra
driverClassName: org.postgresql.Driver
username: kestra
password: kestra
kestra:
repository:
type: postgres
queue:
type: postgres
storage:
type: local
local:
base-path: /app/storage
security:
basic-auth:
enabled: false
api-tokens:
- token: benchtoken12345678901234567890
description: Benchmark token
extended: true
ports:
- "8081:8080"

View File

@@ -0,0 +1,20 @@
id: benchmark-3step
namespace: benchmark
tasks:
- id: step_a
type: io.kestra.plugin.core.debug.Return
format: "1"
- id: step_b
type: io.kestra.plugin.core.debug.Return
format: "2"
- id: step_c
type: io.kestra.plugin.core.debug.Return
format: "3"
triggers:
- id: benchmark_webhook
type: io.kestra.plugin.core.trigger.Webhook
key: benchmark-key

View File

@@ -0,0 +1,85 @@
/**
* Docker Compose lifecycle helpers.
* Wraps `docker compose` CLI for starting/stopping competitor stacks.
*/
import { dirname } from "https://deno.land/std@0.224.0/path/mod.ts";
async function run(
args: string[],
cwd?: string,
): Promise<{ code: number; stdout: string; stderr: string }> {
const cmd = new Deno.Command("docker", { args, cwd, stdout: "piped", stderr: "piped" });
const output = await cmd.output();
return {
code: output.code,
stdout: new TextDecoder().decode(output.stdout),
stderr: new TextDecoder().decode(output.stderr),
};
}
export async function composeUp(composeFile: string): Promise<void> {
const cwd = dirname(composeFile);
const file = composeFile.split("/").pop()!;
// Force-remove any lingering containers from a previous run
await run(["compose", "-f", file, "down", "-v", "--remove-orphans"], cwd);
const { code, stderr } = await run(
["compose", "-f", file, "up", "-d", "--force-recreate"],
cwd,
);
if (code !== 0) {
throw new Error(`docker compose up failed for ${composeFile}:\n${stderr}`);
}
}
export async function composeDown(composeFile: string): Promise<void> {
const cwd = dirname(composeFile);
const file = composeFile.split("/").pop()!;
const { code, stderr } = await run(
["compose", "-f", file, "down", "-v", "--remove-orphans"],
cwd,
);
if (code !== 0) {
console.error(`docker compose down warning for ${composeFile}:\n${stderr}`);
}
}
export async function composeLogs(
composeFile: string,
service?: string,
): Promise<string> {
const cwd = dirname(composeFile);
const file = composeFile.split("/").pop()!;
const args = ["compose", "-f", file, "logs", "--tail=100"];
if (service) args.push(service);
const { stdout } = await run(args, cwd);
return stdout;
}
/**
* Poll an HTTP endpoint until it returns 2xx or we exceed maxRetries.
*/
export async function waitForHealth(
url: string,
{ maxRetries = 30, intervalMs = 2000 } = {},
): Promise<void> {
for (let i = 0; i < maxRetries; i++) {
try {
const resp = await fetch(url);
if (resp.ok) {
// drain body
await resp.text();
return;
}
await resp.body?.cancel();
} catch (_) {
// connection refused, retry
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(
`Health check failed after ${maxRetries} retries: ${url}`,
);
}

View File

@@ -0,0 +1,89 @@
/**
* Result serialization and file I/O for benchmark results.
*/
import type { BenchmarkResult } from "../types.ts";
/** Save a benchmark result to a JSON file. */
export async function saveResult(
result: BenchmarkResult,
outputDir: string,
): Promise<string> {
const filename = `${result.competitor}_competitor_benchmark.json`;
const filepath = `${outputDir}/${filename}`;
// Append to existing array if file exists, otherwise create new
let data: BenchmarkResult[] = [];
try {
const existing = await Deno.readTextFile(filepath);
data = JSON.parse(existing);
} catch (_) {
// File doesn't exist, start fresh
}
data.push(result);
await Deno.writeTextFile(filepath, JSON.stringify(data, null, 2));
console.log(`Results saved to ${filepath}`);
return filepath;
}
/** Generate a flat comparison summary for graphing. */
export async function saveSummary(
results: BenchmarkResult[],
outputDir: string,
): Promise<string> {
const filepath = `${outputDir}/competitor_comparison_benchmark.json`;
const summary = results.flatMap((r) => [
{
competitor: r.competitor,
metric: "cold_start_ms",
value: r.cold_start.latency_ms,
ts: r.timestamp,
},
{
competitor: r.competitor,
metric: "single_latency_median_ms",
value: r.single_latency.median_ms,
ts: r.timestamp,
},
{
competitor: r.competitor,
metric: "single_latency_p95_ms",
value: r.single_latency.p95_ms,
ts: r.timestamp,
},
{
competitor: r.competitor,
metric: "throughput_per_second",
value: r.throughput.per_second,
ts: r.timestamp,
},
{
competitor: r.competitor,
metric: "step_overhead_ms",
value: r.step_overhead.per_step_ms,
ts: r.timestamp,
},
]);
await Deno.writeTextFile(filepath, JSON.stringify(summary, null, 2));
console.log(`Summary saved to ${filepath}`);
return filepath;
}
/** Get approximate CPU count for environment metadata. */
export function getCpuCount(): number {
return navigator.hardwareConcurrency ?? 0;
}
/** Get machine identifier from environment or hostname. */
export async function getMachineId(): Promise<string> {
const github = Deno.env.get("RUNNER_NAME");
if (github) return github;
try {
const cmd = new Deno.Command("hostname", { stdout: "piped" });
const { stdout } = await cmd.output();
return new TextDecoder().decode(stdout).trim();
} catch (_) {
return "unknown";
}
}

View File

@@ -0,0 +1,77 @@
/**
* Precision timing utilities for benchmark measurements.
*/
import type { CompetitorAdapter, LatencyStats } from "../types.ts";
/** Measure a single workflow execution latency in ms. */
export async function measureLatency(
adapter: CompetitorAdapter,
): Promise<{ latencyMs: number; result: unknown }> {
const start = performance.now();
const { result } = await adapter.triggerOne();
const latencyMs = performance.now() - start;
return { latencyMs, result };
}
/** Run N warmup executions (results discarded). */
export async function warmup(
adapter: CompetitorAdapter,
count: number,
): Promise<void> {
for (let i = 0; i < count; i++) {
await adapter.triggerOne();
}
}
/** Collect N latency samples sequentially. */
export async function collectLatencySamples(
adapter: CompetitorAdapter,
count: number,
): Promise<number[]> {
const samples: number[] = [];
for (let i = 0; i < count; i++) {
const { latencyMs } = await measureLatency(adapter);
samples.push(latencyMs);
}
return samples;
}
/** Measure throughput by triggering a batch and timing total completion. */
export async function measureThroughput(
adapter: CompetitorAdapter,
batchSize: number,
): Promise<{ totalMs: number; perSecond: number }> {
const start = performance.now();
await adapter.triggerBatch(batchSize);
const totalMs = performance.now() - start;
return {
totalMs,
perSecond: (batchSize / totalMs) * 1000,
};
}
/** Compute statistics from a set of latency samples. */
export function computeStats(samples: number[]): LatencyStats {
const sorted = [...samples].sort((a, b) => a - b);
const n = sorted.length;
const mean = sorted.reduce((a, b) => a + b, 0) / n;
const variance = sorted.reduce((sum, v) => sum + (v - mean) ** 2, 0) / n;
return {
samples: sorted,
median_ms: percentile(sorted, 50),
p95_ms: percentile(sorted, 95),
mean_ms: round(mean),
stdev_ms: round(Math.sqrt(variance)),
};
}
function percentile(sorted: number[], p: number): number {
const idx = Math.ceil((p / 100) * sorted.length) - 1;
return round(sorted[Math.max(0, idx)]);
}
function round(v: number): number {
return Math.round(v * 100) / 100;
}

View File

@@ -0,0 +1,4 @@
FROM prefecthq/prefect:3-latest
WORKDIR /app
COPY flow.py .
CMD ["python", "flow.py"]

View File

@@ -0,0 +1,120 @@
/**
* Prefect CompetitorAdapter.
*
* Uses Prefect 3.x REST API:
* - GET /api/deployments/name/{flow}/{deployment} to find deployment ID
* - POST /api/deployments/{id}/create_flow_run to trigger
* - GET /api/flow_runs/{id} to poll status
*
* The worker runs flow.py with .serve() which both registers and executes.
*/
import type { CompetitorAdapter } from "../types.ts";
import { composeUp, composeDown, waitForHealth } from "../lib/docker.ts";
import { dirname, fromFileUrl, resolve } from "https://deno.land/std@0.224.0/path/mod.ts";
const SELF_DIR = dirname(fromFileUrl(import.meta.url));
const PREFECT_URL = "http://127.0.0.1:4200/api";
let deploymentId = "";
async function getDeploymentId(): Promise<string> {
const resp = await fetch(
`${PREFECT_URL}/deployments/name/benchmark-3step/benchmark-deployment`,
);
if (!resp.ok) throw new Error(`Get deployment failed: ${resp.status} ${await resp.text()}`);
const body = await resp.json();
return body.id;
}
async function triggerFlowRun(): Promise<string> {
const resp = await fetch(
`${PREFECT_URL}/deployments/${deploymentId}/create_flow_run`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
},
);
if (!resp.ok) throw new Error(`Trigger failed: ${resp.status} ${await resp.text()}`);
const body = await resp.json();
return body.id;
}
async function waitForFlowRun(runId: string, timeoutMs = 60000): Promise<unknown> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const resp = await fetch(`${PREFECT_URL}/flow_runs/${runId}`);
if (resp.ok) {
const body = await resp.json();
const state = body.state_type;
if (state === "COMPLETED") return body.state?.result;
if (state === "FAILED" || state === "CRASHED" || state === "CANCELLED") {
throw new Error(`Prefect flow run failed: ${state}`);
}
} else {
await resp.body?.cancel();
}
await new Promise((r) => setTimeout(r, 100));
}
throw new Error(`Prefect flow run ${runId} did not complete within ${timeoutMs}ms`);
}
async function triggerAndWait(): Promise<unknown> {
const runId = await triggerFlowRun();
return await waitForFlowRun(runId);
}
export const prefectAdapter: CompetitorAdapter = {
name: "prefect",
composeFile: resolve(SELF_DIR, "docker-compose.yml"),
async setup() {
await composeUp(this.composeFile);
await waitForHealth(`${PREFECT_URL}/health`, { maxRetries: 40, intervalMs: 3000 });
// Wait for worker to register the deployment
await new Promise((r) => setTimeout(r, 10000));
},
async deployWorkflow() {
// The worker's .serve() auto-registers. Poll until the deployment appears.
for (let i = 0; i < 30; i++) {
try {
deploymentId = await getDeploymentId();
if (deploymentId) return;
} catch (_) {
// not registered yet
}
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error("Prefect deployment did not register in time");
},
async triggerOne() {
const start = performance.now();
const result = await triggerAndWait();
const latencyMs = performance.now() - start;
return { latencyMs, result };
},
async triggerBatch(n: number) {
const start = performance.now();
const promises = Array.from({ length: n }, () => triggerAndWait());
const results = await Promise.all(promises);
const totalMs = performance.now() - start;
return { totalMs, results };
},
async teardown() {
await composeDown(this.composeFile);
},
async getVersion() {
try {
const resp = await fetch(`${PREFECT_URL}/admin/version`);
return (await resp.text()).replace(/"/g, "");
} catch (_) {
return "unknown";
}
},
};

View File

@@ -0,0 +1,27 @@
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: prefect
POSTGRES_PASSWORD: prefect
POSTGRES_DB: prefect
ports:
- "5435:5432"
server:
image: prefecthq/prefect:3-latest
command: prefect server start --host 0.0.0.0
environment:
PREFECT_API_DATABASE_CONNECTION_URL: "postgresql+asyncpg://prefect:prefect@postgres:5432/prefect"
ports:
- "4200:4200"
depends_on:
- postgres
worker:
image: prefect-benchmark-worker
environment:
PREFECT_API_URL: "http://server:4200/api"
depends_on:
- server
restart: on-failure

View File

@@ -0,0 +1,23 @@
from prefect import flow, task
@task
def step_a():
return 1
@task
def step_b():
return 2
@task
def step_c():
return 3
@flow(name="benchmark-3step")
def benchmark_flow():
a = step_a()
b = step_b()
c = step_c()
return {"a": a, "b": b, "c": c}
if __name__ == "__main__":
benchmark_flow.serve(name="benchmark-deployment")

View File

@@ -0,0 +1,6 @@
FROM node:20-slim
WORKDIR /app
COPY package.json ./
RUN npm install
COPY workflow.ts serve.ts ./
CMD ["npx", "tsx", "serve.ts"]

View File

@@ -0,0 +1,110 @@
/**
* Restate CompetitorAdapter.
*
* Uses Restate's pure HTTP APIs:
* - POST /deployments (admin) to register the app
* - POST /benchmark/{workflowId}/run (ingress) to start a workflow
* - GET /restate/workflow/benchmark/{workflowId}/attach to wait for result
*/
import type { CompetitorAdapter } from "../types.ts";
import { composeUp, composeDown, waitForHealth } from "../lib/docker.ts";
import { dirname, fromFileUrl, resolve } from "https://deno.land/std@0.224.0/path/mod.ts";
const SELF_DIR = dirname(fromFileUrl(import.meta.url));
const INGRESS_URL = "http://127.0.0.1:8085";
const ADMIN_URL = "http://127.0.0.1:9075";
async function triggerWorkflow(): Promise<unknown> {
const workflowId = crypto.randomUUID();
// Start the workflow via ingress (send mode for immediate return)
const startResp = await fetch(
`${INGRESS_URL}/benchmark/${workflowId}/run/send`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
},
);
if (!startResp.ok) {
throw new Error(
`Restate workflow start failed: ${startResp.status} ${await startResp.text()}`,
);
}
await startResp.body?.cancel();
// Attach and wait for the result
const attachResp = await fetch(
`${INGRESS_URL}/restate/workflow/benchmark/${workflowId}/attach`,
{
method: "GET",
headers: { Accept: "application/json" },
},
);
if (!attachResp.ok) {
throw new Error(
`Restate workflow attach failed: ${attachResp.status} ${await attachResp.text()}`,
);
}
return await attachResp.json();
}
export const restateAdapter: CompetitorAdapter = {
name: "restate",
composeFile: resolve(SELF_DIR, "docker-compose.yml"),
async setup() {
await composeUp(this.composeFile);
await waitForHealth(`${ADMIN_URL}/health`, { maxRetries: 30 });
// Wait for app server
await new Promise((r) => setTimeout(r, 3000));
},
async deployWorkflow() {
// Register the app deployment with Restate admin API
const resp = await fetch(`${ADMIN_URL}/deployments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uri: "http://app:9080" }),
});
if (!resp.ok) {
const body = await resp.text();
// 409 = already registered, that's fine
if (resp.status !== 409) {
throw new Error(`Restate deployment registration failed: ${resp.status} ${body}`);
}
} else {
await resp.body?.cancel();
}
await new Promise((r) => setTimeout(r, 1000));
},
async triggerOne() {
const start = performance.now();
const result = await triggerWorkflow();
const latencyMs = performance.now() - start;
return { latencyMs, result };
},
async triggerBatch(n: number) {
const start = performance.now();
const promises = Array.from({ length: n }, () => triggerWorkflow());
const results = await Promise.all(promises);
const totalMs = performance.now() - start;
return { totalMs, results };
},
async teardown() {
await composeDown(this.composeFile);
},
async getVersion() {
try {
const resp = await fetch(`${ADMIN_URL}/health`);
return (await resp.text()).trim();
} catch (_) {
return "unknown";
}
},
};

View File

@@ -0,0 +1,13 @@
services:
restate:
image: docker.io/restatedev/restate:latest
ports:
- "8085:8080" # Ingress
- "9075:9070" # Admin/meta
app:
image: restate-benchmark-app
depends_on:
- restate
ports:
- "9080:9080"

View File

@@ -0,0 +1,9 @@
{
"name": "restate-benchmark",
"private": true,
"dependencies": {
"@restatedev/restate-sdk": "^1.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,5 @@
import * as restate from "@restatedev/restate-sdk";
import { benchmarkWorkflow } from "./workflow";
restate.endpoint().bind(benchmarkWorkflow).listen(9080);
console.log("Restate app server listening on port 9080");

View File

@@ -0,0 +1,15 @@
import * as restate from "@restatedev/restate-sdk";
export const benchmarkWorkflow = restate.workflow({
name: "benchmark",
handlers: {
run: async (
ctx: restate.WorkflowContext,
): Promise<{ a: number; b: number; c: number }> => {
const a = await ctx.run("step-a", async () => 1);
const b = await ctx.run("step-b", async () => 2);
const c = await ctx.run("step-c", async () => 3);
return { a, b, c };
},
},
});

View File

@@ -0,0 +1,6 @@
FROM node:20-slim
WORKDIR /app
COPY package.json ./
RUN npm install
COPY activities.ts workflow.ts worker.ts ./
CMD ["npx", "tsx", "worker.ts"]

View File

@@ -0,0 +1,11 @@
export async function stepA(): Promise<number> {
return 1;
}
export async function stepB(): Promise<number> {
return 2;
}
export async function stepC(): Promise<number> {
return 3;
}

View File

@@ -0,0 +1,145 @@
/**
* Temporal CompetitorAdapter.
*
* Since the Temporal TypeScript SDK requires Node.js native bindings (gRPC),
* we can't import it directly in Deno. Instead we spawn a Node.js subprocess
* that connects to Temporal, starts a workflow, waits for the result, and
* prints JSON to stdout.
*
* The worker itself runs inside Docker (Dockerfile.worker).
*/
import type { CompetitorAdapter } from "../types.ts";
import { composeUp, composeDown } from "../lib/docker.ts";
import { dirname, fromFileUrl, resolve } from "https://deno.land/std@0.224.0/path/mod.ts";
const SELF_DIR = dirname(fromFileUrl(import.meta.url));
const TEMPORAL_ADDRESS = "127.0.0.1:7233";
async function nodeEval(script: string): Promise<string> {
const cmd = new Deno.Command("node", {
args: ["-e", script],
cwd: resolve(SELF_DIR),
stdout: "piped",
stderr: "piped",
env: {
...Deno.env.toObject(),
TEMPORAL_ADDRESS,
},
});
const output = await cmd.output();
if (output.code !== 0) {
const stderr = new TextDecoder().decode(output.stderr);
throw new Error(`Temporal node call failed:\n${stderr}`);
}
return new TextDecoder().decode(output.stdout).trim();
}
async function triggerWorkflow(): Promise<unknown> {
const script = `
const { Client, Connection } = require("@temporalio/client");
(async () => {
const conn = await Connection.connect({ address: "${TEMPORAL_ADDRESS}" });
const client = new Client({ connection: conn });
const handle = await client.workflow.start("benchmarkWorkflow", {
taskQueue: "benchmark",
workflowId: "bench-" + require("crypto").randomUUID(),
});
const result = await handle.result();
console.log(JSON.stringify(result));
process.exit(0);
})();
`;
const out = await nodeEval(script);
return JSON.parse(out);
}
export const temporalAdapter: CompetitorAdapter = {
name: "temporal",
composeFile: resolve(SELF_DIR, "docker-compose.yml"),
async setup() {
// Install npm deps locally first (for the client subprocess)
console.log("[temporal] Installing npm deps...");
const install = new Deno.Command("npm", {
args: ["install"],
cwd: resolve(SELF_DIR),
stdout: "piped",
stderr: "piped",
});
const { code } = await install.output();
if (code !== 0) throw new Error("npm install failed in temporal/");
await composeUp(this.composeFile);
// Wait for Temporal server to be ready (auto-setup takes time for DB schema)
console.log("[temporal] Waiting for Temporal server to be ready...");
for (let i = 0; i < 60; i++) {
try {
const script = `
const { Connection } = require("@temporalio/client");
(async () => {
const conn = await Connection.connect({ address: "${TEMPORAL_ADDRESS}" });
console.log("ok");
process.exit(0);
})();
`;
const result = await nodeEval(script);
if (result === "ok") break;
} catch (_) {
// not ready yet
}
await new Promise((r) => setTimeout(r, 3000));
}
// Give the worker time to register after server is up
await new Promise((r) => setTimeout(r, 5000));
},
async deployWorkflow() {
// The worker auto-registers the workflow on startup.
// Verify connectivity.
const script = `
const { Connection } = require("@temporalio/client");
(async () => {
const conn = await Connection.connect({ address: "${TEMPORAL_ADDRESS}" });
console.log("connected");
process.exit(0);
})();
`;
await nodeEval(script);
},
async triggerOne() {
const start = performance.now();
const result = await triggerWorkflow();
const latencyMs = performance.now() - start;
return { latencyMs, result };
},
async triggerBatch(n: number) {
const start = performance.now();
const promises = Array.from({ length: n }, () => triggerWorkflow());
const results = await Promise.all(promises);
const totalMs = performance.now() - start;
return { totalMs, results };
},
async teardown() {
await composeDown(this.composeFile);
},
async getVersion() {
try {
const script = `
const { Connection } = require("@temporalio/client");
(async () => {
const conn = await Connection.connect({ address: "${TEMPORAL_ADDRESS}" });
console.log("temporal");
process.exit(0);
})();
`;
return await nodeEval(script);
} catch (_) {
return "unknown";
}
},
};

View File

@@ -0,0 +1,30 @@
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: temporal
POSTGRES_PASSWORD: temporal
POSTGRES_DB: temporal
ports:
- "5433:5432"
temporal:
image: temporalio/auto-setup:latest
depends_on:
- postgres
environment:
- DB=postgres12
- DB_PORT=5432
- POSTGRES_USER=temporal
- POSTGRES_PWD=temporal
- POSTGRES_SEEDS=postgres
ports:
- "7233:7233"
worker:
image: temporal-benchmark-worker
depends_on:
- temporal
environment:
- TEMPORAL_ADDRESS=temporal:7233
restart: on-failure

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
{
"name": "temporal-benchmark",
"private": true,
"dependencies": {
"@temporalio/activity": "^1.11.0",
"@temporalio/client": "^1.11.0",
"@temporalio/worker": "^1.11.0",
"@temporalio/workflow": "^1.11.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,25 @@
import { Worker, NativeConnection } from "@temporalio/worker";
import * as activities from "./activities";
async function run() {
const address = process.env.TEMPORAL_ADDRESS || "localhost:7233";
console.log(`Connecting to Temporal at ${address}...`);
const connection = await NativeConnection.connect({ address });
const worker = await Worker.create({
connection,
workflowsPath: require.resolve("./workflow"),
activities,
taskQueue: "benchmark",
maxConcurrentWorkflowTaskExecution: 10,
maxConcurrentActivityTaskExecution: 10,
});
console.log("Temporal worker started on task queue: benchmark");
await worker.run();
}
run().catch((err) => {
console.error("Worker failed:", err);
process.exit(1);
});

View File

@@ -0,0 +1,17 @@
import { proxyActivities } from "@temporalio/workflow";
import type * as activities from "./activities";
const { stepA, stepB, stepC } = proxyActivities<typeof activities>({
startToCloseTimeout: "10s",
});
export async function benchmarkWorkflow(): Promise<{
a: number;
b: number;
c: number;
}> {
const a = await stepA();
const b = await stepB();
const c = await stepC();
return { a, b, c };
}

View File

@@ -0,0 +1,66 @@
/**
* Shared types for the competitor benchmark framework.
*
* Every competitor implements CompetitorAdapter. The harness handles
* all timing, statistics, and result serialization.
*/
export interface CompetitorAdapter {
readonly name: string;
readonly composeFile: string;
/** Start containers, wait for health checks. */
setup(): Promise<void>;
/** Deploy/register the 3-step sequential workflow. */
deployWorkflow(): Promise<void>;
/** Trigger one workflow execution. Returns wall-clock ms and result. */
triggerOne(): Promise<{ latencyMs: number; result: unknown }>;
/** Trigger N concurrent workflow executions. Returns wall-clock ms for all to complete. */
triggerBatch(n: number): Promise<{ totalMs: number; results: unknown[] }>;
/** Tear down all containers. */
teardown(): Promise<void>;
/** Return the competitor server version string. */
getVersion(): Promise<string>;
}
export interface LatencyStats {
samples: number[];
median_ms: number;
p95_ms: number;
mean_ms: number;
stdev_ms: number;
}
export interface BenchmarkResult {
competitor: string;
timestamp: number;
environment: {
machine: string;
competitor_version: string;
num_cpus: number;
};
cold_start: {
latency_ms: number;
};
single_latency: LatencyStats;
throughput: {
batch_size: number;
total_ms: number;
per_second: number;
};
step_overhead: {
per_step_ms: number;
};
}
export interface SuiteConfig {
competitors: string[];
latency_samples: number;
throughput_batch: number;
warmup_count: number;
}

View File

@@ -0,0 +1,150 @@
/**
* Windmill CompetitorAdapter.
*
* Uses Windmill REST API to deploy and trigger WAC 3-step sequential workflows.
* Follows the same patterns as benchmarks/lib.ts.
*/
import type { CompetitorAdapter } from "../types.ts";
import { composeUp, composeDown, waitForHealth } from "../lib/docker.ts";
import { dirname, fromFileUrl, resolve } from "https://deno.land/std@0.224.0/path/mod.ts";
const SELF_DIR = dirname(fromFileUrl(import.meta.url));
const HOST = "http://127.0.0.1:8010";
const WORKSPACE = "admins";
const EMAIL = "admin@windmill.dev";
const PASSWORD = "changeme";
// Use step() (inline execution, no child jobs) — equivalent to what
// Restate ctx.run(), Inngest step.run(), and Kestra Return tasks do.
// task() would create separate child jobs which none of the competitors do.
const WAC_SCRIPT_CONTENT = [
'import { step, workflow } from "windmill-client";',
"export const main = workflow(async () => {",
' const a = await step("a", () => 1);',
' const b = await step("b", () => 2);',
' const c = await step("c", () => 3);',
" return { a, b, c };",
"});",
].join("\n");
const SCRIPT_PATH = "f/benchmarks/wac_competitor_seq_3";
async function getToken(): Promise<string> {
const resp = await fetch(`${HOST}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
if (!resp.ok) throw new Error(`Login failed: ${resp.status} ${await resp.text()}`);
return (await resp.text()).replace(/"/g, "");
}
let token = "";
function headers(): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
};
}
export const windmillAdapter: CompetitorAdapter = {
name: "windmill",
composeFile: resolve(SELF_DIR, "docker-compose.bench.yml"),
async setup() {
await composeUp(this.composeFile);
await waitForHealth(`${HOST}/api/version`, { maxRetries: 40 });
token = await getToken();
},
async deployWorkflow() {
// Delete if exists
try {
await fetch(
`${HOST}/api/w/${WORKSPACE}/scripts/delete/p/${SCRIPT_PATH}`,
{ method: "POST", headers: headers() },
);
} catch (_) {
// ignore
}
// Create script
const resp = await fetch(`${HOST}/api/w/${WORKSPACE}/scripts/create`, {
method: "POST",
headers: headers(),
body: JSON.stringify({
path: SCRIPT_PATH,
content: WAC_SCRIPT_CONTENT,
summary: "WAC competitor benchmark (3 sequential steps)",
description: "",
language: "bun",
schema: {
$schema: "https://json-schema.org/draft/2020-12/schema",
properties: {},
required: [],
type: "object",
},
}),
});
if (!resp.ok) throw new Error(`Deploy failed: ${resp.status} ${await resp.text()}`);
const hash = await resp.text();
// Wait for deployment (lock file generation)
for (let i = 0; i < 30; i++) {
try {
const status = await fetch(
`${HOST}/api/w/${WORKSPACE}/scripts/deployment_status/h/${hash.replace(/"/g, "")}`,
{ headers: headers() },
);
const data = await status.json();
if (data.lock !== null && data.lock !== undefined) return;
} catch (_) {
// retry
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error("Script did not deploy in time");
},
async triggerOne() {
const start = performance.now();
const resp = await fetch(
`${HOST}/api/w/${WORKSPACE}/jobs/run_wait_result/p/${SCRIPT_PATH}`,
{
method: "POST",
headers: headers(),
body: "{}",
},
);
const latencyMs = performance.now() - start;
if (!resp.ok) throw new Error(`Trigger failed: ${resp.status} ${await resp.text()}`);
const result = await resp.json();
return { latencyMs, result };
},
async triggerBatch(n: number) {
const start = performance.now();
const promises = Array.from({ length: n }, () =>
fetch(`${HOST}/api/w/${WORKSPACE}/jobs/run_wait_result/p/${SCRIPT_PATH}`, {
method: "POST",
headers: headers(),
body: "{}",
}).then((r) => r.json()),
);
const results = await Promise.all(promises);
const totalMs = performance.now() - start;
return { totalMs, results };
},
async teardown() {
await composeDown(this.composeFile);
},
async getVersion() {
const resp = await fetch(`${HOST}/api/version`);
return (await resp.text()).trim();
},
};

View File

@@ -0,0 +1,35 @@
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: changeme
POSTGRES_DB: windmill
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
ports:
- "5440:5432"
shm_size: 1g
windmill:
image: ghcr.io/windmill-labs/windmill:main
pull_policy: always
depends_on:
postgres:
condition: service_healthy
environment:
- DATABASE_URL=postgres://postgres:changeme@postgres:5432/windmill
- WORKER_GROUP=main
- WORKER_TAGS=bun,flow,dependency
- NUM_WORKERS=10
- I_ACK_NUM_WORKERS_IS_UNSAFE=1
- SLEEP_QUEUE=50
ports:
- "8010:8000"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8000/api/version"]
interval: 5s
timeout: 3s
retries: 20