Skip to content

[CI] Parallelize unit-test phase with fork-safe port/temp-dir isolation - #19199

Open
xiangfu0 wants to merge 9 commits into
apache:masterfrom
xiangfu0:cs_9_nz_3gup3/ci/unit-tests-speedup
Open

[CI] Parallelize unit-test phase with fork-safe port/temp-dir isolation#19199
xiangfu0 wants to merge 9 commits into
apache:masterfrom
xiangfu0:cs_9_nz_3gup3/ci/unit-tests-speedup

Conversation

@xiangfu0

@xiangfu0 xiangfu0 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

The Unit Tests workflow currently takes ~63 min wall-clock, with ~83% of that spent in a single-threaded mvn test step. This PR keeps the existing 2-shard matrix but parallelizes tests within each shard using surefire fork-level parallelism, with fork-safe port/temp-dir isolation so concurrent forks don't collide.

All new knobs default to the prior behavior, so local/dev builds are unchanged; only CI opts into parallelism.

What changed

Parallelism knobs (pom.xml, overridable, default = prior behavior)

  • unit.test.fork.count (default 1) → surefire forkCount. With reuseForks=false, each fork runs one test class in its own JVM, so this is process-level isolation (not TestNG intra-JVM threading) — tests that were unsafe multi-threaded within a single JVM are unaffected. CI sets 3.
  • unit.test.fork.heap (default 4g) → per-fork -Xms/-Xmx. CI lowers this to 2500m so 3 forks * 2500m + the Maven JVM stays within the runner's RAM.
  • unit.test.rerun.count (default 0) → rerunFailingTestsCount. Left at 0; kept as an escape hatch so real failures are never masked.
  • jacoco.exec.suffix (default empty) → per-fork exec file. The unit-test script sets it to -${surefire.forkNumber} so parallel forks each write a distinct jacoco-<n>.exec instead of corrupting one shared file; coverage is then aggregated via a jacoco-*.exec glob.
  • surefire.forkNumber exposed as a system property so tests can offset resources per fork.

Fork-safe resource isolation

  • ZkStarter: per-fork ZK test-port offset derived from surefire.forkNumber (production-inert — the property is absent outside a surefire fork, so it falls back to the historical default port). ZK data dir now uses a random UUID instead of System.currentTimeMillis() (which collides when two instances start in the same ms).
  • ControllerTest: per-fork controller/ZK port offset; data/temp dirs switched to UUID-based names.

Fork-unsafe test fixes (pinot-segment-local)

  • DictionariesTest / DictionaryOptimiserTest / SegmentLocalFSDirectoryTest: these derived their index dir from another test's class name, so they shared a directory. Switched to per-run UUID dirs under the temp dir.

Root-caused flakes (not masked by retry)

  • SegmentPreProcessorTest: assert the no-op index recreation doesn't move the mtime by more than filesystem timestamp granularity, instead of requiring exact equality (flaky under CPU load).
  • LuceneMutableTextIndexTest: replace the fixed Thread.sleep(100) with polling on an analyzer-independent refresh sentinel until the async NRT refresh is visible (up to a generous timeout).

CI script (.pinot_tests_unit.sh)

  • Both shards run plain mvn test (modules already built/installed by .pinot_tests_build.sh).
  • Env-overridable UNIT_TEST_FORK_COUNT (3), UNIT_TEST_FORK_HEAP (2500m), UNIT_TEST_RERUN_COUNT (0), RUN_CODECOVERAGE (true).
  • Shard rebalancing: pinot-segment-local tests moved to set add pinot-trace #1 to keep both shards near-equal in wall-clock.
  • Per-fork JaCoCo aggregation via jacoco-*.exec glob.

Backward compatibility

  • No production code behavior changes. ZkStarter's fork offset is inert outside a surefire fork.
  • All pom knobs default to the historical values (1 fork, 4g heap, 0 reruns, single jacoco.exec), so mvn test locally behaves exactly as before.

Testing

CI Unit Tests workflow (both shards) with parallel forks enabled.

The Pinot Unit Tests workflow spends ~83% of its ~63 min wall-clock in a
single-threaded `mvn test` phase, using only 1 of the runner's 4 cores.

Make surefire forkCount and per-fork heap overridable via properties
(default 1 fork / 4g, preserving local/dev behavior). CI opts into 2 forks
/ 3g so test classes run in parallel JVMs (reuseForks=false keeps one class
per JVM, so this is process-level isolation, not TestNG intra-JVM threading).

To keep parallel forks collision-free:
- ZkStarter offsets its default test port per surefire.forkNumber and uses a
  UUID (not currentTimeMillis) for the ZK data dir.
- ControllerTest offsets its controller/broker/server/minion and configured
  ZK port bases per fork, and uses UUIDs for its data/temp dirs. A ZK base of
  0 (the default) still defers to ZkStarter's fork-aware allocation.
- surefire.forkNumber is exposed to forked JVMs via systemPropertyVariables.
- JaCoCo writes a per-fork jacoco-<forkNumber>.exec so concurrent forks no
  longer append to one shared exec file; report-aggregate globs jacoco-*.exec.
…0 min

Set 2's serial test phase is ~53 min; at forkCount=2 the parallelized phase
(~27 min ideal, more with imperfect balance) plus the ~9.5 min build lands
too close to the 40 min target. forkCount=3 with 2500m/fork (3*2500m + the 2g
Maven JVM ~= 9.5g, well under the runner's 16g) gives comfortable margin.
Test JVMs spend much of their runtime blocked on ZK/Helix/socket startup, so
a third fork still pays off on the 4-vCPU runner. Still a single env override
(UNIT_TEST_FORK_COUNT) to dial back if CI shows CPU/memory pressure.
Follow-ups from review of the unit-test parallelization:

- Move pinot-segment-local's tests to test-set #1 (already built there) so the
  two shards stay balanced now that set #2's build is ~3x longer than set #1's.

- Fix fork-unsafe temp-dir sharing surfaced by parallel forks:
  DictionariesTest and DictionaryOptimiserTest both derived their index dir from
  DictionariesTest.class (same path) and wiped it in @BeforeClass, colliding when
  run concurrently; SegmentLocalFSDirectoryTest derived its dir from another
  test's class. All three now use a per-run UUID temp dir.

- Scope the JaCoCo exec file per lane instead of globally: prepare-agent now
  writes jacoco${jacoco.exec.suffix}.exec, defaulting to the historical
  jacoco.exec so the integration lanes' jacoco:report keep working; only the
  unit-test script sets the suffix to -${surefire.forkNumber} so parallel forks
  write distinct jacoco-<n>.exec files (aggregated via a jacoco-*.exec glob).

- Correct comments: surefire.forkNumber is 1-based (1 even at forkCount=1), and
  clarify the ZkStarter fork offset is a test-only hook (0 in production).
Two pre-existing tests (SegmentPreProcessorTest's file-mtime assertion and
LuceneMutableTextIndexTest's NRT-refresh assertion) fail only when many parallel
forks saturate the runner; they pass in isolation and on retry. Add an
overridable unit.test.rerun.count (default 0 = unchanged locally) wired to
surefire rerunFailingTestsCount, and set it to 2 in the unit-test CI script.
Surefire still reports a retry-only pass as flaky, so genuine failures fail the
build.
testV3CreateInvertedIndices asserts the no-op second index creation does not
rewrite columns.psf by comparing FileTime at nanosecond precision. The 2s
sleeps in the test mean a real rewrite moves the mtime by ~2000ms, so
nanosecond exactness is unnecessary and flaky: under CPU load (parallel forks)
two getLastModifiedTime syscalls can report sub-microsecond jitter for an
untouched file. Compare toMillis() instead — still catches any real rewrite.
JaCoCo adds ~30% to the unit-test phase (agent per fork + aggregate report).
Add RUN_CODECOVERAGE (default true, so behavior is unchanged) so a caller can
set RUN_CODECOVERAGE=false to trade coverage for a faster run when needed. When
disabled, the codecoverage profile and the report-aggregate step are skipped.
Replace LuceneMutableTextIndexTest's fixed Thread.sleep(100) after commit() with
a bounded poll that waits (up to 30s) until a sentinel query reflects the
committed docs. The fixed sleep was too short under CPU load (the async NRT
refresh thread may not run in time), which is what made the test flaky under
parallel forks. With both load-sensitive flakes now fixed at the root
(this and the SegmentPreProcessorTest mtime assertion), set the default
unit.test.rerun.count back to 0 so no real failure is ever masked; the knob
remains as an escape hatch.

Also document that ZkStarter's fork-port offset is deliberately centralized on
the no-arg entry point (production-inert; avoids duplicating fork math across
several test callers).
…ProcessorTest

Even at millisecond granularity the no-op index recreation can leave the
columns.psf mtime 1ms later than the prior read under CPU load (filesystem
timestamp granularity / metadata flush), so exact equality still flaked. The
test's 2s sleeps guarantee a genuine rewrite would move the mtime by ~2000ms,
so assert the delta stays under 1s instead — still catches a real rewrite,
robust to sub-second jitter.
…tIndexTest

The NRT-refresh barrier polled getDocIds("stream") expecting doc 0, which only
matches under the default StandardAnalyzer. The five custom-analyzer tests use a
KeywordTokenizer that indexes each value as one token, so the term "stream"
never matches and each test spun the full 30s timeout (~150s wasted on the very
class this change speeds up). Poll the regex /.*house.*/ -> doc 1 instead, which
every config indexes and which matches under both analyzers. Full class now runs
in ~7s (was ~150s+).
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