Skip to content

Commit 16fb8ae

Browse files
committed
chore: add local benchmark proof
1 parent 07a171e commit 16fb8ae

6 files changed

Lines changed: 274 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Prometheus metrics for article-search cache hit/miss behavior and upstream provider request latency/outcomes.
1414
- Curl-based `npm run smoke` check for a running instance.
1515
- Configurable `GNEWS_BASE_URL` for deterministic local integration tests and benchmarks.
16+
- Deterministic `npm run benchmark:local` benchmark for cold upstream-backed searches and warm cache hits.
1617

1718
### Security
1819

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ BASE_URL=http://localhost:3000 QUERY=postgres npm run smoke
4848
CLIENT_API_KEY=client-secret-one npm run smoke
4949
```
5050

51+
Run the deterministic local benchmark:
52+
53+
```bash
54+
npm run benchmark:local
55+
```
56+
5157
## Quick start (Docker)
5258

5359
```bash
@@ -105,6 +111,7 @@ Errors: `{ "error": "message" }`. Rate limit: `429` with standard rate-limit hea
105111
| `npm run test:coverage` | Tests + coverage. |
106112
| `npm run lint` | ESLint. |
107113
| `npm run smoke` | Curl-based smoke test against a running instance (`BASE_URL`, `QUERY`, `COUNT`, optional `CLIENT_API_KEY`). |
114+
| `npm run benchmark:local` | Builds the app, starts a fake GNews provider, and measures cold searches vs warm cache hits. See [docs/BENCHMARKS.md](docs/BENCHMARKS.md). |
108115

109116
## Project layout
110117

docs/BENCHMARKS.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Benchmarks
2+
3+
`news-api` includes a deterministic local benchmark so performance claims can be reproduced without a live GNews key, provider quota, or public-internet latency.
4+
5+
The benchmark:
6+
7+
1. Builds the TypeScript app.
8+
2. Starts a local GNews-compatible fake provider.
9+
3. Starts `news-api` with `GNEWS_BASE_URL` pointed at the fake provider.
10+
4. Measures cold unique searches and warm cached searches.
11+
12+
```bash
13+
npm run benchmark:local
14+
```
15+
16+
Useful overrides:
17+
18+
```bash
19+
BENCHMARK_PROVIDER_DELAY_MS=25 \
20+
BENCHMARK_COLD_REQUESTS=500 \
21+
BENCHMARK_COLD_CONCURRENCY=25 \
22+
BENCHMARK_WARM_REQUESTS=1000 \
23+
BENCHMARK_WARM_CONCURRENCY=100 \
24+
npm run benchmark:local
25+
```
26+
27+
## Latest Local Run
28+
29+
Run this on your machine before publishing numbers in a profile or portfolio; benchmark results depend on host CPU, Node version, and local load.
30+
31+
Command: `npm run benchmark:local`
32+
33+
- Date: `2026-06-24T00:07:47.186Z`
34+
- Commit: `07a171e`
35+
- Node: `v26.0.0`
36+
- Host: `Darwin 25.5.0 (Apple M1)`
37+
- Fake provider delay: `15ms`
38+
- Rate limiting: disabled for benchmark
39+
40+
| Scenario | Requests | Concurrency | Upstream calls | Success | Failed | p50 ms | p95 ms | p99 ms | Throughput req/s |
41+
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
42+
| Cold unique searches | 300 | 20 | 300 | 300 | 0 | 28.6 | 54.2 | 67.7 | 611 |
43+
| Warm cached search | 500 | 50 | 0 | 500 | 0 | 9.1 | 101.8 | 180.0 | 2484 |
44+
45+
## Metric Notes
46+
47+
- **Cold unique searches** use a different query per request, so every successful request should call the upstream provider.
48+
- **Warm cached search** performs one warmup request, then measures repeated identical searches. The warmup request is excluded from the table, so upstream calls should be `0` during the measured run.
49+
- Rate limiting is disabled by the benchmark process because the goal is service/cache behavior, not limiter behavior.
50+
- The fake provider adds a configurable delay (`BENCHMARK_PROVIDER_DELAY_MS`, default `15`) to make cache behavior visible.

docs/OPERATIONS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ CLIENT_API_KEY=client-secret-one npm run smoke
8181

8282
The smoke test checks `/health`, `/ready`, `/openapi.yaml`, `/api/articles`, and `/metrics`.
8383

84+
## Local Benchmark
85+
86+
For reproducible cache/upstream performance checks without a live GNews key:
87+
88+
```bash
89+
npm run benchmark:local
90+
```
91+
92+
See [BENCHMARKS.md](BENCHMARKS.md) for methodology and tuning variables.
93+
8494
## Logs
8595

8696
Logs are **JSON** (Pino). Each response includes an `x-request-id` header for correlation.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"test:watch": "vitest",
1515
"test:coverage": "vitest run --coverage",
1616
"lint": "eslint src test vitest.config.ts",
17-
"smoke": "sh scripts/smoke-test.sh"
17+
"smoke": "sh scripts/smoke-test.sh",
18+
"benchmark:local": "npm run build && node scripts/local-benchmark.mjs"
1819
},
1920
"keywords": [
2021
"news",

scripts/local-benchmark.mjs

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import http from "node:http";
2+
import os from "node:os";
3+
import { execSync } from "node:child_process";
4+
import { createRequire } from "node:module";
5+
import { performance } from "node:perf_hooks";
6+
7+
const require = createRequire(import.meta.url);
8+
9+
const providerDelayMs = Number(process.env.BENCHMARK_PROVIDER_DELAY_MS ?? 15);
10+
const coldRequests = Number(process.env.BENCHMARK_COLD_REQUESTS ?? 300);
11+
const coldConcurrency = Number(process.env.BENCHMARK_COLD_CONCURRENCY ?? 20);
12+
const warmRequests = Number(process.env.BENCHMARK_WARM_REQUESTS ?? 500);
13+
const warmConcurrency = Number(process.env.BENCHMARK_WARM_CONCURRENCY ?? 50);
14+
15+
function listen(server) {
16+
return new Promise((resolve) => {
17+
server.listen(0, "127.0.0.1", () => {
18+
const address = server.address();
19+
if (!address || typeof address === "string") {
20+
throw new Error("expected TCP server address");
21+
}
22+
resolve(address.port);
23+
});
24+
});
25+
}
26+
27+
function close(server) {
28+
return new Promise((resolve, reject) => {
29+
server.close((err) => (err ? reject(err) : resolve()));
30+
});
31+
}
32+
33+
function percentile(values, p) {
34+
if (values.length === 0) {
35+
return 0;
36+
}
37+
const sorted = [...values].sort((a, b) => a - b);
38+
const index = Math.ceil((p / 100) * sorted.length) - 1;
39+
return sorted[Math.max(0, Math.min(index, sorted.length - 1))];
40+
}
41+
42+
async function runScenario({ baseUrl, name, requests, concurrency, queryFor }) {
43+
const latencies = [];
44+
let next = 0;
45+
let ok = 0;
46+
let failed = 0;
47+
const startedAt = performance.now();
48+
49+
async function worker() {
50+
while (next < requests) {
51+
const index = next;
52+
next += 1;
53+
const query = encodeURIComponent(queryFor(index));
54+
const requestStartedAt = performance.now();
55+
try {
56+
const res = await fetch(`${baseUrl}/api/articles?query=${query}&count=5&lang=en`);
57+
const body = await res.json();
58+
if (!res.ok || !Array.isArray(body) || body.length !== 5) {
59+
failed += 1;
60+
} else {
61+
ok += 1;
62+
latencies.push(performance.now() - requestStartedAt);
63+
}
64+
} catch {
65+
failed += 1;
66+
}
67+
}
68+
}
69+
70+
const workers = Array.from({ length: concurrency }, () => worker());
71+
await Promise.all(workers);
72+
const durationMs = performance.now() - startedAt;
73+
74+
return {
75+
name,
76+
requests,
77+
concurrency,
78+
ok,
79+
failed,
80+
durationMs,
81+
throughput: ok / (durationMs / 1000),
82+
p50: percentile(latencies, 50),
83+
p95: percentile(latencies, 95),
84+
p99: percentile(latencies, 99),
85+
};
86+
}
87+
88+
function formatMs(value) {
89+
return value.toFixed(1);
90+
}
91+
92+
function formatRate(value) {
93+
return value.toFixed(0);
94+
}
95+
96+
function gitSha() {
97+
try {
98+
return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
99+
} catch {
100+
return "unknown";
101+
}
102+
}
103+
104+
let providerRequests = 0;
105+
const fakeProvider = http.createServer((req, res) => {
106+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
107+
if (url.pathname !== "/search") {
108+
res.writeHead(404, { "content-type": "application/json" });
109+
res.end(JSON.stringify({ error: "not found" }));
110+
return;
111+
}
112+
113+
providerRequests += 1;
114+
const query = url.searchParams.get("q") ?? "news";
115+
const max = Math.max(1, Math.min(Number(url.searchParams.get("max") ?? 5), 100));
116+
const articles = Array.from({ length: max }, (_, index) => ({
117+
title: `${query} headline ${index + 1}`,
118+
description: `Synthetic benchmark article ${index + 1}`,
119+
content: `Benchmark content for ${query}`,
120+
url: `https://example.test/${encodeURIComponent(query)}/${index + 1}`,
121+
image: null,
122+
publishedAt: "2026-01-01T00:00:00.000Z",
123+
source: {
124+
name: "Benchmark News",
125+
url: "https://example.test",
126+
},
127+
}));
128+
129+
setTimeout(() => {
130+
res.writeHead(200, { "content-type": "application/json" });
131+
res.end(JSON.stringify({ articles }));
132+
}, providerDelayMs);
133+
});
134+
135+
const providerPort = await listen(fakeProvider);
136+
137+
process.env.GNEWS_API_KEY = "benchmark";
138+
process.env.GNEWS_BASE_URL = `http://127.0.0.1:${providerPort}`;
139+
process.env.DISABLE_RATE_LIMIT = "1";
140+
process.env.HTTP_TIMEOUT_MS = "5000";
141+
process.env.LOG_LEVEL = "silent";
142+
process.env.NODE_ENV = "benchmark";
143+
144+
const { default: app } = require("../dist/app.js");
145+
const { disconnectCacheStore } = require("../dist/cache/store.js");
146+
const apiServer = http.createServer(app);
147+
const apiPort = await listen(apiServer);
148+
const baseUrl = `http://127.0.0.1:${apiPort}`;
149+
150+
try {
151+
const ready = await fetch(`${baseUrl}/ready`);
152+
if (!ready.ok) {
153+
throw new Error(`/ready returned HTTP ${ready.status}`);
154+
}
155+
156+
providerRequests = 0;
157+
const cold = await runScenario({
158+
baseUrl,
159+
name: "Cold unique searches",
160+
requests: coldRequests,
161+
concurrency: coldConcurrency,
162+
queryFor: (index) => `cold-${index}`,
163+
});
164+
const coldUpstream = providerRequests;
165+
166+
await fetch(`${baseUrl}/api/articles?query=warm-cache&count=5&lang=en`);
167+
providerRequests = 0;
168+
const warm = await runScenario({
169+
baseUrl,
170+
name: "Warm cached search",
171+
requests: warmRequests,
172+
concurrency: warmConcurrency,
173+
queryFor: () => "warm-cache",
174+
});
175+
const warmUpstream = providerRequests;
176+
177+
const rows = [
178+
{ ...cold, upstream: coldUpstream },
179+
{ ...warm, upstream: warmUpstream },
180+
];
181+
182+
console.log(`# news-api local benchmark\n`);
183+
console.log(`- Date: ${new Date().toISOString()}`);
184+
console.log(`- Commit: ${gitSha()}`);
185+
console.log(`- Node: ${process.version}`);
186+
console.log(`- Host: ${os.type()} ${os.release()} (${os.cpus()[0]?.model ?? "unknown CPU"})`);
187+
console.log(`- Fake provider delay: ${providerDelayMs}ms`);
188+
console.log(`- Rate limiting: disabled for benchmark\n`);
189+
console.log(
190+
"| Scenario | Requests | Concurrency | Upstream calls | Success | Failed | p50 ms | p95 ms | p99 ms | Throughput req/s |"
191+
);
192+
console.log(
193+
"|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|"
194+
);
195+
for (const row of rows) {
196+
console.log(
197+
`| ${row.name} | ${row.requests} | ${row.concurrency} | ${row.upstream} | ${row.ok} | ${row.failed} | ${formatMs(row.p50)} | ${formatMs(row.p95)} | ${formatMs(row.p99)} | ${formatRate(row.throughput)} |`
198+
);
199+
}
200+
} finally {
201+
await disconnectCacheStore();
202+
await close(apiServer);
203+
await close(fakeProvider);
204+
}

0 commit comments

Comments
 (0)