Files
open-connector/scripts/benchmark-sqlite-token-statements.ts
fcc773e4f3 perf(sqlite): reuse prepared statements for runtime token authentication (#629)
Runtime token authentication prepares the same SQLite SELECT and
last-use UPDATE on every successful request. Reuse those two statements
per `SqliteRuntimeTokenStore` instance while continuing to read current
database rows on every call.

Preparation is lazy: custom migration sources can omit `runtime_tokens`,
so eagerly preparing in the constructor would break existing database
initialization behavior. SQL, parameter binding, row decoding, error
propagation, and public interfaces are unchanged. No token records or
authorization decisions are cached.

### Validation

- `npm run fix-check` passed (lint, formatting, and src/scripts/examples
typechecks). Unrelated formatter changes were excluded from the commit.
- SQLite store and runtime token service suites: **36 tests passed**,
including alternating token bindings, all policy fields, revocation,
independent last-use updates, cross-connection changes, reopening,
custom migrations, and best-effort last-use failure handling.
- `npm test` on Windows: 3,132 passed, 9 failed, 4 skipped. Six failures
were source-scan/catalog timeouts under the default parallelism.
- `npm test -- --maxWorkers=2 --testTimeout=30000`: **3,138 passed, 3
failed, 4 skipped**. Remaining failures are in unchanged provider source
guards: two clone-baseline assertions (Windows backslash paths do not
match the committed slash-separated baseline) and one auth-status
error-code assertion. No provider sources or guard tests are changed by
this PR. The upstream Linux workflow remains the CI check for this
change.

### Benchmark

Run `node scripts/benchmark-sqlite-token-statements.ts` after `npm ci`.
It compares the pre-change store method implementations with the actual
updated store, including row decoding and async method calls. Both use
the same migrated schema, 100 tokens, precomputed inputs, warmup, and
seven alternating measurement rounds. Database creation and seeding are
outside the timed loops; there is no timing assertion in CI.

Windows, Node **24.14.1**, SQLite **3.51.2**, `synchronous=2` (FULL):

| Storage | Lookup/update pairs per round | Baseline median | Reused
median | Baseline / reused per pair | Speedup |
| --- | ---: | ---: | ---: | ---: | ---: |
| In-memory | 30,000 | 1,205.28 ms | 247.22 ms | 40.18 / 8.24 us | 4.88x
|
| File, WAL | 1,000 | 652.60 ms | 591.26 ms | 652.60 / 591.26 us | 1.10x
|

The file-backed case reduces elapsed time by about **9.4%** on this
machine. These are isolated store-operation measurements, not end-to-end
HTTP throughput claims; filesystem synchronization and upstream API
latency affect real workloads.

Co-authored-by: machaoxin0407 <[email protected]>
Co-authored-by: l1shen <[email protected]>
2026-09-30 13:35:16 +08:00

135 lines
5.1 KiB
TypeScript

/** Run with `node scripts/benchmark-sqlite-token-statements.ts`; timings are not CI assertions. */
import type { RuntimeTokenRecord } from "../src/server/storage/runtime-token-service.ts";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
import { DatabaseSync } from "node:sqlite";
import { readRuntimeTokenRow, runtimeTokenColumns } from "../src/server/storage/runtime-sql.ts";
import { SqliteRuntimeDatabase, SqliteRuntimeTokenStore } from "../src/server/storage/sqlite/runtime-store.ts";
/** The two store methods before statement reuse, with the same decoding and async boundary. */
class PreparePerCallTokenStore extends SqliteRuntimeTokenStore {
private readonly connection: DatabaseSync;
constructor(database: DatabaseSync) {
super(database);
this.connection = database;
}
override async findByHash(tokenHash: string): Promise<RuntimeTokenRecord | undefined> {
const row = this.connection
.prepare(`select ${runtimeTokenColumns} from runtime_tokens where token_hash = ?`)
.get(tokenHash);
return row ? readRuntimeTokenRow(row) : undefined;
}
override async markUsed(id: string, usedAt: string): Promise<void> {
this.connection.prepare("update runtime_tokens set last_used_at = ? where id = ?").run(usedAt, id);
}
}
const tokenCount = 100;
const rounds = 7;
const hashes = Array.from({ length: tokenCount }, (_, index) => `hash-${index}`);
const timestamps = Array.from({ length: 30_000 }, (_, index) => new Date(1_790_726_400_000 + index).toISOString());
async function measure(store: SqliteRuntimeTokenStore, iterations: number): Promise<number> {
const start = performance.now();
for (let index = 0; index < iterations; index++) {
const token = await store.findByHash(hashes[index % tokenCount]);
if (!token) throw new Error("Benchmark token is missing.");
await store.markUsed(token.id, timestamps[index]);
}
return performance.now() - start;
}
function median(values: number[]): number {
return [...values].sort((left, right) => left - right)[Math.floor(values.length / 2)];
}
async function benchmark(filename: string, iterations: number, schema?: string): Promise<void> {
const database = new DatabaseSync(filename);
try {
if (schema) database.exec(schema);
// Match the runtime's WAL setting; in-memory SQLite reports "memory" instead.
database.exec("pragma journal_mode = wal;");
const baseline = new PreparePerCallTokenStore(database);
const reused = new SqliteRuntimeTokenStore(database);
for (let index = 0; index < tokenCount; index++) {
await reused.add({
id: `token-${index}`,
name: `Token ${index}`,
tokenHash: hashes[index],
allowedActions: ["github.*"],
blockedActions: ["github.delete_repository"],
allowedProxies: ["github"],
allowedConnections: ["example:work"],
createdAt: timestamps[0],
});
}
await measure(baseline, 500);
await measure(reused, 500);
const baselineMs: number[] = [];
const reusedMs: number[] = [];
for (let round = 0; round < rounds; round++) {
if (round % 2 === 0) {
baselineMs.push(await measure(baseline, iterations));
reusedMs.push(await measure(reused, iterations));
} else {
reusedMs.push(await measure(reused, iterations));
baselineMs.push(await measure(baseline, iterations));
}
}
const before = median(baselineMs);
const after = median(reusedMs);
console.log(
JSON.stringify(
{
storage: filename === ":memory:" ? "memory" : "file",
node: process.version,
sqlite: database.prepare("select sqlite_version() as version").get()?.version,
journalMode: database.prepare("pragma journal_mode").get()?.journal_mode,
synchronous: database.prepare("pragma synchronous").get()?.synchronous,
tokenCount,
iterations,
rounds,
baselineMs,
reusedMs,
baselineMedianMs: before,
reusedMedianMs: after,
baselineMicrosecondsPerPair: (before * 1000) / iterations,
reusedMicrosecondsPerPair: (after * 1000) / iterations,
speedup: before / after,
},
null,
2,
),
);
} finally {
database.close();
}
}
const directory = await mkdtemp(join(tmpdir(), "sqlite-token-benchmark-"));
try {
const filename = join(directory, "runtime.sqlite");
// Apply actual runtime migrations once, outside the measured loops.
const runtime = new SqliteRuntimeDatabase(filename);
runtime.close();
// Copy the migrated empty schema so both modes use the same table and indexes.
const source = new DatabaseSync(filename);
const schema = source
.prepare("select sql from sqlite_master where sql is not null and name not like 'sqlite_%'")
.all()
.map((row) => String(row.sql))
.join(";\n");
source.close();
// An in-memory database needs its schema on the same connection as the benchmark.
await benchmark(":memory:", 30_000, schema);
await benchmark(filename, 1000);
} finally {
await rm(directory, { recursive: true, force: true });
}