Sitelet https://github.com/MapleTechLabs/effect-orm/blob/main/docs/benchmarking.md
Skip to content

Latest commit

 

History

History
263 lines (221 loc) · 13.6 KB

File metadata and controls

263 lines (221 loc) · 13.6 KB

Benchmarking ClickHouse queries

The package ships a driver-free @maple-dev/effect-orm/benchmark API, an HTTP adapter at /benchmark/http, and the ch-bench executable. The query builder entries (/clickhouse, /postgres) still do no networking. No Maple account or schema is required.

The executable runs on Node 22.18+ or Bun. For TypeScript suites with extensionless imports or application path aliases, use Bun (bun run ch-bench …). With Node, use JavaScript modules or TypeScript supported by Node's native type stripping, with explicit import extensions. Suite modules execute trusted local code.

Setup

Install the library with its Effect 4 peer dependency using Getting started. That guide also covers installing a source-built tarball. The CLI is included in the same package, with no separate benchmark dependency.

In your project's package.json, add:

{
	"scripts": {
		"bench": "ch-bench"
	}
}

Run commands with npm run bench -- <command> or bun run bench <command>. Examples below use ch-bench directly, as it is available inside package scripts; from a shell, use npm exec -- ch-bench <command>. Bun users can run the local executable with bun run ch-bench <command>.

Provide an existing ClickHouse HTTP endpoint, its credentials, and a populated snapshot of your workload. doctor checks connectivity and measurement capabilities; it does not create a dataset or determine whether your query inputs are representative.

Define a workload

import { compile, engine, from, param, string, table, uint32 } from "@maple-dev/effect-orm/clickhouse"
import * as Bench from "@maple-dev/effect-orm/benchmark"

const events = table("events", {
	columns: { id: uint32, name: string },
	engine: engine.mergeTree(),
	orderBy: ["id"],
})
const byName = from(events)
	.select("id", "name")
	.where(($) => [$.name.eq(param.string("name"))])

export default Bench.defineSuite({
	name: "events",
	dataset: "events-snapshot-v1",
	cases: [
		Bench.query({
			id: "events/by-name",
			inputs: { name: "checkout" },
			compile: (inputs) => compile(byName, inputs),
			results: "unordered",
		}),
	],
})

Save this as benchmark-suite.ts. Point it at an existing, populated dataset. query creates an Effect; defineSuite compiles all cases before measurement. Its default export is an Effect, which the CLI runs. Plain Suite values and JSON suite files are also supported. caseFromCompiled(id, compiled, inputs) adapts an already compiled query. Raw cases supply id, inputs (a canonical JSON string), context, profile, fingerprint, and sampleSql directly.

Use stable IDs for experiments, independent of SQL fingerprints. Include all parameters and builder options in inputs; use plain JSON values, converting Dates, Sets, and Maps explicitly. Separate selective/unselective filters, different windows, and capability routes into distinct cases. An input is part of the experiment contract; changing it makes comparisons incompatible.

Measure a change

export CLICKHOUSE_URL=http://localhost:8123
export CLICKHOUSE_USER=default
export CLICKHOUSE_PASSWORD=your-password
export CLICKHOUSE_DATABASE=default

ch-bench doctor --json
ch-bench run benchmark-suite.ts --runs 20 --warmup 2 --threads 2 \
  --out .bench/baseline.json --json
# Edit the query, retaining the case IDs and inputs.
ch-bench run benchmark-suite.ts --runs 20 --warmup 2 --threads 2 \
  --out .bench/candidate.json --json
ch-bench compare .bench/baseline.json .bench/candidate.json \
  --metric meanReadBytes --threshold 10 --min-delta 1048576 --json
ch-bench inspect .bench/candidate.json --case events/by-name \
  --out .bench/plans.json --json

Every run imports and compiles the current module in a fresh CLI process. export benchmark-suite.ts --out .bench/suite.json explicitly freezes SQL for replay. Replaying a frozen JSON suite does not pick up source changes. --match filters case ID/context substrings; --case selects an exact ID. Empty selections fail. --dataset overrides the suite's revision; --source-revision records a Git SHA or other caller-supplied revision. Neither field creates a snapshot or detects data drift.

Credentials are read from the environment and are not written into artifacts. Endpoints must be HTTP(S), without embedded credentials, query strings or fragments. Reports contain literal SQL and input values; keep .bench/ gitignored.

Evidence and verdicts

Runs are serial, with rotating case order and excluded warmups. They measure isolated-query behavior, not concurrent throughput. Reports retain each query ID, full-response wall time, server time, read rows/bytes, result rows, memory, ProfileEvents, exact executed SQL, controls, server version, and database. Schema DDL is hashed before measurement where table metadata is accessible; row counts and bytes are intentionally excluded from that hash. This detects DDL changes, not dataset changes. inspect saves current plans and table metadata for recorded SQL; it cannot recover historical plans and warns on a changed target/version.

Query logs are collected after timing, with --log-wait controlling the polling window (each HTTP request is separately bounded). --cluster uses clusterAllReplicas; without it logs are node-local. Missing logs retain HTTP summary metrics, with missing memory/counters represented explicitly. A memory budget cannot pass without memory measurements. Per the official query-log documentation, initial queries are selected to avoid double counting child queries.

Result and condition caches are disabled. --cache warm leaves filesystem and uncompressed caches available; --cache bypass disables them per query. Neither clears the OS page cache. --threads pins parallelism. Controls override matching inline settings, preserving other settings. Queries use server-side readonly=2; the tool does not seed data, create tables, flush global caches or deploy changes.

results: "unordered" hashes canonical JSON rows, retaining duplicates and ignoring row order. Numeric tokens are normalized without JavaScript number conversion, so Decimal and unquoted large integer precision is retained. "ordered" also checks order. "skip" explicitly skips verification. Without a per-case mode, --verify-results and --result-order apply. Exact hashes can differ for approximate aggregates, floating point changes, nondeterministic ordering, or concurrent ingestion. Use domain parity tests when exact equality is not appropriate. The comparison separately reports correctness as verified, different-or-unstable, or not-fully-verified. Exact hashes carry the json-exact-v1: prefix; regenerate both baseline and candidate when comparing against artifacts made before lossless result hashing was introduced.

Verdict Exit Meaning
pass 0 All selected metric budgets pass the configured thresholds
regression 1 At least one budget exceeds both percentage and absolute limits
invalid 2 Failed/partial execution, incompatible experiment, changed results, or invalid input
inconclusive 3 Missing cases/metrics, or no dataset revision

Comparisons always gate; --fail-on-regression is accepted for existing scripts. A pass is not a statistical significance claim or proof of correctness when verification was skipped. Small-sample percentiles are noisy; zero-row reads warn that the workload may be empty (metadata optimizations can also read zero rows). Reports are written before failure. An ordinary query failure retains completed cases and measurements plus the failed query ID when available. Forced process termination is not checkpointed.

Multiple budgets are supplied with --budgets budgets.json:

[
	{ "metric": "meanReadBytes", "thresholdPercent": 10, "minDelta": 1048576 },
	{ "metric": "meanMemoryUsage", "thresholdPercent": 10, "minDelta": 1048576 },
	{ "metric": "p95WallMs", "thresholdPercent": 15, "minDelta": 5 }
]

Other metrics: meanServerMs, meanReadRows. Missing measurements never become zeros.

Use the runner from an Effect program

The CLI owns full report persistence and environment metadata. For a custom harness, use the same runner and HTTP adapter directly. This example imports the suite above, compiles it before timing, and prints the case measurements and warnings. Its output is the runner result, not a complete RunOutput file for the CLI's compare command.

Save this beside benchmark-suite.ts as benchmark-runner.ts, set the same CLICKHOUSE_* environment variables, and run bun benchmark-runner.ts:

import { Effect } from "effect"
import * as Bench from "@maple-dev/effect-orm/benchmark"
import {
	httpConfigFromEnv,
	makeHttpClient,
	makeHttpTransport,
} from "@maple-dev/effect-orm/benchmark/http"
import suiteDefinition from "./benchmark-suite"

const measurements = await Effect.runPromise(
	Effect.gen(function* () {
		const suite = yield* suiteDefinition
		const client = makeHttpClient(yield* httpConfigFromEnv)
		const transport = makeHttpTransport(client, {
			timeoutSeconds: 30,
			logWaitSeconds: 12,
			verifyResults: true,
			resultOrder: "unordered",
		})
		return yield* Bench.runSuite(transport, suite, {
			runs: 20,
			warmup: 2,
			verifyResults: true,
			settings: {
				max_threads: "2",
				max_execution_time: "30",
				use_query_cache: "0",
				use_query_condition_cache: "0",
				log_queries: "1",
				log_query_settings: "1",
				log_queries_probability: "1",
			},
		})
	}),
)
console.log(JSON.stringify(measurements, null, 2))

The suite example is compiled and exercised by our offline documentation checks. The custom harness is typechecked there; executing it requires your populated database.

Troubleshooting a benchmark

Symptom What to check
ch-bench is not found Run through the local package script or npm exec -- ch-bench; the package must be installed in that project.
A TypeScript suite cannot be imported Use Bun for aliases and extensionless imports; Node requires imports its native module loader can resolve.
The query reads zero rows Confirm the database, fixed time window, and filters match populated data. A metadata-only optimization can also legitimately read zero rows.
Memory or ProfileEvents are missing Run doctor; check query-log permissions, --cluster, and the log polling window. Missing logs retain available HTTP summary metrics.
Comparison is invalid Inspect each comparison row's reason: inputs, settings, dataset, schema hash, server version, or result contracts may differ; executions may also have failed.
Correctness is different-or-unstable Check ordering, nondeterministic expressions, approximate aggregates, and ingestion during the run. Verify semantics before interpreting a speed change.
Comparison is inconclusive Supply a dataset revision and collect the missing cases or metrics. Do not interpret missing evidence as a passing budget.

Programmatic API and machine output

/benchmark exports query, defineSuite, caseFromCompiled, runSuite, compareRuns (one metric), compareBudgets (verdict across budgets), the Effect schemas Sample, Suite, RunMetrics, Aggregates, SampleResult, RunOutput, and BenchmarkError. BenchmarkTransport is the execution/log-collection port; RunOptions, BenchmarkProgress, ComparisonOptions, ComparisonMetric, ComparisonRow, Verdict, BenchmarkInput, BenchmarkResponse, and LogMetrics describe the public contracts. SQL helpers (benchmarkSql, explainSql, validateReplaySql) and measurement helpers (aggregate, mean, percentile, metricNumber, canonicalJson, sampleId, validateSuite) are also available.

/benchmark/http exports makeHttpClient(config), httpConfigFromEnv, and makeHttpTransport(client, options), plus HttpConfig, decodeJson, and decodeJsonLines. The client exposes query, run (including non-200 responses), metadata, tables, target, and collectLogs; every operation returns an Effect. /benchmark/cli exports runCli(args): Promise<number> for application wrappers. Only the executable sets process.exitCode.

With --json, stdout contains one JSON envelope: { version: 1, command, artifact, result }. Input/connection failures produce { version: 1, command, verdict: "invalid", diagnostics }. Progress events are JSON lines on stderr with version, type, and message; round events include round and warmup, and case failures include caseId. Keep suite modules silent on stdout to preserve this contract. schema --json emits JSON Schema documents for suite files, run reports and budget files. Version 1 run reports remain readable.

For the agent workflow, read benchmark-agent.md.