Skip to content

Commit 9588d67

Browse files
Add required ci check for keynote-2 benchmark
1 parent 868f65f commit 9588d67

7 files changed

Lines changed: 307 additions & 4 deletions

File tree

.github/workflows/ci.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,56 @@ jobs:
265265
source ~/emsdk/emsdk_env.sh
266266
cargo ci test
267267
268+
keynote_bench:
269+
needs: [lints]
270+
name: Keynote Bench
271+
runs-on: spacetimedb-new-runner-2
272+
timeout-minutes: 60
273+
env:
274+
CARGO_TARGET_DIR: ${{ github.workspace }}/target
275+
steps:
276+
- name: Find Git ref
277+
env:
278+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
279+
run: |
280+
PR_NUMBER="${{ github.event.inputs.pr_number || null }}"
281+
if test -n "${PR_NUMBER}"; then
282+
GIT_REF="$( gh pr view --repo clockworklabs/SpacetimeDB $PR_NUMBER --json headRefName --jq .headRefName )"
283+
else
284+
GIT_REF="${{ github.ref }}"
285+
fi
286+
echo "GIT_REF=${GIT_REF}" >>"$GITHUB_ENV"
287+
288+
- name: Checkout sources
289+
uses: actions/checkout@v4
290+
with:
291+
ref: ${{ env.GIT_REF }}
292+
293+
- uses: dsherret/rust-toolchain-file@v1
294+
- name: Set default rust toolchain
295+
run: rustup default $(rustup show active-toolchain | cut -d' ' -f1)
296+
297+
- name: Cache Rust dependencies
298+
uses: Swatinem/rust-cache@v2
299+
with:
300+
workspaces: ${{ github.workspace }}
301+
shared-key: spacetimedb
302+
save-if: false
303+
prefix-key: v1
304+
305+
# Node 24 is the current Active LTS line.
306+
- name: Set up Node.js
307+
uses: actions/setup-node@v4
308+
with:
309+
node-version: 24
310+
311+
- uses: ./.github/actions/setup-pnpm
312+
with:
313+
run_install: true
314+
315+
- name: Run keynote-2 benchmark regression check
316+
run: cargo ci keynote-bench
317+
268318
lints:
269319
name: Lints
270320
runs-on: spacetimedb-new-runner-2

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

templates/keynote-2/src/cli.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { CONNECTORS } from './connectors';
55
import { runOne } from './core/runner';
66
import type { TestCaseModule } from './tests/types';
77
import { fileURLToPath } from 'node:url';
8-
import { join } from 'node:path';
8+
import { join, resolve } from 'node:path';
99
import { RunResult } from './core/types.ts';
1010
import { parseBenchOptions } from './opts.ts';
1111

@@ -153,12 +153,21 @@ function runPrep(): Promise<void> {
153153

154154
const testDirUrl = new URL(`./tests/${testName}/`, import.meta.url);
155155
const testDirPath = fileURLToPath(testDirUrl);
156-
const runsDir = fileURLToPath(new URL('../runs/', import.meta.url));
156+
const runsDir = process.env.BENCH_RUNS_DIR
157+
? resolve(process.env.BENCH_RUNS_DIR)
158+
: fileURLToPath(new URL('../runs/', import.meta.url));
157159

158-
async function writeRunJson(payload: object, connectorName: string, alpha: number) {
160+
async function writeRunJson(
161+
payload: object,
162+
connectorName: string,
163+
alpha: number,
164+
) {
159165
await mkdir(runsDir, { recursive: true });
160166
const ts = new Date().toISOString().replace(/[:.]/g, '-');
161-
const outFile = join(runsDir, `${testName}-${connectorName}-a${alpha}-${ts}.json`);
167+
const outFile = join(
168+
runsDir,
169+
`${testName}-${connectorName}-a${alpha}-${ts}.json`,
170+
);
162171
await writeFile(outFile, JSON.stringify(payload, null, 2));
163172
console.log(`Wrote results to ${outFile}`);
164173
return outFile;

tools/ci/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ serde_json.workspace = true
1313
duct.workspace = true
1414
tempfile.workspace = true
1515
env_logger.workspace = true
16+
spacetimedb-guard.workspace = true

tools/ci/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,21 @@ Usage: help [COMMAND]...
143143

144144
- `subcommand`: Print help for the subcommand(s)
145145

146+
### `keynote-bench`
147+
148+
Runs the keynote benchmark as a CI performance regression gate.
149+
150+
Builds release SpacetimeDB binaries, runs the keynote SpacetimeDB benchmark for 60 seconds, and fails if throughput is below 250K TPS.
151+
152+
**Usage:**
153+
```bash
154+
Usage: keynote-bench
155+
```
156+
157+
**Options:**
158+
159+
- `--help`: Print help (see a summary with '-h')
160+
146161
### `update-flow`
147162

148163
Tests the update flow

tools/ci/src/keynote_bench.rs

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
#![allow(clippy::disallowed_macros)]
2+
3+
use anyhow::{bail, ensure, Context, Result};
4+
use serde_json::Value;
5+
use spacetimedb_guard::{ensure_binaries_built, SpacetimeDbGuard};
6+
use std::{
7+
env, fs,
8+
path::{Path, PathBuf},
9+
process::Command,
10+
};
11+
12+
const DATABASE_NAME: &str = "test-1";
13+
const KEYNOTE_DIR: &str = "templates/keynote-2";
14+
const KEYNOTE_MODULE_DIR: &str = "templates/keynote-2/spacetimedb";
15+
const KEYNOTE_BINDINGS_DIR: &str = "templates/keynote-2/module_bindings";
16+
const MIN_TPS: f64 = 250_000.0;
17+
const BENCH_SECONDS: &str = "60";
18+
const BENCH_CONCURRENCY: &str = "64";
19+
const MAX_INFLIGHT_PER_WORKER: &str = "64";
20+
const SEED_ACCOUNTS: &str = "100000";
21+
const SEED_INITIAL_BALANCE: &str = "1000000000000";
22+
23+
pub fn run() -> Result<()> {
24+
build_typescript_sdk()?;
25+
build_release_binaries()?;
26+
27+
let cli_path = ensure_binaries_built();
28+
let server = SpacetimeDbGuard::spawn_in_temp_data_dir();
29+
let cli_config_dir = tempfile::tempdir().context("failed to create temporary CLI config directory")?;
30+
let cli_config_path = cli_config_dir.path().join("config.toml");
31+
32+
publish_module(&cli_path, &cli_config_path, &server.host_url)?;
33+
generate_module_bindings(&cli_path, &cli_config_path)?;
34+
seed_accounts(&cli_path, &cli_config_path, &server.host_url)?;
35+
36+
let runs_dir = tempfile::tempdir().context("failed to create temporary benchmark runs directory")?;
37+
run_benchmark(&server.host_url, runs_dir.path())?;
38+
39+
let result_path = find_result_json(runs_dir.path())?;
40+
let result_json = fs::read_to_string(&result_path)
41+
.with_context(|| format!("failed to read benchmark result {}", result_path.display()))?;
42+
let tps = result_tps(&result_json)?;
43+
44+
if tps < MIN_TPS {
45+
eprintln!(
46+
"Keynote perf regression: throughput {tps:.0} TPS < {MIN_TPS:.0} TPS\n\nResult JSON:\n{}",
47+
result_json
48+
);
49+
bail!("keynote benchmark throughput is below threshold");
50+
}
51+
52+
println!(
53+
"Keynote perf check passed: throughput {tps:.0} TPS >= {MIN_TPS:.0} TPS ({})",
54+
result_path.display()
55+
);
56+
Ok(())
57+
}
58+
59+
fn build_typescript_sdk() -> Result<()> {
60+
let mut cmd = Command::new("pnpm");
61+
cmd.arg("build").current_dir("crates/bindings-typescript");
62+
run_command(&mut cmd, "pnpm build in crates/bindings-typescript")
63+
}
64+
65+
fn build_release_binaries() -> Result<()> {
66+
eprintln!("Building spacetimedb-cli and spacetimedb-standalone (release)...");
67+
let mut cmd = Command::new("cargo");
68+
cmd.args([
69+
"build",
70+
"--release",
71+
"-p",
72+
"spacetimedb-cli",
73+
"-p",
74+
"spacetimedb-standalone",
75+
"--features",
76+
"spacetimedb-standalone/allow_loopback_http_for_tests",
77+
]);
78+
remove_cargo_env_vars(&mut cmd);
79+
run_command(&mut cmd, "cargo build --release spacetimedb-cli spacetimedb-standalone")
80+
}
81+
82+
fn remove_cargo_env_vars(cmd: &mut Command) {
83+
for (key, _) in env::vars() {
84+
let should_remove = (key.starts_with("CARGO") && key != "CARGO_HOME" && key != "CARGO_TARGET_DIR")
85+
|| key.starts_with("RUST")
86+
|| key == "__CARGO_FIX_YOLO";
87+
if should_remove {
88+
cmd.env_remove(key);
89+
}
90+
}
91+
}
92+
93+
fn publish_module(cli_path: &Path, config_path: &Path, server_url: &str) -> Result<()> {
94+
run_cli(
95+
cli_path,
96+
config_path,
97+
&[
98+
"publish",
99+
"--server",
100+
server_url,
101+
"--module-path",
102+
KEYNOTE_MODULE_DIR,
103+
"--yes",
104+
"--clear-database",
105+
DATABASE_NAME,
106+
],
107+
"spacetime publish keynote module",
108+
)
109+
}
110+
111+
fn generate_module_bindings(cli_path: &Path, config_path: &Path) -> Result<()> {
112+
run_cli(
113+
cli_path,
114+
config_path,
115+
&[
116+
"generate",
117+
"--lang",
118+
"typescript",
119+
"--out-dir",
120+
KEYNOTE_BINDINGS_DIR,
121+
"--module-path",
122+
KEYNOTE_MODULE_DIR,
123+
"--yes",
124+
],
125+
"spacetime generate keynote TypeScript bindings",
126+
)
127+
}
128+
129+
fn seed_accounts(cli_path: &Path, config_path: &Path, server_url: &str) -> Result<()> {
130+
run_cli(
131+
cli_path,
132+
config_path,
133+
&[
134+
"call",
135+
"--server",
136+
server_url,
137+
DATABASE_NAME,
138+
"seed",
139+
SEED_ACCOUNTS,
140+
SEED_INITIAL_BALANCE,
141+
],
142+
"spacetime call seed",
143+
)
144+
}
145+
146+
fn run_cli(cli_path: &Path, config_path: &Path, args: &[&str], label: &str) -> Result<()> {
147+
let mut cmd = Command::new(cli_path);
148+
cmd.arg("--config-path").arg(config_path).args(args);
149+
run_command(&mut cmd, label)
150+
}
151+
152+
fn run_benchmark(server_url: &str, runs_dir: &Path) -> Result<()> {
153+
let mut cmd = Command::new("pnpm");
154+
cmd.args([
155+
"run",
156+
"bench",
157+
DATABASE_NAME,
158+
"--seconds",
159+
BENCH_SECONDS,
160+
"--concurrency",
161+
BENCH_CONCURRENCY,
162+
"--connectors",
163+
"spacetimedb",
164+
])
165+
.current_dir(KEYNOTE_DIR)
166+
.env("NODE_ENV", "production")
167+
.env("BENCH_PIPELINED", "1")
168+
.env("MAX_INFLIGHT_PER_WORKER", MAX_INFLIGHT_PER_WORKER)
169+
.env("BENCH_RUNS_DIR", runs_dir)
170+
.env("STDB_URL", server_url)
171+
.env("STDB_MODULE", DATABASE_NAME)
172+
.env("SEED_ACCOUNTS", SEED_ACCOUNTS)
173+
.env("SEED_INITIAL_BALANCE", SEED_INITIAL_BALANCE);
174+
run_command(&mut cmd, "keynote SpacetimeDB benchmark")
175+
}
176+
177+
fn run_command(cmd: &mut Command, label: &str) -> Result<()> {
178+
let status = cmd.status().with_context(|| format!("failed to spawn {label}"))?;
179+
ensure!(status.success(), "{label} failed with status {status}");
180+
Ok(())
181+
}
182+
183+
fn find_result_json(runs_dir: &Path) -> Result<PathBuf> {
184+
let mut matches = Vec::new();
185+
for entry in fs::read_dir(runs_dir).with_context(|| format!("failed to read {}", runs_dir.display()))? {
186+
let entry = entry?;
187+
let path = entry.path();
188+
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
189+
continue;
190+
};
191+
if name.starts_with("test-1-spacetimedb-") && name.ends_with(".json") {
192+
matches.push(path);
193+
}
194+
}
195+
196+
match matches.len() {
197+
0 => bail!(
198+
"benchmark did not write a test-1-spacetimedb result JSON in {}",
199+
runs_dir.display()
200+
),
201+
1 => Ok(matches.remove(0)),
202+
_ => bail!(
203+
"benchmark wrote multiple test-1-spacetimedb result JSON files in {}: {:?}",
204+
runs_dir.display(),
205+
matches
206+
),
207+
}
208+
}
209+
210+
fn result_tps(result_json: &str) -> Result<f64> {
211+
let value: Value = serde_json::from_str(result_json).context("failed to parse benchmark result JSON")?;
212+
value
213+
.pointer("/results/0/res/tps")
214+
.and_then(Value::as_f64)
215+
.context("benchmark result JSON is missing results[0].res.tps")
216+
}

tools/ci/src/main.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use std::{env, fs};
1313
const README_PATH: &str = "tools/ci/README.md";
1414

1515
mod ci_docs;
16+
mod keynote_bench;
1617
mod smoketest;
1718
mod util;
1819

@@ -299,6 +300,11 @@ enum CiCmd {
299300
///
300301
/// Executes the smoketests suite with some default exclusions.
301302
Smoketests(smoketest::SmoketestsArgs),
303+
/// Runs the keynote benchmark as a CI performance regression gate.
304+
///
305+
/// Builds release SpacetimeDB binaries, runs the keynote SpacetimeDB benchmark for 60 seconds,
306+
/// and fails if throughput is below 250K TPS.
307+
KeynoteBench,
302308
/// Tests the update flow
303309
///
304310
/// Tests the self-update flow by building the spacetimedb-update binary for the specified
@@ -619,6 +625,11 @@ fn main() -> Result<()> {
619625
smoketest::run(args)?;
620626
}
621627

628+
Some(CiCmd::KeynoteBench) => {
629+
ensure_repo_root()?;
630+
keynote_bench::run()?;
631+
}
632+
622633
Some(CiCmd::UpdateFlow {
623634
target,
624635
github_token_auth,

0 commit comments

Comments
 (0)