Compare commits
3 Commits
v1.682.0
...
wac-compet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adbf4720ae | ||
|
|
97c81650b3 | ||
|
|
480d9acd83 |
74
.github/workflows/benchmark-competitors.yml
vendored
Normal file
74
.github/workflows/benchmark-competitors.yml
vendored
Normal 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
|
||||
@@ -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
2
benchmarks/competitors/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
results/
|
||||
178
benchmarks/competitors/CONTEXT.md
Normal file
178
benchmarks/competitors/CONTEXT.md
Normal 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
|
||||
85
benchmarks/competitors/README.md
Normal file
85
benchmarks/competitors/README.md
Normal 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.
|
||||
142
benchmarks/competitors/airflow/adapter.ts
Normal file
142
benchmarks/competitors/airflow/adapter.ts
Normal 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";
|
||||
}
|
||||
},
|
||||
};
|
||||
29
benchmarks/competitors/airflow/dags/benchmark_dag.py
Normal file
29
benchmarks/competitors/airflow/dags/benchmark_dag.py
Normal 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
|
||||
57
benchmarks/competitors/airflow/docker-compose.yml
Normal file
57
benchmarks/competitors/airflow/docker-compose.yml
Normal 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
|
||||
229
benchmarks/competitors/competitor_graphs.ts
Normal file
229
benchmarks/competitors/competitor_graphs.ts
Normal 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();
|
||||
37
benchmarks/competitors/competitor_graphs_config.json
Normal file
37
benchmarks/competitors/competitor_graphs_config.json
Normal 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"
|
||||
}
|
||||
]
|
||||
174
benchmarks/competitors/competitor_harness.ts
Normal file
174
benchmarks/competitors/competitor_harness.ts
Normal 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;
|
||||
}
|
||||
120
benchmarks/competitors/competitor_suite.ts
Normal file
120
benchmarks/competitors/competitor_suite.ts
Normal 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();
|
||||
6
benchmarks/competitors/competitor_suite_config.json
Normal file
6
benchmarks/competitors/competitor_suite_config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"competitors": ["windmill", "temporal", "inngest", "restate", "kestra"],
|
||||
"latency_samples": 50,
|
||||
"throughput_batch": 100,
|
||||
"warmup_count": 5
|
||||
}
|
||||
6
benchmarks/competitors/inngest/Dockerfile.app
Normal file
6
benchmarks/competitors/inngest/Dockerfile.app
Normal 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"]
|
||||
149
benchmarks/competitors/inngest/adapter.ts
Normal file
149
benchmarks/competitors/inngest/adapter.ts
Normal 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";
|
||||
}
|
||||
},
|
||||
};
|
||||
21
benchmarks/competitors/inngest/docker-compose.yml
Normal file
21
benchmarks/competitors/inngest/docker-compose.yml
Normal 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"
|
||||
13
benchmarks/competitors/inngest/package.json
Normal file
13
benchmarks/competitors/inngest/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
19
benchmarks/competitors/inngest/serve.ts
Normal file
19
benchmarks/competitors/inngest/serve.ts
Normal 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");
|
||||
});
|
||||
14
benchmarks/competitors/inngest/workflow.ts
Normal file
14
benchmarks/competitors/inngest/workflow.ts
Normal 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 };
|
||||
},
|
||||
);
|
||||
185
benchmarks/competitors/kestra/adapter.ts
Normal file
185
benchmarks/competitors/kestra/adapter.ts
Normal 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";
|
||||
}
|
||||
},
|
||||
};
|
||||
41
benchmarks/competitors/kestra/docker-compose.yml
Normal file
41
benchmarks/competitors/kestra/docker-compose.yml
Normal 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"
|
||||
20
benchmarks/competitors/kestra/workflow.yml
Normal file
20
benchmarks/competitors/kestra/workflow.yml
Normal 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
|
||||
85
benchmarks/competitors/lib/docker.ts
Normal file
85
benchmarks/competitors/lib/docker.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
89
benchmarks/competitors/lib/results.ts
Normal file
89
benchmarks/competitors/lib/results.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
77
benchmarks/competitors/lib/timing.ts
Normal file
77
benchmarks/competitors/lib/timing.ts
Normal 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;
|
||||
}
|
||||
4
benchmarks/competitors/prefect/Dockerfile.worker
Normal file
4
benchmarks/competitors/prefect/Dockerfile.worker
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM prefecthq/prefect:3-latest
|
||||
WORKDIR /app
|
||||
COPY flow.py .
|
||||
CMD ["python", "flow.py"]
|
||||
120
benchmarks/competitors/prefect/adapter.ts
Normal file
120
benchmarks/competitors/prefect/adapter.ts
Normal 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";
|
||||
}
|
||||
},
|
||||
};
|
||||
27
benchmarks/competitors/prefect/docker-compose.yml
Normal file
27
benchmarks/competitors/prefect/docker-compose.yml
Normal 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
|
||||
23
benchmarks/competitors/prefect/flow.py
Normal file
23
benchmarks/competitors/prefect/flow.py
Normal 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")
|
||||
6
benchmarks/competitors/restate/Dockerfile.app
Normal file
6
benchmarks/competitors/restate/Dockerfile.app
Normal 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"]
|
||||
110
benchmarks/competitors/restate/adapter.ts
Normal file
110
benchmarks/competitors/restate/adapter.ts
Normal 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";
|
||||
}
|
||||
},
|
||||
};
|
||||
13
benchmarks/competitors/restate/docker-compose.yml
Normal file
13
benchmarks/competitors/restate/docker-compose.yml
Normal 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"
|
||||
9
benchmarks/competitors/restate/package.json
Normal file
9
benchmarks/competitors/restate/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
benchmarks/competitors/restate/serve.ts
Normal file
5
benchmarks/competitors/restate/serve.ts
Normal 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");
|
||||
15
benchmarks/competitors/restate/workflow.ts
Normal file
15
benchmarks/competitors/restate/workflow.ts
Normal 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 };
|
||||
},
|
||||
},
|
||||
});
|
||||
6
benchmarks/competitors/temporal/Dockerfile.worker
Normal file
6
benchmarks/competitors/temporal/Dockerfile.worker
Normal 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"]
|
||||
11
benchmarks/competitors/temporal/activities.ts
Normal file
11
benchmarks/competitors/temporal/activities.ts
Normal 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;
|
||||
}
|
||||
145
benchmarks/competitors/temporal/adapter.ts
Normal file
145
benchmarks/competitors/temporal/adapter.ts
Normal 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";
|
||||
}
|
||||
},
|
||||
};
|
||||
30
benchmarks/competitors/temporal/docker-compose.yml
Normal file
30
benchmarks/competitors/temporal/docker-compose.yml
Normal 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
|
||||
2762
benchmarks/competitors/temporal/package-lock.json
generated
Normal file
2762
benchmarks/competitors/temporal/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
benchmarks/competitors/temporal/package.json
Normal file
12
benchmarks/competitors/temporal/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
25
benchmarks/competitors/temporal/worker.ts
Normal file
25
benchmarks/competitors/temporal/worker.ts
Normal 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);
|
||||
});
|
||||
17
benchmarks/competitors/temporal/workflow.ts
Normal file
17
benchmarks/competitors/temporal/workflow.ts
Normal 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 };
|
||||
}
|
||||
66
benchmarks/competitors/types.ts
Normal file
66
benchmarks/competitors/types.ts
Normal 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;
|
||||
}
|
||||
150
benchmarks/competitors/windmill/adapter.ts
Normal file
150
benchmarks/competitors/windmill/adapter.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
35
benchmarks/competitors/windmill/docker-compose.bench.yml
Normal file
35
benchmarks/competitors/windmill/docker-compose.bench.yml
Normal 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
|
||||
Reference in New Issue
Block a user