Skip to content

Commit e7dd14c

Browse files
Merge branch 'master' into jlarabie/hangman
2 parents e0e7b78 + 4cf01ed commit e7dd14c

9 files changed

Lines changed: 324 additions & 11 deletions

File tree

.github/workflows/ci.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,63 @@ 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-benchmark-runner
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+
- name: Build keynote benchmark binaries
306+
run: cargo build --release -p spacetimedb-cli -p spacetimedb-standalone
307+
308+
# Node 24 is the current Active LTS line.
309+
- name: Set up Node.js
310+
uses: actions/setup-node@v4
311+
with:
312+
node-version: 24
313+
314+
- uses: ./.github/actions/setup-pnpm
315+
with:
316+
run_install: true
317+
318+
- name: Build TypeScript SDK
319+
run: pnpm build
320+
working-directory: crates/bindings-typescript
321+
322+
- name: Run keynote-2 benchmark regression check
323+
run: cargo ci keynote-bench
324+
268325
lints:
269326
name: Lints
270327
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.

sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
<AssemblyVersion>2.3.0</AssemblyVersion>
2020
<Version>2.3.0</Version>
2121
<DefaultItemExcludes>$(DefaultItemExcludes);*~/**</DefaultItemExcludes>
22-
<RestorePackagesPath>packages</RestorePackagesPath>
22+
<RestorePackagesPath>obj~/godot/packages</RestorePackagesPath>
2323
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
2424
<DefineConstants>$(DefineConstants);GODOT</DefineConstants>
2525
<!-- Godot.NET.Sdk defaults to .godot/mono/temp; build this package like the regular C# SDK. -->

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+
Assumes release SpacetimeDB binaries and the TypeScript SDK are already built, runs the keynote SpacetimeDB benchmark for 60 seconds against the TypeScript and Rust modules, and fails if throughput is below 275K TPS for TypeScript or 300K TPS for Rust.
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+
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_BINDINGS_DIR: &str = "templates/keynote-2/module_bindings";
15+
const BENCH_SECONDS: &str = "60";
16+
const BENCH_CONCURRENCY: &str = "64";
17+
const MAX_INFLIGHT_PER_WORKER: &str = "96";
18+
const SEED_ACCOUNTS: &str = "100000";
19+
const SEED_INITIAL_BALANCE: &str = "1000000000000";
20+
21+
struct BenchmarkModule {
22+
label: &'static str,
23+
module_dir: &'static str,
24+
min_tps: f64,
25+
}
26+
27+
const BENCHMARK_MODULES: &[BenchmarkModule] = &[
28+
BenchmarkModule {
29+
label: "TypeScript",
30+
module_dir: "templates/keynote-2/spacetimedb",
31+
min_tps: 275_000.0,
32+
},
33+
BenchmarkModule {
34+
label: "Rust",
35+
module_dir: "templates/keynote-2/rust_module",
36+
min_tps: 300_000.0,
37+
},
38+
];
39+
40+
pub fn run() -> Result<()> {
41+
let cli_path = ensure_binaries_built();
42+
let server = SpacetimeDbGuard::spawn_in_temp_data_dir();
43+
let cli_config_dir = tempfile::tempdir().context("failed to create temporary CLI config directory")?;
44+
let cli_config_path = cli_config_dir.path().join("config.toml");
45+
46+
for module in BENCHMARK_MODULES {
47+
run_module_benchmark(module, &cli_path, &cli_config_path, &server.host_url)?;
48+
}
49+
50+
Ok(())
51+
}
52+
53+
fn run_module_benchmark(module: &BenchmarkModule, cli_path: &Path, config_path: &Path, server_url: &str) -> Result<()> {
54+
eprintln!(
55+
"Running keynote benchmark against {} module ({})...",
56+
module.label, module.module_dir
57+
);
58+
59+
publish_module(module, cli_path, config_path, server_url)?;
60+
generate_module_bindings(module, cli_path, config_path)?;
61+
seed_accounts(cli_path, config_path, server_url)?;
62+
let runs_dir = tempfile::tempdir().context("failed to create temporary benchmark runs directory")?;
63+
run_benchmark(module, server_url, runs_dir.path())?;
64+
65+
let result_path = find_result_json(runs_dir.path())?;
66+
let result_json = fs::read_to_string(&result_path)
67+
.with_context(|| format!("failed to read benchmark result {}", result_path.display()))?;
68+
let tps = result_tps(&result_json)?;
69+
70+
if tps < module.min_tps {
71+
eprintln!(
72+
"Keynote perf regression for {} module: throughput {tps:.0} TPS < {:.0} TPS\n\nResult JSON:\n{}",
73+
module.label, module.min_tps, result_json
74+
);
75+
bail!(
76+
"keynote benchmark throughput for {} module is below threshold",
77+
module.label
78+
);
79+
}
80+
81+
println!(
82+
"Keynote perf check passed for {} module: throughput {tps:.0} TPS >= {:.0} TPS ({})",
83+
module.label,
84+
module.min_tps,
85+
result_path.display()
86+
);
87+
Ok(())
88+
}
89+
90+
fn publish_module(module: &BenchmarkModule, cli_path: &Path, config_path: &Path, server_url: &str) -> Result<()> {
91+
let label = format!("spacetime publish keynote {} module", module.label);
92+
run_cli(
93+
cli_path,
94+
config_path,
95+
&[
96+
"publish",
97+
"--server",
98+
server_url,
99+
"--module-path",
100+
module.module_dir,
101+
"--yes",
102+
"--clear-database",
103+
DATABASE_NAME,
104+
],
105+
&label,
106+
)
107+
}
108+
109+
fn generate_module_bindings(module: &BenchmarkModule, cli_path: &Path, config_path: &Path) -> Result<()> {
110+
let label = format!("spacetime generate keynote {} TypeScript bindings", module.label);
111+
run_cli(
112+
cli_path,
113+
config_path,
114+
&[
115+
"generate",
116+
"--lang",
117+
"typescript",
118+
"--out-dir",
119+
KEYNOTE_BINDINGS_DIR,
120+
"--module-path",
121+
module.module_dir,
122+
"--yes",
123+
],
124+
&label,
125+
)
126+
}
127+
128+
fn seed_accounts(cli_path: &Path, config_path: &Path, server_url: &str) -> Result<()> {
129+
run_cli(
130+
cli_path,
131+
config_path,
132+
&[
133+
"call",
134+
"--server",
135+
server_url,
136+
DATABASE_NAME,
137+
"seed",
138+
SEED_ACCOUNTS,
139+
SEED_INITIAL_BALANCE,
140+
],
141+
"spacetime call seed",
142+
)
143+
}
144+
145+
fn run_cli(cli_path: &Path, config_path: &Path, args: &[&str], label: &str) -> Result<()> {
146+
let mut cmd = Command::new(cli_path);
147+
cmd.arg("--config-path").arg(config_path).args(args);
148+
run_command(&mut cmd, label)
149+
}
150+
151+
fn run_benchmark(module: &BenchmarkModule, server_url: &str, runs_dir: &Path) -> Result<()> {
152+
let mut cmd = Command::new("pnpm");
153+
cmd.args([
154+
"run",
155+
"bench",
156+
DATABASE_NAME,
157+
"--seconds",
158+
BENCH_SECONDS,
159+
"--concurrency",
160+
BENCH_CONCURRENCY,
161+
"--connectors",
162+
"spacetimedb",
163+
])
164+
.current_dir(KEYNOTE_DIR)
165+
.env("NODE_ENV", "production")
166+
.env("BENCH_PIPELINED", "1")
167+
.env("MAX_INFLIGHT_PER_WORKER", MAX_INFLIGHT_PER_WORKER)
168+
.env("BENCH_RUNS_DIR", runs_dir)
169+
.env("STDB_URL", server_url)
170+
.env("STDB_MODULE", DATABASE_NAME)
171+
.env("SEED_ACCOUNTS", SEED_ACCOUNTS)
172+
.env("SEED_INITIAL_BALANCE", SEED_INITIAL_BALANCE);
173+
let label = format!("keynote SpacetimeDB benchmark against {} module", module.label);
174+
run_command(&mut cmd, &label)
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: 12 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

@@ -315,6 +316,12 @@ enum CiCmd {
315316
///
316317
/// Executes the smoketests suite with some default exclusions.
317318
Smoketests(smoketest::SmoketestsArgs),
319+
/// Runs the keynote benchmark as a CI performance regression gate.
320+
///
321+
/// Assumes release SpacetimeDB binaries and the TypeScript SDK are already built, runs the
322+
/// keynote SpacetimeDB benchmark for 60 seconds against the TypeScript and Rust modules, and
323+
/// fails if throughput is below 275K TPS for TypeScript or 300K TPS for Rust.
324+
KeynoteBench,
318325
/// Tests the update flow
319326
///
320327
/// Tests the self-update flow by building the spacetimedb-update binary for the specified
@@ -635,6 +642,11 @@ fn main() -> Result<()> {
635642
smoketest::run(args)?;
636643
}
637644

645+
Some(CiCmd::KeynoteBench) => {
646+
ensure_repo_root()?;
647+
keynote_bench::run()?;
648+
}
649+
638650
Some(CiCmd::UpdateFlow {
639651
target,
640652
github_token_auth,

0 commit comments

Comments
 (0)