From 79fb71af55b95247bf697d4fa3b1c9df2e2089bd Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Thu, 7 May 2026 11:49:54 -0700 Subject: [PATCH 01/47] Update keynote benchmark to run for 60s by default --- templates/keynote-2/DEVELOP.md | 4 ++-- templates/keynote-2/README.md | 6 +++--- templates/keynote-2/src/opts.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/templates/keynote-2/DEVELOP.md b/templates/keynote-2/DEVELOP.md index d99c1ffedb4..f3211094255 100644 --- a/templates/keynote-2/DEVELOP.md +++ b/templates/keynote-2/DEVELOP.md @@ -27,7 +27,7 @@ The script will: **Options:** -- `--seconds N` - Benchmark duration (default: 10) +- `--seconds N` - Benchmark duration (default: 60) - `--concurrency N` - Concurrent connections (default: 50) - `--alpha N` - Contention level (default: 1.5) - `--systems a,b,c` - Systems to compare (default: convex,spacetimedb) @@ -205,7 +205,7 @@ From `src/cli.ts`: - **`--seconds N`** - Duration of the benchmark in seconds - - Default: `10` + - Default: `60` - **`--concurrency N`** - Number of workers / in-flight operations diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index 9f3cbc9e70f..3eef2aec448 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -20,7 +20,7 @@ The demo compares SpacetimeDB and Convex by default, since both are easy for any ## Results Summary -All tests use 50 concurrent connections with a transfer workload (read-modify-write transaction between two accounts). +All tests run for 60 seconds and use 50 concurrent connections with a transfer workload (read-modify-write transaction between two accounts). | System | TPS (~0% Contention) | TPS (~80% Contention) | | --------------------------------- | -------------------- | --------------------- | @@ -87,10 +87,10 @@ This is a classic read-modify-write workload that tests transactional integrity ### Test Command ```bash -docker compose run --rm bench -- --seconds 10 --concurrency 50 --alpha XX --connectors YY +docker compose run --rm bench -- --seconds 60 --concurrency 50 --alpha XX --connectors YY ``` -- `--seconds 10`: Duration of benchmark run +- `--seconds 60`: Duration of benchmark run - `--concurrency 50`: Number of concurrent client connections - `--alpha 0`: ~0% contention (uniform account distribution) - `--alpha 1.5`: ~80% contention (Zipf distribution concentrating on hot accounts) diff --git a/templates/keynote-2/src/opts.ts b/templates/keynote-2/src/opts.ts index 98474c53082..ecd14538eb7 100644 --- a/templates/keynote-2/src/opts.ts +++ b/templates/keynote-2/src/opts.ts @@ -301,7 +301,7 @@ export function parseDemoOptions(argv: string[] = process.argv): DemoOptions { return { ...runtimeOptions, - seconds: options.seconds ?? 10, + seconds: options.seconds ?? 60, concurrency: options.concurrency ?? 50, alpha: options.alpha ?? 1.5, systems: @@ -374,7 +374,7 @@ export function parseBenchOptions(argv: string[] = process.argv): BenchOptions { return { ...runtimeOptions, testName: args[0] ?? defaultBenchTestName, - seconds: options.seconds ?? 10, + seconds: options.seconds ?? 60, concurrency: contentionTests?.concurrency ?? options.concurrency ?? 50, alpha: concurrencyTests?.alpha ?? options.alpha ?? 1.5, From 54d17a4b76a9d01a83e1c2da41e2e36b3aae1f47 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Thu, 7 May 2026 17:35:43 -0400 Subject: [PATCH 02/47] Updates - bump convex to latest - update numbers - update init_convex due to new limits on writes/second on local dev - update compose to default settings (*except* default_transaction_isolation=serializable for pg) --- templates/keynote-2/README.md | 37 +++++++++++---------- templates/keynote-2/convex-app/package.json | 2 +- templates/keynote-2/docker-compose.yml | 16 ++------- templates/keynote-2/package.json | 2 +- templates/keynote-2/src/init/init_convex.ts | 3 +- 5 files changed, 26 insertions(+), 34 deletions(-) diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index 3eef2aec448..578cc9d3a23 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -20,21 +20,21 @@ The demo compares SpacetimeDB and Convex by default, since both are easy for any ## Results Summary -All tests run for 60 seconds and use 50 concurrent connections with a transfer workload (read-modify-write transaction between two accounts). +All tests run for 60 seconds with 50 concurrent connections, with a transfer workload (read-modify-write transaction between two accounts). | System | TPS (~0% Contention) | TPS (~80% Contention) | | --------------------------------- | -------------------- | --------------------- | | SpacetimeDB (TypeScript Module) | | 307,074 | | SpacetimeDB (Rust Module) | | 265,542 | -| SQLite + Node HTTP + Drizzle | | 3,236 | -| Bun + Drizzle + Postgres | 7,115 | 2,074 | -| Postgres + Node HTTP + Drizzle | 6,429 | 2,798 | -| Supabase + Node HTTP + Drizzle | 6,310 | 1,268 | -| CockroachDB + Node HTTP + Drizzle | 5,129 | 197 | -| PlanetScale + Node HTTP + Drizzle | 477 | 30 | -| Convex | 438 | 58 | +| SQLite + Node HTTP + Drizzle | 3,095 | 3,186 | +| Bun + Drizzle + Postgres | 10,736 | 2,817 | +| Supabase + Node HTTP + Drizzle | 7,299 | 2,582 | +| Postgres + Node HTTP + Drizzle | 9,946 | 1,111 | +| Convex (self-hosted local) | 1,218 | 225 | +| PlanetScale + Node HTTP + Drizzle | - | 203 | +| CockroachDB + Node HTTP + Drizzle | 3,931 | 142 | -**Key Finding:** SpacetimeDB reaches hundreds of thousands of TPS for the transfer workload, while the best non-SpacetimeDB result shown here is SQLite RPC at 3,236 TPS. Traditional databases also suffer significant degradation under high contention (CockroachDB drops 96%). +**Key Finding:** SpacetimeDB reaches hundreds of thousands of TPS for the transfer workload, while the best non-SpacetimeDB result shown here is SQLite at 3,186 TPS. Traditional databases also suffer significant degradation under high contention (CockroachDB drops 96%). ### Contention Impact @@ -44,13 +44,9 @@ The chart above shows TPS vs Zipf Alpha (contention level). Higher alpha values ## Methodology -All systems were tested with **out-of-the-box default settings** - no custom tuning, no configuration optimization. This reflects what developers experience when they first adopt these technologies. +All systems were tested with **out-of-the-box default settings**: no custom tuning, no configuration optimization. This reflects what developers experience when they first adopt these technologies. -For cloud services, we tested paid tiers to give them their best chance: - -- **PlanetScale**: PS-2560 (32 vCPUs, 256 GB RAM), single node, us-central1. -- **Supabase**: Pro tier -- **Convex**: Pro tier +Postgres (and Bun, which uses the same Postgres instance) is configured with `default_transaction_isolation = 'serializable'`. The reported SpacetimeDB module results were run against a 5-way replicated cluster rather than a single standalone node. @@ -86,8 +82,13 @@ This is a classic read-modify-write workload that tests transactional integrity ### Test Command +The numbers in the table above were collected by running each connector directly with `pnpm`: + ```bash -docker compose run --rm bench -- --seconds 60 --concurrency 50 --alpha XX --connectors YY +pnpm install +pnpm run prep # seed all backing databases once +pnpm run bench test-1 --seconds 60 --concurrency 50 --alpha 0 --connectors # uncontended +pnpm run bench test-1 --seconds 60 --concurrency 50 --alpha 1.5 --connectors # ~80% contention ``` - `--seconds 60`: Duration of benchmark run @@ -96,6 +97,8 @@ docker compose run --rm bench -- --seconds 60 --concurrency 50 --alpha XX --conn - `--alpha 1.5`: ~80% contention (Zipf distribution concentrating on hot accounts) - `--stdb-compression none|gzip`: SpacetimeDB client compression mode (default: `none`) +The Docker workflow (`docker compose run --rm bench -- ...`) produces equivalent numbers. + ### Hardware Configuration **Server Machine (Variant A - PhoenixNAP):** @@ -160,7 +163,7 @@ SpacetimeDB supports `withConfirmedReads` mode which ensures transactions are du ### Cloud vs Local Results -PlanetScale results (~477 TPS) demonstrate the **significant impact of cloud database latency**. When the database is accessed over the network (even within the same cloud region), round-trip latency dominates performance. This is why SpacetimeDB's colocated architecture provides such dramatic improvements. +PlanetScale results (203 TPS under high contention) demonstrate the **significant impact of cloud database latency**. When the database is accessed over the network (even within the same cloud region), round-trip latency dominates performance. This is why SpacetimeDB's colocated architecture provides such dramatic improvements. ## Systems Tested diff --git a/templates/keynote-2/convex-app/package.json b/templates/keynote-2/convex-app/package.json index eebdd65afc6..e5997e51d0f 100644 --- a/templates/keynote-2/convex-app/package.json +++ b/templates/keynote-2/convex-app/package.json @@ -7,7 +7,7 @@ }, "dependencies": { "@convex-dev/sharded-counter": "^0.2.0", - "convex": "^1.29.3", + "convex": "^1.37.0", "convex-helpers": "^0.1.0" } } diff --git a/templates/keynote-2/docker-compose.yml b/templates/keynote-2/docker-compose.yml index e86735fd13d..d4d07dd8ab6 100644 --- a/templates/keynote-2/docker-compose.yml +++ b/templates/keynote-2/docker-compose.yml @@ -7,19 +7,7 @@ POSTGRES_DB: ${POSTGRES_DB} ports: - "5432:5432" - # command: > - # -c fsync=on - # -c synchronous_commit=on - # -c shared_buffers=2GB - # -c work_mem=64MB - # -c max_connections=1000 - command: > # faster setup - -c fsync=on - -c synchronous_commit=off - -c shared_buffers=8GB - -c work_mem=64MB - -c max_connections=10000 - -c default_transaction_isolation=serializable + command: -c default_transaction_isolation=serializable healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 2s @@ -31,7 +19,7 @@ crdb: image: cockroachdb/cockroach:latest - command: start-single-node --insecure --max-sql-memory=.25 --cache=.5 + command: start-single-node --insecure ports: - "26257:26257" - "8082:8080" diff --git a/templates/keynote-2/package.json b/templates/keynote-2/package.json index 05157e7d59e..e7278fdb908 100644 --- a/templates/keynote-2/package.json +++ b/templates/keynote-2/package.json @@ -20,7 +20,7 @@ "@types/pg": "^8.15.6", "@types/sql.js": "^1.4.9", "bun-types": "^1.3.2", - "convex": "^1.29.0", + "convex": "^1.37.0", "dotenv": "^17.2.3", "node-gyp": "^12.1.0", "pg": "^8.16.3", diff --git a/templates/keynote-2/src/init/init_convex.ts b/templates/keynote-2/src/init/init_convex.ts index 5fcb630294c..1fae6e0c786 100644 --- a/templates/keynote-2/src/init/init_convex.ts +++ b/templates/keynote-2/src/init/init_convex.ts @@ -50,7 +50,7 @@ export async function initConvex(config: ConvexInitConfig) { } // Max ~16k writes per function; keep a safety margin - const CHUNK = 10_000; + const CHUNK = 7_000; console.log( `[convex] seeding ${accounts} accounts in chunks of ${CHUNK} (initial=${initialBalance})`, ); @@ -65,6 +65,7 @@ export async function initConvex(config: ConvexInitConfig) { count, initial: initialBalance, }); + await new Promise(r => setTimeout(r, 500)); } console.log('[convex] seed complete.'); From 94023cb288cfe83cacc51024c8d8e35d0555dbee Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Thu, 7 May 2026 17:38:53 -0400 Subject: [PATCH 03/47] Remove separate client in readme --- templates/keynote-2/README.md | 26 ++------------------------ 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index 578cc9d3a23..497ead6754a 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -101,31 +101,9 @@ The Docker workflow (`docker compose run --rm bench -- ...`) produces equivalent ### Hardware Configuration -**Server Machine (Variant A - PhoenixNAP):** +**Server Machine:** -- s3.c3.medium bare metal instance - Intel i9-14900k 24 cores (32 threads), 128GB DDR5 Memory, OS: Ubuntu 24.04 - -**Server Machine (Variant B - Google Cloud):** - -- c4-standard-32-lssd (32 vCPUs, 120 GB Memory) OS: Ubuntu 24.04 -- RAID 0 on 5 Local SSDs -- Region: us-central1 - -**Client Machine:** - -- c4-standard-32 (32 vCPUs, 120 GB Memory) OS: Ubuntu 24.04 -- Region: us-central1 -- Runs on a **separate machine** from the server - -**Note:** All services (databases, web servers, benchmark runner) except Convex local dev backend run in the same Docker environment on the server machine. - -### Why Separate Client Machines? - -Running clients on separate machines ensures: - -- Network round-trip latency is measured (realistic production scenario) -- Client CPU/memory doesn't compete with server resources -- Results reflect actual deployment conditions +- PhoenixNAP s3.c3.medium bare metal instance - Intel i9-14900k 24 cores (32 threads), 128GB DDR5 Memory, OS: Ubuntu 24.04 ### Account Seeding From 283e8129662c09da90d65603c3264a6d0156e57b Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Thu, 7 May 2026 17:42:46 -0400 Subject: [PATCH 04/47] Update README.md --- templates/keynote-2/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index 497ead6754a..c47145b4b6c 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -44,9 +44,7 @@ The chart above shows TPS vs Zipf Alpha (contention level). Higher alpha values ## Methodology -All systems were tested with **out-of-the-box default settings**: no custom tuning, no configuration optimization. This reflects what developers experience when they first adopt these technologies. - -Postgres (and Bun, which uses the same Postgres instance) is configured with `default_transaction_isolation = 'serializable'`. +All systems were tested with **out-of-the-box default settings**, with one exception: Postgres (and Bun, which uses the same Postgres instance) is configured with `default_transaction_isolation = 'serializable'` for an apples-to-apples comparison. No other custom tuning or configuration optimization was applied. The reported SpacetimeDB module results were run against a 5-way replicated cluster rather than a single standalone node. From ed552b1683e2c05bb3c08e9596639da3e0b17091 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Thu, 7 May 2026 17:53:02 -0400 Subject: [PATCH 05/47] Update README.md --- templates/keynote-2/README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index c47145b4b6c..6221526f983 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -36,12 +36,6 @@ All tests run for 60 seconds with 50 concurrent connections, with a transfer wor **Key Finding:** SpacetimeDB reaches hundreds of thousands of TPS for the transfer workload, while the best non-SpacetimeDB result shown here is SQLite at 3,186 TPS. Traditional databases also suffer significant degradation under high contention (CockroachDB drops 96%). -### Contention Impact - -![Contention Chart](./contention-chart.png) - -The chart above shows TPS vs Zipf Alpha (contention level). Higher alpha values concentrate more transactions on fewer "hot" accounts, increasing contention. SpacetimeDB maintains consistent performance regardless of contention level, while traditional database architectures show significant degradation. - ## Methodology All systems were tested with **out-of-the-box default settings**, with one exception: Postgres (and Bun, which uses the same Postgres instance) is configured with `default_transaction_isolation = 'serializable'` for an apples-to-apples comparison. No other custom tuning or configuration optimization was applied. From 07850c6803ded9f016c03d8d4b7fe1f942041001 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Thu, 7 May 2026 19:48:29 -0400 Subject: [PATCH 06/47] Update README.md --- templates/keynote-2/README.md | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index 6221526f983..dac961b5923 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -22,19 +22,20 @@ The demo compares SpacetimeDB and Convex by default, since both are easy for any All tests run for 60 seconds with 50 concurrent connections, with a transfer workload (read-modify-write transaction between two accounts). -| System | TPS (~0% Contention) | TPS (~80% Contention) | -| --------------------------------- | -------------------- | --------------------- | -| SpacetimeDB (TypeScript Module) | | 307,074 | -| SpacetimeDB (Rust Module) | | 265,542 | -| SQLite + Node HTTP + Drizzle | 3,095 | 3,186 | -| Bun + Drizzle + Postgres | 10,736 | 2,817 | -| Supabase + Node HTTP + Drizzle | 7,299 | 2,582 | -| Postgres + Node HTTP + Drizzle | 9,946 | 1,111 | -| Convex (self-hosted local) | 1,218 | 225 | -| PlanetScale + Node HTTP + Drizzle | - | 203 | -| CockroachDB + Node HTTP + Drizzle | 3,931 | 142 | - -**Key Finding:** SpacetimeDB reaches hundreds of thousands of TPS for the transfer workload, while the best non-SpacetimeDB result shown here is SQLite at 3,186 TPS. Traditional databases also suffer significant degradation under high contention (CockroachDB drops 96%). +Each cell shows **mean TPS ± sample standard deviation** across 3 × 60-second runs. Cells where the standard deviation approaches or exceeds the mean (e.g. CockroachDB and Convex at ~80% contention) indicate that the system's throughput varies substantially between runs. + +| System | Mean TPS (~0% Contention) | Mean TPS (~80% Contention) | +| --------------------------------- | ------------------------- | -------------------------- | +| SpacetimeDB (TypeScript Module) | | 307,074 | +| SpacetimeDB (Rust Module) | | 265,542 | +| SQLite + Node HTTP + Drizzle | 3,081 ± 15 | 3,169 ± 16 | +| Bun + Drizzle + Postgres | 10,582 ± 7 | 2,754 ± 5 | +| Supabase + Node HTTP + Drizzle | 7,116 ± 161 | 2,581 ± 2 | +| Postgres + Node HTTP + Drizzle | 9,425 ± 25 | 1,087 ± 5 | +| CockroachDB + Node HTTP + Drizzle | 3,933 ± 28 | 145 ± 168 | +| Convex (self-hosted local) | 1,210 ± 47 | 130 ± 98 | + +**Key Finding:** SpacetimeDB reaches hundreds of thousands of TPS for the transfer workload, while the best non-SpacetimeDB result shown here is SQLite at 3,169 TPS. Traditional databases also suffer significant degradation under high contention (CockroachDB drops 96%). ## Methodology From 6758146561645202f6f215c4d5968469a8d831c7 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Thu, 7 May 2026 20:11:32 -0400 Subject: [PATCH 07/47] Update README.md --- templates/keynote-2/README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index dac961b5923..9308a3dfa35 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -22,18 +22,18 @@ The demo compares SpacetimeDB and Convex by default, since both are easy for any All tests run for 60 seconds with 50 concurrent connections, with a transfer workload (read-modify-write transaction between two accounts). -Each cell shows **mean TPS ± sample standard deviation** across 3 × 60-second runs. Cells where the standard deviation approaches or exceeds the mean (e.g. CockroachDB and Convex at ~80% contention) indicate that the system's throughput varies substantially between runs. - -| System | Mean TPS (~0% Contention) | Mean TPS (~80% Contention) | -| --------------------------------- | ------------------------- | -------------------------- | -| SpacetimeDB (TypeScript Module) | | 307,074 | -| SpacetimeDB (Rust Module) | | 265,542 | -| SQLite + Node HTTP + Drizzle | 3,081 ± 15 | 3,169 ± 16 | -| Bun + Drizzle + Postgres | 10,582 ± 7 | 2,754 ± 5 | -| Supabase + Node HTTP + Drizzle | 7,116 ± 161 | 2,581 ± 2 | -| Postgres + Node HTTP + Drizzle | 9,425 ± 25 | 1,087 ± 5 | -| CockroachDB + Node HTTP + Drizzle | 3,933 ± 28 | 145 ± 168 | -| Convex (self-hosted local) | 1,210 ± 47 | 130 ± 98 | +Each cell shows **mean TPS ± sample standard deviation** across 3 × 60-second runs, with the sample variance in parentheses. Cells where the standard deviation approaches or exceeds the mean (e.g. CockroachDB and Convex at ~80% contention) indicate that the system's throughput varies substantially between runs. + +| System | Mean TPS ± σ (Var) (~0% Contention) | Mean TPS ± σ (Var) (~80% Contention) | +| --------------------------------- | ----------------------------------- | ------------------------------------ | +| SpacetimeDB (TypeScript Module) | | 307,074 | +| SpacetimeDB (Rust Module) | | 265,542 | +| SQLite + Node HTTP + Drizzle | 3,081 ± 15 (224) | 3,169 ± 16 (242) | +| Bun + Drizzle + Postgres | 10,582 ± 7 (53) | 2,754 ± 5 (28) | +| Supabase + Node HTTP + Drizzle | 7,116 ± 161 (25,763) | 2,581 ± 2 (3) | +| Postgres + Node HTTP + Drizzle | 9,425 ± 25 (644) | 1,087 ± 5 (21) | +| CockroachDB + Node HTTP + Drizzle | 3,933 ± 28 (762) | 145 ± 168 (28,157) | +| Convex (self-hosted local) | 1,210 ± 47 (2,224) | 130 ± 98 (9,675) | **Key Finding:** SpacetimeDB reaches hundreds of thousands of TPS for the transfer workload, while the best non-SpacetimeDB result shown here is SQLite at 3,169 TPS. Traditional databases also suffer significant degradation under high contention (CockroachDB drops 96%). From 4b4e9383497aa7725e81635785a61e98b069c8ed Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Fri, 8 May 2026 11:44:22 -0400 Subject: [PATCH 08/47] time series + scripts --- templates/keynote-2/scripts/README.md | 45 +++++++++ templates/keynote-2/scripts/check-bench.sh | 28 ++++++ templates/keynote-2/scripts/plot-bench.py | 79 +++++++++++++++ .../keynote-2/scripts/run-all-benches.sh | 98 +++++++++++++++++++ templates/keynote-2/scripts/start-bench.sh | 38 +++++++ templates/keynote-2/scripts/stop-bench.sh | 13 +++ templates/keynote-2/src/core/runner.ts | 53 +++++++++- templates/keynote-2/src/core/types.ts | 10 ++ 8 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 templates/keynote-2/scripts/README.md create mode 100644 templates/keynote-2/scripts/check-bench.sh create mode 100644 templates/keynote-2/scripts/plot-bench.py create mode 100644 templates/keynote-2/scripts/run-all-benches.sh create mode 100644 templates/keynote-2/scripts/start-bench.sh create mode 100644 templates/keynote-2/scripts/stop-bench.sh diff --git a/templates/keynote-2/scripts/README.md b/templates/keynote-2/scripts/README.md new file mode 100644 index 00000000000..65a53d1c093 --- /dev/null +++ b/templates/keynote-2/scripts/README.md @@ -0,0 +1,45 @@ +# Bench scripts + +Helpers for running the keynote benchmark on a single host. + +| Script | What it does | +| --- | --- | +| `start-bench.sh` | Bring up every backend service (sqlite-rpc, postgres-rpc, bun-rpc, cockroach + cockroach-rpc, supabase-rpc, convex local) in its own tmux window inside session `bench`. | +| `stop-bench.sh` | Kill every foreground bench process and the tmux session. Leaves Postgres (systemd) and Supabase (Docker) running. | +| `check-bench.sh` | Health-check each service with a single HTTP call. | +| `run-all-benches.sh` | Run the bench across connectors and alphas, capturing per-run TPS/latency/verify output to `/tmp/bench-results.tsv`. | +| `plot-bench.py` | Read the `timeSeries` field from `runs/test-1-*.json` and produce per-alpha TPS + p99-latency charts. Requires matplotlib. | + +## Typical flow + +```bash +# one-time prerequisites +sudo systemctl start postgresql # Postgres as system service +supabase start # Supabase Docker stack +sudo -u postgres psql -c "CREATE DATABASE bun_bench;" + +# bring services up +scripts/start-bench.sh +tmux attach -t bench # poke around if needed +scripts/check-bench.sh # confirm all green + +# seed (one-time, or after wiping a DB) +pnpm run prep + +# benchmark — args: RUNS SECONDS CONNECTORS_CSV ALPHAS_CSV +scripts/run-all-benches.sh 3 60 # 3 runs x 60s, all connectors, both alphas +scripts/run-all-benches.sh 5 60 sqlite_rpc,postgres_rpc 1.5 + +# plot +python3 scripts/plot-bench.py 0 # writes bench-alpha0.0.png +python3 scripts/plot-bench.py 1.5 + +# tear down +scripts/stop-bench.sh +``` + +## Notes + +- `run-all-benches.sh` overwrites `/tmp/bench-results.tsv` on each invocation. Archive it first if you want to preserve a prior sweep. +- All scripts assume the repo lives at `~/SpacetimeDB`. Edit the hardcoded paths if your checkout is elsewhere. +- `plot-bench.py` requires the `timeSeries` field added to `core/runner.ts`. Older `runs/*.json` files without that field are silently skipped. diff --git a/templates/keynote-2/scripts/check-bench.sh b/templates/keynote-2/scripts/check-bench.sh new file mode 100644 index 00000000000..72d34646ce9 --- /dev/null +++ b/templates/keynote-2/scripts/check-bench.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Health-check every backend the bench needs. +# Prints one line per service. Returns 0 even on failures — visual inspection. + +CONVEX_URL=$(grep '^CONVEX_URL=' ~/SpacetimeDB/templates/keynote-2/convex-app/.env.local 2>/dev/null | cut -d= -f2) + +checks=( + "sqlite_rpc|http://127.0.0.1:4103/rpc|{\"name\":\"health\",\"args\":{}}" + "postgres_rpc|http://127.0.0.1:4101/rpc|{\"name\":\"health\",\"args\":{}}" + "cockroach_rpc|http://127.0.0.1:4102/rpc|{\"name\":\"health\",\"args\":{}}" + "bun|http://127.0.0.1:4001/rpc|{\"name\":\"health\",\"args\":{}}" + "supabase_rpc|http://127.0.0.1:4106/rpc|{\"name\":\"health\",\"args\":{}}" +) + +for c in "${checks[@]}"; do + IFS='|' read -r name url body <<<"$c" + out=$(curl -s --max-time 3 -X POST "$url" -H 'content-type: application/json' -d "$body" 2>&1) + printf "%-15s %s\n" "$name" "${out:-NO RESPONSE}" +done + +echo +printf "%-15s " "convex" +if [ -n "$CONVEX_URL" ]; then + curl -s --max-time 3 "$CONVEX_URL/instance_name" 2>&1 || echo "(no response)" + echo +else + echo "URL not set in convex-app/.env.local" +fi diff --git a/templates/keynote-2/scripts/plot-bench.py b/templates/keynote-2/scripts/plot-bench.py new file mode 100644 index 00000000000..d39fd482467 --- /dev/null +++ b/templates/keynote-2/scripts/plot-bench.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Plot TPS and p99 latency over time for a single alpha across connectors. + +Reads timeSeries arrays from runs/test-1-*.json (added by core/runner.ts) +and emits a stacked TPS + p99-latency chart per alpha. + +Usage: + python3 plot-bench.py [alpha] [outfile] + +Examples: + python3 plot-bench.py 0 + python3 plot-bench.py 1.5 + python3 plot-bench.py 1.5 contended.png +""" +import json +import sys +from pathlib import Path +import matplotlib.pyplot as plt + +RUNS_DIR = Path.home() / "SpacetimeDB/templates/keynote-2/runs" + + +def load_run(path): + data = json.loads(path.read_text()) + r = data["results"][0] + return { + "path": path.name, + "connector": r["file"].replace(".ts", ""), + "alpha": data["alpha"], + "ts": r["res"].get("timeSeries", []), + } + + +def plot(runs, alpha, outfile): + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 8), sharex=True) + + matched = [r for r in runs if r["alpha"] == alpha and r["ts"]] + if not matched: + print(f"no runs with timeSeries data found at alpha={alpha}", file=sys.stderr) + sys.exit(1) + + # one line per run; group by connector for color reuse + seen_connectors = {} + for r in matched: + ts = r["ts"] + x = [p["tSec"] for p in ts] + + label = r["connector"] + if label in seen_connectors: + label = None # avoid duplicate legend entries when there are multiple runs + else: + seen_connectors[r["connector"]] = True + + ax1.plot(x, [p["tps"] for p in ts], label=label, linewidth=2, alpha=0.85) + ax2.plot(x, [p["p99_ms"] for p in ts], label=label, linewidth=2, alpha=0.85) + + contention = "uncontended" if alpha == 0 else f"alpha={alpha}" + ax1.set_ylabel("TPS") + ax1.set_title(f"alpha={alpha} ({contention})") + ax1.legend(loc="upper right") + ax1.grid(True, alpha=0.3) + + ax2.set_ylabel("p99 latency (ms)") + ax2.set_xlabel("Time (s)") + ax2.set_yscale("log") + ax2.legend(loc="upper left") + ax2.grid(True, alpha=0.3) + + plt.tight_layout() + plt.savefig(outfile, dpi=120) + print(f"wrote {outfile} ({len(matched)} runs)") + + +if __name__ == "__main__": + alpha = float(sys.argv[1]) if len(sys.argv) > 1 else 0 + outfile = sys.argv[2] if len(sys.argv) > 2 else f"bench-alpha{alpha}.png" + + runs = [load_run(p) for p in sorted(RUNS_DIR.glob("test-1-*.json"))] + plot(runs, alpha, outfile) diff --git a/templates/keynote-2/scripts/run-all-benches.sh b/templates/keynote-2/scripts/run-all-benches.sh new file mode 100644 index 00000000000..d02f0d7deb2 --- /dev/null +++ b/templates/keynote-2/scripts/run-all-benches.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Usage: run-all-benches.sh [RUNS] [SECONDS] [CONNECTORS_CSV] [ALPHAS_CSV] +# +# Defaults: +# RUNS=5 SECONDS=60 CONNECTORS=all ALPHAS=0,1.5 +# +# Examples: +# run-all-benches.sh 3 60 sqlite_rpc # 3 runs x 60s, sqlite, both alphas +# run-all-benches.sh 3 60 sqlite_rpc 1.5 # 3 runs x 60s, sqlite, alpha=1.5 only +# run-all-benches.sh 3 60 sqlite_rpc,bun 0 # alpha=0 only +# run-all-benches.sh 1 10 convex 0.5,1.0,1.5 # alpha sweep + +set -uo pipefail +cd ~/SpacetimeDB/templates/keynote-2 + +RUNS=${1:-5} +SECS=${2:-60} +CONNECTORS_CSV=${3:-sqlite_rpc,postgres_rpc,cockroach_rpc,bun,supabase_rpc,convex} +ALPHAS_CSV=${4:-0,1.5} + +OUT=/tmp/bench-results.tsv +LOG=/tmp/bench-progress.log +: > "$LOG" + +CONVEX_URL=$(grep '^CONVEX_URL=' convex-app/.env.local 2>/dev/null | cut -d= -f2) +[ -z "$CONVEX_URL" ] && CONVEX_URL=http://127.0.0.1:3210 + +echo "config: runs=$RUNS seconds=$SECS connectors=$CONNECTORS_CSV alphas=$ALPHAS_CSV convex=$CONVEX_URL" | tee -a "$LOG" + +printf 'connector\talpha\trun\ttps\tsamples\tp50_ms\tp95_ms\tp99_ms\tcollision_rate\tverify_ok\tverify_total\tverify_changed\n' > "$OUT" + +verify_convex() { + local count=0 + local changed=0 + local total_seen=0 + for id in $(seq 0 63); do + local r=$(curl -s --max-time 5 -X POST "$CONVEX_URL/api/query" \ + -H 'content-type: application/json' \ + -d "{\"path\":\"accounts:get_account\",\"args\":{\"id\":$id}}") + local bal=$(echo "$r" | jq -r '.value.balance // empty' 2>/dev/null) + if [ -n "$bal" ]; then + bal=${bal%.*} + count=$((count+1)) + total_seen=$((total_seen+bal)) + [ "$bal" != "10000000" ] && changed=$((changed+1)) + fi + done + printf '{"ok":"success","result":{"accounts":"%d","total":"sampled_64=%d","changed":"%d"}}' \ + "$count" "$total_seen" "$changed" +} + +verify() { + local c=$1 + case "$c" in + sqlite_rpc) curl -s --max-time 30 -X POST http://127.0.0.1:4103/rpc -H 'content-type: application/json' -d '{"name":"verify","args":{}}' ;; + postgres_rpc) curl -s --max-time 30 -X POST http://127.0.0.1:4101/rpc -H 'content-type: application/json' -d '{"name":"verify","args":{}}' ;; + cockroach_rpc) curl -s --max-time 30 -X POST http://127.0.0.1:4102/rpc -H 'content-type: application/json' -d '{"name":"verify","args":{}}' ;; + bun) curl -s --max-time 30 -X POST http://127.0.0.1:4001/rpc -H 'content-type: application/json' -d '{"name":"verify","args":{}}' ;; + supabase_rpc) curl -s --max-time 30 -X POST http://127.0.0.1:4106/rpc -H 'content-type: application/json' -d '{"name":"verify","args":{}}' ;; + convex) verify_convex ;; + esac +} + +IFS=',' read -ra CONNECTORS <<< "$CONNECTORS_CSV" +IFS=',' read -ra ALPHAS <<< "$ALPHAS_CSV" + +for c in "${CONNECTORS[@]}"; do + for a in "${ALPHAS[@]}"; do + for ((i=1; i<=RUNS; i++)); do + ts=$(date '+%H:%M:%S') + echo "[$ts] $c alpha=$a run $i/$RUNS" | tee -a "$LOG" + + pnpm run bench test-1 --seconds "$SECS" --concurrency 50 --alpha "$a" --connectors "$c" \ + >> "$LOG" 2>&1 + + latest=$(ls -t runs/test-1-*.json 2>/dev/null | head -1) + + read tps samples p50 p95 p99 crate < <( + jq -r '.results[0].res | "\(.tps) \(.samples) \(.p50_ms) \(.p95_ms) \(.p99_ms) \(.collision_rate)"' "$latest" 2>/dev/null \ + || echo "NA NA NA NA NA NA" + ) + + vraw=$(verify "$c") + vok=$(echo "$vraw" | jq -r '.ok // .status // "?"' 2>/dev/null) + vtotal=$(echo "$vraw" | jq -r '.result.total // .value.total // "?"' 2>/dev/null) + vchanged=$(echo "$vraw" | jq -r '.result.changed // .value.changed // "?"' 2>/dev/null) + + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$c" "$a" "$i" "$tps" "$samples" "$p50" "$p95" "$p99" "$crate" "$vok" "$vtotal" "$vchanged" \ + | tee -a "$OUT" + done + done +done + +echo +echo "=== DONE ===" +echo "Results: $OUT" +echo "Log: $LOG" diff --git a/templates/keynote-2/scripts/start-bench.sh b/templates/keynote-2/scripts/start-bench.sh new file mode 100644 index 00000000000..a20b107f597 --- /dev/null +++ b/templates/keynote-2/scripts/start-bench.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Start every backend service we bench in its own tmux window inside session "bench". +# Idempotent — re-running kills+recreates each named window. +# +# Prerequisites the script doesn't manage: +# - Postgres running as a system service (sudo systemctl start postgresql) +# - Supabase Docker stack up (supabase start) +# - bun_bench database created in Postgres +# - .env populated (CONVEX_URL etc.) + +set -uo pipefail + +SESSION=bench +ROOT=~/SpacetimeDB/templates/keynote-2 + +tmux has-session -t "$SESSION" 2>/dev/null || tmux new-session -d -s "$SESSION" -n bench + +start_window() { + local name=$1; shift + local cmd="$*" + tmux kill-window -t "${SESSION}:${name}" 2>/dev/null || true + tmux new-window -t "${SESSION}" -n "${name}" \ + "bash -c '${cmd}; rc=\$?; echo; echo \"[${name} exited rc=\$rc]\"; read'" +} + +start_window sqlite-rpc "cd $ROOT && pnpm tsx src/rpc-servers/sqlite-rpc-server.ts" +start_window postgres-rpc "cd $ROOT && pnpm tsx src/rpc-servers/postgres-rpc-server.ts" +start_window bun-rpc "cd $ROOT && bun run bun/bun-server.ts" + +start_window cockroach "mkdir -p /tmp/crdb-data && cockroach start-single-node --insecure --listen-addr=127.0.0.1:26257 --http-addr=127.0.0.1:8081 --store=/tmp/crdb-data" +sleep 5 +start_window cockroach-rpc "cd $ROOT && pnpm tsx src/rpc-servers/cockroach-rpc-server.ts" + +start_window supabase-rpc "cd $ROOT && pnpm tsx src/rpc-servers/supabase-rpc-server.ts" +start_window convex "cd $ROOT/convex-app && npx convex dev --local" + +echo "All windows started in tmux session '$SESSION'." +echo "Attach: tmux attach -t $SESSION (then Ctrl+B w to browse)" diff --git a/templates/keynote-2/scripts/stop-bench.sh b/templates/keynote-2/scripts/stop-bench.sh new file mode 100644 index 00000000000..c528434107d --- /dev/null +++ b/templates/keynote-2/scripts/stop-bench.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Kill every foreground bench process and the tmux session. +# Leaves Postgres (systemd) and Supabase (Docker) running — those have their own lifecycles. + +pkill -f sqlite-rpc-server 2>/dev/null +pkill -f postgres-rpc-server 2>/dev/null +pkill -f cockroach-rpc-server 2>/dev/null +pkill -f supabase-rpc-server 2>/dev/null +pkill -f bun-server 2>/dev/null +pkill -f "convex dev" 2>/dev/null +pkill -f "cockroach start-single-node" 2>/dev/null +tmux kill-session -t bench 2>/dev/null +echo "stopped." diff --git a/templates/keynote-2/src/core/runner.ts b/templates/keynote-2/src/core/runner.ts index d363586170f..5d9336b76e6 100644 --- a/templates/keynote-2/src/core/runner.ts +++ b/templates/keynote-2/src/core/runner.ts @@ -143,6 +143,41 @@ export async function runOne({ let completedWithinWindow = 0; let completedTotal = 0; + // === per-second time-series tracking === + const intervalMs = 1000; + const series: { + tSec: number; + tps: number; + p50_ms: number; + p95_ms: number; + p99_ms: number; + samples: number; + }[] = []; + const intervalHist = hdr.build({ + lowestDiscernibleValue: 1, + highestTrackableValue: 10_000_000_000, + numberOfSignificantValueDigits: 3, + }); + let intervalCount = 0; + + const intervalTimer = setInterval(() => { + const now = performance.now(); + // Stop recording once we've passed the test window + if (now > endAt) return; + const elapsedSec = (now - start) / 1000; + const samples = intervalCount; + series.push({ + tSec: Math.round(elapsedSec * 10) / 10, + tps: samples * (1000 / intervalMs), + p50_ms: samples ? intervalHist.getValueAtPercentile(50) / 1000 : 0, + p95_ms: samples ? intervalHist.getValueAtPercentile(95) / 1000 : 0, + p99_ms: samples ? intervalHist.getValueAtPercentile(99) / 1000 : 0, + samples, + }); + intervalCount = 0; + intervalHist.reset(); + }, intervalMs); + // Track when workers reach end of test window (before waiting for in-flight ops) let workersReachedEnd = 0; let resolveTestWindowEnd: () => void; @@ -223,7 +258,10 @@ export async function runOne({ completedTotal++; if (t1 <= endAt) { completedWithinWindow++; - hist.recordValue(Math.max(1, Math.round((t1 - t0) * 1e3))); + const latencyUs = Math.max(1, Math.round((t1 - t0) * 1e3)); + hist.recordValue(latencyUs); + intervalHist.recordValue(latencyUs); + intervalCount++; } } } @@ -254,7 +292,10 @@ export async function runOne({ completedTotal++; if (t1 <= endAt) { completedWithinWindow++; - hist.recordValue(Math.max(1, Math.round((t1 - t0) * 1e3))); + const latencyUs = Math.max(1, Math.round((t1 - t0) * 1e3)); + hist.recordValue(latencyUs); + intervalHist.recordValue(latencyUs); + intervalCount++; } } catch (err) { if (logErrors) { @@ -317,12 +358,15 @@ export async function runOne({ // Now wait for all workers to fully complete (including in-flight ops) await Promise.all(workerPromises); - return { start, completedWithinWindow, completedTotal }; + clearInterval(intervalTimer); + + return { start, completedWithinWindow, completedTotal, series }; }; console.log(`[${connector.name}] Starting workers for ${seconds}s run...`); - const { start, completedWithinWindow, completedTotal } = await run(seconds); + const { start, completedWithinWindow, completedTotal, series } = + await run(seconds); console.log( `[${connector.name}] All workers finished (including in-flight ops)`, @@ -397,5 +441,6 @@ export async function runOne({ collision_ops: c.total, collision_count: c.collisions, collision_rate: c.collisionRate, + timeSeries: series, }; } diff --git a/templates/keynote-2/src/core/types.ts b/templates/keynote-2/src/core/types.ts index ca9815945cd..22a1fe632b7 100644 --- a/templates/keynote-2/src/core/types.ts +++ b/templates/keynote-2/src/core/types.ts @@ -1,3 +1,12 @@ +export type TimeSeriesPoint = { + tSec: number; + tps: number; + p50_ms: number; + p95_ms: number; + p99_ms: number; + samples: number; +}; + export type RunResult = { tps: number; samples: number; @@ -7,4 +16,5 @@ export type RunResult = { collision_ops: number; collision_count: number; collision_rate: number; + timeSeries: TimeSeriesPoint[]; }; From 29276e86b4aff8b971f6a1fec24e5e23f1560b0a Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Fri, 8 May 2026 11:58:57 -0400 Subject: [PATCH 09/47] Update start-bench.sh --- templates/keynote-2/scripts/start-bench.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/keynote-2/scripts/start-bench.sh b/templates/keynote-2/scripts/start-bench.sh index a20b107f597..bdec67f1202 100644 --- a/templates/keynote-2/scripts/start-bench.sh +++ b/templates/keynote-2/scripts/start-bench.sh @@ -19,7 +19,7 @@ start_window() { local name=$1; shift local cmd="$*" tmux kill-window -t "${SESSION}:${name}" 2>/dev/null || true - tmux new-window -t "${SESSION}" -n "${name}" \ + tmux new-window -a -t "${SESSION}:" -n "${name}" \ "bash -c '${cmd}; rc=\$?; echo; echo \"[${name} exited rc=\$rc]\"; read'" } From 4a8e1a01024c5de8a50d6ed10bf82f728af48f39 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Fri, 8 May 2026 12:35:16 -0400 Subject: [PATCH 10/47] scripts --- templates/keynote-2/scripts/plot-bench.py | 41 ++++++++++++++----- .../keynote-2/scripts/run-all-benches.sh | 29 +++++++++---- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/templates/keynote-2/scripts/plot-bench.py b/templates/keynote-2/scripts/plot-bench.py index d39fd482467..ec92cc2ae81 100644 --- a/templates/keynote-2/scripts/plot-bench.py +++ b/templates/keynote-2/scripts/plot-bench.py @@ -5,19 +5,22 @@ and emits a stacked TPS + p99-latency chart per alpha. Usage: - python3 plot-bench.py [alpha] [outfile] + python3 plot-bench.py [alpha] [outfile] [--runs-dir DIR] [--exclude conn1,conn2] Examples: python3 plot-bench.py 0 python3 plot-bench.py 1.5 python3 plot-bench.py 1.5 contended.png + python3 plot-bench.py 1.5 no-stdb.png --exclude spacetimedb + python3 plot-bench.py 1.5 chart.png --runs-dir D:/keynote-2-runs """ +import argparse import json import sys from pathlib import Path import matplotlib.pyplot as plt -RUNS_DIR = Path.home() / "SpacetimeDB/templates/keynote-2/runs" +DEFAULT_RUNS_DIR = Path.home() / "SpacetimeDB/templates/keynote-2/runs" def load_run(path): @@ -31,15 +34,18 @@ def load_run(path): } -def plot(runs, alpha, outfile): +def plot(runs, alpha, outfile, exclude=None): fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 8), sharex=True) matched = [r for r in runs if r["alpha"] == alpha and r["ts"]] + if exclude: + matched = [r for r in matched if r["connector"] not in exclude] + if not matched: print(f"no runs with timeSeries data found at alpha={alpha}", file=sys.stderr) sys.exit(1) - # one line per run; group by connector for color reuse + # one line per run; group by connector for legend de-dup seen_connectors = {} for r in matched: ts = r["ts"] @@ -47,7 +53,7 @@ def plot(runs, alpha, outfile): label = r["connector"] if label in seen_connectors: - label = None # avoid duplicate legend entries when there are multiple runs + label = None else: seen_connectors[r["connector"]] = True @@ -55,8 +61,12 @@ def plot(runs, alpha, outfile): ax2.plot(x, [p["p99_ms"] for p in ts], label=label, linewidth=2, alpha=0.85) contention = "uncontended" if alpha == 0 else f"alpha={alpha}" + title = f"alpha={alpha} ({contention})" + if exclude: + title += f" (excluded: {','.join(exclude)})" + ax1.set_ylabel("TPS") - ax1.set_title(f"alpha={alpha} ({contention})") + ax1.set_title(title) ax1.legend(loc="upper right") ax1.grid(True, alpha=0.3) @@ -72,8 +82,17 @@ def plot(runs, alpha, outfile): if __name__ == "__main__": - alpha = float(sys.argv[1]) if len(sys.argv) > 1 else 0 - outfile = sys.argv[2] if len(sys.argv) > 2 else f"bench-alpha{alpha}.png" - - runs = [load_run(p) for p in sorted(RUNS_DIR.glob("test-1-*.json"))] - plot(runs, alpha, outfile) + parser = argparse.ArgumentParser() + parser.add_argument("alpha", nargs="?", type=float, default=0) + parser.add_argument("outfile", nargs="?", default=None) + parser.add_argument("--runs-dir", type=Path, default=DEFAULT_RUNS_DIR, + help="directory containing test-1-*.json files") + parser.add_argument("--exclude", default="", + help="comma-separated connectors to skip") + args = parser.parse_args() + + outfile = args.outfile or f"bench-alpha{args.alpha}.png" + exclude = [c.strip() for c in args.exclude.split(",") if c.strip()] + + runs = [load_run(p) for p in sorted(args.runs_dir.glob("test-1-*.json"))] + plot(runs, args.alpha, outfile, exclude=exclude) diff --git a/templates/keynote-2/scripts/run-all-benches.sh b/templates/keynote-2/scripts/run-all-benches.sh index d02f0d7deb2..62566481f71 100644 --- a/templates/keynote-2/scripts/run-all-benches.sh +++ b/templates/keynote-2/scripts/run-all-benches.sh @@ -1,14 +1,12 @@ #!/usr/bin/env bash -# Usage: run-all-benches.sh [RUNS] [SECONDS] [CONNECTORS_CSV] [ALPHAS_CSV] +# Usage: run-all-benches.sh [RUNS] [SECONDS] [CONNECTORS_CSV] [ALPHAS_CSV] [OUT_NAME] # # Defaults: # RUNS=5 SECONDS=60 CONNECTORS=all ALPHAS=0,1.5 +# OUT_NAME=auto-generated timestamp + tags # -# Examples: -# run-all-benches.sh 3 60 sqlite_rpc # 3 runs x 60s, sqlite, both alphas -# run-all-benches.sh 3 60 sqlite_rpc 1.5 # 3 runs x 60s, sqlite, alpha=1.5 only -# run-all-benches.sh 3 60 sqlite_rpc,bun 0 # alpha=0 only -# run-all-benches.sh 1 10 convex 0.5,1.0,1.5 # alpha sweep +# Output goes to /tmp/.tsv (or /tmp/bench--.tsv if not specified) +# A symlink /tmp/bench-results.tsv points at the most recent run. set -uo pipefail cd ~/SpacetimeDB/templates/keynote-2 @@ -17,15 +15,25 @@ RUNS=${1:-5} SECS=${2:-60} CONNECTORS_CSV=${3:-sqlite_rpc,postgres_rpc,cockroach_rpc,bun,supabase_rpc,convex} ALPHAS_CSV=${4:-0,1.5} - -OUT=/tmp/bench-results.tsv -LOG=/tmp/bench-progress.log +OUT_NAME=${5:-} + +if [ -z "$OUT_NAME" ]; then + TS=$(date +%Y%m%dT%H%M%S) + CONN_TAG=$(echo "$CONNECTORS_CSV" | tr ',' '-') + ALPHA_TAG=$(echo "$ALPHAS_CSV" | tr ',' '-') + OUT_NAME="bench-${TS}-${CONN_TAG}-a${ALPHA_TAG}-${RUNS}x${SECS}s" +fi + +OUT="/tmp/${OUT_NAME}.tsv" +LOG="/tmp/${OUT_NAME}.log" +LATEST_LINK="/tmp/bench-results.tsv" : > "$LOG" CONVEX_URL=$(grep '^CONVEX_URL=' convex-app/.env.local 2>/dev/null | cut -d= -f2) [ -z "$CONVEX_URL" ] && CONVEX_URL=http://127.0.0.1:3210 echo "config: runs=$RUNS seconds=$SECS connectors=$CONNECTORS_CSV alphas=$ALPHAS_CSV convex=$CONVEX_URL" | tee -a "$LOG" +echo "out: $OUT" | tee -a "$LOG" printf 'connector\talpha\trun\ttps\tsamples\tp50_ms\tp95_ms\tp99_ms\tcollision_rate\tverify_ok\tverify_total\tverify_changed\n' > "$OUT" @@ -92,7 +100,10 @@ for c in "${CONNECTORS[@]}"; do done done +ln -sfn "$OUT" "$LATEST_LINK" + echo echo "=== DONE ===" echo "Results: $OUT" echo "Log: $LOG" +echo "Latest symlink: $LATEST_LINK -> $OUT" From 14189b8c8542cd22858589bff111c75277564a10 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Fri, 8 May 2026 13:45:00 -0400 Subject: [PATCH 11/47] Update plot-bench.py --- templates/keynote-2/scripts/plot-bench.py | 24 +++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/templates/keynote-2/scripts/plot-bench.py b/templates/keynote-2/scripts/plot-bench.py index ec92cc2ae81..f0b65287755 100644 --- a/templates/keynote-2/scripts/plot-bench.py +++ b/templates/keynote-2/scripts/plot-bench.py @@ -1,18 +1,18 @@ #!/usr/bin/env python3 -"""Plot TPS and p99 latency over time for a single alpha across connectors. +"""Plot TPS and latency-percentile over time for a single alpha across connectors. Reads timeSeries arrays from runs/test-1-*.json (added by core/runner.ts) -and emits a stacked TPS + p99-latency chart per alpha. +and emits a stacked TPS + latency chart per alpha. Usage: - python3 plot-bench.py [alpha] [outfile] [--runs-dir DIR] [--exclude conn1,conn2] + python3 plot-bench.py [alpha] [outfile] [--runs-dir DIR] [--exclude conn1,conn2] [--latency p50|p95|p99] Examples: python3 plot-bench.py 0 python3 plot-bench.py 1.5 python3 plot-bench.py 1.5 contended.png python3 plot-bench.py 1.5 no-stdb.png --exclude spacetimedb - python3 plot-bench.py 1.5 chart.png --runs-dir D:/keynote-2-runs + python3 plot-bench.py 1.5 chart.png --runs-dir D:/keynote-2-runs --latency p95 """ import argparse import json @@ -34,7 +34,7 @@ def load_run(path): } -def plot(runs, alpha, outfile, exclude=None): +def plot(runs, alpha, outfile, exclude=None, latency="p99"): fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 8), sharex=True) matched = [r for r in runs if r["alpha"] == alpha and r["ts"]] @@ -45,6 +45,8 @@ def plot(runs, alpha, outfile, exclude=None): print(f"no runs with timeSeries data found at alpha={alpha}", file=sys.stderr) sys.exit(1) + latency_key = f"{latency}_ms" + # one line per run; group by connector for legend de-dup seen_connectors = {} for r in matched: @@ -58,7 +60,7 @@ def plot(runs, alpha, outfile, exclude=None): seen_connectors[r["connector"]] = True ax1.plot(x, [p["tps"] for p in ts], label=label, linewidth=2, alpha=0.85) - ax2.plot(x, [p["p99_ms"] for p in ts], label=label, linewidth=2, alpha=0.85) + ax2.plot(x, [p[latency_key] for p in ts], label=label, linewidth=2, alpha=0.85) contention = "uncontended" if alpha == 0 else f"alpha={alpha}" title = f"alpha={alpha} ({contention})" @@ -70,7 +72,7 @@ def plot(runs, alpha, outfile, exclude=None): ax1.legend(loc="upper right") ax1.grid(True, alpha=0.3) - ax2.set_ylabel("p99 latency (ms)") + ax2.set_ylabel(f"{latency} latency (ms)") ax2.set_xlabel("Time (s)") ax2.set_yscale("log") ax2.legend(loc="upper left") @@ -78,7 +80,7 @@ def plot(runs, alpha, outfile, exclude=None): plt.tight_layout() plt.savefig(outfile, dpi=120) - print(f"wrote {outfile} ({len(matched)} runs)") + print(f"wrote {outfile} ({len(matched)} runs, latency={latency})") if __name__ == "__main__": @@ -89,10 +91,12 @@ def plot(runs, alpha, outfile, exclude=None): help="directory containing test-1-*.json files") parser.add_argument("--exclude", default="", help="comma-separated connectors to skip") + parser.add_argument("--latency", choices=["p50", "p95", "p99"], default="p99", + help="which latency percentile to plot in the bottom panel") args = parser.parse_args() - outfile = args.outfile or f"bench-alpha{args.alpha}.png" + outfile = args.outfile or f"bench-alpha{args.alpha}-{args.latency}.png" exclude = [c.strip() for c in args.exclude.split(",") if c.strip()] runs = [load_run(p) for p in sorted(args.runs_dir.glob("test-1-*.json"))] - plot(runs, args.alpha, outfile, exclude=exclude) + plot(runs, args.alpha, outfile, exclude=exclude, latency=args.latency) From 18da1b37dd2d4f68047f3d8cdf8e3c4923cd5c68 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Fri, 8 May 2026 13:50:58 -0400 Subject: [PATCH 12/47] Update plot-bench.py --- templates/keynote-2/scripts/plot-bench.py | 55 +++++++++++++++-------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/templates/keynote-2/scripts/plot-bench.py b/templates/keynote-2/scripts/plot-bench.py index f0b65287755..a324db4b60e 100644 --- a/templates/keynote-2/scripts/plot-bench.py +++ b/templates/keynote-2/scripts/plot-bench.py @@ -34,9 +34,7 @@ def load_run(path): } -def plot(runs, alpha, outfile, exclude=None, latency="p99"): - fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 8), sharex=True) - +def plot(runs, alpha, outfile, exclude=None, latency="p99", metric="both"): matched = [r for r in runs if r["alpha"] == alpha and r["ts"]] if exclude: matched = [r for r in matched if r["connector"] not in exclude] @@ -47,6 +45,18 @@ def plot(runs, alpha, outfile, exclude=None, latency="p99"): latency_key = f"{latency}_ms" + if metric == "both": + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 8), sharex=True) + axes = [(ax1, "tps", "TPS"), (ax2, latency_key, f"{latency} latency (ms)")] + elif metric == "tps": + fig, ax1 = plt.subplots(1, 1, figsize=(11, 5)) + axes = [(ax1, "tps", "TPS")] + elif metric == "latency": + fig, ax1 = plt.subplots(1, 1, figsize=(11, 5)) + axes = [(ax1, latency_key, f"{latency} latency (ms)")] + else: + raise ValueError(f"unknown metric: {metric}") + # one line per run; group by connector for legend de-dup seen_connectors = {} for r in matched: @@ -59,28 +69,28 @@ def plot(runs, alpha, outfile, exclude=None, latency="p99"): else: seen_connectors[r["connector"]] = True - ax1.plot(x, [p["tps"] for p in ts], label=label, linewidth=2, alpha=0.85) - ax2.plot(x, [p[latency_key] for p in ts], label=label, linewidth=2, alpha=0.85) + for ax, key, _ in axes: + ax.plot(x, [p[key] for p in ts], label=label, linewidth=2, alpha=0.85) contention = "uncontended" if alpha == 0 else f"alpha={alpha}" title = f"alpha={alpha} ({contention})" if exclude: title += f" (excluded: {','.join(exclude)})" - ax1.set_ylabel("TPS") - ax1.set_title(title) - ax1.legend(loc="upper right") - ax1.grid(True, alpha=0.3) + for i, (ax, key, ylabel) in enumerate(axes): + ax.set_ylabel(ylabel) + ax.legend(loc="upper right" if key == "tps" else "upper left") + ax.grid(True, alpha=0.3) + if key != "tps": + ax.set_yscale("log") + if i == 0: + ax.set_title(title) - ax2.set_ylabel(f"{latency} latency (ms)") - ax2.set_xlabel("Time (s)") - ax2.set_yscale("log") - ax2.legend(loc="upper left") - ax2.grid(True, alpha=0.3) + axes[-1][0].set_xlabel("Time (s)") plt.tight_layout() plt.savefig(outfile, dpi=120) - print(f"wrote {outfile} ({len(matched)} runs, latency={latency})") + print(f"wrote {outfile} ({len(matched)} runs, metric={metric}, latency={latency})") if __name__ == "__main__": @@ -92,11 +102,20 @@ def plot(runs, alpha, outfile, exclude=None, latency="p99"): parser.add_argument("--exclude", default="", help="comma-separated connectors to skip") parser.add_argument("--latency", choices=["p50", "p95", "p99"], default="p99", - help="which latency percentile to plot in the bottom panel") + help="which latency percentile to plot") + parser.add_argument("--metric", choices=["both", "tps", "latency"], default="both", + help="show TPS only, latency only, or both panels") args = parser.parse_args() - outfile = args.outfile or f"bench-alpha{args.alpha}-{args.latency}.png" + # If outfile is just a filename (not a path), put it in the runs dir. + if args.outfile: + outfile_path = Path(args.outfile) + if outfile_path.parent == Path("."): + outfile_path = args.runs_dir / outfile_path + else: + outfile_path = args.runs_dir / f"bench-alpha{args.alpha}-{args.metric}-{args.latency}.png" + exclude = [c.strip() for c in args.exclude.split(",") if c.strip()] runs = [load_run(p) for p in sorted(args.runs_dir.glob("test-1-*.json"))] - plot(runs, args.alpha, outfile, exclude=exclude, latency=args.latency) + plot(runs, args.alpha, str(outfile_path), exclude=exclude, latency=args.latency, metric=args.metric) From b55f482146932c97849f12d03b40c294c1428b04 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Fri, 8 May 2026 15:49:03 -0400 Subject: [PATCH 13/47] remove retry --- templates/keynote-2/src/connectors/convex.ts | 63 ++++++-------------- 1 file changed, 17 insertions(+), 46 deletions(-) diff --git a/templates/keynote-2/src/connectors/convex.ts b/templates/keynote-2/src/connectors/convex.ts index b6585772fab..eba0912162d 100644 --- a/templates/keynote-2/src/connectors/convex.ts +++ b/templates/keynote-2/src/connectors/convex.ts @@ -3,14 +3,6 @@ import type { RpcConnector } from '../core/connectors.ts'; export default function convex(url: string): RpcConnector { if (!url) throw new Error('CONVEX_URL not set'); - function isWriteConflict(msg: unknown): boolean { - if (typeof msg !== 'string') return false; - return ( - msg.includes('Documents read from or written to the') && - msg.includes('while this mutation was being run') - ); - } - async function queryConvex(path: string, args: any) { const res = await fetch(`${url}/api/query?format=json`, { method: 'POST', @@ -36,48 +28,27 @@ export default function convex(url: string): RpcConnector { } async function mutationConvex(path: string, args: any) { - const MAX_RETRIES = 32; - const BASE_DELAY_MS = 0.1; - const MAX_DELAY_MS = 100; - - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - const res = await fetch(`${url}/api/mutation?format=json`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ path, args }), - }); - - let json: any = {}; - try { - json = await res.json(); - } catch {} - - const ok = res.ok && json.status === 'success'; - const msgRaw = - json?.errorMessage ?? - json?.message ?? - `HTTP ${res.status} ${res.statusText}`; - const msg = String(msgRaw); - const writeConflict = isWriteConflict(msg); - - if (ok) { - return json.value; - } + const res = await fetch(`${url}/api/mutation?format=json`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path, args }), + }); - if (writeConflict && attempt < MAX_RETRIES) { - const base = BASE_DELAY_MS * 2 ** attempt; - const delay = - Math.min(MAX_DELAY_MS, base) + Math.floor(Math.random() * 10); - await new Promise((r) => setTimeout(r, delay)); - continue; - } + let json: any = {}; + try { + json = await res.json(); + } catch {} - throw new Error(`convex mutation ${path} failed: ${msg}`); + if (res.ok && json.status === 'success') { + return json.value; } - throw new Error( - `convex mutation ${path} failed after ${MAX_RETRIES} retries due to write conflicts`, - ); + const msg = + json?.errorMessage ?? + json?.message ?? + `HTTP ${res.status} ${res.statusText}`; + + throw new Error(`convex mutation ${path} failed: ${msg}`); } const root: RpcConnector = { From 5b9450ec5851b329cc3f1c9edb86d4562501a739 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Fri, 8 May 2026 17:07:50 -0400 Subject: [PATCH 14/47] stats / plotting --- templates/keynote-2/scripts/bench-stats.py | 237 +++++++++++++++++++++ templates/keynote-2/scripts/plot-bench.py | 33 ++- 2 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 templates/keynote-2/scripts/bench-stats.py diff --git a/templates/keynote-2/scripts/bench-stats.py b/templates/keynote-2/scripts/bench-stats.py new file mode 100644 index 00000000000..40ee76e45a1 --- /dev/null +++ b/templates/keynote-2/scripts/bench-stats.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Compute detailed stats from each test-1-*.json run in a runs-dir. + +Pulls out: + - aggregate metrics (already in res.*) + - steady-state window stats (t >= --warmup-sec, default 30) + - tail window stats (last --tail-sec seconds, default 30) + - time-series shape: tps min/max/mean/median/stdev, stability (CV) + - collapse detection: first second where TPS drops below 10% of peak + - death detection: first second where TPS=0 and stays 0 for 30s + +Usage: + python3 bench-stats.py [--runs-dir DIR] [--warmup-sec N] [--tail-sec N] [--out FILE] + +Examples: + python3 bench-stats.py --runs-dir D:/keynote-2-runs + python3 bench-stats.py --runs-dir D:/keynote-2-runs --warmup-sec 60 --out stats.tsv +""" +import argparse +import json +import math +import statistics +import sys +from pathlib import Path + +DEFAULT_RUNS_DIR = Path.home() / "SpacetimeDB/templates/keynote-2/runs" + + +def load_run(path): + data = json.loads(path.read_text()) + r = data["results"][0] + return { + "path": path.name, + "system": r.get("system", r.get("file", "?").replace(".ts", "")), + "alpha": data["alpha"], + "seconds": data["seconds"], + "concurrency": data["concurrency"], + "agg": r["res"], + "ts": r["res"].get("timeSeries", []), + } + + +def safe_div(a, b): + return a / b if b else 0.0 + + +def window_stats(ts, t_min, t_max=None): + """Return aggregate stats for time-series points where t_min <= tSec < t_max.""" + pts = [p for p in ts if p["tSec"] >= t_min and (t_max is None or p["tSec"] < t_max)] + if not pts: + return None + + samples = sum(p["samples"] for p in pts) + duration = sum(1 for _ in pts) # one point per second + tps_values = [p["tps"] for p in pts] + nonzero_tps = [v for v in tps_values if v > 0] + p50_values = [p["p50_ms"] for p in pts if p["samples"] > 0] + p99_values = [p["p99_ms"] for p in pts if p["samples"] > 0] + + return { + "samples": samples, + "duration_s": duration, + "tps_mean": safe_div(samples, duration), + "tps_min": min(tps_values) if tps_values else 0, + "tps_max": max(tps_values) if tps_values else 0, + "tps_median": statistics.median(tps_values) if tps_values else 0, + "tps_stdev": statistics.stdev(tps_values) if len(tps_values) > 1 else 0, + "tps_cv_pct": ( + (statistics.stdev(tps_values) / statistics.mean(tps_values) * 100) + if len(tps_values) > 1 and statistics.mean(tps_values) > 0 + else 0 + ), + "zero_seconds": sum(1 for v in tps_values if v == 0), + "p50_ms_median": statistics.median(p50_values) if p50_values else 0, + "p99_ms_median": statistics.median(p99_values) if p99_values else 0, + "p99_ms_max": max(p99_values) if p99_values else 0, + } + + +def find_collapse(ts, threshold_pct=10): + """First tSec where TPS drops below threshold_pct% of peak. + Returns None if it never collapses.""" + if not ts: + return None + peak = max(p["tps"] for p in ts) + if peak == 0: + return None + threshold = peak * threshold_pct / 100 + # require sustained drop (3 consecutive seconds below threshold) + streak = 0 + for p in ts: + if p["tps"] < threshold: + streak += 1 + if streak >= 3: + return p["tSec"] - 2 # first second of the streak + else: + streak = 0 + return None + + +def find_death(ts, hold_sec=30): + """First tSec where TPS hits 0 and stays 0 for hold_sec seconds. + Returns None if never dies.""" + streak = 0 + for p in ts: + if p["tps"] == 0: + streak += 1 + if streak >= hold_sec: + return p["tSec"] - hold_sec + 1 + else: + streak = 0 + return None + + +def fmt(v): + if v is None: + return "" + if isinstance(v, float): + if math.isnan(v) or math.isinf(v): + return "" + if abs(v) >= 100: + return f"{v:.1f}" + if abs(v) >= 1: + return f"{v:.2f}" + return f"{v:.4f}" + return str(v) + + +COLUMNS = [ + "system", + "alpha", + "duration_s", + "concurrency", + # aggregate (whole run) + "agg_tps", + "agg_samples", + "agg_p50_ms", + "agg_p95_ms", + "agg_p99_ms", + "agg_collision_rate", + # steady-state (after warmup) + "ss_tps_mean", + "ss_tps_median", + "ss_tps_stdev", + "ss_tps_cv_pct", + "ss_p50_ms", + "ss_p99_ms", + "ss_zero_secs", + # tail (last N seconds) + "tail_tps_mean", + "tail_p50_ms", + "tail_p99_ms", + # time-series shape + "ts_tps_min", + "ts_tps_max", + "ts_p99_max", + # collapse / death + "collapse_at_s", + "death_at_s", + # source + "file", +] + + +def row_for_run(run, warmup_sec, tail_sec): + agg = run["agg"] + ts = run["ts"] + duration = run["seconds"] + + ss = window_stats(ts, warmup_sec) if ts else None + tail_start = duration - tail_sec + tail = window_stats(ts, tail_start) if ts else None + full = window_stats(ts, 0) if ts else None + + return { + "system": run["system"], + "alpha": run["alpha"], + "duration_s": duration, + "concurrency": run["concurrency"], + "agg_tps": agg.get("tps"), + "agg_samples": agg.get("samples"), + "agg_p50_ms": agg.get("p50_ms"), + "agg_p95_ms": agg.get("p95_ms"), + "agg_p99_ms": agg.get("p99_ms"), + "agg_collision_rate": agg.get("collision_rate"), + "ss_tps_mean": ss["tps_mean"] if ss else None, + "ss_tps_median": ss["tps_median"] if ss else None, + "ss_tps_stdev": ss["tps_stdev"] if ss else None, + "ss_tps_cv_pct": ss["tps_cv_pct"] if ss else None, + "ss_p50_ms": ss["p50_ms_median"] if ss else None, + "ss_p99_ms": ss["p99_ms_median"] if ss else None, + "ss_zero_secs": ss["zero_seconds"] if ss else None, + "tail_tps_mean": tail["tps_mean"] if tail else None, + "tail_p50_ms": tail["p50_ms_median"] if tail else None, + "tail_p99_ms": tail["p99_ms_median"] if tail else None, + "ts_tps_min": full["tps_min"] if full else None, + "ts_tps_max": full["tps_max"] if full else None, + "ts_p99_max": full["p99_ms_max"] if full else None, + "collapse_at_s": find_collapse(ts), + "death_at_s": find_death(ts), + "file": run["path"], + } + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--runs-dir", type=Path, default=DEFAULT_RUNS_DIR) + parser.add_argument("--warmup-sec", type=int, default=30, + help="Seconds to skip before computing steady-state stats") + parser.add_argument("--tail-sec", type=int, default=30, + help="Tail window size for last-N-seconds stats") + parser.add_argument("--out", type=Path, default=None, + help="Write TSV to this file in addition to stdout") + args = parser.parse_args() + + files = sorted(args.runs_dir.glob("test-1-*.json")) + if not files: + print(f"no test-1-*.json files in {args.runs_dir}", file=sys.stderr) + sys.exit(1) + + rows = [row_for_run(load_run(p), args.warmup_sec, args.tail_sec) for p in files] + + # sort by (alpha, system) for readability + rows.sort(key=lambda r: (r["alpha"], r["system"])) + + header = "\t".join(COLUMNS) + body = "\n".join("\t".join(fmt(r[c]) for c in COLUMNS) for r in rows) + output = header + "\n" + body + "\n" + + print(output) + if args.out: + args.out.write_text(output) + print(f"\nwrote {args.out}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/templates/keynote-2/scripts/plot-bench.py b/templates/keynote-2/scripts/plot-bench.py index a324db4b60e..1e205d855c7 100644 --- a/templates/keynote-2/scripts/plot-bench.py +++ b/templates/keynote-2/scripts/plot-bench.py @@ -5,7 +5,9 @@ and emits a stacked TPS + latency chart per alpha. Usage: - python3 plot-bench.py [alpha] [outfile] [--runs-dir DIR] [--exclude conn1,conn2] [--latency p50|p95|p99] + python3 plot-bench.py [alpha] [outfile] [--runs-dir DIR] [--exclude conn1,conn2] + [--latency p50|p95|p99] [--metric both|tps|latency] + [--tps-scale linear|log|symlog] [--latency-scale log|linear|symlog] Examples: python3 plot-bench.py 0 @@ -13,6 +15,7 @@ python3 plot-bench.py 1.5 contended.png python3 plot-bench.py 1.5 no-stdb.png --exclude spacetimedb python3 plot-bench.py 1.5 chart.png --runs-dir D:/keynote-2-runs --latency p95 + python3 plot-bench.py 1.5 chart.png --tps-scale log --latency-scale linear """ import argparse import json @@ -34,7 +37,8 @@ def load_run(path): } -def plot(runs, alpha, outfile, exclude=None, latency="p99", metric="both"): +def plot(runs, alpha, outfile, exclude=None, latency="p99", metric="both", + tps_scale="linear", latency_scale="log"): matched = [r for r in runs if r["alpha"] == alpha and r["ts"]] if exclude: matched = [r for r in matched if r["connector"] not in exclude] @@ -80,9 +84,15 @@ def plot(runs, alpha, outfile, exclude=None, latency="p99", metric="both"): for i, (ax, key, ylabel) in enumerate(axes): ax.set_ylabel(ylabel) ax.legend(loc="upper right" if key == "tps" else "upper left") - ax.grid(True, alpha=0.3) - if key != "tps": + ax.grid(True, alpha=0.3, which="both") + + scale = tps_scale if key == "tps" else latency_scale + if scale == "log": ax.set_yscale("log") + elif scale == "symlog": + ax.set_yscale("symlog", linthresh=1) + # "linear" leaves the matplotlib default + if i == 0: ax.set_title(title) @@ -105,6 +115,10 @@ def plot(runs, alpha, outfile, exclude=None, latency="p99", metric="both"): help="which latency percentile to plot") parser.add_argument("--metric", choices=["both", "tps", "latency"], default="both", help="show TPS only, latency only, or both panels") + parser.add_argument("--tps-scale", choices=["linear", "log", "symlog"], default="linear", + help="y-axis scale for the TPS panel") + parser.add_argument("--latency-scale", choices=["log", "linear", "symlog"], default="log", + help="y-axis scale for the latency panel") args = parser.parse_args() # If outfile is just a filename (not a path), put it in the runs dir. @@ -118,4 +132,13 @@ def plot(runs, alpha, outfile, exclude=None, latency="p99", metric="both"): exclude = [c.strip() for c in args.exclude.split(",") if c.strip()] runs = [load_run(p) for p in sorted(args.runs_dir.glob("test-1-*.json"))] - plot(runs, args.alpha, str(outfile_path), exclude=exclude, latency=args.latency, metric=args.metric) + plot( + runs, + args.alpha, + str(outfile_path), + exclude=exclude, + latency=args.latency, + metric=args.metric, + tps_scale=args.tps_scale, + latency_scale=args.latency_scale, + ) From 8f2b9e18a1a41876fe35920cf2af88fac6760188 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Fri, 8 May 2026 17:38:53 -0400 Subject: [PATCH 15/47] Update bench-stats.py --- templates/keynote-2/scripts/bench-stats.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/templates/keynote-2/scripts/bench-stats.py b/templates/keynote-2/scripts/bench-stats.py index 40ee76e45a1..c869bcde7d3 100644 --- a/templates/keynote-2/scripts/bench-stats.py +++ b/templates/keynote-2/scripts/bench-stats.py @@ -210,7 +210,8 @@ def main(): parser.add_argument("--tail-sec", type=int, default=30, help="Tail window size for last-N-seconds stats") parser.add_argument("--out", type=Path, default=None, - help="Write TSV to this file in addition to stdout") + help="Write TSV here. If a bare filename, lands in --runs-dir. " + "If omitted, defaults to /stats.tsv.") args = parser.parse_args() files = sorted(args.runs_dir.glob("test-1-*.json")) @@ -227,10 +228,18 @@ def main(): body = "\n".join("\t".join(fmt(r[c]) for c in COLUMNS) for r in rows) output = header + "\n" + body + "\n" + # Resolve output path: default to /stats.tsv; + # bare filenames land in runs-dir; absolute paths are used as-is. + if args.out is None: + out_path = args.runs_dir / "stats.tsv" + elif args.out.parent == Path("."): + out_path = args.runs_dir / args.out + else: + out_path = args.out + + out_path.write_text(output) print(output) - if args.out: - args.out.write_text(output) - print(f"\nwrote {args.out}", file=sys.stderr) + print(f"\nwrote {out_path}", file=sys.stderr) if __name__ == "__main__": From 8c52939ab124a334b2ea138d1b100f525507c62b Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Mon, 11 May 2026 08:52:16 -0400 Subject: [PATCH 16/47] default pool size --- templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts | 5 ----- templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts | 5 ----- templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts | 5 ----- 3 files changed, 15 deletions(-) diff --git a/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts b/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts index 852d13c2e68..f7ad685c51a 100644 --- a/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts @@ -5,15 +5,11 @@ import { drizzle } from 'drizzle-orm/node-postgres'; import { pgTable, integer, bigint as pgBigint } from 'drizzle-orm/pg-core'; import { eq, inArray, sql } from 'drizzle-orm'; import { RpcRequest, RpcResponse } from '../connectors/rpc/rpc_common.ts'; -import { getSharedRuntimeDefaults } from '../config.ts'; - const CRDB_URL = process.env.CRDB_URL; if (!CRDB_URL) { throw new Error('CRDB_URL not set'); } -const { poolMax } = getSharedRuntimeDefaults(); - const accounts = pgTable('accounts', { id: integer('id').primaryKey(), balance: pgBigint('balance', { mode: 'bigint' }).notNull(), @@ -22,7 +18,6 @@ const accounts = pgTable('accounts', { const pool = new Pool({ connectionString: CRDB_URL, application_name: 'crdb-rpc-drizzle', - max: poolMax, }); const db = drizzle(pool, { schema: { accounts } }); diff --git a/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts b/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts index 72441607c6c..837b56a816c 100644 --- a/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts @@ -5,15 +5,11 @@ import { drizzle } from 'drizzle-orm/node-postgres'; import { pgTable, integer, bigint as pgBigint } from 'drizzle-orm/pg-core'; import { sql } from 'drizzle-orm'; import { RpcRequest, RpcResponse } from '../connectors/rpc/rpc_common.ts'; -import { getSharedRuntimeDefaults } from '../config.ts'; - const PG_URL = process.env.PG_URL; if (!PG_URL) { throw new Error('PG_URL not set'); } -const { poolMax } = getSharedRuntimeDefaults(); - const accounts = pgTable('accounts', { id: integer('id').primaryKey(), balance: pgBigint('balance', { mode: 'bigint' }).notNull(), @@ -22,7 +18,6 @@ const accounts = pgTable('accounts', { const pool = new Pool({ connectionString: PG_URL, application_name: 'pg-rpc-drizzle', - max: poolMax, }); const db = drizzle(pool, { schema: { accounts } }); diff --git a/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts b/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts index 81c0565d821..2080ff5296b 100644 --- a/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts @@ -5,15 +5,11 @@ import { drizzle } from 'drizzle-orm/node-postgres'; import { pgTable, integer, bigint as pgBigint } from 'drizzle-orm/pg-core'; import { eq, inArray, sql } from 'drizzle-orm'; import type { RpcRequest, RpcResponse } from '../connectors/rpc/rpc_common.ts'; -import { getSharedRuntimeDefaults } from '../config.ts'; - const DB_URL = process.env.SUPABASE_DB_URL ?? process.env.PG_URL; if (!DB_URL) { throw new Error('SUPABASE_DB_URL / PG_URL not set'); } -const { poolMax } = getSharedRuntimeDefaults(); - const accounts = pgTable('accounts', { id: integer('id').primaryKey(), balance: pgBigint('balance', { mode: 'bigint' }).notNull(), @@ -22,7 +18,6 @@ const accounts = pgTable('accounts', { const pool = new Pool({ connectionString: DB_URL, application_name: 'supabase-rpc-drizzle', - max: poolMax, }); const db = drizzle(pool, { schema: { accounts } }); From a32f72ba37db7a9f2bc2f375ad01c55c24bd6c88 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Mon, 11 May 2026 10:21:34 -0400 Subject: [PATCH 17/47] Update package.json --- templates/keynote-2/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/keynote-2/package.json b/templates/keynote-2/package.json index e7278fdb908..a0b9799697b 100644 --- a/templates/keynote-2/package.json +++ b/templates/keynote-2/package.json @@ -11,7 +11,8 @@ "prep": "tsx src/init/init-all.ts", "down": "docker compose down || exit 0", "test-1": "tsx src/cli.ts test-1", - "bench": "tsx src/cli.ts" + "bench": "tsx src/cli.ts", + "bench:all": "bash scripts/run-all-benches.sh" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", From e0c99c6c95bb71b62961ac99f7df958747e2aedc Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Mon, 11 May 2026 11:08:03 -0400 Subject: [PATCH 18/47] Update start-bench.sh --- templates/keynote-2/scripts/start-bench.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/templates/keynote-2/scripts/start-bench.sh b/templates/keynote-2/scripts/start-bench.sh index bdec67f1202..a92d02fff00 100644 --- a/templates/keynote-2/scripts/start-bench.sh +++ b/templates/keynote-2/scripts/start-bench.sh @@ -34,5 +34,12 @@ start_window cockroach-rpc "cd $ROOT && pnpm tsx src/rpc-servers/cockroach-rpc- start_window supabase-rpc "cd $ROOT && pnpm tsx src/rpc-servers/supabase-rpc-server.ts" start_window convex "cd $ROOT/convex-app && npx convex dev --local" +# PlanetScale RPC server: reuses postgres-rpc-server.ts but with PLANETSCALE_PG_URL +# and a different port (4104) to avoid colliding with local postgres-rpc on 4101. +# Only starts if PLANETSCALE_PG_URL is set in .env. +if [ -f "$ROOT/.env" ] && grep -q '^PLANETSCALE_PG_URL=postgres' "$ROOT/.env"; then + start_window planetscale-rpc "cd $ROOT && set -a && . ./.env && set +a && PG_URL=\"\$PLANETSCALE_PG_URL\" PG_RPC_PORT=4104 pnpm tsx src/rpc-servers/postgres-rpc-server.ts" +fi + echo "All windows started in tmux session '$SESSION'." echo "Attach: tmux attach -t $SESSION (then Ctrl+B w to browse)" From 646a3c28a01905ef8e96dc308b91c269e4c55cd5 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Mon, 11 May 2026 13:35:44 -0400 Subject: [PATCH 19/47] Update README.md --- templates/keynote-2/README.md | 45 ++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index 9308a3dfa35..6dea19f42a0 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -20,22 +20,24 @@ The demo compares SpacetimeDB and Convex by default, since both are easy for any ## Results Summary -All tests run for 60 seconds with 50 concurrent connections, with a transfer workload (read-modify-write transaction between two accounts). +All tests run for 300 seconds with 50 concurrent connections, with a transfer workload (read-modify-write transaction between two accounts). -Each cell shows **mean TPS ± sample standard deviation** across 3 × 60-second runs, with the sample variance in parentheses. Cells where the standard deviation approaches or exceeds the mean (e.g. CockroachDB and Convex at ~80% contention) indicate that the system's throughput varies substantially between runs. +Each cell shows **mean TPS ± sample standard deviation** of the per-second throughput within a single 300-second run, with the sample variance in parentheses. Cells where the standard deviation approaches or exceeds the mean (e.g. CockroachDB and Convex at ~80% contention) indicate that the system's throughput is unstable across the run. -| System | Mean TPS ± σ (Var) (~0% Contention) | Mean TPS ± σ (Var) (~80% Contention) | -| --------------------------------- | ----------------------------------- | ------------------------------------ | -| SpacetimeDB (TypeScript Module) | | 307,074 | -| SpacetimeDB (Rust Module) | | 265,542 | -| SQLite + Node HTTP + Drizzle | 3,081 ± 15 (224) | 3,169 ± 16 (242) | -| Bun + Drizzle + Postgres | 10,582 ± 7 (53) | 2,754 ± 5 (28) | -| Supabase + Node HTTP + Drizzle | 7,116 ± 161 (25,763) | 2,581 ± 2 (3) | -| Postgres + Node HTTP + Drizzle | 9,425 ± 25 (644) | 1,087 ± 5 (21) | -| CockroachDB + Node HTTP + Drizzle | 3,933 ± 28 (762) | 145 ± 168 (28,157) | -| Convex (self-hosted local) | 1,210 ± 47 (2,224) | 130 ± 98 (9,675) | +| System | Mean TPS ± σ (Var) (~0% Contention) | Mean TPS ± σ (Var) (~80% Contention) | +| --------------------------------------- | ----------------------------------- | ------------------------------------ | +| SpacetimeDB (TypeScript Module) | | 307,074 | +| SpacetimeDB (Rust Module) | | 265,542 | +| SQLite + Node HTTP + Drizzle | 3,109 ± 86 (7,326) | 3,228 ± 80 (6,396) | +| Bun + Drizzle + Postgres | 10,662 ± 215 (46,418) | 2,773 ± 83 (6,930) | +| Supabase + Node HTTP + Drizzle | 6,853 ± 1,017 (1,034,915) | 2,896 ± 111 (12,414) | +| Postgres + Node HTTP + Drizzle | 9,933 ± 184 (33,704) | 2,169 ± 56 (3,161) | +| CockroachDB + Node HTTP + Drizzle | 3,353 ± 25 (630) | 79 ± 127 (16,059) | +| Convex (self-hosted local) | 1,120 ± 161 (25,856) | 118 ± 97 (9,335) | +| PlanetScale PS-2560 (single-node, EBS) | 1,513 ± 26 (678) | 289 ± 15 (238) | +| PlanetScale M-15360 (Metal NVMe, HA) | 1,351 ± 25 (637) | 279 ± 16 (257) | -**Key Finding:** SpacetimeDB reaches hundreds of thousands of TPS for the transfer workload, while the best non-SpacetimeDB result shown here is SQLite at 3,169 TPS. Traditional databases also suffer significant degradation under high contention (CockroachDB drops 96%). +**Key Finding:** SpacetimeDB reaches hundreds of thousands of TPS for the transfer workload, while the best non-SpacetimeDB result shown here is SQLite at 3,228 TPS. Traditional databases also suffer significant degradation under high contention (CockroachDB drops 98%). ## Methodology @@ -79,12 +81,13 @@ The numbers in the table above were collected by running each connector directly ```bash pnpm install -pnpm run prep # seed all backing databases once -pnpm run bench test-1 --seconds 60 --concurrency 50 --alpha 0 --connectors # uncontended -pnpm run bench test-1 --seconds 60 --concurrency 50 --alpha 1.5 --connectors # ~80% contention +pnpm run prep # seed all backing databases once +pnpm run bench:all 1 300 0,1.5 # 1 run × 300s × both alphas ``` -- `--seconds 60`: Duration of benchmark run +The `bench:all` script wraps `scripts/run-all-benches.sh` with positional args: `RUNS SECONDS CONNECTORS_CSV ALPHAS_CSV OUT_NAME`. Multiple connectors and alphas can be passed comma-separated. + +- `300`: Duration of each benchmark run, in seconds - `--concurrency 50`: Number of concurrent client connections - `--alpha 0`: ~0% contention (uniform account distribution) - `--alpha 1.5`: ~80% contention (Zipf distribution concentrating on hot accounts) @@ -94,10 +97,14 @@ The Docker workflow (`docker compose run --rm bench -- ...`) produces equivalent ### Hardware Configuration -**Server Machine:** +**Server Machine (all systems except PlanetScale):** - PhoenixNAP s3.c3.medium bare metal instance - Intel i9-14900k 24 cores (32 threads), 128GB DDR5 Memory, OS: Ubuntu 24.04 +**Bench client for PlanetScale:** + +- AWS `m7i.8xlarge` in `us-east-2`, colocated with the PlanetScale cluster. Clusters tested: PS-2560 single-node EBS, M-15360 Metal HA (1 primary + 2 replicas). Both Postgres 18.3. + ### Account Seeding - 100,000 accounts seeded before each benchmark @@ -134,7 +141,7 @@ SpacetimeDB supports `withConfirmedReads` mode which ensures transactions are du ### Cloud vs Local Results -PlanetScale results (203 TPS under high contention) demonstrate the **significant impact of cloud database latency**. When the database is accessed over the network (even within the same cloud region), round-trip latency dominates performance. This is why SpacetimeDB's colocated architecture provides such dramatic improvements. +PlanetScale results (~280 TPS under high contention, regardless of cluster tier) demonstrate the **significant impact of cloud database latency**. When the database is accessed over the network (even within the same cloud region), round-trip latency dominates performance. This is why SpacetimeDB's colocated architecture provides such dramatic improvements. ## Systems Tested From 330f78d08a63aa7359e0604256a3ce04c5df201a Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 11 May 2026 12:28:53 -0700 Subject: [PATCH 20/47] Clarify machine topology --- templates/keynote-2/README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index 9308a3dfa35..499e03881a9 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -41,6 +41,8 @@ Each cell shows **mean TPS ± sample standard deviation** across 3 × 60-second All systems were tested with **out-of-the-box default settings**, with one exception: Postgres (and Bun, which uses the same Postgres instance) is configured with `default_transaction_isolation = 'serializable'` for an apples-to-apples comparison. No other custom tuning or configuration optimization was applied. +Throughput is counted from successful operations that the benchmark client observes completing inside the configured test window for every system. + The reported SpacetimeDB module results were run against a 5-way replicated cluster rather than a single standalone node. ### Test Architecture @@ -59,7 +61,15 @@ Client → Integrated Platform (compute + storage colocated) This ensures we're measuring real-world application performance, not raw database throughput. -Throughput is counted from successful operations that the benchmark client observes completing inside the configured test window for every system. +### Machine Topology + +The reported numbers use a single benchmark host wherever possible. This means client, server, and database were all run on the same machine. + +We did this mainly for ease of testing and reproducibility, but also because it was a more favorable set up for the other platforms we tested against. + +We also tested separated-machine topologies, where the benchmark client, server, and database processes were not colocated on one machine. However that did not improve the throughput of the other systems. The added network hop actually made those systems slower. + +The platforms that cannot use this exact topology are PlanetScale and CockroachDB. Both are managed cloud database, so the benchmark client and RPC server are colocated on the benchmark host while the database/cluster is remote. ### The Transaction From 340514e97dd188cfcf81bc2cd1a72254fa9ce26e Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Mon, 11 May 2026 15:31:01 -0400 Subject: [PATCH 21/47] fixes --- templates/keynote-2/README.md | 26 ++- templates/keynote-2/package.json | 3 +- templates/keynote-2/scripts/README.md | 25 ++- templates/keynote-2/scripts/bench-stats.py | 2 +- templates/keynote-2/scripts/check-bench.sh | 3 +- templates/keynote-2/scripts/plot-bench.py | 2 +- .../keynote-2/scripts/run-all-benches.sh | 109 ---------- templates/keynote-2/scripts/start-bench.sh | 2 +- templates/keynote-2/src/cli.ts | 197 ++++++++++++------ templates/keynote-2/src/config.ts | 32 ++- templates/keynote-2/src/core/runner.ts | 23 +- templates/keynote-2/src/opts.ts | 32 ++- 12 files changed, 243 insertions(+), 213 deletions(-) delete mode 100644 templates/keynote-2/scripts/run-all-benches.sh diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index 6dea19f42a0..4764d82ab15 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -41,7 +41,9 @@ Each cell shows **mean TPS ± sample standard deviation** of the per-second thro ## Methodology -All systems were tested with **out-of-the-box default settings**, with one exception: Postgres (and Bun, which uses the same Postgres instance) is configured with `default_transaction_isolation = 'serializable'` for an apples-to-apples comparison. No other custom tuning or configuration optimization was applied. +All systems were tested with **out-of-the-box default settings**, with one exception: the local Postgres instance (and Bun, which uses the same Postgres instance) is configured with `default_transaction_isolation = 'serializable'`. No other custom tuning or configuration optimization was applied. + +The managed Postgres services (Supabase, PlanetScale) run at their default isolation level of `READ COMMITTED`. The reported SpacetimeDB module results were run against a 5-way replicated cluster rather than a single standalone node. @@ -77,23 +79,25 @@ This is a classic read-modify-write workload that tests transactional integrity ### Test Command -The numbers in the table above were collected by running each connector directly with `pnpm`: +The numbers in the table above were collected with `pnpm run bench`: ```bash pnpm install -pnpm run prep # seed all backing databases once -pnpm run bench:all 1 300 0,1.5 # 1 run × 300s × both alphas +pnpm run prep # seed all backing databases once +pnpm run bench --alpha 0,1.5 --connectors --seconds 300 # one JSON per (connector, alpha) ``` -The `bench:all` script wraps `scripts/run-all-benches.sh` with positional args: `RUNS SECONDS CONNECTORS_CSV ALPHAS_CSV OUT_NAME`. Multiple connectors and alphas can be passed comma-separated. +`--alpha` and `--connectors` both accept comma-separated values. The bench writes one JSON per (connector, alpha, run) tuple into `runs/`. -- `300`: Duration of each benchmark run, in seconds -- `--concurrency 50`: Number of concurrent client connections -- `--alpha 0`: ~0% contention (uniform account distribution) -- `--alpha 1.5`: ~80% contention (Zipf distribution concentrating on hot accounts) -- `--stdb-compression none|gzip`: SpacetimeDB client compression mode (default: `none`) +Useful flags: -The Docker workflow (`docker compose run --rm bench -- ...`) produces equivalent numbers. +- `--alpha `: Zipf alpha. This benchmark reports `0` (uniform / ~0% contention) and `1.5` (Zipf / ~80% contention). +- `--connectors `: which connectors to run. Defaults to every test in `src/tests/test-1/`. +- `--seconds `: duration of each run. +- `--concurrency `: number of concurrent clients (default: `50`). +- `--runs `: repeat each (connector, alpha) combination this many times (default: `1`). Each repeat writes its own JSON. +- `--prep-between-alphas`: run `pnpm run prep` before each (connector, alpha) combination to reset DB state. +- `--stdb-compression `: SpacetimeDB client compression mode (default: `none`). ### Hardware Configuration diff --git a/templates/keynote-2/package.json b/templates/keynote-2/package.json index a0b9799697b..e7278fdb908 100644 --- a/templates/keynote-2/package.json +++ b/templates/keynote-2/package.json @@ -11,8 +11,7 @@ "prep": "tsx src/init/init-all.ts", "down": "docker compose down || exit 0", "test-1": "tsx src/cli.ts test-1", - "bench": "tsx src/cli.ts", - "bench:all": "bash scripts/run-all-benches.sh" + "bench": "tsx src/cli.ts" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", diff --git a/templates/keynote-2/scripts/README.md b/templates/keynote-2/scripts/README.md index 65a53d1c093..643147047f4 100644 --- a/templates/keynote-2/scripts/README.md +++ b/templates/keynote-2/scripts/README.md @@ -7,8 +7,10 @@ Helpers for running the keynote benchmark on a single host. | `start-bench.sh` | Bring up every backend service (sqlite-rpc, postgres-rpc, bun-rpc, cockroach + cockroach-rpc, supabase-rpc, convex local) in its own tmux window inside session `bench`. | | `stop-bench.sh` | Kill every foreground bench process and the tmux session. Leaves Postgres (systemd) and Supabase (Docker) running. | | `check-bench.sh` | Health-check each service with a single HTTP call. | -| `run-all-benches.sh` | Run the bench across connectors and alphas, capturing per-run TPS/latency/verify output to `/tmp/bench-results.tsv`. | -| `plot-bench.py` | Read the `timeSeries` field from `runs/test-1-*.json` and produce per-alpha TPS + p99-latency charts. Requires matplotlib. | +| `bench-stats.py` | Read `runs/test-1-*.json` and emit a TSV with aggregate, steady-state, tail-window, and time-series stats. Detects collapse/death points. | +| `plot-bench.py` | Read the `timeSeries` field from `runs/test-1-*.json` and produce per-alpha TPS + latency-percentile charts. Requires matplotlib. | + +Sweep orchestration (multiple alphas, multiple runs, optional state reset) is now built into `pnpm run bench` itself. See the project README's "Test Command" section. ## Typical flow @@ -23,15 +25,16 @@ scripts/start-bench.sh tmux attach -t bench # poke around if needed scripts/check-bench.sh # confirm all green -# seed (one-time, or after wiping a DB) +# seed once, then sweep alphas and connectors pnpm run prep +pnpm run bench --alpha 0,1.5 --connectors postgres_rpc,bun --seconds 300 -# benchmark — args: RUNS SECONDS CONNECTORS_CSV ALPHAS_CSV -scripts/run-all-benches.sh 3 60 # 3 runs x 60s, all connectors, both alphas -scripts/run-all-benches.sh 5 60 sqlite_rpc,postgres_rpc 1.5 +# multi-run sweep with auto-reset between alphas +pnpm run bench --alpha 0,1.5 --connectors postgres_rpc --seconds 300 --runs 3 --prep-between-alphas -# plot -python3 scripts/plot-bench.py 0 # writes bench-alpha0.0.png +# stats + plots from the per-run JSONs +python3 scripts/bench-stats.py --runs-dir runs +python3 scripts/plot-bench.py 0 python3 scripts/plot-bench.py 1.5 # tear down @@ -40,6 +43,6 @@ scripts/stop-bench.sh ## Notes -- `run-all-benches.sh` overwrites `/tmp/bench-results.tsv` on each invocation. Archive it first if you want to preserve a prior sweep. -- All scripts assume the repo lives at `~/SpacetimeDB`. Edit the hardcoded paths if your checkout is elsewhere. -- `plot-bench.py` requires the `timeSeries` field added to `core/runner.ts`. Older `runs/*.json` files without that field are silently skipped. +- `bench-stats.py` and `plot-bench.py` glob `runs/test-1-*.json`. To keep separate sweeps from mixing, organize JSONs into subdirectories per sweep and point `--runs-dir` at each one. +- `plot-bench.py` requires the `timeSeries` field on each run, added by the current `core/runner.ts`. Older JSON files without that field are silently skipped. +- All scripts resolve paths relative to the script file, so the checkout can live anywhere. diff --git a/templates/keynote-2/scripts/bench-stats.py b/templates/keynote-2/scripts/bench-stats.py index c869bcde7d3..a67b977028d 100644 --- a/templates/keynote-2/scripts/bench-stats.py +++ b/templates/keynote-2/scripts/bench-stats.py @@ -23,7 +23,7 @@ import sys from pathlib import Path -DEFAULT_RUNS_DIR = Path.home() / "SpacetimeDB/templates/keynote-2/runs" +DEFAULT_RUNS_DIR = Path(__file__).resolve().parent.parent / "runs" def load_run(path): diff --git a/templates/keynote-2/scripts/check-bench.sh b/templates/keynote-2/scripts/check-bench.sh index 72d34646ce9..fc3e63fe6cb 100644 --- a/templates/keynote-2/scripts/check-bench.sh +++ b/templates/keynote-2/scripts/check-bench.sh @@ -2,7 +2,8 @@ # Health-check every backend the bench needs. # Prints one line per service. Returns 0 even on failures — visual inspection. -CONVEX_URL=$(grep '^CONVEX_URL=' ~/SpacetimeDB/templates/keynote-2/convex-app/.env.local 2>/dev/null | cut -d= -f2) +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." &> /dev/null && pwd)" +CONVEX_URL=$(grep '^CONVEX_URL=' "$ROOT/convex-app/.env.local" 2>/dev/null | cut -d= -f2) checks=( "sqlite_rpc|http://127.0.0.1:4103/rpc|{\"name\":\"health\",\"args\":{}}" diff --git a/templates/keynote-2/scripts/plot-bench.py b/templates/keynote-2/scripts/plot-bench.py index 1e205d855c7..6195bd28ab7 100644 --- a/templates/keynote-2/scripts/plot-bench.py +++ b/templates/keynote-2/scripts/plot-bench.py @@ -23,7 +23,7 @@ from pathlib import Path import matplotlib.pyplot as plt -DEFAULT_RUNS_DIR = Path.home() / "SpacetimeDB/templates/keynote-2/runs" +DEFAULT_RUNS_DIR = Path(__file__).resolve().parent.parent / "runs" def load_run(path): diff --git a/templates/keynote-2/scripts/run-all-benches.sh b/templates/keynote-2/scripts/run-all-benches.sh deleted file mode 100644 index 62566481f71..00000000000 --- a/templates/keynote-2/scripts/run-all-benches.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# Usage: run-all-benches.sh [RUNS] [SECONDS] [CONNECTORS_CSV] [ALPHAS_CSV] [OUT_NAME] -# -# Defaults: -# RUNS=5 SECONDS=60 CONNECTORS=all ALPHAS=0,1.5 -# OUT_NAME=auto-generated timestamp + tags -# -# Output goes to /tmp/.tsv (or /tmp/bench--.tsv if not specified) -# A symlink /tmp/bench-results.tsv points at the most recent run. - -set -uo pipefail -cd ~/SpacetimeDB/templates/keynote-2 - -RUNS=${1:-5} -SECS=${2:-60} -CONNECTORS_CSV=${3:-sqlite_rpc,postgres_rpc,cockroach_rpc,bun,supabase_rpc,convex} -ALPHAS_CSV=${4:-0,1.5} -OUT_NAME=${5:-} - -if [ -z "$OUT_NAME" ]; then - TS=$(date +%Y%m%dT%H%M%S) - CONN_TAG=$(echo "$CONNECTORS_CSV" | tr ',' '-') - ALPHA_TAG=$(echo "$ALPHAS_CSV" | tr ',' '-') - OUT_NAME="bench-${TS}-${CONN_TAG}-a${ALPHA_TAG}-${RUNS}x${SECS}s" -fi - -OUT="/tmp/${OUT_NAME}.tsv" -LOG="/tmp/${OUT_NAME}.log" -LATEST_LINK="/tmp/bench-results.tsv" -: > "$LOG" - -CONVEX_URL=$(grep '^CONVEX_URL=' convex-app/.env.local 2>/dev/null | cut -d= -f2) -[ -z "$CONVEX_URL" ] && CONVEX_URL=http://127.0.0.1:3210 - -echo "config: runs=$RUNS seconds=$SECS connectors=$CONNECTORS_CSV alphas=$ALPHAS_CSV convex=$CONVEX_URL" | tee -a "$LOG" -echo "out: $OUT" | tee -a "$LOG" - -printf 'connector\talpha\trun\ttps\tsamples\tp50_ms\tp95_ms\tp99_ms\tcollision_rate\tverify_ok\tverify_total\tverify_changed\n' > "$OUT" - -verify_convex() { - local count=0 - local changed=0 - local total_seen=0 - for id in $(seq 0 63); do - local r=$(curl -s --max-time 5 -X POST "$CONVEX_URL/api/query" \ - -H 'content-type: application/json' \ - -d "{\"path\":\"accounts:get_account\",\"args\":{\"id\":$id}}") - local bal=$(echo "$r" | jq -r '.value.balance // empty' 2>/dev/null) - if [ -n "$bal" ]; then - bal=${bal%.*} - count=$((count+1)) - total_seen=$((total_seen+bal)) - [ "$bal" != "10000000" ] && changed=$((changed+1)) - fi - done - printf '{"ok":"success","result":{"accounts":"%d","total":"sampled_64=%d","changed":"%d"}}' \ - "$count" "$total_seen" "$changed" -} - -verify() { - local c=$1 - case "$c" in - sqlite_rpc) curl -s --max-time 30 -X POST http://127.0.0.1:4103/rpc -H 'content-type: application/json' -d '{"name":"verify","args":{}}' ;; - postgres_rpc) curl -s --max-time 30 -X POST http://127.0.0.1:4101/rpc -H 'content-type: application/json' -d '{"name":"verify","args":{}}' ;; - cockroach_rpc) curl -s --max-time 30 -X POST http://127.0.0.1:4102/rpc -H 'content-type: application/json' -d '{"name":"verify","args":{}}' ;; - bun) curl -s --max-time 30 -X POST http://127.0.0.1:4001/rpc -H 'content-type: application/json' -d '{"name":"verify","args":{}}' ;; - supabase_rpc) curl -s --max-time 30 -X POST http://127.0.0.1:4106/rpc -H 'content-type: application/json' -d '{"name":"verify","args":{}}' ;; - convex) verify_convex ;; - esac -} - -IFS=',' read -ra CONNECTORS <<< "$CONNECTORS_CSV" -IFS=',' read -ra ALPHAS <<< "$ALPHAS_CSV" - -for c in "${CONNECTORS[@]}"; do - for a in "${ALPHAS[@]}"; do - for ((i=1; i<=RUNS; i++)); do - ts=$(date '+%H:%M:%S') - echo "[$ts] $c alpha=$a run $i/$RUNS" | tee -a "$LOG" - - pnpm run bench test-1 --seconds "$SECS" --concurrency 50 --alpha "$a" --connectors "$c" \ - >> "$LOG" 2>&1 - - latest=$(ls -t runs/test-1-*.json 2>/dev/null | head -1) - - read tps samples p50 p95 p99 crate < <( - jq -r '.results[0].res | "\(.tps) \(.samples) \(.p50_ms) \(.p95_ms) \(.p99_ms) \(.collision_rate)"' "$latest" 2>/dev/null \ - || echo "NA NA NA NA NA NA" - ) - - vraw=$(verify "$c") - vok=$(echo "$vraw" | jq -r '.ok // .status // "?"' 2>/dev/null) - vtotal=$(echo "$vraw" | jq -r '.result.total // .value.total // "?"' 2>/dev/null) - vchanged=$(echo "$vraw" | jq -r '.result.changed // .value.changed // "?"' 2>/dev/null) - - printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ - "$c" "$a" "$i" "$tps" "$samples" "$p50" "$p95" "$p99" "$crate" "$vok" "$vtotal" "$vchanged" \ - | tee -a "$OUT" - done - done -done - -ln -sfn "$OUT" "$LATEST_LINK" - -echo -echo "=== DONE ===" -echo "Results: $OUT" -echo "Log: $LOG" -echo "Latest symlink: $LATEST_LINK -> $OUT" diff --git a/templates/keynote-2/scripts/start-bench.sh b/templates/keynote-2/scripts/start-bench.sh index a92d02fff00..c02d13292b1 100644 --- a/templates/keynote-2/scripts/start-bench.sh +++ b/templates/keynote-2/scripts/start-bench.sh @@ -11,7 +11,7 @@ set -uo pipefail SESSION=bench -ROOT=~/SpacetimeDB/templates/keynote-2 +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." &> /dev/null && pwd)" tmux has-session -t "$SESSION" 2>/dev/null || tmux new-session -d -s "$SESSION" -n bench diff --git a/templates/keynote-2/src/cli.ts b/templates/keynote-2/src/cli.ts index a5da83b8002..967881e2daa 100644 --- a/templates/keynote-2/src/cli.ts +++ b/templates/keynote-2/src/cli.ts @@ -1,5 +1,6 @@ import 'dotenv/config'; -import { readdir, mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, readdir, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; import { CONNECTORS } from './connectors'; import { runOne } from './core/runner'; import type { TestCaseModule } from './tests/types'; @@ -14,7 +15,9 @@ const { seconds, concurrency, accounts, - alpha, + alphas, + runs, + prepBetweenAlphas, connectors, contentionTests, concurrencyTests, @@ -62,7 +65,7 @@ class BenchmarkTester { await new Promise((resolve) => setTimeout(resolve, 1000)); } - const avg = { + const avg: RunResult = { tps: totals.tps / runs, samples: totals.samples / runs, p50_ms: totals.p50_ms / runs, @@ -71,6 +74,9 @@ class BenchmarkTester { collision_ops: totals.collision_ops / runs, collision_count: totals.collision_count / runs, collision_rate: totals.collision_rate / runs, + // timeSeries can't be meaningfully averaged across runs (each run has its + // own t=0..N curve), so the aggregated avg drops it. + timeSeries: [], }; return avg; } @@ -130,15 +136,42 @@ class BenchmarkTester { } } +/** Subprocess `pnpm run prep` to reset DB state. Inherits stdio so output is visible. */ +function runPrep(): Promise { + return new Promise((resolve, reject) => { + const child = spawn('pnpm', ['run', 'prep'], { + stdio: 'inherit', + shell: true, + }); + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`prep exited with code ${code}`)); + }); + child.on('error', reject); + }); +} + const testDirUrl = new URL(`./tests/${testName}/`, import.meta.url); const testDirPath = fileURLToPath(testDirUrl); +const runsDir = fileURLToPath(new URL('../runs/', import.meta.url)); + +async function writeRunJson(payload: object, connectorName: string, alpha: number) { + await mkdir(runsDir, { recursive: true }); + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const outFile = join(runsDir, `${testName}-${connectorName}-a${alpha}-${ts}.json`); + await writeFile(outFile, JSON.stringify(payload, null, 2)); + console.log(`Wrote results to ${outFile}`); + return outFile; +} (async () => { const files = (await readdir(testDirPath)).filter( (f) => (f.endsWith('.ts') || f.endsWith('.js')) && !f.endsWith('.d.ts'), ); - const results: any[] = []; + // Sweep-mode results accumulate into a single combined JSON (legacy behavior). + const sweepResults: any[] = []; + let sweepAlpha: number | null = null; for (const file of files) { const mod = (await import( @@ -151,74 +184,114 @@ const testDirPath = fileURLToPath(testDirUrl); const makeConnector = CONNECTORS[tc.system]; if (!makeConnector) throw new Error(`Unknown connector ${tc.system}`); - const connector = makeConnector(options); - - let res: any; - - const config = { - connector, - scenario: tc.run, - seconds, - accounts, - runtimeConfig: options, - }; - - const tester = new BenchmarkTester(config); - - if (contentionTests) { - res = await tester.contentionTests( - contentionTests.startAlpha, - contentionTests.endAlpha, - contentionTests.step, - contentionTests.concurrency, - ); - } else if (concurrencyTests) { - res = await tester.concurrencyTestsMutiply( - concurrencyTests.startConc, - concurrencyTests.endConc, - concurrencyTests.step, - concurrencyTests.alpha, - ); - } else { - res = await runOne({ + if (contentionTests || concurrencyTests) { + // Sweep modes: one connector, one combined result, accumulate for a single JSON. + const connector = makeConnector(options); + const config = { connector, scenario: tc.run, seconds, - concurrency, accounts, - alpha, runtimeConfig: options, + }; + const tester = new BenchmarkTester(config); + + let res: any; + if (contentionTests) { + res = await tester.contentionTests( + contentionTests.startAlpha, + contentionTests.endAlpha, + contentionTests.step, + contentionTests.concurrency, + ); + } else if (concurrencyTests) { + res = await tester.concurrencyTestsMutiply( + concurrencyTests.startConc, + concurrencyTests.endConc, + concurrencyTests.step, + concurrencyTests.alpha, + ); + sweepAlpha = concurrencyTests.alpha; + } + + sweepResults.push({ + system: connector.name, + label: tc.label ?? file, + file, + seconds, + concurrency, + accounts, + alpha: sweepAlpha ?? alphas[0], + res, }); + console.log(`${file}:`, res); + continue; + } + + // Basic mode: sweep alphas and repeat runs, writing one JSON per + // (connector, alpha, run) tuple. Optionally prep before each alpha. + for (const alpha of alphas) { + if (prepBetweenAlphas) { + console.log(`[bench] prep before ${tc.system} alpha=${alpha}`); + await runPrep(); + } + + for (let r = 0; r < runs; r++) { + // Create the connector fresh per (alpha, run) so that prep-induced + // schema/state changes don't get cached on a stale connector instance. + const connector = makeConnector(options); + const res = await runOne({ + connector, + scenario: tc.run, + seconds, + concurrency, + accounts, + alpha, + runtimeConfig: options, + }); + + const payload = { + test: testName, + seconds, + concurrency, + accounts, + alpha, + run: r + 1, + runs, + results: [ + { + system: connector.name, + label: tc.label ?? file, + file, + seconds, + concurrency, + accounts, + alpha, + res, + }, + ], + }; + await writeRunJson(payload, connector.name, alpha); + console.log( + `[bench] ${tc.system} alpha=${alpha} run ${r + 1}/${runs} done`, + ); + } } + } - results.push({ - system: connector.name, - label: tc.label ?? file, - file, + if (sweepResults.length > 0) { + const payload = { + test: testName, seconds, concurrency, accounts, - alpha, - res, - }); - console.log(`${file}:`, res); + alpha: sweepAlpha ?? alphas[0], + results: sweepResults, + }; + await mkdir(runsDir, { recursive: true }); + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const outFile = join(runsDir, `${testName}-${ts}.json`); + await writeFile(outFile, JSON.stringify(payload, null, 2)); + console.log(`Wrote sweep results to ${outFile}`); } - - const runData = { - test: testName, - seconds, - concurrency, - accounts, - alpha, - results, - }; - const runsDir = fileURLToPath(new URL('../runs/', import.meta.url)); - await mkdir(runsDir, { recursive: true }); - const outFile = join( - runsDir, - `${testName}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`, - ); - await writeFile(outFile, JSON.stringify(runData, null, 2)); - - console.log(`Wrote results to ${outFile}`); })(); diff --git a/templates/keynote-2/src/config.ts b/templates/keynote-2/src/config.ts index d6d30977986..a117c993ff0 100644 --- a/templates/keynote-2/src/config.ts +++ b/templates/keynote-2/src/config.ts @@ -72,7 +72,20 @@ export interface BenchOptions extends SharedRuntimeConfig { testName: string; seconds: number; concurrency: number; - alpha: number; + /** + * Alphas to sweep. The basic-bench code path writes one JSON per (connector, + * alpha, run) tuple. For backward compatibility, a single `--alpha N` argument + * resolves to a single-element array. + */ + alphas: number[]; + /** Number of times to repeat each (connector, alpha) combination. */ + runs: number; + /** + * If true, runs `pnpm run prep` before each (connector, alpha) combination + * to reset DB state. Each repeat run within the same (connector, alpha) uses + * the same prepped state (so inter-run variance is meaningful). + */ + prepBetweenAlphas: boolean; connectors: ConnectorKey[] | null; contentionTests: ContentionTests | null; concurrencyTests: ConcurrencyTests | null; @@ -193,6 +206,23 @@ export function readOptionalBooleanEnv( return parseBooleanLike(raw); } +export function parseAlphaList( + raw: string | number | string[] | undefined, + label: string, +): number[] | undefined { + if (raw === undefined) return undefined; + if (typeof raw === 'number') return [raw]; + + const values = (Array.isArray(raw) ? raw : [raw]) + .flatMap((value) => String(value).split(',')) + .map((value) => value.trim()) + .filter(Boolean); + + if (values.length === 0) return undefined; + + return values.map((value) => parseFiniteNumber(value, label)); +} + export function parseConnectorList( raw: string | string[] | undefined, label: string, diff --git a/templates/keynote-2/src/core/runner.ts b/templates/keynote-2/src/core/runner.ts index 5d9336b76e6..4dca5e7759b 100644 --- a/templates/keynote-2/src/core/runner.ts +++ b/templates/keynote-2/src/core/runner.ts @@ -347,18 +347,21 @@ export async function runOne({ worker(i), ); - // Wait for all workers to reach end of test window (before they wait for in-flight ops) - await testWindowEndPromise; - - const testWindowEndTime = performance.now(); - console.log( - `[${connector.name}] Test window ended at ${((testWindowEndTime - start) / 1000).toFixed(2)}s; waiting for in-flight operations...`, - ); + try { + // Wait for all workers to reach end of test window (before they wait for in-flight ops) + await testWindowEndPromise; - // Now wait for all workers to fully complete (including in-flight ops) - await Promise.all(workerPromises); + const testWindowEndTime = performance.now(); + console.log( + `[${connector.name}] Test window ended at ${((testWindowEndTime - start) / 1000).toFixed(2)}s; waiting for in-flight operations...`, + ); - clearInterval(intervalTimer); + // Now wait for all workers to fully complete (including in-flight ops) + await Promise.all(workerPromises); + } finally { + // Ensure the per-second sampler stops even if a worker throws. + clearInterval(intervalTimer); + } return { start, completedWithinWindow, completedTotal, series }; }; diff --git a/templates/keynote-2/src/opts.ts b/templates/keynote-2/src/opts.ts index ecd14538eb7..d44ddf5ba72 100644 --- a/templates/keynote-2/src/opts.ts +++ b/templates/keynote-2/src/opts.ts @@ -4,6 +4,7 @@ import { defaultBenchTestName, defaultDemoSystems, getSharedRuntimeDefaults, + parseAlphaList, parseStdbCompression, parseConnectorList, type BenchOptions, @@ -142,7 +143,11 @@ function addSharedRuntimeOptions(parser: CLIParser): CLIParser { return parser .option('--seconds ', 'Number of seconds to benchmark for', num()) .option('--concurrency ', 'Concurrent clients to run', num()) - .option('--alpha ', 'Alpha value', num()) + .option( + '--alpha ', + 'Zipf alpha. Accepts a single value or a comma-separated list (e.g. `0,1.5`).', + str(), + ) .option('--accounts ', 'Number of accounts to run with', num()) .option( '--initial-balance ', @@ -299,11 +304,16 @@ export function parseDemoOptions(argv: string[] = process.argv): DemoOptions { const runtimeOptions = resolveRuntimeOptions(options, runtimeDefaults); + const demoAlphas = parseAlphaList( + options.alpha as string | number | string[] | undefined, + '--alpha', + ); + return { ...runtimeOptions, seconds: options.seconds ?? 60, concurrency: options.concurrency ?? 50, - alpha: options.alpha ?? 1.5, + alpha: demoAlphas?.[0] ?? 1.5, systems: options.systems ?? options.connectors ?? [...defaultDemoSystems], skipPrep: options.skipPrep ?? false, @@ -329,6 +339,15 @@ export function parseBenchOptions(argv: string[] = process.argv): BenchOptions { type: 'strings', possibleValues: validConnectors, }) + .option( + '--runs ', + 'Repeat each (connector, alpha) combination this many times. One JSON written per run.', + num(), + ) + .option( + '--prep-between-alphas', + 'Run `pnpm run prep` before each (connector, alpha) combination to reset DB state.', + ) .option( '--contention-tests ', 'Run alpha sweep as start,end,step,concurrency', @@ -371,13 +390,20 @@ export function parseBenchOptions(argv: string[] = process.argv): BenchOptions { } : null; + const parsedAlphas = parseAlphaList( + options.alpha as string | number | string[] | undefined, + '--alpha', + ); + return { ...runtimeOptions, testName: args[0] ?? defaultBenchTestName, seconds: options.seconds ?? 60, concurrency: contentionTests?.concurrency ?? options.concurrency ?? 50, - alpha: concurrencyTests?.alpha ?? options.alpha ?? 1.5, + alphas: parsedAlphas ?? [concurrencyTests?.alpha ?? 1.5], + runs: options.runs ?? 1, + prepBetweenAlphas: options.prepBetweenAlphas ?? false, connectors: options.connectors ?? options.systems ?? null, contentionTests, concurrencyTests, From f01342f8520b04f2175afa209de733aa9385a168 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Mon, 11 May 2026 16:27:20 -0400 Subject: [PATCH 22/47] remove unused compose --- .../docker-compose-crdb-loadbalancer.yml | 7 - .../docker-compose-crdb-rpc-server.yml | 14 - .../docker-compose-linux-raid-crdb.yml | 209 ----------- .../keynote-2/docker-compose-linux-raid.yml | 330 ------------------ 4 files changed, 560 deletions(-) delete mode 100644 templates/keynote-2/docker-compose-crdb-loadbalancer.yml delete mode 100644 templates/keynote-2/docker-compose-crdb-rpc-server.yml delete mode 100644 templates/keynote-2/docker-compose-linux-raid-crdb.yml delete mode 100644 templates/keynote-2/docker-compose-linux-raid.yml diff --git a/templates/keynote-2/docker-compose-crdb-loadbalancer.yml b/templates/keynote-2/docker-compose-crdb-loadbalancer.yml deleted file mode 100644 index 26c5cfbdcbb..00000000000 --- a/templates/keynote-2/docker-compose-crdb-loadbalancer.yml +++ /dev/null @@ -1,7 +0,0 @@ -services: - crdb-rpc-lb: - image: nginx:stable - ports: - - "4102:4102" - volumes: - - ./nginx-crdb.conf:/etc/nginx/nginx.conf:ro diff --git a/templates/keynote-2/docker-compose-crdb-rpc-server.yml b/templates/keynote-2/docker-compose-crdb-rpc-server.yml deleted file mode 100644 index 6b78dd7ae83..00000000000 --- a/templates/keynote-2/docker-compose-crdb-rpc-server.yml +++ /dev/null @@ -1,14 +0,0 @@ -services: - crdb-rpc: - build: - context: . - dockerfile: Dockerfile.rpc - command: ["pnpm", "tsx", "src/rpc-servers/cockroach-rpc-server.ts"] - ports: - - "5001:5001" - environment: - CRDB_URL: ${CRDB_URL} # Point to remote CRDB cluster - CRDB_RPC_PORT: "5001" - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - diff --git a/templates/keynote-2/docker-compose-linux-raid-crdb.yml b/templates/keynote-2/docker-compose-linux-raid-crdb.yml deleted file mode 100644 index 08310ec21ed..00000000000 --- a/templates/keynote-2/docker-compose-linux-raid-crdb.yml +++ /dev/null @@ -1,209 +0,0 @@ -services: - pg: - image: postgres:16 - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - ports: - - "5432:5432" - command: > # faster setup - -c fsync=on - -c synchronous_commit=off - -c shared_buffers=8GB - -c work_mem=64MB - -c max_connections=10000 - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 2s - timeout: 2s - retries: 15 - volumes: - - /mnt/local-ssd/pg_data:/var/lib/postgresql/data - network_mode: host - - pg-rpc: - build: - context: . - dockerfile: Dockerfile.rpc - command: ["pnpm", "tsx", "src/rpc-servers/postgres-rpc-server.ts"] - ports: - - "4101:4101" - environment: - PG_URL: ${PG_URL} - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - depends_on: - pg: - condition: service_healthy - network_mode: host - - # crdb-rpc-1: - # build: - # context: . - # dockerfile: Dockerfile.rpc - # command: ["pnpm", "tsx", "src/rpc-servers/cockroach-rpc-server.ts"] - # environment: - # CRDB_URL: ${CRDB_URL} - # CRDB_RPC_PORT: "5001" - # SEED_ACCOUNTS: ${SEED_ACCOUNTS} - # SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - # network_mode: host - # - # crdb-rpc-2: - # build: - # context: . - # dockerfile: Dockerfile.rpc - # command: ["pnpm", "tsx", "src/rpc-servers/cockroach-rpc-server.ts"] - # environment: - # CRDB_URL: ${CRDB_URL} - # CRDB_RPC_PORT: "5002" - # SEED_ACCOUNTS: ${SEED_ACCOUNTS} - # SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - # network_mode: host - # - # crdb-rpc-3: - # build: - # context: . - # dockerfile: Dockerfile.rpc - # command: ["pnpm", "tsx", "src/rpc-servers/cockroach-rpc-server.ts"] - # environment: - # CRDB_URL: ${CRDB_URL} - # CRDB_RPC_PORT: "5003" - # SEED_ACCOUNTS: ${SEED_ACCOUNTS} - # SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - # network_mode: host - # - # crdb-rpc-lb: - # image: nginx:stable - # volumes: - # - ./nginx-crdb.conf:/etc/nginx/nginx.conf:ro - # depends_on: - # crdb-rpc-1: - # condition: service_started - # crdb-rpc-2: - # condition: service_started - # crdb-rpc-3: - # condition: service_started - # network_mode: host - - sqlite-rpc: - build: - context: . - dockerfile: Dockerfile.rpc - command: ["pnpm", "tsx", "src/rpc-servers/sqlite-rpc-server.ts"] - ports: - - "4103:4103" - environment: - SQLITE_FILE: /data/accounts.sqlite - SQLITE_MODE: ${SQLITE_MODE} - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - volumes: - - /mnt/local-ssd/sqlite_data:/data - network_mode: host - - bun-rpc: - build: - context: . - dockerfile: Dockerfile.bun - ports: - - "4001:4001" - environment: - BUN_PG_URL: ${BUN_PG_URL} - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - depends_on: - pg: - condition: service_healthy - network_mode: host - - supabase-rpc: - build: - context: . - dockerfile: Dockerfile.rpc - command: [ "pnpm", "tsx", "src/rpc-servers/supabase-rpc-server.ts" ] - ports: - - "4106:4106" - environment: - SUPABASE_DB_URL: ${SUPABASE_DB_URL} - PG_RPC_PORT: "4106" - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - network_mode: host - - planetscale-pg-rpc: - build: - context: . - dockerfile: Dockerfile.rpc - command: [ "pnpm", "tsx", "src/rpc-servers/postgres-rpc-server.ts" ] - ports: - - "4104:4104" - environment: - PG_URL: ${PLANETSCALE_PG_URL} - PG_RPC_PORT: "4104" - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - network_mode: host - - spacetime: - image: clockworklabs/spacetime:latest - command: start - ports: - - "3000:3000" - volumes: - - /mnt/local-ssd/spacetime_data:/data - network_mode: host - - bench: - build: - context: . - dockerfile: Dockerfile.bench - depends_on: - spacetime: - condition: service_started - pg-rpc: - condition: service_started - sqlite-rpc: - condition: service_started - environment: - USE_DOCKER: ${USE_DOCKER} - PG_URL: ${PG_URL} - CRDB_URL: ${CRDB_URL} - CONVEX_URL: ${CONVEX_URL} - STDB_URL: ${STDB_URL} - STDB_MODULE: ${STDB_MODULE} - STDB_MODULE_PATH: ${STDB_MODULE_PATH} - STDB_CONFIRMED_READS: ${STDB_CONFIRMED_READS} - BUN_URL: ${BUN_URL} - SQLITE_FILE: /data/accounts.sqlite - SQLITE_MODE: ${SQLITE_MODE} - SUPABASE_URL: ${SUPABASE_URL} - SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY} - SUPABASE_DB_URL: ${SUPABASE_DB_URL} - PG_RPC_URL: ${PG_RPC_URL} - CRDB_RPC_URL: ${CRDB_RPC_URL} - SQLITE_RPC_URL: ${SQLITE_RPC_URL} - SUPABASE_RPC_URL: ${SUPABASE_RPC_URL} - PLANETSCALE_RPC_URL: ${PLANETSCALE_RPC_URL} - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - CONVEX_USE_SHARDED_COUNTER: ${CONVEX_USE_SHARDED_COUNTER} - VERIFY: ${VERIFY} - volumes: - - /mnt/local-ssd/sqlite_data:/data - - ./runs:/app/runs - command: ["--seconds", "5", "--concurrency", "50", "--alpha", "1.5", "--connectors", "sqlite"] - network_mode: host - - sqlite-seed: - build: - context: . - dockerfile: Dockerfile.sqlite-seed - environment: - SQLITE_FILE: /data/accounts.sqlite - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - volumes: - - /mnt/local-ssd/sqlite_data:/data - network_mode: host diff --git a/templates/keynote-2/docker-compose-linux-raid.yml b/templates/keynote-2/docker-compose-linux-raid.yml deleted file mode 100644 index d03fc7339ee..00000000000 --- a/templates/keynote-2/docker-compose-linux-raid.yml +++ /dev/null @@ -1,330 +0,0 @@ -services: - pg: - image: postgres:16 - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - ports: - - "5432:5432" - # command: > - # -c fsync=on - # -c synchronous_commit=on - # -c shared_buffers=2GB - # -c work_mem=64MB - # -c max_connections=1000 - command: > # faster setup - -c fsync=on - -c synchronous_commit=off - -c shared_buffers=8GB - -c work_mem=64MB - -c max_connections=10000 - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 2s - timeout: 2s - retries: 15 - volumes: - - /mnt/local-ssd/pg_data:/var/lib/postgresql/data - network_mode: host - - crdb: - image: cockroachdb/cockroach:latest - command: start-single-node --insecure --max-sql-memory=.25 --cache=.5 - ports: - - "26257:26257" - - "8082:8080" - healthcheck: - test: ["CMD", "/cockroach/cockroach", "node", "status", "--insecure"] - interval: 2s - timeout: 2s - retries: 15 - volumes: - - /mnt/local-ssd/crdb_data:/cockroach/cockroach-data - network_mode: host - -# crdb: -# image: cockroachdb/cockroach:latest -# command: > -# start --insecure -# --max-sql-memory=.25 --cache=.3 -# --listen-addr=0.0.0.0:36257 -# --http-addr=0.0.0.0:18080 -# --advertise-addr=127.0.0.1:36257 -# --join=127.0.0.1:36257,127.0.0.1:36258,127.0.0.1:36259 -# healthcheck: -# test: ["CMD-SHELL", "/cockroach/cockroach sql --insecure --host=127.0.0.1:36257 -e 'SELECT 1' >/dev/null 2>&1"] -# interval: 2s -# timeout: 2s -# retries: 60 -# volumes: -# - /mnt/local-ssd/crdb_data:/cockroach/cockroach-data -# network_mode: host -# -# crdb-node2: -# image: cockroachdb/cockroach:latest -# command: > -# start --insecure -# --max-sql-memory=.25 --cache=.3 -# --listen-addr=0.0.0.0:36258 -# --http-addr=0.0.0.0:18081 -# --advertise-addr=127.0.0.1:36258 -# --join=127.0.0.1:36257,127.0.0.1:36258,127.0.0.1:36259 -# volumes: -# - /mnt/local-ssd/crdb_data2:/cockroach/cockroach-data -# network_mode: host -# -# crdb-node3: -# image: cockroachdb/cockroach:latest -# command: > -# start --insecure -# --max-sql-memory=.25 --cache=.3 -# --listen-addr=0.0.0.0:36259 -# --http-addr=0.0.0.0:18082 -# --advertise-addr=127.0.0.1:36259 -# --join=127.0.0.1:36257,127.0.0.1:36258,127.0.0.1:36259 -# volumes: -# - /mnt/local-ssd/crdb_data3:/cockroach/cockroach-data -# network_mode: host -# -# crdb-init: -# image: cockroachdb/cockroach:latest -# depends_on: -# crdb: -# condition: service_healthy -# crdb-node2: -# condition: service_started -# crdb-node3: -# condition: service_started -# network_mode: host -# restart: "no" -# entrypoint: [ "sh", "-c" ] -# command: > -# cockroach init --insecure --host=127.0.0.1:36257 || true && -# cockroach sql --insecure --host=127.0.0.1:36257 -e 'CREATE DATABASE IF NOT EXISTS bench;' - - pg-rpc: - build: - context: . - dockerfile: Dockerfile.rpc - command: ["pnpm", "tsx", "src/rpc-servers/postgres-rpc-server.ts"] - ports: - - "4101:4101" - environment: - PG_URL: ${PG_URL} - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - depends_on: - pg: - condition: service_healthy - network_mode: host - -# crdb-rpc-1: -# build: -# context: . -# dockerfile: Dockerfile.rpc -# command: ["pnpm", "tsx", "src/rpc-servers/cockroach-rpc-server.ts"] -# environment: -# CRDB_URL: ${CRDB_URL} -# CRDB_RPC_PORT: "5001" -# SEED_ACCOUNTS: ${SEED_ACCOUNTS} -# SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} -# network_mode: host -# -# crdb-rpc-2: -# build: -# context: . -# dockerfile: Dockerfile.rpc -# command: ["pnpm", "tsx", "src/rpc-servers/cockroach-rpc-server.ts"] -# environment: -# CRDB_URL: ${CRDB_URL} -# CRDB_RPC_PORT: "5002" -# SEED_ACCOUNTS: ${SEED_ACCOUNTS} -# SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} -# network_mode: host -# -# crdb-rpc-3: -# build: -# context: . -# dockerfile: Dockerfile.rpc -# command: ["pnpm", "tsx", "src/rpc-servers/cockroach-rpc-server.ts"] -# environment: -# CRDB_URL: ${CRDB_URL} -# CRDB_RPC_PORT: "5003" -# SEED_ACCOUNTS: ${SEED_ACCOUNTS} -# SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} -# network_mode: host -# -# crdb-rpc-lb: -# image: nginx:stable -# volumes: -# - ./nginx-crdb.conf:/etc/nginx/nginx.conf:ro -# depends_on: -# crdb-rpc-1: -# condition: service_started -# crdb-rpc-2: -# condition: service_started -# crdb-rpc-3: -# condition: service_started -# network_mode: host - - crdb-rpc: - build: - context: . - dockerfile: Dockerfile.rpc - command: ["pnpm", "tsx", "src/rpc-servers/cockroach-rpc-server.ts"] - ports: - - "4102:4102" - environment: - CRDB_URL: ${CRDB_URL} - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - depends_on: - crdb: - condition: service_healthy -# crdb-init: -# condition: service_completed_successfully - network_mode: host - - sqlite-rpc: - build: - context: . - dockerfile: Dockerfile.rpc - command: ["pnpm", "tsx", "src/rpc-servers/sqlite-rpc-server.ts"] - ports: - - "4103:4103" - environment: - SQLITE_FILE: /data/accounts.sqlite - SQLITE_MODE: ${SQLITE_MODE} - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - volumes: - - /mnt/local-ssd/sqlite_data:/data - network_mode: host - - bun-rpc: - build: - context: . - dockerfile: Dockerfile.bun - ports: - - "4001:4001" - environment: - BUN_PG_URL: ${BUN_PG_URL} - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - depends_on: - pg: - condition: service_healthy - network_mode: host - - supabase-rpc: - build: - context: . - dockerfile: Dockerfile.rpc - command: [ "pnpm", "tsx", "src/rpc-servers/supabase-rpc-server.ts" ] - ports: - - "4106:4106" - environment: - SUPABASE_DB_URL: ${SUPABASE_DB_URL} - PG_RPC_PORT: "4106" - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - network_mode: host - - planetscale-pg-rpc: - build: - context: . - dockerfile: Dockerfile.rpc - command: [ "pnpm", "tsx", "src/rpc-servers/postgres-rpc-server.ts" ] - ports: - - "4104:4104" - environment: - PG_URL: ${PLANETSCALE_PG_URL} - PG_RPC_PORT: "4104" - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - network_mode: host - - spacetime: - image: clockworklabs/spacetime:latest - command: start - ports: - - "3000:3000" - volumes: - - /mnt/local-ssd/spacetime_data:/data - network_mode: host - # healthcheck: - # test: ["CMD-SHELL", "curl -fsS http://localhost:3000/health || exit 1"] - # interval: 2s - # timeout: 2s - # retries: 15 - - bench: - build: - context: . - dockerfile: Dockerfile.bench - depends_on: - pg: - condition: service_healthy - crdb: - condition: service_healthy -# crdb-init: -# condition: service_completed_successfully - spacetime: - condition: service_started - pg-rpc: - condition: service_started -# crdb-rpc-lb: -# condition: service_started -# crdb-rpc-1: -# condition: service_started -# crdb-rpc-2: -# condition: service_started -# crdb-rpc-3: -# condition: service_started - sqlite-rpc: - condition: service_started - environment: - USE_DOCKER: ${USE_DOCKER} - PG_URL: ${PG_URL} - CRDB_URL: ${CRDB_URL} - CONVEX_URL: ${CONVEX_URL} - STDB_URL: ${STDB_URL} - STDB_MODULE: ${STDB_MODULE} - STDB_MODULE_PATH: ${STDB_MODULE_PATH} - STDB_CONFIRMED_READS: ${STDB_CONFIRMED_READS} - BUN_URL: ${BUN_URL} - SQLITE_FILE: /data/accounts.sqlite - SQLITE_MODE: ${SQLITE_MODE} - SUPABASE_URL: ${SUPABASE_URL} - SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY} - SUPABASE_DB_URL: ${SUPABASE_DB_URL} - PG_RPC_URL: ${PG_RPC_URL} - CRDB_RPC_URL: ${CRDB_RPC_URL} - SQLITE_RPC_URL: ${SQLITE_RPC_URL} - SUPABASE_RPC_URL: ${SUPABASE_RPC_URL} - PLANETSCALE_RPC_URL: ${PLANETSCALE_RPC_URL} - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - CONVEX_USE_SHARDED_COUNTER: ${CONVEX_USE_SHARDED_COUNTER} - VERIFY: ${VERIFY} - BENCH_PIPELINED: ${BENCH_PIPELINED} - MAX_INFLIGHT_PER_WORKER: ${MAX_INFLIGHT_PER_WORKER} - ENABLE_RPC_SERVERS: ${ENABLE_RPC_SERVERS} - volumes: - - /mnt/local-ssd/sqlite_data:/data - - ./runs:/app/runs - command: ["--seconds", "5", "--concurrency", "50", "--alpha", "1.5", "--connectors", "sqlite"] - network_mode: host - - sqlite-seed: - build: - context: . - dockerfile: Dockerfile.sqlite-seed - environment: - SQLITE_FILE: /data/accounts.sqlite - SEED_ACCOUNTS: ${SEED_ACCOUNTS} - SEED_INITIAL_BALANCE: ${SEED_INITIAL_BALANCE} - volumes: - - /mnt/local-ssd/sqlite_data:/data - network_mode: host From b80be5a02d8a9d7b8cf7b4d60c2e4a24ceef6f4c Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 11 May 2026 20:02:47 -0700 Subject: [PATCH 23/47] Clarify machine topology further --- templates/keynote-2/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index 0268b729189..2e2a0f1aee4 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -69,11 +69,11 @@ This ensures we're measuring real-world application performance, not raw databas The reported numbers use a single benchmark host wherever possible. This means client, server, and database were all run on the same machine. -We did this mainly for ease of testing and reproducibility, but also because it was a more favorable set up for the other platforms we tested against. +We did this mainly because it was the most favorable benchmarking setup for the competitor platforms, because it minimizes server to database latency, but also because it allows others to easily reproduce the results. -We also tested separated-machine topologies, where the benchmark client, server, and database processes were not colocated on one machine. However that did not improve the throughput of the other systems. The added network hop actually made those systems slower. +For completeness, we also tested separated-machine topologies, where the benchmark client, server, and database processes were not colocated on one machine. However, in each case we found that doing so either did not change or reduced the throughput of other systems due to the additional network hop. We published the most favorable numbers for our competitors. -The platforms that cannot use this exact topology are PlanetScale and CockroachDB. Both are managed cloud database, so the benchmark client and RPC server are colocated on the benchmark host while the database/cluster is remote. +The platforms that cannot use this exact topology are PlanetScale and CockroachDB. PlanetScale operates a managed cloud database and does not have a self-hosted variant of the service, so the benchmark client and RPC server are colocated on a benchmark host in the same region and availability zone as the database host. CockroachDB is a distributed database running across multiple nodes, so the benchmark client and RPC server cannot be colocated with the database on a single node. ### The Transaction From 986030575bb324f88a16fdd6b60519444b3a630d Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 11 May 2026 21:58:53 -0700 Subject: [PATCH 24/47] Update spacetimedb ts numbers --- templates/keynote-2/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index 2e2a0f1aee4..4a2b95961fd 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -26,7 +26,7 @@ Each cell shows **mean TPS ± sample standard deviation** of the per-second thro | System | Mean TPS ± σ (Var) (~0% Contention) | Mean TPS ± σ (Var) (~80% Contention) | | --------------------------------------- | ----------------------------------- | ------------------------------------ | -| SpacetimeDB (TypeScript Module) | | 307,074 | +| SpacetimeDB (TypeScript Module) | 294,827 ± 5,266 (27,728,435) | 304,865 ± 4,751 (22,569,090) | | SpacetimeDB (Rust Module) | | 265,542 | | SQLite + Node HTTP + Drizzle | 3,109 ± 86 (7,326) | 3,228 ± 80 (6,396) | | Bun + Drizzle + Postgres | 10,662 ± 215 (46,418) | 2,773 ± 83 (6,930) | From a93caa13e6b2cdf97152c639e054916026218819 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 11 May 2026 22:26:32 -0700 Subject: [PATCH 25/47] Update spacetimedb rust numbers --- templates/keynote-2/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index 4a2b95961fd..19430315314 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -27,7 +27,7 @@ Each cell shows **mean TPS ± sample standard deviation** of the per-second thro | System | Mean TPS ± σ (Var) (~0% Contention) | Mean TPS ± σ (Var) (~80% Contention) | | --------------------------------------- | ----------------------------------- | ------------------------------------ | | SpacetimeDB (TypeScript Module) | 294,827 ± 5,266 (27,728,435) | 304,865 ± 4,751 (22,569,090) | -| SpacetimeDB (Rust Module) | | 265,542 | +| SpacetimeDB (Rust Module) | 266,139 ± 4,662 (21,730,912) | 278,070 ± 4,279 (18,312,134) | | SQLite + Node HTTP + Drizzle | 3,109 ± 86 (7,326) | 3,228 ± 80 (6,396) | | Bun + Drizzle + Postgres | 10,662 ± 215 (46,418) | 2,773 ± 83 (6,930) | | Supabase + Node HTTP + Drizzle | 6,853 ± 1,017 (1,034,915) | 2,896 ± 111 (12,414) | From a000a76d98208de9952f601bf6ec970ddd17e019 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 11 May 2026 22:52:08 -0700 Subject: [PATCH 26/47] Use spacetimedb standalone --- templates/keynote-2/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index 19430315314..982d8fe12f5 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -22,6 +22,8 @@ The demo compares SpacetimeDB and Convex by default, since both are easy for any All tests run for 300 seconds with 50 concurrent connections, with a transfer workload (read-modify-write transaction between two accounts). +The SpacetimeDB rows were obtained using a single-node SpacetimeDB Standalone instance, so the published numbers are reproducible with the public, downloadable server. + Each cell shows **mean TPS ± sample standard deviation** of the per-second throughput within a single 300-second run, with the sample variance in parentheses. Cells where the standard deviation approaches or exceeds the mean (e.g. CockroachDB and Convex at ~80% contention) indicate that the system's throughput is unstable across the run. | System | Mean TPS ± σ (Var) (~0% Contention) | Mean TPS ± σ (Var) (~80% Contention) | @@ -47,8 +49,6 @@ The managed Postgres services (Supabase, PlanetScale) run at their default isola Throughput is counted from successful operations that the benchmark client observes completing inside the configured test window for every system. -The reported SpacetimeDB module results were run against a 5-way replicated cluster rather than a single standalone node. - ### Test Architecture All benchmarks follow an **apples-to-apples** comparison using the same architecture pattern: @@ -122,7 +122,7 @@ Useful flags: ### Account Seeding - 100,000 accounts seeded before each benchmark -- Initial balance: 10,000,000 per account +- Initial balance: 1,000,000,000 per account - Zipf distribution controls which accounts are selected for transfers ## Technical Notes @@ -161,7 +161,7 @@ PlanetScale results (~280 TPS under high contention, regardless of cluster tier) | System | Architecture | | --------------------------------- | ------------------------------------------------------- | -| SpacetimeDB | Integrated platform. | +| SpacetimeDB Standalone | Integrated platform; single-node downloadable server. | | SQLite + Node HTTP + Drizzle | Node.js HTTP server → Drizzle ORM → SQLite | | Bun + Drizzle + Postgres | Bun HTTP server → Drizzle ORM → PostgreSQL | | Postgres + Node HTTP + Drizzle | Node.js HTTP server → Drizzle ORM → PostgreSQL | From 7c03f57b002aebc72eae6c489cbc3ab6e886aa50 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Mon, 11 May 2026 23:00:02 -0700 Subject: [PATCH 27/47] update pnpm lock file --- pnpm-lock.yaml | 51 +++++++++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6930daea56..e5dc0160c37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -470,7 +470,7 @@ importers: dependencies: '@convex-dev/sharded-counter': specifier: ^0.2.0 - version: 0.2.0(convex@1.32.0(react@19.2.4)) + version: 0.2.0(convex@1.38.0(react@19.2.4)) '@supabase/supabase-js': specifier: ^2.80.0 version: 2.97.0 @@ -521,8 +521,8 @@ importers: specifier: ^1.3.2 version: 1.3.9 convex: - specifier: ^1.29.0 - version: 1.32.0(react@19.2.4) + specifier: ^1.37.0 + version: 1.38.0(react@19.2.4) dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -546,7 +546,7 @@ importers: dependencies: nuxt: specifier: ~3.16.0 - version: 3.16.2(@parcel/watcher@2.5.6)(@types/node@24.3.0)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2) + version: 3.16.2(@parcel/watcher@2.5.6)(@types/node@24.3.0)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.1)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2) spacetimedb: specifier: workspace:* version: link:../../crates/bindings-typescript @@ -7311,7 +7311,6 @@ packages: bun@1.3.9: resolution: {integrity: sha512-v5hkh1us7sMNjfimWE70flYbD5I1/qWQaqmJ45q2qk5H/7muQVa478LSVRSFyGTBUBog2LsPQnfIRdjyWJRY+A==} - cpu: [arm64, x64] os: [darwin, linux, win32] hasBin: true @@ -7705,19 +7704,22 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - convex@1.32.0: - resolution: {integrity: sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw==} + convex@1.38.0: + resolution: {integrity: sha512-122AC6y5lUS7mr39cluLw9+TOtRX5d/XxeivHhHObs/NTXoVvOnIgDzexVcxaz6Rk0oLFSoydSR1rDCltEz/0A==} engines: {node: '>=18.0.0', npm: '>=7.0.0'} hasBin: true peerDependencies: '@auth0/auth0-react': ^2.0.1 '@clerk/clerk-react': ^4.12.8 || ^5.0.0 + '@clerk/react': ^6.4.3 react: ^18.0.0 || ^19.0.0-0 || ^19.0.0 peerDependenciesMeta: '@auth0/auth0-react': optional: true '@clerk/clerk-react': optional: true + '@clerk/react': + optional: true react: optional: true @@ -16608,9 +16610,9 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@convex-dev/sharded-counter@0.2.0(convex@1.32.0(react@19.2.4))': + '@convex-dev/sharded-counter@0.2.0(convex@1.38.0(react@19.2.4))': dependencies: - convex: 1.32.0(react@19.2.4) + convex: 1.38.0(react@19.2.4) '@cspotcode/source-map-support@0.8.1': dependencies: @@ -19433,11 +19435,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@nuxt/cli@3.33.1(@nuxt/schema@3.16.2)(cac@6.7.14)(magicast@0.3.5)': + '@nuxt/cli@3.33.1(@nuxt/schema@3.16.2)(cac@6.7.14)(magicast@0.5.1)': dependencies: '@bomb.sh/tab': 0.0.12(cac@6.7.14)(citty@0.2.0) '@clack/prompts': 1.0.0 - c12: 3.3.3(magicast@0.3.5) + c12: 3.3.3(magicast@0.5.1) citty: 0.2.0 confbox: 0.2.4 consola: 3.4.2 @@ -19533,9 +19535,9 @@ snapshots: - utf-8-validate - vue - '@nuxt/kit@3.16.2(magicast@0.3.5)': + '@nuxt/kit@3.16.2(magicast@0.5.1)': dependencies: - c12: 3.3.3(magicast@0.3.5) + c12: 3.3.3(magicast@0.5.1) consola: 3.4.2 defu: 6.1.4 destr: 2.0.5 @@ -19593,18 +19595,18 @@ snapshots: pathe: 2.0.3 std-env: 3.10.0 - '@nuxt/telemetry@2.7.0(@nuxt/kit@3.16.2(magicast@0.3.5))': + '@nuxt/telemetry@2.7.0(@nuxt/kit@3.16.2(magicast@0.5.1))': dependencies: - '@nuxt/kit': 3.16.2(magicast@0.3.5) + '@nuxt/kit': 3.16.2(magicast@0.5.1) citty: 0.2.0 consola: 3.4.2 ofetch: 2.0.0-alpha.3 rc9: 3.0.0 std-env: 3.10.0 - '@nuxt/vite-builder@3.16.2(@types/node@24.3.0)(eslint@9.33.0(jiti@2.6.1))(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vue-tsc@2.2.12(typescript@5.6.3))(vue@3.5.26(typescript@5.6.3))(yaml@2.8.2)': + '@nuxt/vite-builder@3.16.2(@types/node@24.3.0)(eslint@9.33.0(jiti@2.6.1))(magicast@0.5.1)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vue-tsc@2.2.12(typescript@5.6.3))(vue@3.5.26(typescript@5.6.3))(yaml@2.8.2)': dependencies: - '@nuxt/kit': 3.16.2(magicast@0.3.5) + '@nuxt/kit': 3.16.2(magicast@0.5.1) '@rollup/plugin-replace': 6.0.3(rollup@4.56.0) '@vitejs/plugin-vue': 5.2.4(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) '@vitejs/plugin-vue-jsx': 4.2.0(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) @@ -24408,7 +24410,7 @@ snapshots: convert-source-map@2.0.0: {} - convex@1.32.0(react@19.2.4): + convex@1.38.0(react@19.2.4): dependencies: esbuild: 0.27.0 prettier: 3.6.2 @@ -28425,19 +28427,19 @@ snapshots: schema-utils: 3.3.0 webpack: 5.102.0 - nuxt@3.16.2(@parcel/watcher@2.5.6)(@types/node@24.3.0)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2): + nuxt@3.16.2(@parcel/watcher@2.5.6)(@types/node@24.3.0)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.11)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.1)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2): dependencies: - '@nuxt/cli': 3.33.1(@nuxt/schema@3.16.2)(cac@6.7.14)(magicast@0.3.5) + '@nuxt/cli': 3.33.1(@nuxt/schema@3.16.2)(cac@6.7.14)(magicast@0.5.1) '@nuxt/devalue': 2.0.2 '@nuxt/devtools': 2.7.0(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) - '@nuxt/kit': 3.16.2(magicast@0.3.5) + '@nuxt/kit': 3.16.2(magicast@0.5.1) '@nuxt/schema': 3.16.2 - '@nuxt/telemetry': 2.7.0(@nuxt/kit@3.16.2(magicast@0.3.5)) - '@nuxt/vite-builder': 3.16.2(@types/node@24.3.0)(eslint@9.33.0(jiti@2.6.1))(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vue-tsc@2.2.12(typescript@5.6.3))(vue@3.5.26(typescript@5.6.3))(yaml@2.8.2) + '@nuxt/telemetry': 2.7.0(@nuxt/kit@3.16.2(magicast@0.5.1)) + '@nuxt/vite-builder': 3.16.2(@types/node@24.3.0)(eslint@9.33.0(jiti@2.6.1))(magicast@0.5.1)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vue-tsc@2.2.12(typescript@5.6.3))(vue@3.5.26(typescript@5.6.3))(yaml@2.8.2) '@oxc-parser/wasm': 0.60.0 '@unhead/vue': 2.1.4(vue@3.5.26(typescript@5.6.3)) '@vue/shared': 3.5.26 - c12: 3.3.3(magicast@0.3.5) + c12: 3.3.3(magicast@0.5.1) chokidar: 4.0.3 compatx: 0.1.8 consola: 3.4.2 @@ -32575,7 +32577,6 @@ snapshots: ws@8.18.3: {} - wsl-utils@0.1.0: dependencies: is-wsl: 3.1.0 From 21f9fad90278bd31cf809584b67aae7ee6c22a4c Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Tue, 12 May 2026 11:56:46 -0400 Subject: [PATCH 28/47] MAX_POOL -> 64 default --- templates/keynote-2/src/config.ts | 2 +- templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts | 4 ++++ templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts | 4 ++++ templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts | 4 ++++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/templates/keynote-2/src/config.ts b/templates/keynote-2/src/config.ts index a117c993ff0..44b828ca374 100644 --- a/templates/keynote-2/src/config.ts +++ b/templates/keynote-2/src/config.ts @@ -262,7 +262,7 @@ export function getSharedRuntimeDefaults( ), stdbConfirmedReads: readBooleanEnv('STDB_CONFIRMED_READS', true, env), useDocker: readBooleanEnv('USE_DOCKER', false, env), - poolMax: readNumberEnv('MAX_POOL', 1000, env), + poolMax: readNumberEnv('MAX_POOL', 64, env), bunUrl: readStringEnv('BUN_URL', 'http://127.0.0.1:4000', env), convexUrl: readStringEnv('CONVEX_URL', 'http://127.0.0.1:3210', env), convexDir: readStringEnv('CONVEX_DIR', './convex-app', env), diff --git a/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts b/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts index f7ad685c51a..0f3e302db4f 100644 --- a/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts @@ -5,11 +5,14 @@ import { drizzle } from 'drizzle-orm/node-postgres'; import { pgTable, integer, bigint as pgBigint } from 'drizzle-orm/pg-core'; import { eq, inArray, sql } from 'drizzle-orm'; import { RpcRequest, RpcResponse } from '../connectors/rpc/rpc_common.ts'; +import { getSharedRuntimeDefaults } from '../config.ts'; const CRDB_URL = process.env.CRDB_URL; if (!CRDB_URL) { throw new Error('CRDB_URL not set'); } +const { poolMax } = getSharedRuntimeDefaults(); + const accounts = pgTable('accounts', { id: integer('id').primaryKey(), balance: pgBigint('balance', { mode: 'bigint' }).notNull(), @@ -18,6 +21,7 @@ const accounts = pgTable('accounts', { const pool = new Pool({ connectionString: CRDB_URL, application_name: 'crdb-rpc-drizzle', + max: poolMax, }); const db = drizzle(pool, { schema: { accounts } }); diff --git a/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts b/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts index f66e421c10c..5db3f4b76ef 100644 --- a/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts @@ -5,11 +5,14 @@ import { drizzle } from 'drizzle-orm/node-postgres'; import { pgTable, integer, bigint as pgBigint } from 'drizzle-orm/pg-core'; import { eq, inArray, sql } from 'drizzle-orm'; import { RpcRequest, RpcResponse } from '../connectors/rpc/rpc_common.ts'; +import { getSharedRuntimeDefaults } from '../config.ts'; const PG_URL = process.env.PG_URL; if (!PG_URL) { throw new Error('PG_URL not set'); } +const { poolMax } = getSharedRuntimeDefaults(); + const accounts = pgTable('accounts', { id: integer('id').primaryKey(), balance: pgBigint('balance', { mode: 'bigint' }).notNull(), @@ -18,6 +21,7 @@ const accounts = pgTable('accounts', { const pool = new Pool({ connectionString: PG_URL, application_name: 'pg-rpc-drizzle', + max: poolMax, }); const db = drizzle(pool, { schema: { accounts } }); diff --git a/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts b/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts index 2080ff5296b..4fb766d122a 100644 --- a/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts @@ -5,11 +5,14 @@ import { drizzle } from 'drizzle-orm/node-postgres'; import { pgTable, integer, bigint as pgBigint } from 'drizzle-orm/pg-core'; import { eq, inArray, sql } from 'drizzle-orm'; import type { RpcRequest, RpcResponse } from '../connectors/rpc/rpc_common.ts'; +import { getSharedRuntimeDefaults } from '../config.ts'; const DB_URL = process.env.SUPABASE_DB_URL ?? process.env.PG_URL; if (!DB_URL) { throw new Error('SUPABASE_DB_URL / PG_URL not set'); } +const { poolMax } = getSharedRuntimeDefaults(); + const accounts = pgTable('accounts', { id: integer('id').primaryKey(), balance: pgBigint('balance', { mode: 'bigint' }).notNull(), @@ -18,6 +21,7 @@ const accounts = pgTable('accounts', { const pool = new Pool({ connectionString: DB_URL, application_name: 'supabase-rpc-drizzle', + max: poolMax, }); const db = drizzle(pool, { schema: { accounts } }); From 87255da5a5077e508bf925d045a960d44b178350 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Tue, 12 May 2026 14:15:44 -0400 Subject: [PATCH 29/47] Update postgres-rpc-server.ts --- templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts b/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts index 5db3f4b76ef..a2c643a4926 100644 --- a/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts @@ -47,7 +47,6 @@ async function rpcTransfer(args: Record) { .select() .from(accounts) .where(inArray(accounts.id, [fromId, toId])) - .for('update') .orderBy(accounts.id); if (rows.length !== 2) { From 5ba7c9be2831bf479fcfd2ad89f69b51a18fa9e5 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Tue, 12 May 2026 14:53:14 -0400 Subject: [PATCH 30/47] Update postgres-rpc-server.ts --- .../src/rpc-servers/postgres-rpc-server.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts b/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts index a2c643a4926..69985846c82 100644 --- a/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts @@ -61,15 +61,15 @@ async function rpcTransfer(args: Record) { return; } - await tx - .update(accounts) - .set({ balance: fromRow.balance - delta }) - .where(eq(accounts.id, fromId)); - - await tx - .update(accounts) - .set({ balance: toRow.balance + delta }) - .where(eq(accounts.id, toId)); + // await tx + // .update(accounts) + // .set({ balance: fromRow.balance - delta }) + // .where(eq(accounts.id, fromId)); + // + // await tx + // .update(accounts) + // .set({ balance: toRow.balance + delta }) + // .where(eq(accounts.id, toId)); }); } From 7ee5c8be5484c4974dd66b5ecfff0273759a341a Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Tue, 12 May 2026 15:29:28 -0400 Subject: [PATCH 31/47] Update postgres-rpc-server.ts --- .../src/rpc-servers/postgres-rpc-server.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts b/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts index 69985846c82..5db3f4b76ef 100644 --- a/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts @@ -47,6 +47,7 @@ async function rpcTransfer(args: Record) { .select() .from(accounts) .where(inArray(accounts.id, [fromId, toId])) + .for('update') .orderBy(accounts.id); if (rows.length !== 2) { @@ -61,15 +62,15 @@ async function rpcTransfer(args: Record) { return; } - // await tx - // .update(accounts) - // .set({ balance: fromRow.balance - delta }) - // .where(eq(accounts.id, fromId)); - // - // await tx - // .update(accounts) - // .set({ balance: toRow.balance + delta }) - // .where(eq(accounts.id, toId)); + await tx + .update(accounts) + .set({ balance: fromRow.balance - delta }) + .where(eq(accounts.id, fromId)); + + await tx + .update(accounts) + .set({ balance: toRow.balance + delta }) + .where(eq(accounts.id, toId)); }); } From bd628bc94ec53f454fcad06272adcc2c461e0c0d Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Tue, 12 May 2026 15:46:52 -0400 Subject: [PATCH 32/47] debug logging pool max --- templates/keynote-2/src/cli.ts | 7 +++++++ templates/keynote-2/src/config.ts | 1 + templates/keynote-2/src/core/runner.ts | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/templates/keynote-2/src/cli.ts b/templates/keynote-2/src/cli.ts index 967881e2daa..0c17b2e56df 100644 --- a/templates/keynote-2/src/cli.ts +++ b/templates/keynote-2/src/cli.ts @@ -258,6 +258,13 @@ async function writeRunJson(payload: object, connectorName: string, alpha: numbe alpha, run: r + 1, runs, + config: { + benchPipelined: options.benchPipelined, + maxInflightPerWorker: options.maxInflightPerWorker, + poolMax: options.poolMax, + stdbConfirmedReads: options.stdbConfirmedReads, + stdbCompression: options.stdbCompression, + }, results: [ { system: connector.name, diff --git a/templates/keynote-2/src/config.ts b/templates/keynote-2/src/config.ts index 44b828ca374..4b4703b5644 100644 --- a/templates/keynote-2/src/config.ts +++ b/templates/keynote-2/src/config.ts @@ -119,6 +119,7 @@ export type RunnerRuntimeConfig = Pick< | 'maxInflightPerWorker' | 'minOpTimeoutMs' | 'opTimeoutMs' + | 'poolMax' | 'precomputedTransferPairs' | 'tailSlackMs' | 'verifyTransactions' diff --git a/templates/keynote-2/src/core/runner.ts b/templates/keynote-2/src/core/runner.ts index 4dca5e7759b..7c8797de350 100644 --- a/templates/keynote-2/src/core/runner.ts +++ b/templates/keynote-2/src/core/runner.ts @@ -134,7 +134,7 @@ export async function runOne({ : maxInflightPerWorker; console.log( - `[${connector.name}] max inflight per worker: ${MAX_INFLIGHT_PER_WORKER}`, + `[${connector.name}] pipelined=${PIPELINED} max-inflight-per-worker=${MAX_INFLIGHT_PER_WORKER} pool-max=${runtimeConfig.poolMax}`, ); const run = async (seconds: number) => { const start = performance.now(); From 750676b4b88820975adc36098649c8b0fae09144 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Wed, 13 May 2026 10:13:18 -0400 Subject: [PATCH 33/47] Update bun-server.ts --- templates/keynote-2/bun/bun-server.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/templates/keynote-2/bun/bun-server.ts b/templates/keynote-2/bun/bun-server.ts index 08f769d733d..fe21028c183 100644 --- a/templates/keynote-2/bun/bun-server.ts +++ b/templates/keynote-2/bun/bun-server.ts @@ -3,15 +3,16 @@ import { drizzle } from 'drizzle-orm/node-postgres'; import { pgTable, integer, bigint as pgBigint } from 'drizzle-orm/pg-core'; import { eq, inArray, sql } from 'drizzle-orm'; import { RpcRequest, RpcResponse } from '../src/connectors/rpc/rpc_common'; -// import { poolMaxFromEnv } from '../src/helpers'; const DB_URL = process.env.BUN_PG_URL ?? process.env.PG_URL; if (!DB_URL) throw new Error('BUN_PG_URL or PG_URL not set'); +const poolMax = Number(process.env.MAX_POOL ?? 64); + const pool = new Pool({ connectionString: DB_URL, application_name: 'bun-rpc-drizzle', - // max: poolMaxFromEnv() + max: poolMax, }); const accounts = pgTable('accounts', { From 9b55e2ab67eadd1afa4e7c22de64907935d70c06 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Wed, 13 May 2026 10:13:22 -0400 Subject: [PATCH 34/47] Update .env.example --- templates/keynote-2/.env.example | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/templates/keynote-2/.env.example b/templates/keynote-2/.env.example index 2811d42abd2..1aeffcc2c85 100644 --- a/templates/keynote-2/.env.example +++ b/templates/keynote-2/.env.example @@ -59,5 +59,16 @@ SUPABASE_RPC_URL=http://127.0.0.1:4106 SEED_ACCOUNTS=100000 SEED_INITIAL_BALANCE=10000000 +# ===== Bench knobs ===== +# Pool size for pg-based RPC servers (postgres, cockroach, supabase, planetscale). +# Read at RPC-server startup — restart the RPC if you change this. +MAX_POOL=64 + +# Pipelining for the bench client. Required to enable pipelining for the +# RPC connectors (postgres_rpc, cockroach_rpc, supabase_rpc, planetscale_pg_rpc). +# Setting MAX_INFLIGHT_PER_WORKER alone does NOT enable pipelining for them. +#BENCH_PIPELINED=1 +#MAX_INFLIGHT_PER_WORKER=128 + VERIFY=0 ENABLE_RPC_SERVERS=0 From 1d606a18588c13ee794081a101bee29d006b80c1 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Wed, 13 May 2026 19:33:35 -0400 Subject: [PATCH 35/47] Bun to use the getSharedRuntimeDefaults helper (for consistency) --- templates/keynote-2/bun/bun-server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/keynote-2/bun/bun-server.ts b/templates/keynote-2/bun/bun-server.ts index fe21028c183..58eb5f595b5 100644 --- a/templates/keynote-2/bun/bun-server.ts +++ b/templates/keynote-2/bun/bun-server.ts @@ -3,11 +3,12 @@ import { drizzle } from 'drizzle-orm/node-postgres'; import { pgTable, integer, bigint as pgBigint } from 'drizzle-orm/pg-core'; import { eq, inArray, sql } from 'drizzle-orm'; import { RpcRequest, RpcResponse } from '../src/connectors/rpc/rpc_common'; +import { getSharedRuntimeDefaults } from '../src/config.ts'; const DB_URL = process.env.BUN_PG_URL ?? process.env.PG_URL; if (!DB_URL) throw new Error('BUN_PG_URL or PG_URL not set'); -const poolMax = Number(process.env.MAX_POOL ?? 64); +const { poolMax } = getSharedRuntimeDefaults(); const pool = new Pool({ connectionString: DB_URL, From 0dc526168130eb4e9c6b2739995730577665283e Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Wed, 13 May 2026 20:16:46 -0400 Subject: [PATCH 36/47] with retry --- .../src/rpc-servers/cockroach-rpc-server.ts | 55 +++++++++-------- templates/keynote-2/src/rpc-servers/retry.ts | 61 +++++++++++++++++++ 2 files changed, 90 insertions(+), 26 deletions(-) create mode 100644 templates/keynote-2/src/rpc-servers/retry.ts diff --git a/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts b/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts index 0f3e302db4f..1ad9b8d3285 100644 --- a/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts @@ -6,6 +6,7 @@ import { pgTable, integer, bigint as pgBigint } from 'drizzle-orm/pg-core'; import { eq, inArray, sql } from 'drizzle-orm'; import { RpcRequest, RpcResponse } from '../connectors/rpc/rpc_common.ts'; import { getSharedRuntimeDefaults } from '../config.ts'; +import { withTxnRetry } from './retry.ts'; const CRDB_URL = process.env.CRDB_URL; if (!CRDB_URL) { throw new Error('CRDB_URL not set'); @@ -42,36 +43,38 @@ async function rpcTransfer(args: Record) { const delta = BigInt(amount); - await db.transaction(async (tx) => { - const rows = await tx - .select() - .from(accounts) - .where(inArray(accounts.id, [fromId, toId])) - .for('update') - .orderBy(accounts.id); - - if (rows.length !== 2) { - throw new Error('account_missing'); - } + await withTxnRetry(() => + db.transaction(async (tx) => { + const rows = await tx + .select() + .from(accounts) + .where(inArray(accounts.id, [fromId, toId])) + .for('update') + .orderBy(accounts.id); + + if (rows.length !== 2) { + throw new Error('account_missing'); + } - const [first, second] = rows; - const fromRow = first.id === fromId ? first : second; - const toRow = first.id === fromId ? second : first; + const [first, second] = rows; + const fromRow = first.id === fromId ? first : second; + const toRow = first.id === fromId ? second : first; - if (fromRow.balance < delta) { - return; - } + if (fromRow.balance < delta) { + return; + } - await tx - .update(accounts) - .set({ balance: fromRow.balance - delta }) - .where(eq(accounts.id, fromId)); + await tx + .update(accounts) + .set({ balance: fromRow.balance - delta }) + .where(eq(accounts.id, fromId)); - await tx - .update(accounts) - .set({ balance: toRow.balance + delta }) - .where(eq(accounts.id, toId)); - }); + await tx + .update(accounts) + .set({ balance: toRow.balance + delta }) + .where(eq(accounts.id, toId)); + }), + ); } async function rpcGetAccount(args: Record) { diff --git a/templates/keynote-2/src/rpc-servers/retry.ts b/templates/keynote-2/src/rpc-servers/retry.ts new file mode 100644 index 00000000000..71fe8641efa --- /dev/null +++ b/templates/keynote-2/src/rpc-servers/retry.ts @@ -0,0 +1,61 @@ +// Retry serialization (40001) and deadlock (40P01) errors. +const RETRYABLE_SQLSTATES = new Set(['40001', '40P01']); + +export type RetryOptions = { + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; + onRetry?: (attempt: number, err: unknown) => void; +}; + +export class RetryExhaustedError extends Error { + constructor( + public readonly attempts: number, + public readonly lastError: unknown, + ) { + const cause = + (lastError as { message?: string })?.message ?? String(lastError); + super(`retry exhausted after ${attempts} attempts: ${cause}`); + this.name = 'RetryExhaustedError'; + } +} + +function getSqlstate(err: unknown): string | undefined { + let cursor: any = err; + for (let i = 0; i < 5 && cursor; i++) { + const code = cursor.code; + if (typeof code === 'string' && /^[0-9A-Z]{5}$/.test(code)) return code; + cursor = cursor.cause ?? cursor.originalError ?? cursor.innerError ?? null; + } + return undefined; +} + +function isRetryable(err: unknown): boolean { + const code = getSqlstate(err); + return code !== undefined && RETRYABLE_SQLSTATES.has(code); +} + +export async function withTxnRetry( + fn: () => Promise, + options: RetryOptions = {}, +): Promise { + const maxAttempts = options.maxAttempts ?? 10; + const baseDelayMs = options.baseDelayMs ?? 5; + const maxDelayMs = options.maxDelayMs ?? 200; + + let lastError: unknown; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err; + if (!isRetryable(err)) throw err; + if (attempt === maxAttempts) throw new RetryExhaustedError(attempt, err); + options.onRetry?.(attempt, err); + const cap = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt); + const delay = Math.floor(Math.random() * cap); + if (delay > 0) await new Promise((r) => setTimeout(r, delay)); + } + } + throw new RetryExhaustedError(maxAttempts, lastError); +} From 03bd9fb7d4569ee310024a45eaa16867f7c438f4 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Wed, 13 May 2026 20:29:27 -0400 Subject: [PATCH 37/47] no wait test --- templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts | 2 +- templates/keynote-2/src/rpc-servers/retry.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts b/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts index 1ad9b8d3285..6f2ff016a6e 100644 --- a/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts @@ -49,7 +49,7 @@ async function rpcTransfer(args: Record) { .select() .from(accounts) .where(inArray(accounts.id, [fromId, toId])) - .for('update') + .for('update', { noWait: true }) .orderBy(accounts.id); if (rows.length !== 2) { diff --git a/templates/keynote-2/src/rpc-servers/retry.ts b/templates/keynote-2/src/rpc-servers/retry.ts index 71fe8641efa..d048f8a3ca4 100644 --- a/templates/keynote-2/src/rpc-servers/retry.ts +++ b/templates/keynote-2/src/rpc-servers/retry.ts @@ -1,5 +1,5 @@ -// Retry serialization (40001) and deadlock (40P01) errors. -const RETRYABLE_SQLSTATES = new Set(['40001', '40P01']); +// Retry serialization (40001), deadlock (40P01), lock-not-available (55P03). +const RETRYABLE_SQLSTATES = new Set(['40001', '40P01', '55P03']); export type RetryOptions = { maxAttempts?: number; From 5cafc9bf4f101cc753a82e68734aece533f2500f Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Wed, 13 May 2026 20:55:36 -0400 Subject: [PATCH 38/47] retries --- .../src/connectors/rpc/cockroach_rpc.ts | 34 ----------- .../src/rpc-servers/postgres-rpc-server.ts | 55 +++++++++--------- .../src/rpc-servers/supabase-rpc-server.ts | 57 ++++++++++--------- .../src/scenario_recipes/rpc_single_call.ts | 13 +---- 4 files changed, 60 insertions(+), 99 deletions(-) diff --git a/templates/keynote-2/src/connectors/rpc/cockroach_rpc.ts b/templates/keynote-2/src/connectors/rpc/cockroach_rpc.ts index 01034dc0c89..17d46a49a5c 100644 --- a/templates/keynote-2/src/connectors/rpc/cockroach_rpc.ts +++ b/templates/keynote-2/src/connectors/rpc/cockroach_rpc.ts @@ -46,37 +46,6 @@ export default function cockroach_rpc( return json.result; } - async function callWithRetry( - name: string, - args?: Record, - maxRetries: number = 5, - ) { - let attempts = 0; - while (attempts < maxRetries) { - try { - return await httpCall(name, args); - } catch (err: unknown) { - let errMsg = 'Unknown error'; - if (err instanceof Error) { - errMsg = err.message; - } else if (typeof err === 'string') { - errMsg = err; - } - if ( - errMsg.includes('serialization') || - errMsg.includes('restart transaction') || - errMsg.includes('40001') - ) { - attempts++; - if (attempts >= maxRetries) throw err; - continue; - } - throw err; - } - } - throw new Error('Max retries exceeded'); - } - const root: RpcConnector = { name: 'cockroach_rpc', @@ -107,9 +76,6 @@ export default function cockroach_rpc( }, async call(name: string, args?: Record) { - if (name === 'transfer') { - return callWithRetry(name, args); - } return httpCall(name, args); }, diff --git a/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts b/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts index 5db3f4b76ef..1d0e119401f 100644 --- a/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts @@ -6,6 +6,7 @@ import { pgTable, integer, bigint as pgBigint } from 'drizzle-orm/pg-core'; import { eq, inArray, sql } from 'drizzle-orm'; import { RpcRequest, RpcResponse } from '../connectors/rpc/rpc_common.ts'; import { getSharedRuntimeDefaults } from '../config.ts'; +import { withTxnRetry } from './retry.ts'; const PG_URL = process.env.PG_URL; if (!PG_URL) { throw new Error('PG_URL not set'); @@ -42,36 +43,38 @@ async function rpcTransfer(args: Record) { const delta = BigInt(amount); - await db.transaction(async (tx) => { - const rows = await tx - .select() - .from(accounts) - .where(inArray(accounts.id, [fromId, toId])) - .for('update') - .orderBy(accounts.id); - - if (rows.length !== 2) { - throw new Error('account_missing'); - } + await withTxnRetry(() => + db.transaction(async (tx) => { + const rows = await tx + .select() + .from(accounts) + .where(inArray(accounts.id, [fromId, toId])) + .for('update', { noWait: true }) + .orderBy(accounts.id); + + if (rows.length !== 2) { + throw new Error('account_missing'); + } - const [first, second] = rows; - const fromRow = first.id === fromId ? first : second; - const toRow = first.id === fromId ? second : first; + const [first, second] = rows; + const fromRow = first.id === fromId ? first : second; + const toRow = first.id === fromId ? second : first; - if (fromRow.balance < delta) { - return; - } + if (fromRow.balance < delta) { + return; + } - await tx - .update(accounts) - .set({ balance: fromRow.balance - delta }) - .where(eq(accounts.id, fromId)); + await tx + .update(accounts) + .set({ balance: fromRow.balance - delta }) + .where(eq(accounts.id, fromId)); - await tx - .update(accounts) - .set({ balance: toRow.balance + delta }) - .where(eq(accounts.id, toId)); - }); + await tx + .update(accounts) + .set({ balance: toRow.balance + delta }) + .where(eq(accounts.id, toId)); + }), + ); } async function rpcGetAccount(args: Record) { diff --git a/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts b/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts index 4fb766d122a..93644383f34 100644 --- a/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts @@ -6,6 +6,7 @@ import { pgTable, integer, bigint as pgBigint } from 'drizzle-orm/pg-core'; import { eq, inArray, sql } from 'drizzle-orm'; import type { RpcRequest, RpcResponse } from '../connectors/rpc/rpc_common.ts'; import { getSharedRuntimeDefaults } from '../config.ts'; +import { withTxnRetry } from './retry.ts'; const DB_URL = process.env.SUPABASE_DB_URL ?? process.env.PG_URL; if (!DB_URL) { throw new Error('SUPABASE_DB_URL / PG_URL not set'); @@ -42,37 +43,39 @@ async function rpcTransfer(args: Record) { const delta = BigInt(amount); - await db.transaction(async (tx) => { - const rows = await tx - .select() - .from(accounts) - .where(inArray(accounts.id, [fromId, toId])) - .for('update') - .orderBy(accounts.id); - - if (rows.length !== 2) { - throw new Error('account_missing'); - } + await withTxnRetry(() => + db.transaction(async (tx) => { + const rows = await tx + .select() + .from(accounts) + .where(inArray(accounts.id, [fromId, toId])) + .for('update', { noWait: true }) + .orderBy(accounts.id); + + if (rows.length !== 2) { + throw new Error('account_missing'); + } - const [first, second] = rows; - const fromRow = first.id === fromId ? first : second; - const toRow = first.id === fromId ? second : first; + const [first, second] = rows; + const fromRow = first.id === fromId ? first : second; + const toRow = first.id === fromId ? second : first; - if (fromRow.balance < delta) { - // Not enough balance; just skip - return; - } + if (fromRow.balance < delta) { + // Not enough balance; just skip + return; + } - await tx - .update(accounts) - .set({ balance: fromRow.balance - delta }) - .where(eq(accounts.id, fromId)); + await tx + .update(accounts) + .set({ balance: fromRow.balance - delta }) + .where(eq(accounts.id, fromId)); - await tx - .update(accounts) - .set({ balance: toRow.balance + delta }) - .where(eq(accounts.id, toId)); - }); + await tx + .update(accounts) + .set({ balance: toRow.balance + delta }) + .where(eq(accounts.id, toId)); + }), + ); } async function rpcGetAccount(args: Record) { diff --git a/templates/keynote-2/src/scenario_recipes/rpc_single_call.ts b/templates/keynote-2/src/scenario_recipes/rpc_single_call.ts index 796b633bd2c..c7cf6738545 100644 --- a/templates/keynote-2/src/scenario_recipes/rpc_single_call.ts +++ b/templates/keynote-2/src/scenario_recipes/rpc_single_call.ts @@ -20,16 +20,5 @@ export async function rpc_single_call( : 'transfer:transfer' : 'transfer'; - for (let attempts = 0; attempts < 3; attempts++) { - try { - await api.call(fn, { amount, from_id: from, to_id: to }); - return; - } catch (e: any) { - const msg = String(e?.message ?? ''); - const retriable = /429|502|503|504/.test(msg); - if (!retriable || attempts === 2) throw e; - - await new Promise((r) => setTimeout(r, 50 * (attempts + 1))); - } - } + await api.call(fn, { amount, from_id: from, to_id: to }); } From bd432d974ca5ab2f4d63e4bc495b1bb43dbd28f5 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Wed, 13 May 2026 20:56:41 -0400 Subject: [PATCH 39/47] Update cockroach-rpc-server.ts --- templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts b/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts index 6f2ff016a6e..1ad9b8d3285 100644 --- a/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/cockroach-rpc-server.ts @@ -49,7 +49,7 @@ async function rpcTransfer(args: Record) { .select() .from(accounts) .where(inArray(accounts.id, [fromId, toId])) - .for('update', { noWait: true }) + .for('update') .orderBy(accounts.id); if (rows.length !== 2) { From 5f9d45908186c8b09b6614bcb0965bfb52fa932c Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Wed, 13 May 2026 20:56:46 -0400 Subject: [PATCH 40/47] Update bun-server.ts --- templates/keynote-2/bun/bun-server.ts | 57 ++++++++++++++------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/templates/keynote-2/bun/bun-server.ts b/templates/keynote-2/bun/bun-server.ts index 58eb5f595b5..07f21ea9612 100644 --- a/templates/keynote-2/bun/bun-server.ts +++ b/templates/keynote-2/bun/bun-server.ts @@ -4,6 +4,7 @@ import { pgTable, integer, bigint as pgBigint } from 'drizzle-orm/pg-core'; import { eq, inArray, sql } from 'drizzle-orm'; import { RpcRequest, RpcResponse } from '../src/connectors/rpc/rpc_common'; import { getSharedRuntimeDefaults } from '../src/config.ts'; +import { withTxnRetry } from '../src/rpc-servers/retry.ts'; const DB_URL = process.env.BUN_PG_URL ?? process.env.PG_URL; if (!DB_URL) throw new Error('BUN_PG_URL or PG_URL not set'); @@ -123,37 +124,39 @@ async function rpcTransfer(args: Record) { const delta = BigInt(amount); - await db.transaction(async (tx) => { - // Lock both rows in a deterministic order to avoid deadlocks - const rows = await tx - .select() - .from(accounts) - .where(inArray(accounts.id, [fromId, toId])) - .for('update') - .orderBy(accounts.id); - - if (rows.length !== 2) { - throw new Error('account_missing'); - } + await withTxnRetry(() => + db.transaction(async (tx) => { + // Lock both rows in a deterministic order to avoid deadlocks + const rows = await tx + .select() + .from(accounts) + .where(inArray(accounts.id, [fromId, toId])) + .for('update', { noWait: true }) + .orderBy(accounts.id); + + if (rows.length !== 2) { + throw new Error('account_missing'); + } - const [first, second] = rows; - const fromRow = first.id === fromId ? first : second; - const toRow = first.id === fromId ? second : first; + const [first, second] = rows; + const fromRow = first.id === fromId ? first : second; + const toRow = first.id === fromId ? second : first; - if (fromRow.balance < delta) { - return; // not enough funds, do nothing (same as other backends) - } + if (fromRow.balance < delta) { + return; // not enough funds, do nothing (same as other backends) + } - await tx - .update(accounts) - .set({ balance: fromRow.balance - delta }) - .where(eq(accounts.id, fromId)); + await tx + .update(accounts) + .set({ balance: fromRow.balance - delta }) + .where(eq(accounts.id, fromId)); - await tx - .update(accounts) - .set({ balance: toRow.balance + delta }) - .where(eq(accounts.id, toId)); - }); + await tx + .update(accounts) + .set({ balance: toRow.balance + delta }) + .where(eq(accounts.id, toId)); + }), + ); } async function rpcGetAccount(args: Record) { From fe313eeae2b2f601161bc3ff66bf1596fddfd5f1 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Wed, 13 May 2026 22:34:32 -0400 Subject: [PATCH 41/47] remove nowait --- templates/keynote-2/bun/bun-server.ts | 2 +- templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/keynote-2/bun/bun-server.ts b/templates/keynote-2/bun/bun-server.ts index 07f21ea9612..be518b625f3 100644 --- a/templates/keynote-2/bun/bun-server.ts +++ b/templates/keynote-2/bun/bun-server.ts @@ -131,7 +131,7 @@ async function rpcTransfer(args: Record) { .select() .from(accounts) .where(inArray(accounts.id, [fromId, toId])) - .for('update', { noWait: true }) + .for('update') .orderBy(accounts.id); if (rows.length !== 2) { diff --git a/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts b/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts index 93644383f34..d90d27139ad 100644 --- a/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/supabase-rpc-server.ts @@ -49,7 +49,7 @@ async function rpcTransfer(args: Record) { .select() .from(accounts) .where(inArray(accounts.id, [fromId, toId])) - .for('update', { noWait: true }) + .for('update') .orderBy(accounts.id); if (rows.length !== 2) { From ae312eda1031afb2a63f65001205f8d7808c4532 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Wed, 13 May 2026 22:35:47 -0400 Subject: [PATCH 42/47] Update postgres-rpc-server.ts --- templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts b/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts index 1d0e119401f..1b52025b599 100644 --- a/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts +++ b/templates/keynote-2/src/rpc-servers/postgres-rpc-server.ts @@ -49,7 +49,7 @@ async function rpcTransfer(args: Record) { .select() .from(accounts) .where(inArray(accounts.id, [fromId, toId])) - .for('update', { noWait: true }) + .for('update') .orderBy(accounts.id); if (rows.length !== 2) { From 93367aec54a1680e7fcd8401fc330dcbb8188894 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Thu, 14 May 2026 09:35:41 -0400 Subject: [PATCH 43/47] Remove connector inflights for simplicity + README updates (#'s, default vals, methodology) --- templates/keynote-2/.env.example | 5 +- templates/keynote-2/DEVELOP.md | 59 +++++++++---- templates/keynote-2/README.md | 84 +++++++++++++++---- templates/keynote-2/src/connectors/convex.ts | 1 - .../keynote-2/src/connectors/spacetimedb.ts | 1 - templates/keynote-2/src/core/connectors.ts | 2 - templates/keynote-2/src/core/runner.ts | 20 +++-- templates/keynote-2/src/opts.ts | 4 +- 8 files changed, 127 insertions(+), 49 deletions(-) diff --git a/templates/keynote-2/.env.example b/templates/keynote-2/.env.example index 1aeffcc2c85..4ebd8077e27 100644 --- a/templates/keynote-2/.env.example +++ b/templates/keynote-2/.env.example @@ -60,15 +60,16 @@ SEED_ACCOUNTS=100000 SEED_INITIAL_BALANCE=10000000 # ===== Bench knobs ===== -# Pool size for pg-based RPC servers (postgres, cockroach, supabase, planetscale). +# Pool size for pg-based RPC servers (postgres, cockroach, supabase, planetscale). Default: 64. # Read at RPC-server startup — restart the RPC if you change this. MAX_POOL=64 # Pipelining for the bench client. Required to enable pipelining for the # RPC connectors (postgres_rpc, cockroach_rpc, supabase_rpc, planetscale_pg_rpc). # Setting MAX_INFLIGHT_PER_WORKER alone does NOT enable pipelining for them. +# If BENCH_PIPELINED=1, you must set MAX_INFLIGHT_PER_WORKER explicitly. #BENCH_PIPELINED=1 -#MAX_INFLIGHT_PER_WORKER=128 +#MAX_INFLIGHT_PER_WORKER=40 VERIFY=0 ENABLE_RPC_SERVERS=0 diff --git a/templates/keynote-2/DEVELOP.md b/templates/keynote-2/DEVELOP.md index f3211094255..98b18560968 100644 --- a/templates/keynote-2/DEVELOP.md +++ b/templates/keynote-2/DEVELOP.md @@ -28,7 +28,7 @@ The script will: **Options:** - `--seconds N` - Benchmark duration (default: 60) -- `--concurrency N` - Concurrent connections (default: 50) +- `--concurrency N` - Concurrent connections (default: 64) - `--alpha N` - Contention level (default: 1.5) - `--systems a,b,c` - Systems to compare (default: convex,spacetimedb) - `--stdb-compression none|gzip` - SpacetimeDB client compression mode (default: none) @@ -193,12 +193,19 @@ pnpm run bench test-1 --connectors spacetimedb --stdb-compression gzip # Only run selected connectors pnpm run bench test-1 --connectors spacetimedb,sqlite_rpc + +# Sweep alpha values for a connector set +pnpm run bench test-1 --alpha 0,1.5 --connectors postgres_rpc,bun --seconds 300 + +# Sweep contention (alpha) for a single connector: start,end,step,concurrency +pnpm run bench test-1 --connectors cockroach_rpc --contention-tests 0,1.5,0.5,64 + +# Sweep concurrency for a single connector: start,end,factor,alpha +pnpm run bench test-1 --connectors cockroach_rpc --concurrency-tests 16,512,2,1.5 ``` ## CLI Arguments -From `src/cli.ts`: - - **`test-name`** (positional) - Name of the test folder under `src/tests/` - Default: `test-1` @@ -209,10 +216,10 @@ From `src/cli.ts`: - **`--concurrency N`** - Number of workers / in-flight operations - - Default: `50` + - Default: `64` - **`--alpha A`** - - Zipf α parameter for account selection (hot vs cold distribution) + - Zipf alpha parameter for account selection (hot vs cold distribution) - Default: `1.5` - **`--connectors list`** @@ -227,15 +234,34 @@ From `src/cli.ts`: - The valid names come from `tc.system` in the test modules and the keys in `CONNECTORS` - Valid names: `convex`, `spacetimedb`, `bun`, `postgres_rpc`, `cockroach_rpc`, `sqlite_rpc`, `supabase_rpc`, `planetscale_pg_rpc` -- **`--contention-tests startAlpha endAlpha step concurrency`** - - Runs a sweep over Zipf α values for a single connector - - Uses `startAlpha`, `endAlpha`, and `step` to choose the α values - - Uses the provided `concurrency` for all runs +- **`--systems list`** + - Alias for `--connectors` in bench mode + +- **`--runs N`** + - Repeat each `(connector, alpha)` combination `N` times + - Default: `1` + +- **`--prep-between-alphas`** + - Run `pnpm run prep` before each `(connector, alpha)` combination + +- **`--contention-tests start,end,step,concurrency`** + - Sweep Zipf alpha values for one connector -- **`--concurrency-tests startConc endConc step alpha`** - - Runs a sweep over concurrency levels for a single connector - - Uses `startConc`, `endConc`, and `step` to choose the concurrency values - - Uses the provided `alpha` for all runs +- **`--concurrency-tests start,end,factor,alpha`** + - Sweep concurrency values for one connector + +- **`--bench-pipelined` / `--no-bench-pipelined`** + - Force pipelining on or off across connectors + +- **`--max-inflight-per-worker N`** + - Max in-flight requests per worker when pipelining is enabled + - Required when `--bench-pipelined` is enabled + +- **`--log-errors`** + - Log per-operation errors during runs + +- **`--verify-transactions`** + - Run connector verification at end of run --- @@ -244,7 +270,7 @@ From `src/cli.ts`: You can also run the benchmark via Docker instead of Node directly: ```bash -docker compose run --rm bench -- --seconds 5 --concurrency 50 --alpha 1 --connectors convex +docker compose run --rm bench -- --seconds 5 --concurrency 64 --alpha 1 --connectors convex ``` If using Docker, make sure to set `USE_DOCKER=1` in `.env`, verify docker-compose env variables, verify you've run supabase init, and run `pnpm run prep` before running bench. @@ -257,4 +283,7 @@ Every run writes a JSON file into `./runs/`: - Filename: `-.json` - Example: `test-1-2025-11-17T16-45-12-345Z.json` -Point your visualizations / CSV exports at `./runs/` and you’re good. +For rollup tables, compute steady-state stats after a 30-second warmup window (`tSec >= 30`). The `scripts/bench-stats.py` default matches this (`--warmup-sec 30`). + +Point your visualizations / CSV exports at `./runs/` and you're good. + diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index 982d8fe12f5..ab91363946d 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -20,26 +20,59 @@ The demo compares SpacetimeDB and Convex by default, since both are easy for any ## Results Summary -All tests run for 300 seconds with 50 concurrent connections, with a transfer workload (read-modify-write transaction between two accounts). +All tests run for 300 seconds with 64 concurrent connections, with a transfer workload (read-modify-write transaction between two accounts). The SpacetimeDB rows were obtained using a single-node SpacetimeDB Standalone instance, so the published numbers are reproducible with the public, downloadable server. -Each cell shows **mean TPS ± sample standard deviation** of the per-second throughput within a single 300-second run, with the sample variance in parentheses. Cells where the standard deviation approaches or exceeds the mean (e.g. CockroachDB and Convex at ~80% contention) indicate that the system's throughput is unstable across the run. - -| System | Mean TPS ± σ (Var) (~0% Contention) | Mean TPS ± σ (Var) (~80% Contention) | -| --------------------------------------- | ----------------------------------- | ------------------------------------ | -| SpacetimeDB (TypeScript Module) | 294,827 ± 5,266 (27,728,435) | 304,865 ± 4,751 (22,569,090) | -| SpacetimeDB (Rust Module) | 266,139 ± 4,662 (21,730,912) | 278,070 ± 4,279 (18,312,134) | -| SQLite + Node HTTP + Drizzle | 3,109 ± 86 (7,326) | 3,228 ± 80 (6,396) | -| Bun + Drizzle + Postgres | 10,662 ± 215 (46,418) | 2,773 ± 83 (6,930) | -| Supabase + Node HTTP + Drizzle | 6,853 ± 1,017 (1,034,915) | 2,896 ± 111 (12,414) | -| Postgres + Node HTTP + Drizzle | 9,933 ± 184 (33,704) | 2,169 ± 56 (3,161) | -| CockroachDB + Node HTTP + Drizzle | 3,353 ± 25 (630) | 79 ± 127 (16,059) | -| Convex (self-hosted local) | 1,120 ± 161 (25,856) | 118 ± 97 (9,335) | -| PlanetScale PS-2560 (single-node, EBS) | 1,513 ± 26 (678) | 289 ± 15 (238) | -| PlanetScale M-15360 (Metal NVMe, HA) | 1,351 ± 25 (637) | 279 ± 16 (257) | - -**Key Finding:** SpacetimeDB reaches hundreds of thousands of TPS for the transfer workload, while the best non-SpacetimeDB result shown here is SQLite at 3,228 TPS. Traditional databases also suffer significant degradation under high contention (CockroachDB drops 98%). +Each row reports mean TPS and sample standard deviation of per-second throughput within a single 300-second run. `alpha=1.5` corresponds to ~80% contention. When standard deviation approaches or exceeds mean TPS, throughput is unstable across the run. + +Data description: reported summary metrics are computed from steady-state windows after a 30-second warmup (`tSec >= 30`), using the recorded per-second `timeSeries` data. + +### Alpha = 0 + +| System | clients | pipelining | max_pool | TPS | TPS Stddev | p50 lat ms | p99 lat ms | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| SpacetimeDB | 64 | 40 | N/A | 279,024 | 4,763 | 8 | 12 | +| Node.js + SQLite | 64 | off | N/A | 3,121 | 80 | 19 | 40 | +| Node.js + Supabase | 64 | off | 64 | 7,362 | 1,179 | 6 | 18 | +| Bun + Postgres | 64 | off | 64 | 10,729 | 146 | 5 | 11 | +| Node.js + Postgres | 64 | off | 64 | 9,904 | 223 | 6 | 11 | +| Node.js + PlanetScale (SN) | 64 | off | 64 | 4,535 | 117 | 14 | 20 | +| Node.js + PlanetScale (HA) | 384 | off | 384 | 4,275 | 135 | 89 | 110 | +| Convex | 64 | off | N/A | 1,140 | 118 | 53 | 62 | +| Node.js + CockroachDB (5 node) | 320 | off | 320 | 4,253 | 561 | 71 | 120 | +| HAProxy - Node.js + CockroachDB (5 node) | 320 | off | 320 | 5,481 | 566 | 57 | 95 | + +### Alpha = 1.5 + +| System | clients | pipelining | max_pool | TPS | TPS Stddev | p50 lat ms | p99 lat ms | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| SpacetimeDB | 64 | 40 | N/A | 303,919 | 4,712 | 7 | 11 | +| Node.js + SQLite | 64 | off | N/A | 3,188 | 73 | 18 | 39 | +| Node.js + Supabase | 64 | off | 64 | 2,534 | 57 | 2 | 197 | +| Bun + Postgres | 64 | off | 64 | 2,772 | 61 | 7 | 13 | +| Node.js + Postgres | 64 | off | 64 | 961 | 25 | 10 | 16 | +| Node.js + PlanetScale (SN) | 64 | off | 64 | 235 | 12 | 20 | 2,504 | +| Node.js + PlanetScale (HA) | 384 | off | 384 | 248 | 13 | 416 | 10,121 | +| Convex | 64 | off | N/A | 126 | 52 | 20 | 1,081 | +| Node.js + CockroachDB (5 node) | 320 | off | 320 | 0.03 | 0.18 | 698 | 9,695 | +| HAProxy - Node.js + CockroachDB (5 node) | 64 | off | 64 | 6.87 | 9.12 | 5,943 | 9,880 | + +### Alpha = 0 (Pipelined) + +| System | clients | pipelining | max_pool | TPS | TPS Stddev | p50 lat ms | p99 lat ms | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Node.js + SQLite | 64 | 40 | N/A | 2,977 | 84 | 722 | 747 | +| Node.js + Supabase | 64 | 40 | 64 | 8,874 | 308 | 284 | 303 | +| Bun + Postgres | 64 | 40 | 64 | 10,184 | 120 | 250.1 | 260.5 | +| Node.js + Postgres | 64 | 40 | 64 | 9,165 | 145 | 276 | 290 | +| Node.js + PlanetScale (SN) | 64 | 40 | 64 | 4,325 | 85 | 590 | 604 | +| Node.js + PlanetScale (HA) | 384 | 40 | 384 | 3,355 | 327 | 4,354 | 4,438 | +| Convex | 64 | 40 | N/A | 1,154 | 134 | 2,119 | 2,150 | +| Node.js + CockroachDB (5 node) | 320 | 40 | 320 | 4,250 | 766 | 3,030 | 3,161 | +| HAProxy - Node.js + CockroachDB (5 node) | 320 | 40 | 320 | 5,992 | 1,765 | 2,431 | 2,562 | + +**Key Finding:** In these runs, SpacetimeDB is the only system sustaining hundreds of thousands of TPS in both alpha profiles. Non-SpacetimeDB systems remain in the low-thousands TPS range at best, and several show severe contention sensitivity at `alpha=1.5` with large tail-latency growth. ## Methodology @@ -49,6 +82,18 @@ The managed Postgres services (Supabase, PlanetScale) run at their default isola Throughput is counted from successful operations that the benchmark client observes completing inside the configured test window for every system. +### Published Benchmark Defaults + +The reported tables in this README use the following defaults unless a row explicitly shows a different value: + +- `clients`: `64` +- `pipelining`: `off` for non-pipelined tables +- `MAX_POOL`: `64` for pg-based RPC servers (`postgres_rpc`, `cockroach_rpc`, `supabase_rpc`, `planetscale_pg_rpc`) +- Pipelined table runs use `BENCH_PIPELINED=1` and `MAX_INFLIGHT_PER_WORKER=40` +- `MAX_INFLIGHT_PER_WORKER` is required whenever `BENCH_PIPELINED=1` + +For rows that scale client count above 64 (for example, some HA topologies), `max_pool` is scaled to match the row values shown in the table. + ### Test Architecture All benchmarks follow an **apples-to-apples** comparison using the same architecture pattern: @@ -99,12 +144,14 @@ pnpm run bench --alpha 0,1.5 --connectors --seconds 300 # one `--alpha` and `--connectors` both accept comma-separated values. The bench writes one JSON per (connector, alpha, run) tuple into `runs/`. +When aggregating these JSONs into summary tables, use a 30-second warmup cutoff (`--warmup-sec 30`) to match the published numbers. + Useful flags: - `--alpha `: Zipf alpha. This benchmark reports `0` (uniform / ~0% contention) and `1.5` (Zipf / ~80% contention). - `--connectors `: which connectors to run. Defaults to every test in `src/tests/test-1/`. - `--seconds `: duration of each run. -- `--concurrency `: number of concurrent clients (default: `50`). +- `--concurrency `: number of concurrent clients (default: `64`). - `--runs `: repeat each (connector, alpha) combination this many times (default: `1`). Each repeat writes its own JSON. - `--prep-between-alphas`: run `pnpm run prep` before each (connector, alpha) combination to reset DB state. - `--stdb-compression `: SpacetimeDB client compression mode (default: `none`). @@ -181,3 +228,4 @@ Benchmark results are written to `./runs/` as JSON files with TPS and latency st ## License See repository root for license information. + diff --git a/templates/keynote-2/src/connectors/convex.ts b/templates/keynote-2/src/connectors/convex.ts index eba0912162d..349f02216ea 100644 --- a/templates/keynote-2/src/connectors/convex.ts +++ b/templates/keynote-2/src/connectors/convex.ts @@ -53,7 +53,6 @@ export default function convex(url: string): RpcConnector { const root: RpcConnector = { name: 'convex', - maxInflightPerWorker: 16, async open() {}, async close() {}, diff --git a/templates/keynote-2/src/connectors/spacetimedb.ts b/templates/keynote-2/src/connectors/spacetimedb.ts index 57f25a9d000..5a6ae50e164 100644 --- a/templates/keynote-2/src/connectors/spacetimedb.ts +++ b/templates/keynote-2/src/connectors/spacetimedb.ts @@ -78,7 +78,6 @@ export function spacetimedb(config: SpacetimeConnectorConfig): ReducerConnector return { name: 'spacetimedb', - maxInflightPerWorker: 128, async open() { try { diff --git a/templates/keynote-2/src/core/connectors.ts b/templates/keynote-2/src/core/connectors.ts index e5be3ef5a80..b259b2ad35c 100644 --- a/templates/keynote-2/src/core/connectors.ts +++ b/templates/keynote-2/src/core/connectors.ts @@ -8,8 +8,6 @@ export interface BaseConnector { } | null>; verify(): Promise; - maxInflightPerWorker?: number; - createWorker?(opts: { index: number; total: number }): Promise; } diff --git a/templates/keynote-2/src/core/runner.ts b/templates/keynote-2/src/core/runner.ts index 7c8797de350..c38aab33ba0 100644 --- a/templates/keynote-2/src/core/runner.ts +++ b/templates/keynote-2/src/core/runner.ts @@ -125,16 +125,20 @@ export async function runOne({ `[${connector.name}] precomputed ${transferPairs.count} pairs in ${(precomputeElapsedMs / 1000).toFixed(2)}s`, ); - const PIPELINED = benchPipelined ?? !!connector.maxInflightPerWorker; - const MAX_INFLIGHT_PER_WORKER = - maxInflightPerWorker === undefined - ? (connector.maxInflightPerWorker ?? 8) - : maxInflightPerWorker == 0 - ? Infinity - : maxInflightPerWorker; + const PIPELINED = benchPipelined ?? false; + let MAX_INFLIGHT_PER_WORKER = 1; + if (PIPELINED && maxInflightPerWorker === undefined) { + throw new Error( + `[${connector.name}] pipelining is enabled, but max inflight per worker is not set. Set MAX_INFLIGHT_PER_WORKER or pass --max-inflight-per-worker.`, + ); + } + if (PIPELINED) { + MAX_INFLIGHT_PER_WORKER = + maxInflightPerWorker == 0 ? Infinity : maxInflightPerWorker!; + } console.log( - `[${connector.name}] pipelined=${PIPELINED} max-inflight-per-worker=${MAX_INFLIGHT_PER_WORKER} pool-max=${runtimeConfig.poolMax}`, + `[${connector.name}] pipelined=${PIPELINED} max-inflight-per-worker=${PIPELINED ? MAX_INFLIGHT_PER_WORKER : 'n/a'} pool-max=${runtimeConfig.poolMax}`, ); const run = async (seconds: number) => { const start = performance.now(); diff --git a/templates/keynote-2/src/opts.ts b/templates/keynote-2/src/opts.ts index d44ddf5ba72..74898f2d849 100644 --- a/templates/keynote-2/src/opts.ts +++ b/templates/keynote-2/src/opts.ts @@ -312,7 +312,7 @@ export function parseDemoOptions(argv: string[] = process.argv): DemoOptions { return { ...runtimeOptions, seconds: options.seconds ?? 60, - concurrency: options.concurrency ?? 50, + concurrency: options.concurrency ?? 64, alpha: demoAlphas?.[0] ?? 1.5, systems: options.systems ?? options.connectors ?? [...defaultDemoSystems], @@ -400,7 +400,7 @@ export function parseBenchOptions(argv: string[] = process.argv): BenchOptions { testName: args[0] ?? defaultBenchTestName, seconds: options.seconds ?? 60, concurrency: - contentionTests?.concurrency ?? options.concurrency ?? 50, + contentionTests?.concurrency ?? options.concurrency ?? 64, alphas: parsedAlphas ?? [concurrencyTests?.alpha ?? 1.5], runs: options.runs ?? 1, prepBetweenAlphas: options.prepBetweenAlphas ?? false, From a00d1a86c0fb70c20feab288f0c60f93a1b701d4 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Thu, 14 May 2026 09:44:39 -0400 Subject: [PATCH 44/47] 2x vCPUs instead of 64 --- templates/keynote-2/.env.example | 4 ++-- templates/keynote-2/DEVELOP.md | 4 ++-- templates/keynote-2/README.md | 12 +++++++----- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/templates/keynote-2/.env.example b/templates/keynote-2/.env.example index 4ebd8077e27..f8c57183f01 100644 --- a/templates/keynote-2/.env.example +++ b/templates/keynote-2/.env.example @@ -64,8 +64,8 @@ SEED_INITIAL_BALANCE=10000000 # Read at RPC-server startup — restart the RPC if you change this. MAX_POOL=64 -# Pipelining for the bench client. Required to enable pipelining for the -# RPC connectors (postgres_rpc, cockroach_rpc, supabase_rpc, planetscale_pg_rpc). +# Pipelining for the bench client. Bench pipelining is global across connectors. +# Some connectors may still have their own internal transport details. # Setting MAX_INFLIGHT_PER_WORKER alone does NOT enable pipelining for them. # If BENCH_PIPELINED=1, you must set MAX_INFLIGHT_PER_WORKER explicitly. #BENCH_PIPELINED=1 diff --git a/templates/keynote-2/DEVELOP.md b/templates/keynote-2/DEVELOP.md index 98b18560968..897175ea12d 100644 --- a/templates/keynote-2/DEVELOP.md +++ b/templates/keynote-2/DEVELOP.md @@ -280,8 +280,8 @@ If using Docker, make sure to set `USE_DOCKER=1` in `.env`, verify docker-compos Every run writes a JSON file into `./runs/`: - Directory: `./runs/` -- Filename: `-.json` - - Example: `test-1-2025-11-17T16-45-12-345Z.json` +- Filename: `--a-.json` + - Example: `test-1-postgres_rpc-a1.5-2025-11-17T16-45-12-345Z.json` For rollup tables, compute steady-state stats after a 30-second warmup window (`tSec >= 30`). The `scripts/bench-stats.py` default matches this (`--warmup-sec 30`). diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index ab91363946d..083fe37b5f1 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -20,7 +20,7 @@ The demo compares SpacetimeDB and Convex by default, since both are easy for any ## Results Summary -All tests run for 300 seconds with 64 concurrent connections, with a transfer workload (read-modify-write transaction between two accounts). +All tests run for 300 seconds with client concurrency set to 2x host vCPUs, with a transfer workload (read-modify-write transaction between two accounts). The SpacetimeDB rows were obtained using a single-node SpacetimeDB Standalone instance, so the published numbers are reproducible with the public, downloadable server. @@ -58,6 +58,8 @@ Data description: reported summary metrics are computed from steady-state window | Node.js + CockroachDB (5 node) | 320 | off | 320 | 0.03 | 0.18 | 698 | 9,695 | | HAProxy - Node.js + CockroachDB (5 node) | 64 | off | 64 | 6.87 | 9.12 | 5,943 | 9,880 | +Note: the HAProxy + CockroachDB `alpha=1.5` row uses 64 clients (instead of 320) because 320-way concurrency overwhelmed CRDB and did not produce stable sample data for this profile. + ### Alpha = 0 (Pipelined) | System | clients | pipelining | max_pool | TPS | TPS Stddev | p50 lat ms | p99 lat ms | @@ -72,7 +74,7 @@ Data description: reported summary metrics are computed from steady-state window | Node.js + CockroachDB (5 node) | 320 | 40 | 320 | 4,250 | 766 | 3,030 | 3,161 | | HAProxy - Node.js + CockroachDB (5 node) | 320 | 40 | 320 | 5,992 | 1,765 | 2,431 | 2,562 | -**Key Finding:** In these runs, SpacetimeDB is the only system sustaining hundreds of thousands of TPS in both alpha profiles. Non-SpacetimeDB systems remain in the low-thousands TPS range at best, and several show severe contention sensitivity at `alpha=1.5` with large tail-latency growth. +**Key Finding:** In these runs, SpacetimeDB is the only system sustaining hundreds of thousands of TPS in both alpha profiles. At `alpha=0`, the strongest non-SpacetimeDB results are in the ~10k TPS range, while at `alpha=1.5` several systems show severe contention sensitivity with large tail-latency growth and throughput collapse. ## Methodology @@ -84,13 +86,13 @@ Throughput is counted from successful operations that the benchmark client obser ### Published Benchmark Defaults -The reported tables in this README use the following defaults unless a row explicitly shows a different value: +The reported tables in this README use the following profile defaults unless a row explicitly shows a different value: -- `clients`: `64` +- `clients`: `2x` host vCPUs - `pipelining`: `off` for non-pipelined tables - `MAX_POOL`: `64` for pg-based RPC servers (`postgres_rpc`, `cockroach_rpc`, `supabase_rpc`, `planetscale_pg_rpc`) - Pipelined table runs use `BENCH_PIPELINED=1` and `MAX_INFLIGHT_PER_WORKER=40` -- `MAX_INFLIGHT_PER_WORKER` is required whenever `BENCH_PIPELINED=1` +- When `BENCH_PIPELINED=1`, set `MAX_INFLIGHT_PER_WORKER` explicitly in the environment For rows that scale client count above 64 (for example, some HA topologies), `max_pool` is scaled to match the row values shown in the table. From fa3c51c6f89704cc211bd0d2b2ebcd61c3eca47b Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Thu, 14 May 2026 09:53:32 -0400 Subject: [PATCH 45/47] 60s -> 300 by default --- templates/keynote-2/DEVELOP.md | 2 +- templates/keynote-2/src/opts.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/keynote-2/DEVELOP.md b/templates/keynote-2/DEVELOP.md index 897175ea12d..052ca3547e3 100644 --- a/templates/keynote-2/DEVELOP.md +++ b/templates/keynote-2/DEVELOP.md @@ -27,7 +27,7 @@ The script will: **Options:** -- `--seconds N` - Benchmark duration (default: 60) +- `--seconds N` - Benchmark duration (default: 300) - `--concurrency N` - Concurrent connections (default: 64) - `--alpha N` - Contention level (default: 1.5) - `--systems a,b,c` - Systems to compare (default: convex,spacetimedb) diff --git a/templates/keynote-2/src/opts.ts b/templates/keynote-2/src/opts.ts index 74898f2d849..cbac40e7d52 100644 --- a/templates/keynote-2/src/opts.ts +++ b/templates/keynote-2/src/opts.ts @@ -311,7 +311,7 @@ export function parseDemoOptions(argv: string[] = process.argv): DemoOptions { return { ...runtimeOptions, - seconds: options.seconds ?? 60, + seconds: options.seconds ?? 300, concurrency: options.concurrency ?? 64, alpha: demoAlphas?.[0] ?? 1.5, systems: @@ -398,7 +398,7 @@ export function parseBenchOptions(argv: string[] = process.argv): BenchOptions { return { ...runtimeOptions, testName: args[0] ?? defaultBenchTestName, - seconds: options.seconds ?? 60, + seconds: options.seconds ?? 300, concurrency: contentionTests?.concurrency ?? options.concurrency ?? 64, alphas: parsedAlphas ?? [concurrencyTests?.alpha ?? 1.5], From b4eb382c6af1010b8e419ab3cc0109fd8ce483ff Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Thu, 14 May 2026 09:53:50 -0400 Subject: [PATCH 46/47] Remove confusing table wording --- templates/keynote-2/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index 083fe37b5f1..60ae7905445 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -89,9 +89,9 @@ Throughput is counted from successful operations that the benchmark client obser The reported tables in this README use the following profile defaults unless a row explicitly shows a different value: - `clients`: `2x` host vCPUs -- `pipelining`: `off` for non-pipelined tables +- `pipelining`: `off` for non-pipelined runs - `MAX_POOL`: `64` for pg-based RPC servers (`postgres_rpc`, `cockroach_rpc`, `supabase_rpc`, `planetscale_pg_rpc`) -- Pipelined table runs use `BENCH_PIPELINED=1` and `MAX_INFLIGHT_PER_WORKER=40` +- Pipelined runs use `BENCH_PIPELINED=1` and `MAX_INFLIGHT_PER_WORKER=40` - When `BENCH_PIPELINED=1`, set `MAX_INFLIGHT_PER_WORKER` explicitly in the environment For rows that scale client count above 64 (for example, some HA topologies), `max_pool` is scaled to match the row values shown in the table. From c011dd3f5738e6f64ee7330d19835c78a70b5956 Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Thu, 14 May 2026 10:12:42 -0400 Subject: [PATCH 47/47] Polish from blog --- templates/keynote-2/.env.example | 2 +- templates/keynote-2/DEVELOP.md | 2 +- templates/keynote-2/README.md | 15 +++++++++------ templates/keynote-2/src/config.ts | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/templates/keynote-2/.env.example b/templates/keynote-2/.env.example index f8c57183f01..6515bc0bcb4 100644 --- a/templates/keynote-2/.env.example +++ b/templates/keynote-2/.env.example @@ -57,7 +57,7 @@ SUPABASE_RPC_URL=http://127.0.0.1:4106 # ===== Seeding knobs ===== SEED_ACCOUNTS=100000 -SEED_INITIAL_BALANCE=10000000 +SEED_INITIAL_BALANCE=1000000000 # ===== Bench knobs ===== # Pool size for pg-based RPC servers (postgres, cockroach, supabase, planetscale). Default: 64. diff --git a/templates/keynote-2/DEVELOP.md b/templates/keynote-2/DEVELOP.md index 052ca3547e3..11488a55bb8 100644 --- a/templates/keynote-2/DEVELOP.md +++ b/templates/keynote-2/DEVELOP.md @@ -212,7 +212,7 @@ pnpm run bench test-1 --connectors cockroach_rpc --concurrency-tests 16,512,2,1. - **`--seconds N`** - Duration of the benchmark in seconds - - Default: `60` + - Default: `300` - **`--concurrency N`** - Number of workers / in-flight operations diff --git a/templates/keynote-2/README.md b/templates/keynote-2/README.md index 60ae7905445..05313556944 100644 --- a/templates/keynote-2/README.md +++ b/templates/keynote-2/README.md @@ -20,7 +20,7 @@ The demo compares SpacetimeDB and Convex by default, since both are easy for any ## Results Summary -All tests run for 300 seconds with client concurrency set to 2x host vCPUs, with a transfer workload (read-modify-write transaction between two accounts). +For all tests, we ran N clients where N is 2x the number of CPUs on the database machine used for the test. Exact client counts are shown in each row. The workload is a transfer transaction (read-modify-write transaction between two accounts). The SpacetimeDB rows were obtained using a single-node SpacetimeDB Standalone instance, so the published numbers are reproducible with the public, downloadable server. @@ -60,7 +60,9 @@ Data description: reported summary metrics are computed from steady-state window Note: the HAProxy + CockroachDB `alpha=1.5` row uses 64 clients (instead of 320) because 320-way concurrency overwhelmed CRDB and did not produce stable sample data for this profile. -### Alpha = 0 (Pipelined) +### Alpha = 0 (All-Connectors Pipelining Check) + +The headline comparison allows pipelining only for SpacetimeDB. This separate check enables pipelining for every connector to show how the other systems behave when clients submit up to 40 requests without waiting for each response. | System | clients | pipelining | max_pool | TPS | TPS Stddev | p50 lat ms | p99 lat ms | | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | @@ -78,7 +80,7 @@ Note: the HAProxy + CockroachDB `alpha=1.5` row uses 64 clients (instead of 320) ## Methodology -All systems were tested with **out-of-the-box default settings**, with one exception: the local Postgres instance (and Bun, which uses the same Postgres instance) is configured with `default_transaction_isolation = 'serializable'`. No other custom tuning or configuration optimization was applied. +All systems were tested with **out-of-the-box database and platform settings**, with one exception: the local Postgres instance (and Bun, which uses the same Postgres instance) is configured with `default_transaction_isolation = 'serializable'`. For Postgres-like RPC servers, the app-side Drizzle connection pool is configured as shown in the result tables, and the benchmark connects directly to Postgres. The managed Postgres services (Supabase, PlanetScale) run at their default isolation level of `READ COMMITTED`. @@ -88,10 +90,11 @@ Throughput is counted from successful operations that the benchmark client obser The reported tables in this README use the following profile defaults unless a row explicitly shows a different value: -- `clients`: `2x` host vCPUs +- `clients`: N clients where N is 2x the number of CPUs on the database machine used for the test - `pipelining`: `off` for non-pipelined runs - `MAX_POOL`: `64` for pg-based RPC servers (`postgres_rpc`, `cockroach_rpc`, `supabase_rpc`, `planetscale_pg_rpc`) -- Pipelined runs use `BENCH_PIPELINED=1` and `MAX_INFLIGHT_PER_WORKER=40` +- Main comparison runs use `MAX_INFLIGHT_PER_WORKER=40` for SpacetimeDB only +- All-connectors pipelining-check runs use `BENCH_PIPELINED=1` and `MAX_INFLIGHT_PER_WORKER=40` - When `BENCH_PIPELINED=1`, set `MAX_INFLIGHT_PER_WORKER` explicitly in the environment For rows that scale client count above 64 (for example, some HA topologies), `max_pool` is scaled to match the row values shown in the table. @@ -196,7 +199,7 @@ This architectural difference means SpacetimeDB can execute transactions in micr ### Client Pipelining -The benchmark supports **pipelining** for all clients - sending multiple requests without waiting for responses. This maximizes throughput by keeping connections saturated. +The benchmark supports **pipelining** for all clients - sending multiple requests without waiting for responses. The headline comparison uses this for SpacetimeDB only; the all-connectors pipelining check enables it across systems. ### Confirmed Reads (`withConfirmedReads`) diff --git a/templates/keynote-2/src/config.ts b/templates/keynote-2/src/config.ts index 4b4703b5644..b6a28a52107 100644 --- a/templates/keynote-2/src/config.ts +++ b/templates/keynote-2/src/config.ts @@ -253,7 +253,7 @@ export function getSharedRuntimeDefaults( ): SharedRuntimeConfig { return { accounts: readNumberEnv('SEED_ACCOUNTS', 100_000, env), - initialBalance: readNumberEnv('SEED_INITIAL_BALANCE', 10_000_000, env), + initialBalance: readNumberEnv('SEED_INITIAL_BALANCE', 1_000_000_000, env), stdbUrl: normalizeStdbUrl(readStringEnv('STDB_URL', '127.0.0.1:3000', env)), stdbModule: readStringEnv('STDB_MODULE', 'test-1', env), stdbModulePath: readStringEnv('STDB_MODULE_PATH', './spacetimedb', env),