Skip to content

Commit 6180c66

Browse files
chore: add benchmarks (#2229)
* chore: add benchmarks * upgrade deps * fixes * lint
1 parent de98873 commit 6180c66

File tree

15 files changed

+789
-0
lines changed

15 files changed

+789
-0
lines changed

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ apps/mobile
1818
apps/landing
1919
apps/browser-extension
2020
packages/e2e_tests
21+
packages/benchmarks
2122

2223
# Aider
2324
.aider*

packages/benchmarks/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Docker logs captured during test runs
2+
setup/docker-logs/

packages/benchmarks/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Karakeep Benchmarks
2+
3+
This package spins up a production-like Karakeep stack in Docker, seeds it with a sizeable dataset, then benchmarks a handful of high-signal APIs.
4+
5+
## Usage
6+
7+
```bash
8+
pnpm --filter @karakeep/benchmarks bench
9+
```
10+
11+
The command will:
12+
13+
- Start the docker-compose stack on a random free port
14+
- Create a dedicated benchmark user, tags, lists, and hundreds of bookmarks
15+
- Run a suite of benchmarks (create, list, search, and list metadata calls)
16+
- Print a table with ops/sec and latency percentiles
17+
- Tear down the containers and capture logs (unless you opt out)
18+
19+
## Configuration
20+
21+
Control the run via environment variables:
22+
23+
- `BENCH_BOOKMARKS` (default `400`): number of bookmarks to seed
24+
- `BENCH_TAGS` (default `25`): number of tags to seed
25+
- `BENCH_LISTS` (default `6`): number of lists to seed
26+
- `BENCH_SEED_CONCURRENCY` (default `12`): concurrent seeding operations
27+
- `BENCH_TIME_MS` (default `1000`): time per benchmark case
28+
- `BENCH_WARMUP_MS` (default `300`): warmup time per case
29+
- `BENCH_NO_BUILD=1`: reuse existing docker images instead of rebuilding
30+
- `BENCH_KEEP_CONTAINERS=1`: leave the stack running after the run
31+
32+
The stack uses the package-local `docker-compose.yml` and serves a tiny HTML fixture from `setup/html`.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
services:
2+
web:
3+
build:
4+
dockerfile: docker/Dockerfile
5+
context: ../../
6+
target: aio
7+
restart: unless-stopped
8+
ports:
9+
- "${KARAKEEP_PORT:-3000}:3000"
10+
environment:
11+
DATA_DIR: /tmp
12+
NEXTAUTH_SECRET: secret
13+
NEXTAUTH_URL: http://localhost:${KARAKEEP_PORT:-3000}
14+
MEILI_MASTER_KEY: dummy
15+
MEILI_ADDR: http://meilisearch:7700
16+
BROWSER_WEB_URL: http://chrome:9222
17+
CRAWLER_NUM_WORKERS: 6
18+
CRAWLER_ALLOWED_INTERNAL_HOSTNAMES: nginx
19+
meilisearch:
20+
image: getmeili/meilisearch:v1.13.3
21+
restart: unless-stopped
22+
environment:
23+
MEILI_NO_ANALYTICS: "true"
24+
MEILI_MASTER_KEY: dummy
25+
chrome:
26+
image: gcr.io/zenika-hub/alpine-chrome:124
27+
restart: unless-stopped
28+
command:
29+
- --no-sandbox
30+
- --disable-gpu
31+
- --disable-dev-shm-usage
32+
- --remote-debugging-address=0.0.0.0
33+
- --remote-debugging-port=9222
34+
- --hide-scrollbars
35+
nginx:
36+
image: nginx:alpine
37+
restart: unless-stopped
38+
volumes:
39+
- ./setup/html:/usr/share/nginx/html
40+
minio:
41+
image: minio/minio:latest
42+
restart: unless-stopped
43+
ports:
44+
- "9000:9000"
45+
- "9001:9001"
46+
environment:
47+
MINIO_ROOT_USER: minioadmin
48+
MINIO_ROOT_PASSWORD: minioadmin
49+
command: server /data --console-address ":9001"
50+
volumes:
51+
- minio_data:/data
52+
53+
volumes:
54+
minio_data:

packages/benchmarks/package.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"$schema": "https://json.schemastore.org/package.json",
3+
"name": "@karakeep/benchmarks",
4+
"version": "0.1.0",
5+
"private": true,
6+
"type": "module",
7+
"scripts": {
8+
"bench": "tsx src/index.ts",
9+
"lint": "oxlint .",
10+
"lint:fix": "oxlint . --fix",
11+
"format": "prettier . --cache --ignore-path ../../.prettierignore --check",
12+
"format:fix": "prettier . --cache --write --ignore-path ../../.prettierignore",
13+
"typecheck": "tsc --noEmit"
14+
},
15+
"dependencies": {
16+
"@karakeep/shared": "workspace:^0.1.0",
17+
"@karakeep/trpc": "workspace:^0.1.0",
18+
"@trpc/client": "^11.4.3",
19+
"p-limit": "^7.2.0",
20+
"superjson": "^2.2.1",
21+
"tinybench": "^6.0.0",
22+
"zod": "^3.24.2"
23+
},
24+
"devDependencies": {
25+
"@karakeep/prettier-config": "workspace:^0.1.0",
26+
"@karakeep/tsconfig": "workspace:^0.1.0",
27+
"oxlint": "^1.29.0",
28+
"prettier": "^3.4.2",
29+
"tsx": "^4.8.1"
30+
},
31+
"prettier": "@karakeep/prettier-config"
32+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Benchmarks Fixture</title>
8+
</head>
9+
<body>
10+
<h1>Karakeep Benchmarks</h1>
11+
<p>This page is served by the nginx container during benchmarks.</p>
12+
</body>
13+
</html>
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import type { TaskResult } from "tinybench";
2+
import { Bench } from "tinybench";
3+
4+
import type { SeedResult } from "./seed";
5+
import { logInfo, logStep, logSuccess } from "./log";
6+
import { formatMs, formatNumber } from "./utils";
7+
8+
// Type guard for completed task results
9+
type CompletedTaskResult = Extract<TaskResult, { state: "completed" }>;
10+
11+
export interface BenchmarkRow {
12+
name: string;
13+
ops: number;
14+
mean: number;
15+
p75: number;
16+
p99: number;
17+
samples: number;
18+
}
19+
20+
export interface BenchmarkOptions {
21+
timeMs?: number;
22+
warmupMs?: number;
23+
}
24+
25+
export async function runBenchmarks(
26+
seed: SeedResult,
27+
options?: BenchmarkOptions,
28+
): Promise<BenchmarkRow[]> {
29+
const bench = new Bench({
30+
time: options?.timeMs ?? 1000,
31+
warmupTime: options?.warmupMs ?? 300,
32+
});
33+
34+
const sampleTag = seed.tags[0];
35+
const sampleList = seed.lists[0];
36+
const sampleIds = seed.bookmarks.slice(0, 50).map((b) => b.id);
37+
38+
bench.add("bookmarks.getBookmarks (page)", async () => {
39+
await seed.trpc.bookmarks.getBookmarks.query({
40+
limit: 50,
41+
});
42+
});
43+
44+
if (sampleTag) {
45+
bench.add("bookmarks.getBookmarks (tag filter)", async () => {
46+
await seed.trpc.bookmarks.getBookmarks.query({
47+
limit: 50,
48+
tagId: sampleTag.id,
49+
});
50+
});
51+
}
52+
53+
if (sampleList) {
54+
bench.add("bookmarks.getBookmarks (list filter)", async () => {
55+
await seed.trpc.bookmarks.getBookmarks.query({
56+
limit: 50,
57+
listId: sampleList.id,
58+
});
59+
});
60+
}
61+
62+
if (sampleList && sampleIds.length > 0) {
63+
bench.add("lists.getListsOfBookmark", async () => {
64+
await seed.trpc.lists.getListsOfBookmark.query({
65+
bookmarkId: sampleIds[0],
66+
});
67+
});
68+
}
69+
70+
bench.add("bookmarks.searchBookmarks", async () => {
71+
await seed.trpc.bookmarks.searchBookmarks.query({
72+
text: seed.searchTerm,
73+
limit: 20,
74+
});
75+
});
76+
77+
bench.add("bookmarks.getBookmarks (by ids)", async () => {
78+
await seed.trpc.bookmarks.getBookmarks.query({
79+
ids: sampleIds.slice(0, 20),
80+
includeContent: false,
81+
});
82+
});
83+
84+
logStep("Running benchmarks");
85+
await bench.run();
86+
logSuccess("Benchmarks complete");
87+
88+
const rows = bench.tasks
89+
.map((task) => {
90+
const result = task.result;
91+
92+
// Check for errored state
93+
if ("error" in result) {
94+
console.error(`\n⚠️ Benchmark "${task.name}" failed with error:`);
95+
console.error(result.error);
96+
return null;
97+
}
98+
99+
// Check if task completed successfully
100+
if (result.state !== "completed") {
101+
console.warn(
102+
`\n⚠️ Benchmark "${task.name}" did not complete. State: ${result.state}`,
103+
);
104+
return null;
105+
}
106+
107+
return toRow(task.name, result);
108+
})
109+
.filter(Boolean) as BenchmarkRow[];
110+
111+
renderTable(rows);
112+
logInfo(
113+
"ops/s uses tinybench's hz metric; durations are recorded in milliseconds.",
114+
);
115+
116+
return rows;
117+
}
118+
119+
function toRow(name: string, result: CompletedTaskResult): BenchmarkRow {
120+
// The statistics are now in result.latency and result.throughput
121+
const latency = result.latency;
122+
const throughput = result.throughput;
123+
124+
return {
125+
name,
126+
ops: throughput.mean, // ops/s is the mean throughput
127+
mean: latency.mean,
128+
p75: latency.p75,
129+
p99: latency.p99,
130+
samples: latency.samplesCount,
131+
};
132+
}
133+
134+
function renderTable(rows: BenchmarkRow[]): void {
135+
const headers = ["Benchmark", "ops/s", "avg", "p75", "p99", "samples"];
136+
137+
const data = rows.map((row) => [
138+
row.name,
139+
formatNumber(row.ops, 1),
140+
formatMs(row.mean),
141+
formatMs(row.p75),
142+
formatMs(row.p99),
143+
String(row.samples),
144+
]);
145+
146+
const columnWidths = headers.map((header, index) =>
147+
Math.max(header.length, ...data.map((row) => row[index].length)),
148+
);
149+
150+
const formatRow = (cells: string[]): string =>
151+
cells.map((cell, index) => cell.padEnd(columnWidths[index])).join(" ");
152+
153+
console.log("");
154+
console.log(formatRow(headers));
155+
console.log(columnWidths.map((width) => "-".repeat(width)).join(" "));
156+
data.forEach((row) => console.log(formatRow(row)));
157+
console.log("");
158+
}

packages/benchmarks/src/index.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { runBenchmarks } from "./benchmarks";
2+
import { logInfo, logStep, logSuccess, logWarn } from "./log";
3+
import { seedData } from "./seed";
4+
import { startContainers } from "./startContainers";
5+
6+
interface CliConfig {
7+
bookmarkCount: number;
8+
tagCount: number;
9+
listCount: number;
10+
concurrency: number;
11+
keepContainers: boolean;
12+
timeMs: number;
13+
warmupMs: number;
14+
}
15+
16+
function numberFromEnv(key: string, fallback: number): number {
17+
const raw = process.env[key];
18+
if (!raw) return fallback;
19+
const parsed = Number(raw);
20+
return Number.isFinite(parsed) ? parsed : fallback;
21+
}
22+
23+
function loadConfig(): CliConfig {
24+
return {
25+
bookmarkCount: numberFromEnv("BENCH_BOOKMARKS", 400),
26+
tagCount: numberFromEnv("BENCH_TAGS", 25),
27+
listCount: numberFromEnv("BENCH_LISTS", 6),
28+
concurrency: numberFromEnv("BENCH_SEED_CONCURRENCY", 12),
29+
keepContainers: process.env.BENCH_KEEP_CONTAINERS === "1",
30+
timeMs: numberFromEnv("BENCH_TIME_MS", 1000),
31+
warmupMs: numberFromEnv("BENCH_WARMUP_MS", 300),
32+
};
33+
}
34+
35+
async function main() {
36+
const config = loadConfig();
37+
38+
logStep("Benchmark configuration");
39+
logInfo(`Bookmarks: ${config.bookmarkCount}`);
40+
logInfo(`Tags: ${config.tagCount}`);
41+
logInfo(`Lists: ${config.listCount}`);
42+
logInfo(`Seed concur.: ${config.concurrency}`);
43+
logInfo(`Time per case:${config.timeMs}ms (warmup ${config.warmupMs}ms)`);
44+
logInfo(`Keep containers after run: ${config.keepContainers ? "yes" : "no"}`);
45+
46+
const running = await startContainers();
47+
48+
const stopContainers = async () => {
49+
if (config.keepContainers) {
50+
logWarn(
51+
`Skipping docker compose shutdown (BENCH_KEEP_CONTAINERS=1). Port ${running.port} stays up.`,
52+
);
53+
return;
54+
}
55+
await running.stop();
56+
};
57+
58+
const handleSignal = async (signal: NodeJS.Signals) => {
59+
logWarn(`Received ${signal}, shutting down...`);
60+
await stopContainers();
61+
process.exit(1);
62+
};
63+
64+
process.on("SIGINT", handleSignal);
65+
process.on("SIGTERM", handleSignal);
66+
67+
try {
68+
const seedResult = await seedData({
69+
bookmarkCount: config.bookmarkCount,
70+
tagCount: config.tagCount,
71+
listCount: config.listCount,
72+
concurrency: config.concurrency,
73+
});
74+
75+
await runBenchmarks(seedResult, {
76+
timeMs: config.timeMs,
77+
warmupMs: config.warmupMs,
78+
});
79+
logSuccess("All done");
80+
} catch (error) {
81+
logWarn("Benchmark run failed");
82+
console.error(error);
83+
} finally {
84+
await stopContainers();
85+
}
86+
}
87+
88+
main();

0 commit comments

Comments
 (0)