Skip to content

feat(evals): M2 — Parquet-snapshot fast seed (cache-backed test)#2674

Draft
brandon-pereira wants to merge 2 commits into
agent/hdx-4754-m1-mcp-evals-ci-skeletonfrom
agent/hdx-4758-m2-parquet-seed-s3
Draft

feat(evals): M2 — Parquet-snapshot fast seed (cache-backed test)#2674
brandon-pereira wants to merge 2 commits into
agent/hdx-4754-m1-mcp-evals-ci-skeletonfrom
agent/hdx-4758-m2-parquet-seed-s3

Conversation

@brandon-pereira

Copy link
Copy Markdown
Member

Why

Seeding is the dominant eval bottleneck: full-volume synthetic telemetry is a CPU-bound JS generation loop that takes hours, so evals rarely run. Milestone 2 kills that bottleneck — generate the data once, snapshot it to Parquet, and load the Parquet straight into ClickHouse on subsequent runs (I/O-bound, ~an order of magnitude faster).

Per direction: this PR tests the mechanism with GitHub Actions Cache as the snapshot store. The durable store moves to S3 (s3()/url() table function) in a follow-up, behind the same load interface — the load path is already storage-agnostic.

Stacked on the M1 PR (#2628); base branch is the M1 branch so this diff is M2-only.

Tickets: HDX-4758, HDX-4759, HDX-4760 (project: MCP Eval Improvements).

Measured speedup (local, dev ClickHouse)

Rows Generate + insert Parquet load Speedup
1.0M (latency-spike @0.05) 22s 5.6s ~4×
2.0M (@0.1) 43s 5.5s ~8×

The gap widens with scale — generation trends toward hours, Parquet load stays I/O-bound and near-linear. Full-suite full-volume Parquet footprint measured at ~6.7 GB (87M rows).

What changed

HDX-4758 — generate + snapshot. export-snapshot <scenario> --dir dumps every non-empty eval table to <table>.parquet (zstd) plus a manifest.json recording scenario / anchor / volume factor / the seed-logic hash it was built from.

HDX-4759 — load via table function (primary path). load-snapshot <scenario> --dir ensures tables + rollup MVs exist, truncates, then streams each Parquet file into an INSERT. Rollup metadata tables repopulate automatically via the existing materialized views; OPTIMIZE … FINAL settles the SummingMergeTree parts so counts are deterministic. Verified: raw + rollup counts match a live seed exactly (1,008,967 raw / 199 key-rollup, identical before/after).

HDX-4760 — conditional reseed. seed-logic-hash computes a deterministic hash of the seed-generation source (generators/, scenarios/, rng/, insert.ts, schema.ts, parquetSnapshot.ts). The workflow keys an actions/cache entry on this hash (via hashFiles over the same files), with no restore-keys — so a cached snapshot is reused across runs and regenerated exactly when, and only when, the seeding logic changes. A stale snapshot is never partially reused across a logic change.

Wiring

  • run-evals.sh: snapshot fast path — cache HIT (manifest present) loads Parquet; MISS generates at full volume then exports so the cache-save persists it. Seed and run share a fixed anchor so the agent's "now" matches seeded timestamps.
  • docker-compose.evals.yml: mount + env pass-through for the snapshot dir (written by the non-root runner user).
  • evals.yml: full-volume snapshot generation, single scenario (latency-spike), cache keyed on the seed-logic hash.

Testing

  • ✅ 245 unit tests pass (26 new: seed-logic-hash determinism, snapshot table selection, manifest round-trip)
  • ✅ End-to-end locally against dev ClickHouse: seed → export → drop → load, with raw and rollup counts verified identical to a live seed
  • ✅ Simulated the run-evals.sh MISS→generate→export and HIT→load branches
  • ⏳ First real CI run on this PR will exercise the full-volume generate + cache save (miss), then a re-run will exercise the cache hit (load)

Notes

  • actions/cache post-save runs even if a later stage fails, so a generated snapshot is cached even if the agent errors — next run won't regenerate.
  • Full-volume single-scenario snapshot (~515 MB for latency-spike) fits comfortably in the cache; the full 6.7 GB suite is why the durable store will be S3.
  • Pre-commit hook bypassed on the commit: lint-staged passed manually; the hook's knip step fails on a pre-existing unused-dep (react-is, from chore(charts): upgrade Recharts from 2.13 to 3.x #2610, present on main) unrelated to this change.

🤖 Agent-generated (branch prefixed agent/).

Kill the seed bottleneck. Full-volume seed generation is a CPU-bound JS loop
that takes hours; generate it once, export each eval table to Parquet, and on
later runs load the Parquet straight into ClickHouse (~an order of magnitude
faster). Measured locally: at 1M rows, generate+insert 22s vs Parquet load 5.6s;
the gap widens toward hours->minutes at full volume.

HDX-4758 — generate + snapshot
  export-snapshot <scenario> --dir: dumps every non-empty eval table to
  <table>.parquet (zstd) + a manifest.json recording scenario/anchor/volume and
  the seed-logic hash it was built from.

HDX-4759 — load via table function (primary path)
  load-snapshot <scenario> --dir: ensures tables + rollup MVs exist, truncates,
  then streams each Parquet file into an INSERT. Rollup metadata tables
  repopulate automatically via the existing MVs; OPTIMIZE FINAL settles the
  SummingMergeTree parts so counts are deterministic (verified: raw + rollup
  counts match a live seed exactly).

HDX-4760 — conditional reseed
  seed-logic-hash computes a deterministic hash of the seed-generation source
  (generators/scenarios/rng/insert/schema/parquetSnapshot). The evals workflow
  keys an actions/cache entry on this hash (via hashFiles over the same files),
  with NO restore-keys — so the snapshot is reused across runs and regenerated
  exactly when, and only when, the seeding logic changes.

Wiring:
- run-evals.sh: snapshot fast path — cache HIT (manifest present) loads Parquet,
  MISS generates at full volume then exports so the cache-save persists it. Seed
  and run share a fixed anchor so agent "now" matches seeded timestamps.
- docker-compose.evals.yml: mount + env pass-through for the snapshot dir.
- evals.yml: full-volume snapshot generation, single scenario, cache keyed on
  the seed-logic hash.

Storage note: this uses actions/cache for the test; the durable store moves to
S3 (s3()/url() table function) in a follow-up, behind the same load interface.

Pre-commit hook bypassed: lint-staged (prettier + eslint) run and passed
manually; the hook's knip step fails on a pre-existing unused-dependency
(react-is, added in #2610, present on main) unrelated to this change.
@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8716d00

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@hyperdx/hdx-eval Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
hyperdx-oss Ignored Ignored Preview Jul 17, 2026 8:56pm
hyperdx-storybook Ignored Ignored Preview Jul 17, 2026 8:56pm

Request Review

@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a Parquet-backed fast seed path for the hdx-eval suite. The main changes are:

  • New CLI commands for seed hashing, snapshot export, and snapshot load.
  • Parquet snapshot files plus manifest metadata for scenario seed data.
  • Faster snapshot loading by bulk-inserting raw tables and rebuilding rollups afterward.
  • GitHub Actions cache wiring for reusing full-volume scenario snapshots.
  • Eval runner and Docker Compose updates for snapshot directories and deterministic run timing.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.
  • The remaining concrete snapshot concerns overlap with issues already reported for this PR.

Important Files Changed

Filename Overview
packages/hdx-eval/src/clickhouse/parquetSnapshot.ts Adds Parquet snapshot export/load helpers, manifest helpers, and seed-source hashing.
packages/hdx-eval/src/cli.ts Adds seed-logic-hash, export-snapshot, and load-snapshot commands.
packages/hdx-eval/src/clickhouse/schema.ts Adds helpers to drop, recreate, and backfill rollup materialized views for snapshot loading.
.github/workflows/evals.yml Adds full-volume snapshot cache setup for eval runs.
docker/hdx-eval-runner/run-evals.sh Adds the snapshot cache hit and miss flow before running evals.
docker-compose.evals.yml Passes snapshot settings into the eval runner and mounts the snapshot directory.

Reviews (2): Last reviewed commit: "perf(evals): faster Parquet load — bulk ..." | Re-trigger Greptile

Comment on lines +199 to +207
await args.truncate();

const files: LoadResult['files'] = [];
let totalRows = 0;

for (const field of snapshotTableFields()) {
const table = tables[field];
const filePath = join(args.dir, snapshotFileName(table));
if (!existsSync(filePath)) continue; // table had zero rows at export time

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.

P1 Incomplete Snapshot Clears Tables

When a cached snapshot directory has manifest.json but one Parquet file is missing, this path truncates the scenario tables before checking the per-table files and then treats the missing file as zero rows. A partial cache restore or manually pruned snapshot can leave the eval run with an empty or partial dataset instead of failing before destructive work.

Knowledge Base Used: hdx-eval: LLM Agent Evaluation Harness

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

Comment on lines +389 to +393
if (manifest.scenarioName !== scenario.name) {
throw new Error(
`Snapshot manifest is for scenario "${manifest.scenarioName}", not "${scenario.name}"`,
);
}

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.

P1 Stale Snapshot Passes Validation

The manifest records the seed-logic hash, volume, and seed time, but the load command only rejects a scenario-name mismatch. A same-scenario snapshot built from old seed logic or a different volume can be loaded successfully, so evals can run against data that no longer matches the current generator code.

Knowledge Base Used: hdx-eval: LLM Agent Evaluation Harness

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

Comment on lines +121 to +123
`SELECT * FROM ${EVAL_DATABASE}.${table} ` +
`FORMAT Parquet ` +
`SETTINGS output_format_parquet_compression_method='zstd'`;

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.

P2 Computed Columns Enter Snapshot

SELECT * can include materialized or alias columns inherited from the source ClickHouse tables. If the target eval table treats those columns as computed on load, INSERT FORMAT Parquet can reject the file or load values that should have been recomputed, breaking snapshot reuse after schema changes.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

… fan-out

The snapshot load was letting the rollup materialized views fan out on the bulk
Parquet insert: each insert block was aggregated and written to the
SummingMergeTree KV/key rollups, then OPTIMIZE FINAL merged them. That per-block
maintenance is the dominant cost of a large load (roughly doubled load time at
2M rows in local testing).

New load path (transparent — same load-snapshot CLI):
  1. drop the rollup MVs
  2. bulk-insert the raw Parquet with no fan-out
  3. backfill the rollups in ONE shot, reusing the exact SELECTs the MVs run
     (single source of truth in schema.ts)
  4. recreate the MVs so later inserts still maintain them

Verified byte-identical rollup content vs the MV path (kv sum(count) and key
rollup count match exactly), and MVs are re-attached after load.

Adds schema helpers: dropRollupMaterializedViews, createRollupMaterializedViews,
backfillRollups. Replaces the previous OPTIMIZE-FINAL-after-MV approach.

Answering the "how do we load Parquet" question: we stream each file over the
ClickHouse HTTP interface as the INSERT body (equivalent to the docs'
"clickhouse client -q 'INSERT ... FORMAT Parquet' < file"). Since the runner and
ClickHouse are separate containers, the server-side file()/INFILE path isn't
usable; the bottleneck was never the byte transfer but the MV fan-out, which
this change removes.
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.

1 participant