Skip to content

Commit 615dae8

Browse files
zaloclaude
andcommitted
Address PR review comments on fair benchmark files
- Fix round-trip count: "4 SQL/ORM round-trips" to "5" in doc comment and FAIR-BENCHMARK.md table (BEGIN+SELECT+UPDATE+UPDATE+COMMIT = 5) - Fix getAccount() balance type: annotate as string (from JSON) and parse with BigInt() to match the RpcConnector interface - Fix amount precision loss in rpcTransfer: parse directly to BigInt instead of going through Number() which truncates values > 2^53 - Remove misleading --pipeline-depth flag and related claims; the fair benchmark runs sequentially (non-pipelined) so the setting had no effect - Add CLI numeric arg validation: reject NaN, Infinity, and <= 0 - Fix bare catch on dynamic import: only fall back to rpc_single_call for MODULE_NOT_FOUND errors, rethrow genuine errors Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 30dd5dc commit 615dae8

4 files changed

Lines changed: 39 additions & 33 deletions

File tree

templates/keynote-2/FAIR-BENCHMARK.md

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,17 @@ This is an alternative benchmark configuration that levels the playing field bet
99
| **SpacetimeDB client** | Custom Rust client with 16,384 in-flight ops | Same TypeScript client as everyone else |
1010
| **TPS counting** | Server-side Prometheus metrics (fire-and-forget) | Client-side round-trip counting for ALL systems |
1111
| **Durability** | `confirmedReads=false` (no durability guarantee) | `confirmedReads=true` (durable commits, like Postgres fsync) |
12-
| **Pipeline depth** | 16,384 for SpacetimeDB vs 8 for competitors | Same for all (configurable, default 8) |
12+
| **Concurrency model** | 16,384 in-flight for SpacetimeDB vs 8 for competitors | Sequential (non-pipelined) for all systems |
1313
| **Postgres isolation** | `serializable` (non-default, worst-case for contention) | `read_committed` (Postgres actual default) |
1414
| **Postgres sync commit** | `synchronous_commit=off` | `synchronous_commit=on` (matches SpacetimeDB confirmed reads) |
15-
| **Postgres transfer** | 4 ORM round-trips via Drizzle | Also tested with stored procedure (single DB call) |
15+
| **Postgres transfer** | 5 ORM round-trips via Drizzle | Also tested with stored procedure (single DB call) |
1616
| **Warmup** | 5s warmup for Rust client only | No warmup for any system (equal cold start) |
1717

1818
## Why These Changes Matter
1919

2020
### 1. Same Client Language (TypeScript for All)
2121

22-
The original benchmark uses a hand-tuned **Rust client** for SpacetimeDB that sends 16,384 concurrent operations per connection via binary WebSocket, while all competitors use a TypeScript client with HTTP/JSON and 8 in-flight operations. This alone is a ~2000x difference in pipeline depth.
22+
The original benchmark uses a hand-tuned **Rust client** for SpacetimeDB that sends 16,384 concurrent operations per connection via binary WebSocket, while all competitors use a TypeScript client with HTTP/JSON and 8 in-flight operations. This alone is a ~2000x difference in concurrency per connection.
2323

2424
The README justifies this by saying "we were bottlenecked on our test TypeScript client" — but then no competitor gets the same optimization. A fair comparison uses the same client for all.
2525

@@ -79,9 +79,6 @@ npm run fair-bench -- --alpha 1.5
7979
# Include more systems
8080
npm run fair-bench -- --systems spacetimedb,postgres_rpc,postgres_storedproc_rpc,sqlite_rpc
8181

82-
# Custom pipeline depth
83-
npm run fair-bench -- --pipeline-depth 16
84-
8582
# Skip seeding (if already seeded)
8683
npm run fair-bench -- --skip-prep
8784
```
@@ -103,7 +100,7 @@ With a leveled playing field, SpacetimeDB's genuine architectural advantage (col
103100

104101
The factors that are **not** architectural and were removed:
105102
- Custom Rust client vs shared TypeScript client
106-
- 16,384 vs 8 pipeline depth
103+
- 16,384 vs 8 in-flight operations per connection
107104
- Server-side vs client-side TPS counting
108105
- Unequal durability guarantees
109106
- Non-default Postgres isolation level penalizing competitors

templates/keynote-2/src/connectors/rpc/postgres_storedproc_rpc.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,13 @@ export default function postgres_storedproc_rpc(
6464
async getAccount(id: number) {
6565
const result = (await httpCall('getAccount', { id })) as {
6666
id: number;
67-
balance: bigint;
67+
balance: string;
6868
} | null;
6969

7070
if (!result) return null;
7171
return {
7272
id: result.id,
73-
balance: result.balance,
73+
balance: BigInt(result.balance),
7474
};
7575
},
7676

templates/keynote-2/src/fair-bench.ts

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* 1. Using the TypeScript client for ALL systems (no custom Rust client)
77
* 2. Forcing STDB_CONFIRMED_READS=1 (durable commits, like Postgres with fsync)
88
* 3. Forcing USE_SPACETIME_METRICS_ENDPOINT=0 (client-side TPS counting for all)
9-
* 4. Using the same pipeline depth for all systems
9+
* 4. Sequential (non-pipelined) operations for all systems
1010
* 5. Including postgres_storedproc_rpc (stored procedure, eliminates ORM overhead)
1111
* 6. Using read_committed isolation for Postgres (its actual default)
1212
*
@@ -59,8 +59,12 @@ function getArg(name: string, defaultValue: number): number {
5959
);
6060
if (idx === -1) return defaultValue;
6161
const arg = args[idx];
62-
if (arg.includes('=')) return Number(arg.split('=')[1]);
63-
return Number(args[idx + 1] ?? defaultValue);
62+
const raw = arg.includes('=') ? arg.split('=')[1] : args[idx + 1];
63+
const value = Number(raw ?? defaultValue);
64+
if (!Number.isFinite(value) || value <= 0) {
65+
throw new Error(`Invalid value for --${name}: "${raw}" (must be a finite number > 0)`);
66+
}
67+
return value;
6468
}
6569

6670
function getStringArg(name: string, defaultValue: string): string {
@@ -86,15 +90,11 @@ const systems = getStringArg(
8690
)
8791
.split(',')
8892
.map((s) => s.trim());
89-
const pipelineDepth = getArg('pipeline-depth', 8);
9093
const skipPrep = hasFlag('skip-prep');
9194

9295
const accounts = Number(process.env.SEED_ACCOUNTS ?? 100_000);
9396
const initialBalance = Number(process.env.SEED_INITIAL_BALANCE ?? 10_000_000);
9497

95-
// Set the same pipeline depth for all systems
96-
process.env.MAX_INFLIGHT_PER_WORKER = String(pipelineDepth);
97-
9898
// ============================================================================
9999
// ANSI Colors
100100
// ============================================================================
@@ -228,10 +228,14 @@ async function runBenchmark(system: string): Promise<BenchResult | null> {
228228
try {
229229
const testMod = await import(`./tests/test-1/${system}.ts`);
230230
scenario = testMod.default.run;
231-
} catch {
232-
// Fallback to rpc_single_call for RPC-based systems
233-
const { rpc_single_call } = await import('./scenario_recipes/rpc_single_call.ts');
234-
scenario = rpc_single_call;
231+
} catch (err: any) {
232+
// Only fall back for missing modules; rethrow genuine errors
233+
if (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.code === 'MODULE_NOT_FOUND') {
234+
const { rpc_single_call } = await import('./scenario_recipes/rpc_single_call.ts');
235+
scenario = rpc_single_call;
236+
} else {
237+
throw err;
238+
}
235239
}
236240

237241
const result = await runOne({
@@ -276,15 +280,14 @@ async function main() {
276280
console.log(` Duration: ${seconds}s`);
277281
console.log(` Concurrency: ${concurrency} connections`);
278282
console.log(` Alpha (contention): ${alpha}`);
279-
console.log(` Pipeline depth: ${pipelineDepth} per worker (same for all)`);
280283
console.log(` Systems: ${systems.join(', ')}`);
281284
console.log('');
282285

283286
console.log(c('bold', ' Fairness guarantees:'));
284287
console.log(` ${c('green', '\u2713')} TypeScript client for ALL systems (no custom Rust client)`);
285288
console.log(` ${c('green', '\u2713')} STDB_CONFIRMED_READS=1 (durable commits)`);
286289
console.log(` ${c('green', '\u2713')} Round-trip-awaited operations for ALL systems`);
287-
console.log(` ${c('green', '\u2713')} Same pipeline depth (${pipelineDepth}) for all`);
290+
console.log(` ${c('green', '\u2713')} Sequential (non-pipelined) operations for all systems`);
288291
console.log(` ${c('green', '\u2713')} Postgres: read_committed isolation (actual default)`);
289292
console.log(` ${c('green', '\u2713')} Postgres: synchronous_commit=on`);
290293
console.log('');
@@ -381,11 +384,11 @@ async function main() {
381384
confirmed_reads: true,
382385
metrics_endpoint: false,
383386
client: 'typescript (same for all)',
384-
pipeline_depth: pipelineDepth,
387+
pipelined: false,
385388
postgres_isolation: 'read_committed',
386389
postgres_synchronous_commit: 'on',
387390
},
388-
config: { seconds, concurrency, alpha, accounts, pipelineDepth },
391+
config: { seconds, concurrency, alpha, accounts },
389392
results: results.map((r) => ({
390393
system: r.system,
391394
tps: r.tps,

templates/keynote-2/src/rpc-servers/postgres-storedproc-rpc-server.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { poolMaxFromEnv } from '../helpers.ts';
88
* Fair benchmark variant: Postgres RPC server using a stored procedure
99
* for the transfer operation instead of Drizzle ORM with multiple round-trips.
1010
*
11-
* This eliminates the ORM overhead and reduces the transfer from 4 SQL
11+
* This eliminates the ORM overhead and reduces the transfer from 5 SQL
1212
* round-trips (BEGIN + SELECT FOR UPDATE + UPDATE + UPDATE + COMMIT via Drizzle)
1313
* to a single SQL call (SELECT do_transfer(...)).
1414
*
@@ -78,19 +78,25 @@ async function ensureStoredProcedure() {
7878
async function rpcTransfer(args: Record<string, unknown>) {
7979
const fromId = Number(args.from_id ?? args.from);
8080
const toId = Number(args.to_id ?? args.to);
81-
const amount = Number(args.amount);
8281

83-
if (
84-
!Number.isInteger(fromId) ||
85-
!Number.isInteger(toId) ||
86-
!Number.isFinite(amount)
87-
) {
82+
if (!Number.isInteger(fromId) || !Number.isInteger(toId)) {
8883
throw new Error('invalid transfer args');
8984
}
90-
if (fromId === toId || amount <= 0) return;
85+
86+
// Parse amount directly to BigInt to avoid precision loss for large values.
87+
// Accepts string, number, or bigint input from JSON.
88+
let amount: bigint;
89+
try {
90+
const raw = args.amount;
91+
amount = typeof raw === 'bigint' ? raw : BigInt(raw as string | number);
92+
} catch {
93+
throw new Error('invalid transfer args: amount is not a valid integer');
94+
}
95+
96+
if (fromId === toId || amount <= 0n) return;
9197

9298
// Single database call - no ORM, no multiple round-trips
93-
await pool.query('SELECT do_transfer($1, $2, $3)', [fromId, toId, BigInt(amount)]);
99+
await pool.query('SELECT do_transfer($1, $2, $3)', [fromId, toId, amount]);
94100
}
95101

96102
async function rpcGetAccount(args: Record<string, unknown>) {

0 commit comments

Comments
 (0)