Back to Pulsar

PIP-463: Migrate Build System from Maven to Gradle

pip/pip-463.md

5.0.0-M110.1 KB
Original Source

PIP-463: Migrate Build System from Maven to Gradle

Background Knowledge

Apache Pulsar currently uses Maven as its build system. The project has grown to over 100 modules with complex dependency relationships, shaded JARs, NAR packaging, and Docker image builds. Maven's sequential execution model and limited caching capabilities result in long build times that impact developer productivity and CI throughput.

Gradle is a modern build system used by large-scale Java projects (e.g., Spring Boot, Micronaut, Apache Kafka). It provides parallel task execution, fine-grained caching, and incremental compilation out of the box.

Motivation

The current Maven build has several pain points that affect developer velocity and CI efficiency:

Slow local builds. A full mvn install -DskipTests takes 5-8 minutes on a modern machine. Developers frequently wait for unrelated modules to rebuild when iterating on a single component. Maven has no built-in mechanism to skip unchanged modules — it rebuilds everything in the reactor.

Slow CI. The CI pipeline takes 50-60 minutes end-to-end. Maven's lack of caching means each CI run starts from scratch. Test jobs must either rebuild everything or rely on fragile artifact-sharing workarounds.

Imprecise dependency tracking. Maven treats the entire module as the unit of rebuild. Changing a test resource file triggers a full recompile of the module. There is no way to run "only the tests affected by my change" — developers must run the entire test suite for a module or manually specify test classes.

Limited parallelism. Maven's -T flag enables module-level parallelism, but tasks within a module still run sequentially. The Pulsar build has several bottleneck modules (e.g., pulsar-broker) where compilation, resource processing, and test execution could overlap with other modules but don't.

Complex shading and packaging. The project uses Maven Shade plugin, NAR plugin, and custom Ant tasks for packaging. These configurations are verbose, hard to maintain, and have subtle interactions (e.g., the ahc-default.properties content replacement for AsyncHttpClient requires an Ant <replace> task in Maven but is a single filesMatching call in Gradle).

Poor IDE integration for multi-module builds. IntelliJ IDEA's Maven import for a project of Pulsar's size is slow and memory-intensive. Gradle's tooling API provides faster, more reliable IDE synchronization.

Goals

In Scope

  • 1:1 functional equivalence with Maven. The Gradle build produces identical artifacts:

    • Server distribution tarball (apache-pulsar-X.Y.Z-bin.tar.gz) with the same JARs
    • Shell distribution tarball
    • IO connectors distribution (NAR files)
    • Offloaders distribution (NAR files)
    • Docker images (pulsar, pulsar-all, java-test-image, pulsar-test-latest-version)
    • Shaded client JARs (pulsar-client, pulsar-client-admin, pulsar-client-all) verified to contain the same classes and relocations as Maven output
  • All CI tests passing. Unit tests, integration tests, system tests, shade tests (Java 17/21/24), and backward compatibility tests all pass on the Gradle build.

  • Enforced dependency management. A pulsar-dependencies platform module (Gradle's equivalent of Maven's dependencyManagement) ensures consistent dependency versions across all modules.

  • Version catalog. A single gradle/libs.versions.toml file defines all dependency coordinates and versions, replacing scattered version properties across 100+ POM files.

  • CI workflow migration. All GitHub Actions workflows converted from Maven to Gradle commands.

Out of Scope

  • Changing the project's module structure or merging/splitting modules
  • Migrating to Kotlin DSL for production source code
  • Gradle-specific optimizations beyond what Maven provides (e.g., build cache server, remote caching) — these are future enhancements enabled by the migration
  • Removing the ability to build individual modules in isolation

High Level Design

The migration introduces Gradle build scripts alongside (and eventually replacing) the existing Maven POM files. The approach is:

  1. Add Gradle build files for all modules (build.gradle.kts, settings.gradle.kts, gradle/libs.versions.toml)
  2. Convert CI workflows from Maven to Gradle commands
  3. Remove Maven files (pom.xml, mvnw, .mvn/)

The Gradle build is structured as:

settings.gradle.kts          # Module includes and plugin repositories
build.gradle.kts              # Root build: common config, enforced platform
gradle/libs.versions.toml     # Version catalog (single source of truth for versions)
pulsar-dependencies/          # Enforced platform module (replaces dependencyManagement)
<module>/build.gradle.kts     # Per-module build script

Key design decisions:

  • Shadow plugin for shaded JARs (replaces Maven Shade), with filesMatching for property file content relocation
  • NAR plugin (io.github.merlimat.nar) for connector packaging
  • LightProto plugin for protobuf/lightproto code generation
  • Conditional project includes for shade test modules (avoids implicit parent project conflicts)
  • Enforced platform (pulsar-dependencies) for version pinning across all modules

Detailed Design

Design & Implementation Details

Build Performance Improvements

AspectMavenGradle
Incremental compilationNoYes — only recompiles changed files
Task-level cachingNoYes — skips tasks whose inputs haven't changed
Parallel executionModule-level only (-T)Task-level (automatic dependency graph)
Configuration cachingNoYes — reuses build configuration across runs
Local build cacheNoYes — persists across builds
Remote build cacheNoYes — shared across CI and developers (future)

Expected impact:

  • Local incremental builds (after initial): seconds instead of minutes
  • CI with caching: 30-50% faster (exact numbers depend on cache hit rates)
  • "Build only what I need to test": ./gradlew :pulsar-broker:test builds only the broker and its dependencies, skipping unrelated modules entirely

Develocity Integration

Gradle provides native integration with Develocity (formerly Gradle Enterprise), hosted by the ASF at develocity.apache.org. Every CI build automatically publishes a build scan that provides:

  • Test execution details: per-test timings, pass/fail status, output logs, and stack traces — all searchable and filterable without downloading CI artifacts
  • Task execution timeline: visual breakdown of what ran, what was cached, and what was up-to-date, making it easy to identify bottleneck tasks
  • Dependency resolution: full dependency tree with conflict resolution details
  • Build comparison: diff two builds to see what changed in task execution or outputs
  • Failure analysis: aggregated view of flaky tests across builds

Example build scan from the PoC CI run: https://develocity.apache.org/s/h6ckzn3nn4w2s

This level of observability is not available with the Maven build today.

Dependency Management

Maven's dependencyManagement in the root POM is replaced by:

  1. Version catalog (gradle/libs.versions.toml): Defines all dependency coordinates and version numbers in one file. Modules reference dependencies as libs.netty.buffer instead of hardcoded group:artifact:version strings.

  2. Enforced platform (pulsar-dependencies): A java-platform module that creates version constraints from the catalog. Applied to all subprojects via implementation(enforcedPlatform(project(":pulsar-dependencies"))). This ensures transitive dependencies are pinned to the same versions Maven would resolve.

Shaded JAR Configuration

The Shadow plugin replaces Maven Shade. Key differences handled:

  • AsyncHttpClient properties: Maven uses Ant <replace> to fix property name prefixes in ahc-default.properties. Gradle uses filesMatching { filter { } }.
  • Dependency include/exclude: Shadow's dependencies { include/exclude } DSL replaces Maven Shade's <includes>/<excludes>.
  • Relocation: Shadow's relocate() is functionally identical to Maven Shade's.

NAR Packaging

A custom NAR Gradle plugin (io.github.merlimat.nar) handles connector packaging. Global exclusions for platform modules (provided by java-instance.jar at runtime) are configured in the root build.gradle.kts.

Module-Specific Overrides

Some modules require version overrides that differ from the enforced platform:

  • kinesis-kpl-shaded: Forces protobuf-java:4.29.0 (KPL requires protobuf 4.x, while Pulsar uses 3.x). The protobuf is relocated so no runtime conflict.
  • jclouds-shaded: Forces Guice 7.0.0, jakarta.annotation-api:3.0.0, jakarta.ws.rs-api:3.1.0, jakarta.inject-api:2.0.1 (jclouds 2.6.0 requires Jakarta EE 10+ APIs). All are bundled in the shadow JAR.

Public-facing Changes

Configuration

No new broker/client configuration options. The build system change is transparent to users.

CLI

  • mvn commands replaced by ./gradlew commands in documentation and scripts
  • src/set-project-version.sh updated to modify gradle/libs.versions.toml

Binary Artifacts

Artifacts are functionally identical. Minor differences:

  • Some shaded JARs may have slightly different class counts due to Shadow vs Shade plugin differences in handling package-info.class files (no runtime impact)

Security Considerations

No security implications. The build system change does not affect Pulsar's runtime security model, authentication, or authorization.

The Gradle wrapper (gradlew) is committed to the repository with a checksum-verified distribution URL, following the same security model as the Maven wrapper.

General Notes

The implementation PR demonstrates full CI green status across all test suites, confirming functional equivalence with the Maven build.

Links