Skip to content

Commit 7474750

Browse files
scuffiscuffi
andauthored
Run performance tests daily (#552)
* Run and store perf test metric results in D1 everyday * fix perf test ci setup * update perf report aggregation --------- Co-authored-by: scuffi <aferguson@cloudflare.com>
1 parent b42a57f commit 7474750

File tree

15 files changed

+832
-117
lines changed

15 files changed

+832
-117
lines changed

.github/workflows/performance.yml

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ on:
1414
- sustained-throughput
1515
- bursty-traffic
1616
- concurrent-creation
17+
- burst-startup
18+
- file-io
1719
release:
1820
types: [published]
1921
schedule:
20-
- cron: '0 0 * * 0'
22+
- cron: '0 0 * * *'
2123

2224
permissions:
2325
contents: read
@@ -50,8 +52,35 @@ jobs:
5052
node-version: 24
5153
cache: 'npm'
5254

55+
- uses: oven-sh/setup-bun@v2
56+
with:
57+
bun-version-file: .bun-version
58+
59+
- uses: actions/cache/restore@v5
60+
id: nm-cache
61+
with:
62+
path: |
63+
node_modules
64+
packages/*/node_modules
65+
examples/*/node_modules
66+
sites/*/node_modules
67+
key: node-modules-${{ hashFiles('package-lock.json') }}
68+
69+
- run: npm ci --prefer-offline --no-audit --no-fund
70+
if: steps.nm-cache.outputs.cache-hit != 'true'
71+
72+
- uses: actions/cache/save@v5
73+
if: steps.nm-cache.outputs.cache-hit != 'true'
74+
with:
75+
path: |
76+
node_modules
77+
packages/*/node_modules
78+
examples/*/node_modules
79+
sites/*/node_modules
80+
key: node-modules-${{ hashFiles('package-lock.json') }}
81+
5382
- name: Build packages
54-
run: npx turbo run build
83+
run: npm run build
5584

5685
- name: Get package version
5786
id: version
@@ -97,7 +126,9 @@ jobs:
97126
- name: Generate wrangler config
98127
run: |
99128
cd tests/e2e/test-worker
100-
sed "s/{{WORKER_NAME}}/${{ env.PERF_WORKER_NAME }}/g; s/{{CONTAINER_NAME}}/${{ env.PERF_WORKER_NAME }}/g" \
129+
sed -e "s|{{WORKER_NAME}}|${{ env.PERF_WORKER_NAME }}|g" \
130+
-e "s|{{CONTAINER_NAME}}|${{ env.PERF_WORKER_NAME }}|g" \
131+
-e "s|{{IMAGE_SANDBOX}}|registry.cloudflare.com/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/sandbox:perf|g" \
101132
wrangler.perf.template.jsonc > wrangler.jsonc
102133
103134
- name: Deploy test worker
@@ -175,3 +206,12 @@ jobs:
175206
name: perf-baseline
176207
path: perf-results/latest.json
177208
retention-days: 7
209+
210+
- name: Ingest results into D1
211+
if: always()
212+
env:
213+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
214+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
215+
PERF_D1_DATABASE_ID: ${{ secrets.PERF_D1_DATABASE_ID }}
216+
GITHUB_EVENT_NAME: ${{ github.event_name }}
217+
run: npx tsx tests/perf/scripts/ingest-to-d1.ts perf-results/latest.json

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
"test:perf:concurrent": "vitest run --config vitest.perf.config.ts tests/perf/scenarios/concurrent-creation.test.ts",
2121
"test:perf:sustained": "vitest run --config vitest.perf.config.ts tests/perf/scenarios/sustained-throughput.test.ts",
2222
"test:perf:burst": "vitest run --config vitest.perf.config.ts tests/perf/scenarios/bursty-traffic.test.ts",
23+
"test:perf:burst-startup": "vitest run --config vitest.perf.config.ts tests/perf/scenarios/burst-startup.test.ts",
24+
"test:perf:file-io": "vitest run --config vitest.perf.config.ts tests/perf/scenarios/file-io.test.ts",
25+
"perf:ingest": "npx tsx tests/perf/scripts/ingest-to-d1.ts",
26+
"perf:ingest:dry-run": "npx tsx tests/perf/scripts/ingest-to-d1.ts tests/perf/scripts/fixture-report.json --dry-run",
27+
"perf:query": "wrangler d1 execute perf-results --remote --command",
2328
"docker:rebuild": "npm run build:clean && npm run docker:local -w @cloudflare/sandbox"
2429
},
2530
"keywords": [],

tests/e2e/test-worker/wrangler.perf.template.jsonc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"containers": [
1717
{
1818
"class_name": "Sandbox",
19-
"image": "./Dockerfile",
19+
"image": "{{IMAGE_SANDBOX}}",
2020
"name": "{{CONTAINER_NAME}}"
2121
}
2222
],

tests/perf/global-setup.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ import {
1515
getTestWorkerUrl,
1616
type WranglerDevRunner
1717
} from '../e2e/helpers/wrangler-runner';
18+
import { teardown } from './global-teardown';
1819
import { GlobalMetricsStore } from './helpers/metrics-collector';
1920

2021
export const PERF_STATE_FILE = join(tmpdir(), 'perf-test-state.json');
22+
export const PERF_SCENARIOS_FILE = join(tmpdir(), 'perf-test-scenarios.json');
2123
const TEST_WORKER_DIR = 'tests/e2e/test-worker';
2224

2325
let runner: WranglerDevRunner | null = null;
@@ -43,6 +45,9 @@ export async function setup() {
4345
if (existsSync(PERF_STATE_FILE)) {
4446
unlinkSync(PERF_STATE_FILE);
4547
}
48+
if (existsSync(PERF_SCENARIOS_FILE)) {
49+
unlinkSync(PERF_SCENARIOS_FILE);
50+
}
4651

4752
// Ensure wrangler config exists for local mode
4853
if (!process.env.TEST_WORKER_URL) {
@@ -101,4 +106,6 @@ export async function setup() {
101106
console.log('[PerfSetup] Ready!\n');
102107
}
103108

109+
export { teardown };
110+
104111
export { runner };

tests/perf/global-teardown.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
*/
77

88
import { appendFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs';
9-
import { PERF_STATE_FILE, runner } from './global-setup';
9+
import { PERF_SCENARIOS_FILE, PERF_STATE_FILE, runner } from './global-setup';
1010
import { GlobalMetricsStore } from './helpers/metrics-collector';
11-
import { ReportGenerator } from './helpers/report-generator';
11+
import {
12+
ReportGenerator,
13+
type ScenarioResult
14+
} from './helpers/report-generator';
1215

1316
export async function teardown() {
1417
console.log('\n[PerfTeardown] Generating final reports...');
@@ -24,8 +27,19 @@ export async function teardown() {
2427
// Generate reports
2528
const store = GlobalMetricsStore.getInstance();
2629
const reporter = new ReportGenerator('./perf-results');
27-
28-
const report = reporter.generateJsonReport(store, workerUrl);
30+
const runInfo = store.getRunInfo();
31+
const scenarios: ScenarioResult[] = existsSync(PERF_SCENARIOS_FILE)
32+
? (JSON.parse(
33+
readFileSync(PERF_SCENARIOS_FILE, 'utf-8')
34+
) as ScenarioResult[])
35+
: Array.from(store.getAllScenarios().values()).map((collector) =>
36+
reporter.generateScenarioResult(collector)
37+
);
38+
const report = reporter.generateJsonReportFromScenarios(
39+
scenarios,
40+
workerUrl,
41+
runInfo.duration
42+
);
2943
const filepath = reporter.writeJsonReport(report);
3044

3145
console.log(`[PerfTeardown] JSON report written to: ${filepath}`);
@@ -44,6 +58,9 @@ export async function teardown() {
4458
if (existsSync(PERF_STATE_FILE)) {
4559
unlinkSync(PERF_STATE_FILE);
4660
}
61+
if (existsSync(PERF_SCENARIOS_FILE)) {
62+
unlinkSync(PERF_SCENARIOS_FILE);
63+
}
4764
} catch (error) {
4865
console.error('[PerfTeardown] Error generating reports:', error);
4966
}

tests/perf/helpers/concurrent-runner.ts

Lines changed: 36 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,11 @@ export async function runBurst<T>(
9696
const maxDuration = options?.maxDuration || 120000;
9797

9898
const start = performance.now();
99-
const promises: Promise<
100-
| { index: number; result: T; duration: number }
101-
| { index: number; error: Error; duration: number }
102-
>[] = [];
99+
const results: ConcurrentResult<T>['results'] = [];
100+
const promises: Promise<void>[] = [];
101+
let successCount = 0;
102+
let failureCount = 0;
103+
let timedOut = false;
103104

104105
for (let i = 0; i < count; i++) {
105106
if (i > 0) {
@@ -111,65 +112,42 @@ export async function runBurst<T>(
111112

112113
promises.push(
113114
operation()
114-
.then((result) => ({
115-
index,
116-
result,
117-
duration: performance.now() - opStart
118-
}))
119-
.catch((error) => ({
120-
index,
121-
error: error as Error,
122-
duration: performance.now() - opStart
123-
}))
115+
.then((result) => {
116+
if (timedOut) return;
117+
results.push({
118+
index,
119+
result,
120+
duration: performance.now() - opStart
121+
});
122+
successCount++;
123+
})
124+
.catch((error) => {
125+
if (timedOut) return;
126+
results.push({
127+
index,
128+
error: error as Error,
129+
duration: performance.now() - opStart
130+
});
131+
failureCount++;
132+
})
124133
);
125134
}
126135

127-
// Wait for all with timeout
128136
let timeoutId: ReturnType<typeof setTimeout>;
129-
const timeoutPromise = new Promise<never>((_, reject) => {
130-
timeoutId = setTimeout(
131-
() => reject(new Error('Burst timeout')),
132-
maxDuration
133-
);
137+
const timeoutPromise = new Promise<boolean>((resolve) => {
138+
timeoutId = setTimeout(() => {
139+
timedOut = true;
140+
resolve(true);
141+
}, maxDuration);
134142
});
135143

136-
try {
137-
const results = await Promise.race([Promise.all(promises), timeoutPromise]);
138-
clearTimeout(timeoutId!);
139-
140-
let successCount = 0;
141-
let failureCount = 0;
142-
for (const r of results) {
143-
if ('result' in r) successCount++;
144-
else failureCount++;
145-
}
144+
await Promise.race([Promise.all(promises).then(() => false), timeoutPromise]);
145+
clearTimeout(timeoutId!);
146146

147-
return {
148-
results,
149-
totalDuration: performance.now() - start,
150-
successCount,
151-
failureCount
152-
};
153-
} catch {
154-
// Timeout - return partial results
155-
const settled = await Promise.allSettled(promises);
156-
let successCount = 0;
157-
let failureCount = 0;
158-
const results: ConcurrentResult<T>['results'] = [];
159-
160-
for (const s of settled) {
161-
if (s.status === 'fulfilled') {
162-
results.push(s.value);
163-
if ('result' in s.value) successCount++;
164-
else failureCount++;
165-
}
166-
}
167-
168-
return {
169-
results,
170-
totalDuration: performance.now() - start,
171-
successCount,
172-
failureCount
173-
};
174-
}
147+
return {
148+
results: [...results].sort((a, b) => a.index - b.index),
149+
totalDuration: performance.now() - start,
150+
successCount,
151+
failureCount
152+
};
175153
}

tests/perf/helpers/constants.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ export const SCENARIOS = {
66
COLD_START: 'cold-start',
77
CONCURRENT: 'concurrent-creation',
88
SUSTAINED: 'sustained-throughput',
9-
BURST: 'bursty-traffic'
9+
BURST: 'bursty-traffic',
10+
BURST_STARTUP: 'burst-startup',
11+
FILE_IO: 'file-io'
1012
} as const;
1113

1214
export const METRICS = {
13-
// Cold start
15+
// Cold start / sequential startup (sequential create → first exec)
1416
COLD_START_LATENCY: 'cold-start-latency',
1517
WARM_COMMAND_LATENCY: 'warm-command-latency',
1618
// Concurrent creation
@@ -23,13 +25,23 @@ export const METRICS = {
2325
COMPLETED_COMMANDS: 'completed-commands',
2426
ACTUAL_THROUGHPUT: 'actual-throughput',
2527
LATENCY_DEGRADATION: 'latency-degradation',
26-
// Burst
28+
// Bursty traffic (commands on a warm sandbox)
2729
BURST_COMMAND: 'burst-command',
2830
BURST_DURATION: 'burst-duration',
2931
BURST_SUCCESS_RATE: 'burst-success-rate',
3032
BASELINE_LATENCY: 'baseline-latency',
3133
RECOVERY_LATENCY: 'recovery-latency',
32-
RECOVERY_OVERHEAD: 'recovery-overhead'
34+
RECOVERY_OVERHEAD: 'recovery-overhead',
35+
// Burst startup (rapid sandbox creations)
36+
BURST_STARTUP_LATENCY: 'burst-startup-latency',
37+
BURST_STARTUP_SUCCESS_RATE: 'burst-startup-success-rate',
38+
BURST_STARTUP_TOTAL_TIME: 'burst-startup-total-time',
39+
// File I/O — used as prefixes, appended with '-<size>' (e.g. 'file-write-latency-10kb')
40+
FILE_WRITE_LATENCY: 'file-write-latency',
41+
FILE_READ_LATENCY: 'file-read-latency',
42+
FILE_ROUNDTRIP_LATENCY: 'file-roundtrip-latency',
43+
FILE_CONCURRENT_WRITE: 'file-concurrent-write',
44+
FILE_CONCURRENT_READ: 'file-concurrent-read'
3345
} as const;
3446

3547
/** Minimum success rate to pass a scenario (percentage) */

0 commit comments

Comments
 (0)