We would love for you to contribute to Apache Pulsar and make it even better! The authoritative
contributor guide is the Apache Pulsar Contributing Guide —
please read it before starting. This file is a quick, in-repo reference for the local development
workflow (build, test, PR, CI). For the big-picture module map and the Gradle build infrastructure see
ARCHITECTURE.md; for code style see CODING.md.
JDK 21, 25 or 26 is required to build master (bytecode targets Java 17; -PskipJavaVersionCheck
bypasses the check); zip is also needed. Use the bundled wrapper ./gradlew (Linux/macOS) or
gradlew.bat (Windows) — no separate Gradle install. See the
build-tooling setup guide and the
IDE setup guide.
# Compile and assemble everything (or a single module)
./gradlew assemble
./gradlew :pulsar-broker:assemble
# Source-code conformance across all modules — license headers (rat + spotlessCheck) and checkstyle
# (checkstyleMain + checkstyleTest). Fast: compiles nothing, never builds shadow jars.
./gradlew quickCheck
./gradlew spotlessApply # auto-fix license headers
# Pre-PR gate — quickCheck plus compiling main + test sources of every module. Modules that depend
# on shaded artifacts on their compile classpath are skipped for compilation so no shadow jar is built.
./gradlew sanityCheck
# Verify bundled-dependency LICENSE/NOTICE coverage (after changing a runtime dependency)
./gradlew checkBinaryLicense
# Start a standalone Pulsar service (broker + bookie + metadata in one JVM)
bin/pulsar standalone
# Build docker images apachepulsar/pulsar(-all):latest
./gradlew docker # or docker-allFor the Gradle build infrastructure and how to change build files (convention plugins, version
catalog, configuration-cache rules), see
ARCHITECTURE.md → Build infrastructure.
See Publishing Maven artifacts for ordinary publishing, the API/SPI subset, custom repositories, groups and credentials.
Most of these per-module "unit tests" are actually integration-style — they start a real in-JVM
broker (MockedPulsarServiceBaseTest / pulsarTestContext) rather than testing a class in isolation.
The container-based integration tests that run against a Pulsar Docker image are separate; see
Integration tests below.
# Always scope test runs with --tests — running a whole module's test task is slow.
# Run a single test class
./gradlew :pulsar-client-original:test --tests "ConsumerBuilderImplTest"
# Run a single test method
./gradlew :pulsar-client-original:test --tests "ConsumerBuilderImplTest.<methodName>"
# Run all tests in a specific package
./gradlew :pulsar-broker:test --tests "org.apache.pulsar.broker.admin.*"Note the module-name-vs-directory gotcha: directory
pulsar-client/is the Gradle project:pulsar-client-original.
Tests use TestNG and are tagged with @Test(groups = "..."). By default the build excludes the
quarantine and flaky groups (excludedTestGroups default = quarantine,flaky), so to run a
single test that lives in one of those groups you must clear the exclusion:
# Run a specific test that is in the flaky/quarantine group (otherwise excluded by default)
./gradlew :pulsar-broker:test -PexcludedTestGroups='' --tests "<SomeFlakyTest>"CI selects whole groups with -PtestGroups=<groups> and -PexcludedTestGroups=<groups> (e.g.
broker,broker-admin); locally prefer --tests to scope to specific classes instead of running an
entire group. CI splits pulsar-broker tests into groups (see
pulsar-build/run_unit_group_gradle.sh and gradle/verify-test-groups.gradle.kts). Tests with no
group are treated as other at runtime. ./gradlew verifyTestGroups reports group assignments and
flags tests not covered by any CI group.
Other test-related properties: -PtestJavaVersion=17 (run tests on a different JDK toolchain),
-PtestRetryCount=N, -PtestFailFast=true|false, -PprotobufVersion=4.31.1 (protobuf v4
compatibility tests).
Test JVMs default to a 1300m heap and up to four forks per task. Override these with
-PtestMaxHeapSize=1500m and -PtestMaxParallelForks=2; --max-workers=2 also limits
concurrent workers across projects. Task-specific limits, such as the integration tests' single
fork and 1G heap, take precedence.
Use -PtestForkEvery=50 to replace a test JVM after 50 detected test classes, limiting state
retained across classes. The default is 0 (no class limit). Gradle counts candidate classes
passed to test workers before TestNG group filtering, so classes excluded by groups still count
toward each batch. --tests can narrow candidates, but wildcard filters may still count classes
that execute no matching tests. Test methods, data-provider rows and factory instances do not
count separately.
Tasks using TestNG XML suites and async-profiler keep recycling disabled; task-specific
isolation, such as SASL's one class per worker, takes precedence.
Test JVMs write heap dumps on heap exhaustion to /tmp/java_pid<PID>.hprof, collected by CI's
existing failure artifacts. Set -PtestHeapDumpPath=<existing-directory> to use another directory.
Failed tests are retried once by default (testRetryCount=1; 0 when running inside the IDE). When
running tests locally, prefer -PtestRetryCount=0 to catch failures (including flakiness) early
instead of having retries mask them.
Tests enable Netty's paranoid leak detection by default, using ExtendedNettyLeakDetector
to include test names in leak reports and write netty_leak_*.txt files. Set
NETTY_LEAK_DUMP_DIR to choose the output directory (the default is the JVM's temporary directory).
NETTY_LEAK_DETECTION=report (the default) reports leaks without failing tests.
In CI, NETTY_LEAK_DETECTION=fail_on_leak makes the leak-reporting step fail the job when dumps
are found. Unit, integration, and system test jobs collect dumps from both the test JVMs and
Pulsar Docker containers, including reports generated during container shutdown. For local tests, use -PtestExitJvmOnLeak=true to fail the test JVM on a detected leak:
NETTY_LEAK_DUMP_DIR=/tmp/pulsar-netty-leaks ./gradlew :pulsar-client-original:test \
--tests "ConsumerBuilderImplTest" -PtestExitJvmOnLeak=true -PtestRetryCount=0Set NETTY_LEAK_DETECTION=off to disable detection, or use
-PtestLeakDetectionLevel=simple|advanced|paranoid|disabled to change its level.
-PtestExitJvmOnLeakDelayMillis=1000 controls the delay before exiting on a leak.
Detection is disabled automatically for -PtestAsyncProfiler and profilingIntegrationTest
to avoid distorting profiles.
For a micro-level question — what a single method, data structure or codec costs — write a
JMH benchmark under microbench/. That is the
preferred tool for micro benchmarks: JMH handles warm-up, dead-code elimination and measurement, none
of which hand-written timing code gets right. CODING.md asks for
optimizations to be backed by evidence, and for a small self-contained change a benchmark is the
strongest kind.
./gradlew :microbench:shadowJar # build the runnable jar
java -jar microbench/build/libs/microbench-*-benchmarks.jar -l # list the benchmarks
java -jar microbench/build/libs/microbench-*-benchmarks.jar ".*<Name>.*" # run the ones that matchA benchmark can be profiled directly through JMH's async-profiler integration, which writes forward
and reverse flame graphs per benchmark under dir=:
export LIBASYNCPROFILER_PATH=$(ls $JAVA_HOME/lib/libasyncProfiler.*) # Corretto ships one
java -jar microbench/build/libs/microbench-*-benchmarks.jar \
-prof async:libPath=$LIBASYNCPROFILER_PATH\;output=flamegraph\;dir=profile-results ".*<Name>.*"See microbench/README.md for the rest: the async-profiler setup when the
JDK does not ship one, recording a benchmark to JFR and rendering it, JSON result files for
JMH Visualizer, and the rawCommand escape hatch for async-profiler
options the JMH plugin does not expose.
Profiling a test run answers the question a micro benchmark cannot: not what one method costs, but where a broker actually spends its time end to end.
When the numbers matter, profile and benchmark on Linux x86_64. That is Pulsar's most common deployment target, and results from elsewhere do not carry over. Two differences bite in particular: async-profiler supports only some of its sampling engines on macOS, so a profile taken there is less reliable; and
System.nanoTime()is far more expensive on macOS than on Linux, which skews the results in some cases — code that times itself, and JMH's own measurement loop, both pay that cost. Working on macOS or arm64 is fine for finding your way around the code — just treat what it tells you as provisional until it is confirmed on Linux x86_64.
Pass -PtestAsyncProfiler to run a test task under
async-profiler. The build finds the profiler
library in LIBASYNCPROFILER_PATH, and falls back to the copy that Amazon Corretto ships inside the
JDK that runs the tests, so on Corretto no setup is needed at all:
# only needed when the JDK does not ship async-profiler
export LIBASYNCPROFILER_PATH=$(ls $JAVA_HOME/lib/libasyncProfiler.*)
./gradlew :pulsar-broker:test -PtestAsyncProfiler --tests "<SomeTest>"Enabling it runs the tests in a single JVM with retries off and a pre-touched fixed-size heap, and
sends log4j output to a file, so that one run produces one profile that isn't distorted by console
logging or by heap resizing. The task always re-runs and is never cached. Manual tests are enabled
automatically whenever profiling is on — the long-running, load-generating tests that are otherwise
skipped are usually the ones worth profiling — so ENABLE_MANUAL_TEST does not have to be exported. Recordings are written to
build/test-profiles/ in the repository root, named after the test task and stamped with the start
time and the pid — for example test_profile_pulsar-broker-test_20260904-114040_23420.jfr, next to
test_profile_pulsar-broker-test.log. See Analyzing a JFR file below.
The defaults can be tuned with -Ptest.asyncprofiler.event=<event> (the CPU sampling engine:
cpu on Linux, itimer elsewhere — see
CPU sampling engines),
-Ptest.asyncprofiler.opts=<agent options> (default event=<event>,all,alloc=2m,jfrsync=profile),
-Ptest.asyncprofiler.outputformat=jfr|html|collapsed, -Ptest.asyncprofiler.dir=<dir> (use it to
keep the profiles of an A/B comparison apart) and -Ptest.asyncprofiler.libpath=<path>.
outputformat only sets the file extension; the format itself comes from the agent options, and the
default jfrsync=profile forces a JFR recording. To get something other than JFR, override both — to
profile exception creation, for example (Linux only):
./gradlew :pulsar-broker:test -PtestAsyncProfiler --tests "<SomeTest>" \
"-Ptest.asyncprofiler.opts=event=Java_java_lang_Throwable_fillInStackTrace,tree,reverse" \
-Ptest.asyncprofiler.outputformat=htmlThe module tests above profile the JVM the tests run in. To profile the broker, bookies and ZooKeeper inside the containers of a real cluster, run:
./gradlew :tests:integration:profilingIntegrationTestThat one task does everything the profiling run needs: it builds
apachepulsar/java-test-image:latest-asyncprofiler (the test image with async-profiler installed —
kept under its own tag so it never replaces the image the other integration tests use), relaxes the
kernel perf_event limits that the cpu sampling engine needs by way of a privileged throwaway
container, and runs
PulsarProfilingTest
against it with retries off. That test drives pulsar-perf against a single broker.
There are two variants of it, sharing everything but the client generation and the topic domain
through
AbstractPulsarProfilingTest.
PulsarProfilingTest — the one the task runs by default — drives a v5 scalable (topic://) topic
with the produce / consume commands, and
PulsarProfilingV4Test
drives a classic persistent:// topic with the same commands, for which pulsar-perf picks the v4
client. The pairing is not a free choice: the v4 client rejects the topic:// domain outright, so it
is the v4 client that goes with the classic topic. To profile that baseline instead:
./gradlew :tests:integration:profilingIntegrationTest --tests "*PulsarProfilingV4Test"Both variants write into tests/integration/build/pulsar-profiling. The v4 run's pulsar-perf
output, latency histograms, topic stats and metrics scrapes are suffixed -v4 so the two runs can be
told apart; the .jfr recordings instead carry the container name, which embeds the test class name.
The runs are not otherwise like-for-like: scalable topics split their segments under load
(scalableTopicAutoScaleEnabled defaults to true), so the v5 run profiles a topology that reshapes
itself while the v4 run's stays fixed.
A run sends a fixed 20 million messages, a bit over a minute of load at the throughput the containers
sustain, and has to be done inside three minutes. Both pulsar-perf commands must then have exited
zero, so a run that stalls or dies fails the test rather than passing as a finished profile.
Any other integration test can be profiled without being modified, by pointing the task at it and naming the cluster components to attach the profiler to:
./gradlew :tests:integration:profilingIntegrationTest --tests "<SomeIntegrationTest>" \
-Pinttest.asyncprofiler.components=broker,bookiecomponents takes broker, proxy, functionworker, bookie, zookeeper, or all, and defaults
to broker for this task. It is what PulsarClusterSpec.profileBroker and its siblings fall back to,
so it has no effect on a test that sets those flags itself — PulsarProfilingTest does, which is why
it profiles the broker whatever you pass. Every other test leaves them at their default of off, so
without this property nothing would be profiled. Setting it also enables manual tests, so
-Pinttest.asyncprofiler.components=<...> profiles a cluster through the plain integrationTest
task too.
Recordings land in tests/integration/build/, named inttest_profile_<commit>_<time>_<container>_<pid>.jfr
— the commit id comes from git rev-parse --short HEAD so profiles taken before and after a change
can be told apart — one file per profiled container. Tune the run with
-Pinttest.asyncprofiler.opts=<agent options> (default
event=cpu,lock=1ms,alloc=2m,jfrsync=profile), -Pinttest.asyncprofiler.outputformat=<ext>,
-Pinttest.asyncprofiler.dir=<dir> and -Pgit.commit.id.abbrev=<id>.
Two flags worth knowing: -Pinttest.asyncprofiler.skipPerfEventTuning skips the privileged
container, for a host where the perf_event values are already set through sysctl or where Docker
disallows privileged containers (the run continues either way, with less accurate native stacks).
-Pdocker.wolfi builds the base image from Wolfi, which is what makes the GLIBC_TUNABLES the test
sets take effect.
See tests/performance/README.md for JFR rendering, async-profiler
recording analysis, Jafar MCP usage and MAT MCP based memory-leak investigation. That document also
explains how to keep the raw recordings and analysis next to the workload documentation.
Integration tests live in tests/ (see tests/README.md). They use
Testcontainers to bring up Pulsar services in Docker, so Docker
must be installed and running. Build the test image first, then run the tests.
The full integration suite is heavy and slow. In local development, always run individual
integration tests rather than the whole suite — pass --tests to select a class (TestNG then
discovers it directly from the classpath):
./gradlew :tests:latest-version-image:dockerBuild # build the docker test image
./gradlew :tests:integration:integrationTest --tests "org.apache.pulsar.tests.integration.<TestClass>"To run the entire integration test set, use Personal CI (below) rather than running it
locally. (integrationTest also accepts -PtestGroups / -PexcludedTestGroups and
-PintegrationTestSuiteFile=<suite>.xml to pick a specific TestNG suite.)
The shared setup-gradle action automatically selects a smaller memory profile on Linux runners
with up to 8 GiB of physical RAM: a 2g Gradle heap, two workers across projects, up to two test
forks per task, and new test workers after 50 detected classes. Larger runners retain the usual
settings. The action's memory-profile input accepts auto (default), low-memory (force the
smaller profile), or standard (leave the memory settings unchanged). Command-line -Dorg.gradle.jvmargs,
--max-workers and -Ptest* options can override the profile settings.
Develocity injection and build-scan publishing are disabled when the workflow repository is
private or its visibility is unavailable, even if an access key is configured. Public repositories
can also disable them with the action's build-scan-publish: 'false' input. CodeQL runs by default
for public repositories; private repositories with GitHub Code Security enabled can opt in by
setting the repository variable CI_ENABLE_CODEQL=true.
The CI workflows declare the token permissions needed by their jobs, including reading PR changes. This supports repositories whose organization or enterprise enforces read-only default workflow permissions. Explicit permissions do not override restrictions on tokens for fork pull requests or enterprise policies that prohibit particular actions.
The full test suite is large and slow to run locally. While iterating on a change, run only the narrowly-scoped tests relevant to the change (a single test class or package, see above) rather than a module's entire test task. To validate a larger change against the full CI pipeline, do not run everything locally — use Personal CI, which runs Pulsar's CI workflows in the contributor's own GitHub fork.
If Personal CI is not yet set up, follow the Personal CI documentation to enable it on your fork. Once it is set up, the loop is:
- Keep the local
masterup-to-date withapache/pulsarand rebase the feature branch on it. - Push the feature branch to the fork to trigger CI runs there. CI runs against the PR opened in
your own fork (it is normal to have a PR open in the fork and a PR for the same branch open in
apache/pulsarat the same time). - Monitor CI status on the fork and fix failures.
- Open the PR to
apache/pulsaronly after the fork's CI is green.
Once the PR to apache/pulsar has been opened, stop rebasing as part of this loop: step 1's rebase
no longer applies — bring in upstream changes by merging instead (see Pull requests).
Pulsar has a large number of flaky tests, so GitHub Actions jobs on a PR sometimes fail for reasons
unrelated to the change. When a failure appears to be flakiness rather than a regression caused by the
PR, comment /pulsarbot rerun on the PR to re-run the failed jobs of the workflow run. It only
takes effect once the workflow run has completed (all jobs finished), so wait for the run to
finish before commenting.
For a PR from a fork, a project maintainer must approve the workflow runs before they execute, and approval is required again whenever new changes are pushed. This adds latency to each run and makes it slow to tell flaky failures apart from genuine ones. Setting up Personal CI (above) sidesteps this — the full pipeline runs in your own fork without maintainer approval — so it is especially useful when a PR has legitimate test failures that you need to iterate on.
If a flaky test that is unrelated to your change blocks your PR, you can move it to the flaky group
or disable it within your PR to unblock merging — and report it (search the
existing flaky-test issues
first). Don't push empty "trigger CI" commits to force a rerun; use /pulsarbot rerun instead.
PRs must follow .github/PULL_REQUEST_TEMPLATE.md. PR titles follow the
[<type>][<optional scope>] <description> convention (e.g. [fix][broker] ...,
[improve][build] ...) — refer to .github/workflows/ci-semantic-pull-request.yml for the valid
[type] and [scope] prefixes, which are enforced by CI. The <description> should be in
imperative form, like a good commit message's subject line, and should not reference issue numbers
(put Fixes #N / Closes #N in the description instead).
Keep each PR focused on one change. Don't bundle unrelated drive-by refactoring (for example,
de-duplicating code you happened to touch) into a feature or bug-fix PR — it widens the review surface
and the risk; note such improvements as follow-ups instead. Likewise, don't reformat unrelated files
or lines that aren't part of your change (whitespace, import reordering, re-wrapping) — drive-by
formatting hides the real change in review and pollutes git blame. Discuss large refactorings on
dev@pulsar.apache.org before investing effort: there are many similar code duplications across
Pulsar, and a PR for each creates more maintainer burden than value. AI agents make it easy to generate
large volumes of refactoring PRs, and the project pushes back on these. Every change needs a real,
identifiable contributor who takes responsibility for it; unattributed AI-agent-style contributions —
especially larger ones from anonymous profiles or from people not actually using Pulsar — are typically
rejected.
Describe the change. The PR description must cover, at minimum, the Motivation (why?) and the
Modifications (what / how?) — these map to the corresponding sections of the PR template. A title
alone, or a description that only restates the title, is not sufficient. Link the related issue with
Fixes #N (or equivalently Closes #N) for an issue the PR resolves, Main Issue: #N for one task
of a larger issue, or PIP: #N for a proposal.
Never rebase a PR branch once the PR is opened in apache/pulsar. Rebasing rewrites history and
disrupts reviewers (it invalidates review comments and makes incremental diffs unreadable). To bring
in upstream changes, instead fetch from the apache/pulsar remote and merge its master into the
PR branch:
git fetch <apache-pulsar-remote> # e.g. `upstream` or `apache`
git merge <apache-pulsar-remote>/master(Rebasing onto an updated master is fine before the PR is opened — see the Personal CI loop above
— but not after.)
Target master first. Once the change is merged, project maintainers handle backporting a bug or
security fix to the supported maintenance branches (branch-4.2, branch-4.0, …) when
the bug is also present there, per the
release/support policy. New features are
not added to LTS / maintenance branches without a dev@pulsar.apache.org discussion (and usually a
PIP), to avoid regressions in stable releases.
Backporting is done by cherry-picking commits in their original merge order, which avoids unnecessary merge conflicts; sometimes a dependent change must be cherry-picked before the fix itself. AI tools are effective at resolving the merge conflicts that arise during a backport.
See SECURITY.md (latest at https://github.com/apache/pulsar/security/policy) and
https://pulsar.apache.org/security/. In short: report a
suspected vulnerability privately (never in a public issue, PR, or commit), and never reveal the
security nature of a change in public until it is announced. An already-public CVE that you only
want to check Pulsar's exposure to is not a private disclosure — search the CVE id in apache/pulsar
PRs/issues first, then ask via a GitHub issue or dev@pulsar.apache.org.
If you use an AI coding assistant (Claude Code, Copilot, Cursor, Gemini, Codex, Aider, …), see
AGENTS.md for the agent-facing guidance — a routing index into this guide,
ARCHITECTURE.md, CODING.md, and SECURITY.md, plus
the guardrails that apply specifically to AI-made changes.