Skip to content

benchmarks: add indexed-write (ST-2), ttl-churn (ST-1), concurrent-rw (ST-5) - #1379

Merged
kriszyp merged 8 commits into
mainfrom
kris/perf-benchmarks
Jun 19, 2026
Merged

benchmarks: add indexed-write (ST-2), ttl-churn (ST-1), concurrent-rw (ST-5)#1379
kriszyp merged 8 commits into
mainfrom
kris/perf-benchmarks

Conversation

@kriszyp

@kriszyp kriszyp commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

Three single-node storage benchmarks addressing the performance/benchmark gaps from §6.3 of the Harper Release Testing Strategy and §5 of the v5 Integration Test Plan (ST-1, ST-2, ST-5).

All three use the same @harperfast/integration-testing boot pattern as the existing YCSB harness, emit a single machine-parseable RESULT line for future regression gating, and run in ~30-60 s at their default --scale=quick.


ST-2 — Indexed-write throughput (benchmarks/indexed-write/)

Measures write ops/sec on three table variants that differ only in how many @indexed secondary fields they carry (0 / 3 / 5). Reports absolute ops/sec per variant and a ratio vs. the unindexed baseline so a regression gate can track relative index-maintenance cost across releases independent of hardware.

Scale Records Concurrency Approx. runtime
quick (default) 5 000 16 ~30 s (3 variants)
nightly 1 000 000 64 ~15 min
node benchmarks/indexed-write/run.mts
node benchmarks/indexed-write/run.mts --scale=nightly

Parseable output:

INDEXED_WRITE_RESULT variant=baseline ops_per_sec=NNN
INDEXED_WRITE_RESULT variant=indexed3  ops_per_sec=NNN ratio_vs_baseline=N.NNN
INDEXED_WRITE_RESULT variant=indexed5  ops_per_sec=NNN ratio_vs_baseline=N.NNN

ST-1 — TTL-churn / map-size growth (benchmarks/ttl-churn/)

Sustained insert-with-TTL workload; samples the on-disk data directory size every --sample-every seconds. Asserts (and reports) that final storage size is ≤ 150% of the halfway-point size — i.e. that TTL eviction + compaction keeps the store bounded. The quick scale runs for 30 s (TTL is 60 s, so records are still live — this proves the insert path works). The nightly 30-min scale lets records expire and verifies compaction reclaims the space.

Do not run --scale=nightly locally (30+ min).

Scale Records/wave TTL Duration Sample every
quick (default) 10 000 60 s 30 s 5 s
nightly 1 000 000 60 s 1 800 s (30 min) 60 s
node benchmarks/ttl-churn/run.mts
node benchmarks/ttl-churn/run.mts --scale=nightly   # CI only

Parseable output:

TTL_CHURN_SAMPLE elapsed_s=NNN dir_bytes=NNN records_inserted=NNN
TTL_CHURN_RESULT duration_s=NNN peak_bytes=NNN final_bytes=NNN total_inserts=NNN bounded=true|false

ST-5 — Concurrent read+write (benchmarks/concurrent-rw/)

Seeds a highly-indexed table (5 @indexed fields: category, region, status, priority, tag), then runs N concurrent readers (multi-condition REST queries across those fields) and M concurrent writers (inserts) simultaneously for --duration seconds. Reports read p50 / p95 / p99 / max and checks p99 against a configurable ceiling (--p99-ceiling-ms, default 200 ms).

Scale Seed records Duration Readers Writers
quick (default) 2 000 15 s 4 2
nightly 200 000 120 s 16 8
node benchmarks/concurrent-rw/run.mts
node benchmarks/concurrent-rw/run.mts --scale=nightly

Parseable output:

CONCURRENT_RW_RESULT read_ops=NNN write_ops=NNN read_p50_ms=N.N read_p95_ms=N.N read_p99_ms=N.N p99_ceiling_ms=NNN ceiling_ok=true|false

Sample small-run output (on this dev machine, --scale=quick)

Indexed-write:

ST-2 Indexed-write throughput — RESULTS
variant          ops/sec     ratio    errors
baseline            6104         —         0
indexed3            6672     1.093         0
indexed5            8348     1.368         0

INDEXED_WRITE_RESULT variant=baseline ops_per_sec=6104
INDEXED_WRITE_RESULT variant=indexed3 ops_per_sec=6672 ratio_vs_baseline=1.093
INDEXED_WRITE_RESULT variant=indexed5 ops_per_sec=8348 ratio_vs_baseline=1.368

(Ratios > 1 at 5 k records are within variance — at 1 M records the index overhead becomes the signal.)

TTL-churn:

ST-1 TTL-churn / map-size growth — RESULTS
Total inserts : 268,064 (0 errors)
Peak dir size : 0.81 MiB
Final dir size: 0.77 MiB
Storage bounded: YES

TTL_CHURN_RESULT duration_s=30 peak_bytes=847882 final_bytes=811392 total_inserts=268064 bounded=true

Concurrent R+W:

ST-5 Concurrent read+write — RESULTS
Read ops     : 4,131 (0 errors)
Write ops    : 1,243 (0 errors)
Read latency : p50=11.5ms  p95=34.1ms  p99=49.9ms  max=120.1ms
p99 ceiling  : 200ms — OK

CONCURRENT_RW_RESULT read_ops=4131 write_ops=1243 read_p50_ms=11.5 read_p95_ms=34.1 read_p99_ms=49.9 p99_ceiling_ms=200 ceiling_ok=true

Test plan

  • npm run build succeeds in worktree
  • node benchmarks/indexed-write/run.mts runs end-to-end, 0 errors, prints INDEXED_WRITE_RESULT lines
  • node benchmarks/ttl-churn/run.mts runs end-to-end, 0 errors, prints TTL_CHURN_RESULT bounded=true
  • node benchmarks/concurrent-rw/run.mts runs end-to-end, 0 errors, prints CONCURRENT_RW_RESULT ceiling_ok=true
  • CI: wire --scale=nightly variants into a nightly workflow (follow-up)
  • CI: add regression-gate script that greps *_RESULT lines and diffs vs. stored baseline (follow-up)

Generated by a Claude Sonnet subagent (claude-sonnet-4-6) via Claude Code.

🤖 Generated with Claude Code

… (ST-5)

Three single-node storage benchmarks addressing the performance/benchmark
gaps from §6.3 of the Release Testing Strategy and §5 of the v5 Integration
Test Plan. Each boots a real Harper instance via @harperfast/integration-testing,
drives the workload, and emits parseable RESULT lines for future regression gating.

- benchmarks/indexed-write/run.mts (ST-2): write throughput at 0/3/5 @indexed
  fields; reports ops/sec per variant + ratio vs unindexed baseline.
- benchmarks/ttl-churn/run.mts (ST-1): sustained insert-with-TTL workload;
  samples on-disk dir size to assert storage stays bounded. Quick default ~30s;
  nightly 30-min opt-in via --scale=nightly.
- benchmarks/concurrent-rw/run.mts (ST-5): N concurrent multi-condition readers
  on a 5-index table while M writers insert; reports read p50/p95/p99 and checks
  p99 against configurable ceiling.
- benchmarks/README.md: documents all three benchmarks, flags, small/nightly
  configs, and parseable output format.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces three new single-node storage and throughput benchmarks for Harper: ST-1 (TTL-churn), ST-2 (Indexed-write), and ST-5 (Concurrent R+W), complete with schemas, configurations, and TypeScript runners. Feedback on the implementation highlights two critical issues: first, sorting the latency array inside the progress reporter in the concurrent read/write benchmark blocks the event loop and artificially inflates latency measurements; second, the recursive dirBytes function in the TTL-churn benchmark contains a race condition on the total variable that can lead to under-reported directory sizes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread benchmarks/concurrent-rw/run.mts Outdated
Comment thread benchmarks/ttl-churn/run.mts
@claude

claude Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Review — PR #1379 (ST-1/ST-2/ST-5 benchmarks)

One non-blocking accuracy issue; no blockers.

Finding: totalInserts overcounts errors in ttl-churn (non-blocking)

In benchmarks/ttl-churn/run.mts line 331, totalInserts += batchSize runs unconditionally after each batch regardless of how many in that batch failed. Failed requests increment insertErrors, but totalInserts absorbs them too. The machine-parseable RESULT line therefore emits total_inserts that may exceed actual successful inserts.

The concurrent-rw benchmark does this correctly — writeOps++ is only inside the try block. The ttl-churn benchmark should align with that pattern.

Inline comment posted at the relevant line.

No issues with:

  • dirBytes race: fixed in a976974 (sequential accumulation via reduce over Promise.all)
  • Live p99 sort on the event loop: correctly removed from concurrent-rw; final sort happens once after all workers settle
  • npm run build || true in the new workflow: follows the established pattern in integration-tests.yml for this repo's current type-error state; the "no tsc||true" rule applies to core library CI, not benchmark runners
  • Workflow trigger safety: schedule + workflow_dispatch only (no pull_request), protecting the self-hosted runner from untrusted PR code
  • Input handling: SCALE passed via env: not ${{ }} shell interpolation — avoids script injection
  • bounded heuristic: logic and the false-positive note in the README are both correct
  • TypeStrip compat: .mts files use only erasable syntax; no enums or namespaces

… ordering bias

Without warmup, the baseline variant (measured first) absorbed JIT
compilation, connection-pool ramp, and cold RocksDB cache costs,
causing it to appear slower than indexed3/indexed5 (e.g. 6104 vs
8348 ops/sec) — a physically impossible inversion.

Fix:
- Phase 1 (instance-level): fire --instance-warmup (default 500 quick /
  2000 nightly) untimed requests to the baseline table before any variant
  is measured, heating JIT, keepalive pool, and page cache.
- Phase 2 (per-variant): send --variant-warmup (default 200 / 1000)
  untimed requests at the start of each variant; the measured window
  starts only after those complete.
- Both counts are configurable; pass 0 to disable (not recommended).
- Measured key range is offset by variantWarmup so warmup and measured
  writes use disjoint keys.
- README updated with new flags table and interpreting-results note.

Validated: quick run now shows baseline >= indexed variants with no
inversion (10546 / 11102 / 11990 ops/sec; small-scale noise expected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review June 18, 2026 16:48
Runs ST-1/ST-2/ST-5 at --scale=nightly on the fixed self-hosted runner
(label harper-bench, the same one ycsb-cluster-nightly uses) so perf numbers
are comparable night to night. Schedule + manual dispatch only (never on PR)
since it runs on a self-hosted machine. Uploads raw logs + surfaces RESULT
lines in the job summary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread benchmarks/ttl-churn/run.mts
Comment thread benchmarks/concurrent-rw/run.mts
kriszyp and others added 2 commits June 18, 2026 18:10
The harper-bench runner is now registered at org scope, so the prior
"if scoped to harper-pro only, make it org-level" caveat no longer applies.
Replace it with the actual setup (shared org runner, one job at a time) and a
pointer to the runner-group repo-access check if the job stays queued.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ing live p99 sort

- benchmarks/ttl-churn/run.mts: `dirBytes` accumulated into a shared
  `total` via `+=` inside concurrent `Promise.all` callbacks — classic
  lost-update across awaits. Each entry now returns its own size and
  they are summed with `reduce` after `Promise.all` settles.

- benchmarks/concurrent-rw/run.mts: the per-tick `slice().sort()` in
  the 5 s progress reporter ran O(n log n) on the full growing latency
  array on the main event loop, blocking reader/writer callbacks and
  corrupting the p99 it was trying to display. Removed the live p99
  from the interval; final percentiles are still computed correctly
  outside the hot path in the Report section.

Addresses Gemini/Claude review findings on PR #1379.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@kriszyp

kriszyp commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

Addressed both findings from the Gemini/Claude review — thanks for the sharp catches!

benchmarks/ttl-churn/run.mts — lost-update race in dirBytes (commit a976974)
The shared total += accumulator inside concurrent Promise.all callbacks was a classic lost-update: each async callback could read and overwrite total before another finished, silently dropping sizes. Fixed by having each entry return its own size and summing with reduce after Promise.all settles — no shared mutable state across awaits.

benchmarks/concurrent-rw/run.mts — O(n log n) sort in the hot progress interval (same commit)
The 5 s ticker was calling readLatenciesMs.slice().sort(...) on the full growing array, blocking the event loop that drives the reader/writer workers — coordinated omission that would inflate the very p99 it was displaying. Dropped the live p99 from the interval (kept elapsed/remaining/counts); the final Report section still computes all percentiles correctly on the settled array, outside the hot path. Added a comment explaining the omission.

— Claude (Sonnet 4.6)

kriszyp and others added 3 commits June 18, 2026 23:32
The harper-bench host serves harper + harper-pro via a repo-scoped just-in-time
supervisor (one job at a time), not an org-level runner — org/enterprise runners
aren't routed jobs for these repos. Also merges current main to pick up the blob
fixes (#1353, #1364/#1369) the stale branch was missing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
);
}
await Promise.all(batch);
totalInserts += batchSize;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

totalInserts is incremented by the full batchSize even when some requests in the batch failed (failures increment insertErrors but totalInserts still counts them). This means the machine-parseable RESULT line emits a total_inserts that can be higher than actual successful inserts. In concurrent-rw, writeOps is incremented only inside the try block, which is the correct pattern.

Suggestion: deduct errors from the batch count, or count successes instead:

Suggested change
totalInserts += batchSize;
totalInserts += batchSize - (/* track per-batch errors */ 0);

A minimal fix: change to count only the non-error inserts. For example, accumulate a per-batch error counter and do totalInserts += batchSize - perBatchErrors. Alternatively, align with concurrent-rw's pattern by counting successes individually inside each promise.

@kriszyp
kriszyp merged commit 77d053a into main Jun 19, 2026
43 of 49 checks passed
@kriszyp
kriszyp deleted the kris/perf-benchmarks branch June 19, 2026 11:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants