Skip to content

pinot: fix cold-cycle data loss, silent OOM partial load, and stop/start race - #2124

Merged
alexey-milovidov merged 2 commits into
ClickHouse:mainfrom
KazukiKandaKK:fix-pinot-cold-cycle-and-oom
Sep 21, 2026
Merged

alexey-milovidov merged 2 commits into
ClickHouse:mainfrom
KazukiKandaKK:fix-pinot-cold-cycle-and-oom

Conversation

@KazukiKandaKK

@KazukiKandaKK KazukiKandaKK commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

pinot/ currently fails all 43 queries (0/43) when run against the real ~74GB hits.tsv on a fresh instance. Found and fixed three separate bugs while tracking this down on a c6a.4xlarge (32 GiB RAM).

1. Cold-cycle data loss (root cause of 0/43). Pinot's benchmark.sh has effectively run with BENCH_DURABLE=yes since the BENCH_RESTARTABLEBENCH_DURABLE rename in commit b282aa49a (2026-05-10) — the wrong setting for a system whose loaded state lives only in process memory. Commit b422b2d4e (2026-06-29) later deleted the explicit export BENCH_DURABLE=yes line, but that's a red herring: BENCH_DURABLE already defaults to yes in lib/benchmark-common.sh, so deleting the redundant explicit line changed nothing — Pinot's effective value was yes before that commit and stayed yes after it.

Pinot's own -dataDir persistence flag doesn't help either. The Quick Start docs describe it as reloading data across restarts, but I confirmed locally that the second QuickStart start throws IllegalStateException (Pinot 1.5.1: Preconditions.checkState(quickstartRunnerDir.mkdirs()) fails because the directory already exists — reproduced in Docker and confirmed against the Quickstart.java source).

A full CSV reload takes ~78 minutes and can't run before each of the 43 queries, so this PR sets BENCH_DURABLE=no and has load re-push the segment .tar.gz files already on disk from the first load, via Pinot's own Tar Push API docs, instead of re-parsing hits.tsv every cold cycle.

2. Silent partial segment generation under memory pressure. pinot-admin.sh defaults to -Xms4G with no -Xmx, so it falls back to the JVM's default heap ceiling (~25% of physical RAM). On the real 74GB/100-split hits.tsv this OOMed mid-job around segment 60/100, but LaunchDataIngestionJob still exited 0 and the >5GB data-size guard in bench_load didn't catch it either, since 60 segments already cleared 5GB. load now sizes the JVM heap dynamically instead of hard-coding one value, and compares the number of generated segment tars against the number of input splits, failing loudly on any mismatch.

Update (2026-09-21): this originally set a fixed JAVA_OPTS="-Xms4G -Xmx20G", sized for the c6a.4xlarge this PR was verified on. ClickBench's own machine:all CI run on this PR's follow-up (#2126) showed that fixed value breaking every smaller machine — c6a.large (4GiB), c6a.xlarge (8GiB), and t3a.small (2GiB) all failed to even start the JVM, since it can't reserve a 20G/4G heap on a host with less physical RAM than that. start/load now read /proc/meminfo and size the heap at 64% of RAM capped at 20G — 64% is anchored so c6a.4xlarge (32GiB, the only machine this fix has been end-to-end verified on: 37/43 queries, 3/3 cold-cycle trials) gets back exactly the already-proven 20G, while smaller machines scale down instead of over-requesting; larger machines stay capped at 20G rather than growing further, since Pinot keeps segments memory-mapped/off-heap and a larger heap would fight the OS page cache instead of helping. Re-verified end-to-end on a fresh c6a.large (4GiB): QuickStart now starts (-Xms2425m -Xmx2425m), completes bootstrap, answers queries, and stops cleanly — all of which failed outright with the old fixed value.

Note: c7a.metal-48xl's failure in the same CI run is a separate, unaddressed issue — c8g.metal-48xl (same 384GiB class) succeeded with the old fixed 20G heap, so an undersized heap isn't a sufficient explanation there. That needs its own investigation.

3. stop/start race corrupting the next cold cycle. stop only did pkill plus a fixed sleep 2, but the broker can keep answering for 7-8s after SIGTERM while the JVM shutdown hook is still running, and the embedded ZooKeeper's port teardown lags even further behind that. The next start then either treats the still-alive old broker as "already up" (skipping the real restart) or fails to bind ZooKeeper's port. stop now polls until the pinot-admin JVM process itself is fully gone. check similarly no longer treats broker-responsive as "fully started" — QuickStart only finishes bootstrapping (and registers its clean-shutdown hook) after printing "Quick start setup complete" to pinot.log, so check now greps for that line too, scoped to the current invocation via a recorded log offset.

Verified end-to-end on EC2 (c6a.4xlarge, real 74GB hits.tsv, 100 segments): 37/43 queries pass across all 3 cold-cycle trials, up from 0/43. The remaining 6 failures are a separate, pre-existing Pinot limitation (standard MIN()/MAX() rejects STRING columns) unrelated to this fix, reported upstream at apache/pinot#19603. A follow-up PR here addresses those once the query-side workaround is ready.

…d stop/start race

pinot/ had three independent bugs, found while running the real ~74GB
ClickBench hits.tsv dataset on a c6a.4xlarge (32 GiB RAM) EC2 instance:

1. Cold-cycle data loss (root cause of 0/43 query failures)
   Pinot's benchmark.sh has effectively run with BENCH_DURABLE=yes since
   the BENCH_RESTARTABLE -> BENCH_DURABLE rename in commit b282aa4 --
   the wrong setting for a system whose loaded state lives only in
   process memory. Commit b422b2d ("Remove unnecessary
   BENCH_DURABLE=yes") later deleted the explicit `export
   BENCH_DURABLE=yes` line, but that's a red herring: BENCH_DURABLE
   already defaults to `yes` in lib/benchmark-common.sh, so deleting the
   redundant explicit line changed nothing -- Pinot's effective value was
   `yes` before that commit and stayed `yes` after it.

   Pinot's own `-dataDir` persistence flag does not work as documented
   either (verified locally: throws IllegalStateException on the second
   QuickStart start, Pinot 1.5.1). Since a full CSV reload takes ~78
   minutes and can't run before every one of 43 queries, `load` now sets
   BENCH_DURABLE=no and re-pushes the existing on-disk segment .tar.gz
   files via Pinot's official Tar Push API
   (docs.pinot.apache.org/.../segment-upload) instead of re-parsing
   hits.tsv from scratch on every cold cycle.

2. Silent partial segment generation under memory pressure
   pinot-admin.sh defaults to `-Xms4G` with no `-Xmx`, so it falls back to
   the JVM's default heap ceiling (~25% of physical RAM). On the real
   74GB/100-split hits.tsv this OOMed mid-job around segment 60/100
   (OutOfMemoryError in SegmentDictionaryCreator) -- but
   LaunchDataIngestionJob still exited 0 and bench_load's >5GB data-size
   guard didn't catch it either, since 60 segments already exceeded 5GB.
   `load` now sets `JAVA_OPTS="-Xms4G -Xmx20G"` and explicitly compares
   the number of generated segment tars against the number of input
   splits, failing loudly on any mismatch instead of silently proceeding
   to the query phase with incomplete data.

3. stop/start race corrupting the next cold cycle
   `stop` only did `pkill` + a fixed `sleep 2`, but a broker can keep
   answering for 7-8s after SIGTERM while the JVM shutdown hook is still
   running, and the embedded ZooKeeper's own port teardown lags even
   further behind that. The next `start` then either treats the still-alive
   old broker as "already up" (skipping the real restart) or fails to bind
   ZooKeeper's port. `stop` now polls until the pinot-admin JVM process
   itself is fully gone (matched via `pgrep -f
   'org.apache.pinot.tools.admin.PinotAdministrator'`) instead of guessing
   from a single component's HTTP port. `check` similarly no longer treats
   broker-responsive as "fully started": QuickStart only registers its
   clean-shutdown hook, and finishes bootstrapping, after printing
   "Quick start setup complete" to pinot.log; check now greps for that
   line (scoped to the current invocation via a recorded log offset) in
   addition to the broker health check, so `stop` is never sent while
   Pinot is still mid-startup.

Verified end-to-end on EC2 (c6a.4xlarge, real 74GB hits.tsv, 100
segments): 37/43 queries now pass across all 3 cold-cycle trials (up from
0/43). The remaining 6 failures are a separate, pre-existing Apache Pinot
limitation (standard MIN()/MAX() rejects STRING columns; reported
upstream at apache/pinot#19603) unrelated to this benchmark-driver fix.
The fixed -Xms4G -Xmx20G this PR (ClickHouse#2124) added for c6a.4xlarge's OOM
broke every smaller machine: ClickHouse/ClickBench's own machine:all
CI run on PR ClickHouse#2126 shows QuickStart failing to produce results on
c6a.large (4GiB), c6a.xlarge (8GiB), and t3a.small (2GiB) — the JVM
can't reserve a 20G/4G heap on hosts with less physical RAM than that.

Read /proc/meminfo and size the heap at 64% of RAM, capped at 20G.
64% is anchored so c6a.4xlarge (32GiB, the only machine this fix has
been end-to-end verified on: 43/43 queries, 3/3 tries) gets back
exactly its already-proven 20G, while smaller machines scale down
instead of over-requesting. Not Presto/install's 70% pattern: Pinot
keeps segments memory-mapped/off-heap rather than fully in-heap, so a
heap that large on machines above 32GiB would fight the OS page cache
instead of helping. No 4G floor on -Xms (a 4G floor is exactly what
kills a 2-4GiB machine); -Xms only reaches 4G once the machine can
spare it.

c7a.metal-48xl's failure in the same CI run is NOT addressed here —
c8g.metal-48xl (same 384GiB class) succeeded with the old fixed 20G,
so a too-small heap can't be the explanation there. That needs its
own log-based investigation before any fix is attempted.

Reviewed with Grok (grok-4.6) before implementing; verdict was
'appropriate but needs correction' on the original plan (naive
Presto-pattern port: 70% ratio + 4G floor on Xms). Both points were
folded into this version.

Local commit only, not yet pushed to the open PR ClickHouse#2124.
KazukiKandaKK added a commit to KazukiKandaKK/ClickBench that referenced this pull request Sep 21, 2026
The fixed -Xms4G -Xmx20G this PR (ClickHouse#2124) added for c6a.4xlarge's OOM
broke every smaller machine: ClickHouse/ClickBench's own machine:all
CI run on PR ClickHouse#2126 shows QuickStart failing to produce results on
c6a.large (4GiB), c6a.xlarge (8GiB), and t3a.small (2GiB) — the JVM
can't reserve a 20G/4G heap on hosts with less physical RAM than that.

Read /proc/meminfo and size the heap at 64% of RAM, capped at 20G.
64% is anchored so c6a.4xlarge (32GiB, the only machine this fix has
been end-to-end verified on: 43/43 queries, 3/3 tries) gets back
exactly its already-proven 20G, while smaller machines scale down
instead of over-requesting. Not Presto/install's 70% pattern: Pinot
keeps segments memory-mapped/off-heap rather than fully in-heap, so a
heap that large on machines above 32GiB would fight the OS page cache
instead of helping. No 4G floor on -Xms (a 4G floor is exactly what
kills a 2-4GiB machine); -Xms only reaches 4G once the machine can
spare it.

c7a.metal-48xl's failure in the same CI run is NOT addressed here —
c8g.metal-48xl (same 384GiB class) succeeded with the old fixed 20G,
so a too-small heap can't be the explanation there. That needs its
own log-based investigation before any fix is attempted.

Reviewed with Grok (grok-4.6) before implementing; verdict was
'appropriate but needs correction' on the original plan (naive
Presto-pattern port: 70% ratio + 4G floor on Xms). Both points were
folded into this version.

Local commit only, not yet pushed to the open PR ClickHouse#2124.
alexey-milovidov pushed a commit that referenced this pull request Sep 21, 2026
…inot#19603)

Depends on #2124 (cold-cycle fix) -- without it, pinot/ fails all 43
queries before this even matters.

With that fix applied, 6 of the 43 queries still fail. All 6 use
MIN/MAX/extract/DATE_TRUNC on EventTime, a STRING column with a
SIMPLE_DATE_FORMAT dateTimeFieldSpec. Standard SQL MIN/MAX in Pinot
always compiles to a numeric-only aggregation function regardless of the
column's declared type, confirmed in AggregationFunctionType
(pinot-segment-spi):

  // TODO: min/max only supports NUMERIC in Pinot, where Calcite
  // supports COMPARABLE_ORDERED
  MIN("min", SqlTypeName.DOUBLE, SqlTypeName.DOUBLE),
  MAX("max", SqlTypeName.DOUBLE, SqlTypeName.DOUBLE),

NonScanBasedAggregationOperator's fast aggregation path then calls
toDouble() unconditionally on the dictionary value, which throws
NumberFormatException: For input string: "2013-07-01".

Pinot already ships a fix for this (apache/pinot#16980, merged
2025-10-10): an AggregateFunctionRewriteOptimizer that rewrites MIN/MAX
on a STRING column to MINSTRING/MAXSTRING. A follow-up
(apache/pinot#17058, merged 2025-10-30) gated the rewrite behind a query
option: its diff adds
`if (Boolean.parseBoolean(options.get(QueryOptionKey.AUTO_REWRITE_AGGREGATION_TYPE)))
{ useRuleSet.add(...AGGREGATE_FUNCTION_REWRITE); }`, so
autoRewriteAggregationType defaults to off when unset. Confirmed Pinot
1.5.1 (used here) postdates both merges. Filed apache/pinot#19603
upstream, since neither the option nor MINSTRING/MAXSTRING is mentioned
on the Query Options doc page and the error message gives no hint they
exist.

This PR turns the option on for every ClickBench query:
- pinot/query: adds autoRewriteAggregationType=true
- pinot/queries.sql: EventTime is used directly in
  extract(minute FROM EventTime) and DATE_TRUNC('minute', EventTime) in
  queries 19 and 43. Those aren't MIN/MAX, so the option doesn't touch
  them; they fail for the same underlying reason (Calcite treats the
  STRING column as non-temporal). Wrapped both in
  CAST(EventTime AS TIMESTAMP), matching the existing pattern in this
  benchmark's sail/sail-partitioned queries.sql, which already do the
  exact same cast for these same two queries.

Verified locally (Docker, Pinot 1.5.1, 3-row STRING table) that
OPTION(autoRewriteAggregationType=true) alone fixes the MIN/MAX case, and
on EC2 that all 6 previously-failing queries now return non-null results
with this change plus the cold-cycle fix from #2124.
@alexey-milovidov alexey-milovidov self-assigned this Sep 21, 2026
@alexey-milovidov
alexey-milovidov merged commit d1ec24b into ClickHouse:main Sep 21, 2026
1 of 2 checks passed
alexey-milovidov pushed a commit that referenced this pull request Sep 22, 2026
Depends on #2124 (cold-cycle/OOM fix) and #2125 (STRING MIN/MAX
workaround) -- produced with both applied. Without them, 6 of the 43
queries fail (NumberFormatException/NullPointerException on the STRING
EventTime column).

The existing Pinot result (pinot/results/20220701/c6a.4xlarge.json) only
loaded 94,465,149 of the 99,997,497 rows and has 6 null cells from
NullPointerExceptions during the query run -- the comment on that file
already flags it as incomplete.

Re-ran pinot/benchmark.sh with the #2124/#2125 fixes applied (Pinot
1.5.1) on a fresh c6a.4xlarge. All 99,997,497 rows loaded this time, and
all 43 queries finished 3/3 tries with no errors and no nulls.

- Load time: 697s (segment-generation load path, LaunchDataIngestionJob's
  100-way parallel ingestion; not the single-threaded QuickStart CSV
  reload that PR-A's ~78-minute figure refers to)
- Data size: 9,157,433,125 bytes
- Machine: c6a.4xlarge, Ubuntu 24.04, vanilla config (no star tree /
  secondary indices)

New file: pinot/results/20260920/c6a.4xlarge.json. Per the README, each
new run for an existing machine goes into a new dated subdirectory, and
the website displays the latest dated copy of each <system>/<machine>
pair.

Working separately on a star-tree-indexed Pinot run for #37 and will
send that as its own PR once it's done.

This branch is waiting to be deployed

1 waiting deployment
benchmark-approval 432ae936 Waiting Sep 21, 2026 by KazukiKandaKK via launch #472
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