Scale Operational Benchmarks¶
The scale suite generates deterministic, skewed data and drives it through a
production hubuum-server process with API and worker roles enabled. It is
designed for operational validation, not microbenchmarking. Timing and resource
measurements are evidence; correctness failures fail the run. PostgreSQL is the
first implemented scale adapter.
The loader replaces data in a freshly migrated, disposable database. Never point it at a development, staging, or production database.
Architecture and Backend Comparisons¶
The suite is split along the storage boundary:
hubuum-scale-coreowns backend-neutral profiles, logical manifests, workloads, reports, scale-impact analysis, and backend comparison output.hubuum-scale-benchmarkis the shared command-line and HTTP workload frontend. It does not depend on the roothubuumlibrary.- Each storage adapter implements
ScaleBenchmarkBackend. The adapter owns fixture loading, physical validation, readiness hooks, backend identity and effective settings, and resource probes that only make sense for that backend. PostgreSQL's implementation is opt-in underscale-benchmark-support.
Every selected backend runs the same logical manifest and versioned HTTP
workload. Common measurements use neutral names such as storage bytes, while
optional data, index, write-ahead, resident-memory, CPU, and named custom
metrics are reported only when an adapter can measure them honestly. This lets
the suite measure one-axis corpus growth and placement skew within a backend,
then compare how different backends react to the same logical corpus shapes.
Ordinary rust-pr-bench jobs own base-versus-pull-request code-regression
comparisons at a fixed corpus.
Dataset Profiles¶
The committed profiles are compact TOML specifications. Generated rows, backup artifacts, and reports are CI artifacts and must not be committed.
| Profile | Collections | Classes | Objects | Class relations | Object relations |
|---|---|---|---|---|---|
| large | 1,000 | 4,000 | 250,000 | 8,000 | 1,000,000 |
| huge | 4,000 | 16,000 | 1,000,000 | 32,000 | 4,000,000 |
Each profile contains named object-heavy, class-heavy, balanced, history-heavy, and authorization-adversarial regions. The generator also creates sparse and dense classes, empty classes, a high-degree object, bounded graph components, concentrated relations, varied JSON payloads, history skew, computed fields, templates, disabled remote targets and subscriptions, terminal tasks, audit events, and terminal event-delivery history.
The seed, profile version, exact totals, distribution summaries, stable anchor IDs, and logical corpus SHA-256 are written to the manifest. A run stops before measurement if the loaded rows or required skews do not match that manifest. Generation is set-based and deterministic; increasing the seed changes the logical corpus while preserving the declared shape.
Build and Prepare¶
Build the shared runner and the real production binaries:
cargo build --locked --release --package hubuum-scale-benchmark
cargo build --locked --release --features embedded-migrations \
--bin hubuum-server --bin hubuum-admin
Create two empty disposable databases: one for the benchmark and one for the isolated restore drill. Migrate only the benchmark database. This example uses local non-secret credentials; adapt it to an isolated PostgreSQL instance.
createdb hubuum_scale_local
createdb hubuum_scale_restore
target/release/hubuum-admin \
--migrate --legacy-single-role-migration \
--database-url postgres://localhost/hubuum_scale_local
Run Locally¶
Run the large profile under standard limits:
target/release/hubuum-scale-benchmark run \
--backend postgres \
--profile large \
--limit-mode standard \
--server-binary target/release/hubuum-server \
--admin-binary target/release/hubuum-admin \
--database-url postgres://localhost/hubuum_scale_local \
--restore-test-database-url postgres://localhost/hubuum_scale_restore \
--label local-large-standard \
--manifest-output target/scale/local/manifest.json \
--load-report-output target/scale/local/load.json \
--output target/scale/local/report.json \
--artifact-directory target/scale/local/artifacts
Use --profile huge for the huge tier. Use --limit-mode extended to exercise
the larger page and traversal settings. The profile specification and workload
can be overridden with --profile-spec and --workload-spec; record those
files with the report when doing so. Use --seed for a deliberate corpus
variant.
The two workload modes apply and report these server limits:
| Mode | Default page | Maximum page | Graph depth | Related include | Export output |
|---|---|---|---|---|---|
| standard | 100 | 250 | 100 | 50 | 256 KiB |
| extended | 250 | 1,500 | 200 | 50 | 4 MiB |
The related-object include ceiling is a fixed application safety limit and is therefore unchanged. Unified search is also capped at the backend-neutral 512-candidate page ceiling even in extended mode. Dataset backup/restore ceilings are separate provisioning limits declared by each profile.
The manifest, load, measure, and assess subcommands allow the phases to
be run separately. load, measure, and run accept --backend; PostgreSQL is
the current default. measure requires a manifest from the loaded database.
The following comparison fails on corpus drift, backend drift, or correctness
drift while keeping latency changes informational:
target/release/hubuum-scale-benchmark assess \
--base target/scale/base.json \
--head target/scale/head.json \
--markdown-output target/scale/summary.md
When more than one adapter is available, run each against the same logical profile and workload, then render raw side-by-side results:
target/release/hubuum-scale-benchmark compare-backends \
--reports target/scale/postgres/report.json target/scale/other/report.json \
--output target/scale/backend-comparison.json \
--markdown-output target/scale/backend-comparison.md
The command rejects mismatched corpora, workloads, limits, scenario sets, and failed correctness checks. It does not normalize unlike physical storage models or imply that a single run establishes a winner.
Measure Scale Sensitivity¶
Base/head assessment asks whether code changed at one fixed corpus size. It is
available as a local diagnostic, but ordinary rust-pr-bench CI owns that
regression question. Scale CI instead asks for the marginal cost of more data.
Comparing large with huge is not causal because every dataset dimension
changes together.
For a controlled sensitivity experiment, run the same binary, workload, seed, limit mode, PostgreSQL settings, and fresh-database procedure twice. Leave the first profile unchanged and add an arbitrary positive amount along exactly one axis in the second:
: "${OBJECT_STEP:?set a pilot-derived object increment}"
target/release/hubuum-scale-benchmark run \
--profile large \
--add-objects "$OBJECT_STEP" \
--limit-mode standard \
--server-binary target/release/hubuum-server \
--database-url postgres://localhost/hubuum_scale_objects \
--workload-spec scale-benchmarks/workloads/v1.toml \
--label objects-step \
--manifest-output target/scale/objects/manifest.json \
--load-report-output target/scale/objects/load.json \
--output target/scale/objects/report.json \
--artifact-directory target/scale/objects/artifacts
The baseline uses the same command without an increment. The available controlled axes are:
| CLI option | Placement | Other domain totals |
|---|---|---|
--add-classes |
Existing balanced collections | Objects and relations remain fixed |
--add-objects |
Existing balanced classes | Unchanged |
--add-object-heavy-objects |
Existing object-heavy classes, strengthening the hot-class skew | Unchanged |
--add-object-relations |
Existing balanced class relations | Unchanged |
--add-concentrated-object-relations |
The object-heavy region's concentrated class relation | Unchanged |
--add-dense-object-relations |
Small class-heavy pairs, increasing unique-pair saturation | Unchanged |
Balanced relation increments use source and target classes with enough pair capacity for useful raw-volume experiments. Concentrated increments exercise a large local relation bucket; they do not claim to be a near-capacity saturation test. Dense increments distribute edges through small class-heavy pairs and preflight every pair's finite unique source/target capacity. Every increment has no default and only one may be supplied per run.
Compare the two sanitized reports:
target/release/hubuum-scale-benchmark impact \
--baseline target/scale/baseline/report.json \
--comparison target/scale/objects/report.json \
--axis objects \
--output target/scale/objects/impact.json \
--markdown-output target/scale/objects/impact.md
The command rejects failed correctness checks, mismatched backends, runtimes or
workloads, and changes outside the declared axis and region. Its JSON and
Markdown report matching-scenario latency and throughput deltas together with
storage, data, index, write-ahead, CPU, memory, generation, loading, backup,
restore, and rebuild deltas when those measurements exist. With no
--normalization-unit, the report describes the actual step. Supply a common
unit only when several points need directly comparable slopes.
Choose steps empirically. Run at least three unmodified baselines to establish the local noise envelope, then probe logarithmic relative steps such as small, medium, and large percentages of the starting axis. Refine around the first repeatable change outside that envelope and around operational boundaries such as an extra pagination page, cache or memory capacity, timeout, or uniqueness saturation. Alternate baseline and variant order, keep the hardware quiet, and repeat each candidate point. One paired run discovers signal; it is not a performance conclusion.
The first calibration of this runner found that small count changes could be invisible in endpoint latency even though database and index growth remained measurable. Larger object steps first appeared clearly in global search rather than point or bounded class queries. Relation traversal changed sharply when a representative class relation crossed the 250-row page boundary. It also found that adding edges to small class pairs can exhaust the finite set of unique source/target pairs. That is a density or saturation experiment, not the same question as raw relation-table volume.
The versioned CI experiment in scale-benchmarks/sensitivity-v1.toml uses
independent +20%, +50%, and +100% points. In the volume pilot, +1% and +5%
object changes stayed inside repeated-baseline endpoint variance; +20% was the
first useful object-volume signal, +50% captured the observed
relation-pagination boundary, and +100% tested whether the trend continued.
The initial class-count, object-heavy, and concentrated-relation curves use the
same exact global increments so placement effects can be compared at matching
corpus sizes. The separately calibrated dense-pair curve uses +10%, +30%, and
+55% total relations. In the large profile those points move the measured small
class pair from 23.0% baseline saturation to 34.2%, 56.6%, and 85.2%; a +60%
point is impossible because at least one generated pair would exceed its
unique-edge capacity. These percentages are calibration choices, not permanent
thresholds. Revise the versioned experiment after repeated measurements show
that different points carry more information.
Relation-growth points also run object-graph searches from one bounded, eight-class chain. Spread and concentrated growth place new edges outside that chain, so maximum depths 1, 2, and 4 isolate the effect of a larger global relation corpus while the reachable graph stays fixed. Dense-pair growth places new edges inside the chain and reports depths 1 and 2 together with returned node and edge counts, separating corpus-size cost from added fan-out. Calibration also tested depth 7 for fixed-local growth, but the hosted +100% spread point crossed the 30-second request timeout. Depth 4 already showed the deep-query effect while retaining useful headroom. A focused dense pilot found that depth 4 at the +55% point likewise exceeded 30 seconds and left recursive database work running. Deeper recurring probes would therefore produce timeout backlog rather than a useful curve. The diagnostic depth scenarios use one warm-up and three measured requests and are excluded from the mixed workload.
Page limit 250 runs all six curves. Page limit 1,500 repeats only balanced object and spread-relation growth, where it provides a useful pagination control. This avoids multiplying every shape by every operational setting.
Use sensitivity-plan --limit-mode standard (or extended) to turn the
relative matrix into exact increments for a profile, then produce one impact
report per fresh-database point. After all points complete,
summarize-sensitivity validates that the expected mode-specific matrix is
complete. Its short signal list makes relative p95 curves, pagination knees,
relation saturation, and deepest graph-search changes easy to scan. Exact
corpus, performance, and depth-by-growth matrices are collapsed by default
under each axis. The machine-readable report retains every absolute
measurement, relative cost, and returned graph shape. Once repeated trials are
available, a line chart with uncertainty bands will communicate trends better
than a single-run line. Until then, connecting three informational points would
imply more certainty than the measurements support.
Treat a repeatable corpus-size slope in a bounded, fixed-result operation as an optimization target unless the operation intentionally examines a proportional number of rows. Diagnose that slope with query plans, buffer and row-flow evidence, and application profiling. Keep it separate from costs caused by increasing the requested result set, local relation fan-out, or the number of pagination pages; those experiments change the amount of work the operation is asked to return.
High-value questions include:
| Dimension | Hold fixed | Vary and ask |
|---|---|---|
| Object volume | Classes, relations, authorization, payload shape | Which search, count, filter, aggregation, and pagination paths grow with object count? |
| Relation volume | Objects, classes, graph topology | What are the latency, throughput, index, WAL, and traversal slopes for spread edges? |
| Relation density | Object and class counts | Where do class pairs approach unique-edge capacity, and how does high density affect writes and reads? |
| Graph shape | Total edge count | How do hub degree and component width change cost beyond the bounded depth curves? |
| Tenant skew | Global totals | How do hot classes, empty classes, sparse visibility, and authorization backfill affect tail latency? |
| Authorization size | Domain corpus | How do principals, groups, memberships, grants, and candidate rejection affect each principal shape? |
| History and operations | Live domain rows | How do revision depth, audit events, tasks, and delivery history affect current reads and retention work? |
| Payload and computed work | Row counts | Where do JSON size, filtering selectivity, computed materialization, and rebuild time become dominant? |
| Concurrency | Corpus and request mix | At what client count do pool waits, timeouts, CPU, memory, and throughput become nonlinear? |
| Lifecycle | Logical corpus | How do loading, backup size, verification, restore, and computed rebuilding scale, and where do supported ceilings fail? |
For every curve, report absolute values, percentage changes, normalized slopes, correctness, timeouts, database and index growth, WAL, CPU and memory, and the exact topology. Averages alone can hide the relevant decline; retain p95/p99, first/middle/final traversal pages, and principal-specific results.
Measurement Phases¶
The versioned workload includes collection, class, object, relation, graph, history, aggregation, computed-field, unified-search, task, event, and mutation operations. It runs these phases in order:
- Cold first-touch requests on a fresh production process and freshly loaded database.
- Deliberate warmup, followed by warm single-client samples.
- Complete cursor traversals with duplicate, missing-row, total, and sparse authorization checks.
- Deterministic mixed traffic at moderate and higher concurrency.
- A deterministic weighted interactive mix.
- Create, conditional update, and conditional delete operations last, so mutations cannot alter earlier read samples.
Reports contain per-scenario request counts, p50/p95/p99/max latency, throughput, bytes, returned graph nodes and edges, pages, traversal time, timeouts, failures, resource use, first/middle/final traversal-page latency, authorization candidate and returned counts, storage and index size, write-ahead growth, storage and pool metric deltas, backend identity and effective settings, and lifecycle durations. The runner starts and stops the server itself, and its artifacts never contain bearer tokens, password hashes, database URLs, or environment dumps.
Backup and Restore Lifecycle¶
When --admin-binary is supplied, the runner first streams temporal state and
history rows to count a conservative lower bound on compact backup bytes.
The preflight retains one row at a time and stops above the profile's declared
document-size ceiling. Reports distinguish this lower bound from a generated
artifact's measured size and record preflight time separately. An already
over-limit corpus is reported as backup_exceeds_supported_ceiling without
materializing the full document. Fixture revision/history invariants are checked
independently before workloads, including when backup generation is over its ceiling.
Otherwise the runner creates a backup and invokes hubuum-admin --verify-backup. Supplying --restore-test-database-url additionally restores
into a separately created empty database and performs semantic verification.
The large profile retains the ordinary 256 MiB ceiling; the huge profile
declares an elevated 1 GiB provisioning envelope. Both values are recorded in
the manifest and applied to the server and administrator processes, along with
the profile's database statement timeout. Bulk loading seeds
one open temporal baseline for every live resource and preserves the additional
revisions in the history overlay. Audit revision fields retain matching snapshots.
Administrator failures report the failing stage, exit status, and diagnostic
output with configured connection strings and token keys redacted.
Lifecycle results distinguish a successful offline verification, a successful
isolated restore, an unsupported backup, an artifact that exceeds the ordinary
256 MiB ceiling, and a verification or restore failure. The measured size is
recorded and an over-limit generated document is removed instead of uploaded;
the runner does not silently exempt scale artifacts from production limits.
The preflight is a size check, not proof of backup validity; generation and
verification still run for every corpus admitted by the preflight.
Use --skip-lifecycle only for focused local iteration.
Backup generation also enforces the production byte and row-work budgets during capture, even when the preflight's lower bound fits. The preflight omits operational sections and framing, so it cannot guarantee admission. A capture limit failure produces no artifact and is reported as a backup-stage failure with the resource-limit diagnostic; it must not be claimed as successful verification or restore. The default work ceiling is 1,000,000 enumerated rows. The large and huge profiles retain their existing declared byte ceilings; this change does not assert that either corpus fits those ceilings or completes a restore. Continued lifecycle-envelope work is tracked in #366.
The adapter regression backup_rows_can_exceed_one_postgres_json_array retains
coverage above PostgreSQL's single JSON-array limit under an explicit 512 MiB
budget. Small-budget capture regressions instrument database row evaluation and
logical retained bytes to prove early termination. These tests exercise the
resource enforcement mechanism; they do not replace a large-profile lifecycle
assessment or peak-RSS measurement.
CI Policy¶
Scale jobs are expensive and never run because ci:full, ci:benchmarks, or
an ordinary benchmark-path change requested them. Pull requests opt in with
these labels:
ci:scale-largeruns the large profile under standard and extended limits.ci:scale-hugeruns the huge profile under standard and extended limits.
Applying both labels runs both profiles. The workflow can also be dispatched
manually with explicit profile and limit selections. A weekly schedule runs the
large profile and a monthly schedule runs the huge profile. Superseded runs are
cancelled. Each profile and limit combination has a distinct job summary and
artifact name. It holds one binary, workload, backend, seed, and limit mode
fixed; measures a fresh baseline; then measures each calibrated object and
object-relation growth point against its own fresh database. A final job
consolidates exact corpus sizes, search and relation latency/throughput,
traversal pagination, and database/index costs into one updatable Scale Growth
Report comment with a link to the workflow and its sanitized 14-day artifacts.
The report explicitly leaves code-regression detection to rust-pr-bench.
Generator code, profile and workload specifications, and the scale workflow
have an explicit scale_benchmark classification in
scripts/classify-ci-changes.sh. Update its regression test when moving or
adding inputs.