Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions loadtest/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
results/
node_modules/
90 changes: 90 additions & 0 deletions loadtest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Bridge Load Test

Compares three server implementations against a shared dependency emulator
using [k6](https://k6.io). Each service has its own Docker image.

## Services

| Service | What it runs | Port |
| -------------------- | --------------------------------------------- | ---- |
| `dependency` | nginx serving static JSON fixtures | 8080 |
| `bridge-standalone` | Node.js (`executeBridge`) | 3000 |
| `bridge-graphql` | Node.js (`bridgeTransform` + yoga) | 3000 |
| `handcoded` | Node.js (plain `fetch` + manual map) | 3000 |

## Scenarios

| Scenario | Description |
| --------- | -------------------------------------------------- |
| `simple` | Fetch one object, map 7 fields |
| `array` | Fetch 100-item list, map 4 fields per item |
| `complex` | 3 parallel fetches + array mapping + field merging |

## Quick start

```bash
cd loadtest

# Build & run the full sequential benchmark (~7 min)
docker compose up -d --build
docker compose run --rm k6
node scripts/report.mjs --out report.md
docker compose down

# Or use the npm scripts:
npm run up && npm test && npm run report && npm run down

# Quick smoke test (parallel, ~15s)
PROFILE=quick docker compose run --rm k6
```

## Directory layout

```
loadtest/
├── docker-compose.yml orchestration
├── package.json convenience npm scripts
├── dependency/ nginx static JSON server
│ ├── Dockerfile
│ ├── nginx.conf
│ └── data/ pre-generated JSON fixtures
├── bridge-standalone/ Node.js executeBridge server
│ ├── Dockerfile
│ ├── bridge-standalone.ts
│ ├── endpoints.bridge
│ ├── package.json
│ └── tsconfig.json
├── bridge-graphql/ Node.js graphql-yoga + bridgeTransform
│ ├── Dockerfile
│ ├── bridge-graphql.ts
│ ├── endpoints.bridge
│ ├── schema.graphql
│ ├── package.json
│ └── tsconfig.json
├── handcoded/ Node.js hand-coded baseline
│ ├── Dockerfile
│ ├── handcoded.ts
│ └── tsconfig.json
├── k6/
│ └── test.js k6 load test script
├── scripts/
│ ├── generate-data.mjs regenerate JSON fixtures
│ └── report.mjs parse k6 output → comparison table
└── results/ k6 output (gitignored)
```

## Regenerating test data

```bash
node scripts/generate-data.mjs
```

This writes JSON files into `dependency/data/`. The checked-in files are ready
to use — regenerate only if you want to change the fixture shape.
12 changes: 12 additions & 0 deletions loadtest/bridge-compiler/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Bridge Compiler — Node.js executeBridge server (no GraphQL).

FROM node:24-slim

WORKDIR /app
COPY package.json .
RUN npm install --omit=dev

COPY . .

EXPOSE 3000
CMD ["node", "--experimental-transform-types", "server.ts"]
78 changes: 78 additions & 0 deletions loadtest/bridge-compiler/endpoints.bridge
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
version 1.5

# ── Dependency tools ──────────────────────────────────────────────────────

tool fetchSimple from std.httpCall {
.baseUrl = "http://dependency:8080"
.method = GET
.path = /api/simple
.cache = 0
}

tool fetchList from std.httpCall {
.baseUrl = "http://dependency:8080"
.method = GET
.path = /api/list
.cache = 0
}

tool fetchCatalog from std.httpCall {
.baseUrl = "http://dependency:8080"
.method = GET
.path = /api/catalog
.cache = 0
}

# ── Simple: fetch one object, map fields ──────────────────────────────────

bridge Query.simple {
with fetchSimple as emp
with output as o

o.id <- emp.id
o.firstName <- emp.first_name
o.lastName <- emp.last_name
o.email <- emp.email_address
o.role <- emp.role
o.department <- emp.department_info.name
o.building <- emp.department_info.building
}

# ── Array: fetch list, map 1000 items ────────────────────────────────────

bridge Query.arrayMap {
with fetchList as list
with output as o

o.items <- list[] as item {
.id <- item.item_id
.name <- item.item_name
.category <- item.item_category
.price <- item.unit_price
}
}

# ── Complex: 3 parallel fetches + array mapping + multi-source merge ──────

bridge Query.complex {
with fetchSimple as emp
with fetchList as list
with fetchCatalog as catalog
with output as o

# Flat fields from the simple endpoint
o.assignee <- emp.first_name
o.email <- emp.email_address
o.department <- emp.department_info.name

# Pull from list so the fetch isn't optimised out
o.topItem <- list[0].item_name

# Array mapping from catalog (1000 entries)
o.entries <- catalog[] as entry {
.entryId <- entry.entry_id
.variantId <- entry.variant_id
.quantity <- entry.quantity
.warehouse <- entry.warehouse
}
}
9 changes: 9 additions & 0 deletions loadtest/bridge-compiler/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "bridge-loadtest-compiler",
"private": true,
"type": "module",
"dependencies": {
"@stackables/bridge": "^2.2.0",
"@stackables/bridge-compiler": "^2.3.0"
}
}
55 changes: 55 additions & 0 deletions loadtest/bridge-compiler/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Bridge Standalone (no GraphQL) — HTTP server using executeBridge.
*
* Endpoints:
* GET /simple — fetch + field mapping
* GET /array — fetch + array mapping (100 items)
* GET /complex — fetch catalog + fan-out variant sub-requests
* GET /health — health check
*/

import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { parseBridge } from "@stackables/bridge";
import { executeBridge } from "@stackables/bridge-compiler";

const PORT = parseInt(process.env.PORT || "3000", 10);

const document = parseBridge(
readFileSync(new URL("./endpoints.bridge", import.meta.url), "utf-8"),
);

const server = createServer(async (req, res) => {
try {
if (req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end('{"status":"ok"}');
return;
}

const operations: Record<string, string> = {
"/simple": "Query.simple",
"/array": "Query.arrayMap",
"/complex": "Query.complex",
};

const operation = operations[req.url ?? ""];
if (!operation) {
res.writeHead(404);
res.end('{"error":"not found"}');
return;
}

const { data } = await executeBridge({ document, operation });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
} catch (err) {
console.error("Error:", err);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "internal server error" }));
}
});

server.listen(PORT, () => {
console.log(`Bridge standalone listening on :${PORT}`);
});
10 changes: 10 additions & 0 deletions loadtest/bridge-compiler/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
12 changes: 12 additions & 0 deletions loadtest/bridge-graphql/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Bridge GraphQL — graphql-yoga server with bridgeTransform.

FROM node:24-slim

WORKDIR /app
COPY package.json .
RUN npm install --omit=dev

COPY . .

EXPOSE 3000
CMD ["node", "--experimental-transform-types", "bridge-graphql.ts"]
67 changes: 67 additions & 0 deletions loadtest/bridge-graphql/bridge-graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Bridge GraphQL — graphql-yoga server with bridgeTransform.
*
* Endpoints:
* POST /graphql — GraphQL endpoint
* GET /health — health check
*/

import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { createSchema, createYoga } from "graphql-yoga";
import { bridgeTransform, parseBridge } from "@stackables/bridge";

const PORT = parseInt(process.env.PORT || "3000", 10);

const typeDefs = readFileSync(
new URL("./schema.graphql", import.meta.url),
"utf-8",
);

const document = parseBridge(
readFileSync(new URL("./endpoints.bridge", import.meta.url), "utf-8"),
);

const schema = bridgeTransform(createSchema({ typeDefs }), document);

const yoga = createYoga({
schema,
graphqlEndpoint: "/graphql",
logging: false,
});

// Wrap yoga with health check
const server = createServer(async (req, res) => {
if (req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end('{"status":"ok"}');
return;
}

// Delegate to yoga
const response = await yoga.fetch(`http://localhost:${PORT}${req.url}`, {
method: req.method!,
headers: req.headers as Record<string, string>,
body:
req.method === "POST"
? await new Promise<string>((resolve) => {
let data = "";
req.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
req.on("end", () => resolve(data));
})
: undefined,
});

res.writeHead(
response.status,
Object.fromEntries(response.headers.entries()),
);
const body = await response.text();
res.end(body);
});

server.listen(PORT, () => {
console.log(`Bridge GraphQL server listening on :${PORT}`);
});
Loading