Apache Committer / PMC Engineer — Complete Apprenticeship Curriculum
Target Roles:
- Apache Committer — Spark, Hadoop, Hive, Tez, Flink, and related ASF projects
- PMC Member — Apache Software Foundation
- Staff / Principal Engineer — Data Infrastructure (Databricks, Cloudera, LinkedIn, Uber, Netflix, Meta, Apple)
- Distributed Systems Lead — JVM ecosystem
- Principal Engineer — Open Source Infrastructure
Duration: 24–36 weeks core — extendable to 18 months for full committer readiness
Goal: Develop the technical depth, community understanding, and engineering discipline required to contribute at the committer and PMC level in major Apache Software Foundation projects.
Why This Curriculum Exists
Most engineers who want to contribute to Apache projects approach it backwards: they submit a patch, wait, get confused by the silence or feedback, and quietly give up.
The reason is not lack of technical skill. The reason is that Apache projects run on a trust model that is almost never explicitly documented. Committers and PMC members are not evaluating your code alone. They are evaluating whether you are the kind of engineer they would want to maintain this system with for the next decade.
This curriculum is built backward from what actually makes someone a trusted Apache committer:
- Deep familiarity with JVM distributed systems and their failure modes
- The discipline to write patches that other engineers can review and maintain
- The ability to navigate unfamiliar codebases at production scale
- An understanding of how Apache releases, governance, and consensus actually work
- Experience reviewing code, writing design docs, and operating these systems — not just using them
Reference Apache Projects and Ecosystems
- Apache Spark → Phases 01–02, 06–07, 09, 11
- Apache Hadoop + YARN → Phases 03, 08, 10
- Apache Hive → Phases 03, 09, 10
- Apache Tez → Phases 03, 09
- ASF Release Process → Phase 04
- Cross-project → Phases 05, 11–12
What You Will Build
By the end of this curriculum you will have produced:
- Contributor landscape maps for real Apache projects with committer journey analysis
- Trust gap audits — structured assessments of what separates contributors from committers
- Annotated codebase traces through real Apache Spark and Hadoop source trees
- Post-mortems of documented Apache ecosystem design failures
- A full release checklist and simulated RC vote thread for a production Apache release
- Code review annotations — reviewer-quality comments on real and simulated patches
- Refactored Java code — taking "clever" production code to maintainable quality with tests
- A graduated build-and-review lab ladder — Raft election and Kafka partitioning, JMH/GC/lock-contention performance work, contribution tooling (diff parser, semver engine, issue state machine), release tooling (checklist engine, compat checker, RC vote parser), up to PMC-level API evolution and consensus labs
- A complete design document at subsystem level with alternatives, tradeoffs, and rollout analysis
- A final committer-simulation package — patch, tests, review response, release note, and reflection
Folder Structure
PMC-Apache-Comitter-Engineer/
├── README.md ← You are here
├── jd.md ← Target role profile
├── phase-01-apache-contributor-foundations/ ← Contributor ladder, trust, stewardship
├── phase-02-jvm-codebase-navigation/ ← Navigate and trace Apache codebases
├── phase-03-lessons-from-large-systems/ ← Technical and human lessons from Hadoop/Hive/Tez/Spark
├── phase-04-asf-governance-release/ ← Release lifecycle, signing, japicmp compatibility
├── phase-05-code-quality/ ← Checkstyle, SpotBugs, refactoring "clever" code
├── phase-06-review-culture/ ← Reviewing flawed patches, design tradeoffs, patch archaeology
├── phase-07-production-systems/ ← Raft election, Kafka partitioning, multi-module Maven
├── phase-08-performance-engineering/ ← JMH, GC/allocation profiling, lock contention
├── phase-09-apache-contribution-mechanics/ ← Diff parsing, semver, issue state machines
├── phase-10-release-engineering/ ← Release checklist engine, API compat checker, RC vote parser
├── phase-11-issue-journey-pmc/ ← API migration, format migration, consensus tracking
├── phase-12-final-apprenticeship-milestone/ ← Full contribution cycle capstone
├── interview-prep/ ← Committer-track interviews, systems design, behavioral
└── system-design/ ← Scheduler, metastore, API evolution walkthroughs
24-Week Core Schedule
| Week | Phase | Focus |
|---|---|---|
| 1 | 01 | Apache contributor ladder, trust model, role definitions |
| 2 | 01 | Trust audit — mapping contributor → committer journeys |
| 3 | 02 | Maven module navigation, IntelliJ IDEA setup for Spark |
| 4 | 02 | Tracing a Spark execution path from API to DAG scheduler |
| 5 | 03 | Hadoop 1.x HDFS NameNode SPOF — design failure post-mortem |
| 6 | 03 | Backward compatibility burden — Hadoop FileSystem, Hive SerDe |
| 7 | 04 | ASF release lifecycle — JIRA → RC → vote → distribution |
| 8 | 04 | Compatibility validation with japicmp; release checklist |
| 9 | 05 | Code quality dimensions; annotating good vs poor Java |
| 10 | 05 | Refactor challenge — making "clever" code maintainable |
| 11 | 06 | Patch review philosophy; writing reviewer comments |
| 12 | 06 | Responding to review; patch refactoring for acceptance |
| 13 | 07 | Raft leader election — implement and test consensus behavior |
| 14 | 07 | Kafka partitioning strategies; multi-module Maven + CI |
| 15 | 08 | JMH microbenchmarks — methodology, pitfalls, dead-code elimination |
| 16 | 08 | GC/allocation profiling; lock contention (synchronized vs LongAdder) |
| 17 | 09 | Unified diff parser; semantic versioning engine |
| 18 | 09 | JIRA-style issue state machine; contribution mechanics writeup |
| 19 | 10 | Release checklist engine; API compatibility checker |
| 20 | 10 | RC vote thread parser; simulated release vote |
| 21 | 11 | Deprecated API migration analyzer — PMC-level review |
| 22 | 11 | Storage format migration planner; consensus tracker |
| 23 | 12 | Full contribution cycle capstone |
| 24 | 12 | Final reflection: "What I Wish I Knew" |
Lab Structure
Every lab follows a consistent format:
| File | Purpose |
|---|---|
README.md | Goal, steps, expected output, talking points, resume bullet |
*-template.md | Structured prompts you fill in (document-based labs, Phases 01–03) |
src/main/java + src/test/java | Working reference implementation with a test suite you study, break, extend, and re-verify (code labs, Phases 04–12) |
pom.xml | Maven build file (code labs only) |
Code labs ship as complete, tested reference implementations rather than TODO stubs: the committer skill being trained is reading, reviewing, and extending real code under a test harness — run mvn test, then do the extension exercises in each lab README.
Prerequisites
- Java 11+ fluency (read and write production Java; understand generics, concurrency basics, exceptions)
- Maven fundamentals (multi-module builds,
mvn compile test package) - Git (branch, rebase, cherry-pick, bisect)
- Basic distributed systems concepts (RPC, consensus, replication, fault tolerance)
- Comfortable reading JIRA issues and GitHub pull requests
- Familiarity with at least one of: Spark, Hadoop, or Hive as a user
Target Role — Apache Committer / PMC Member / Principal Distributed Systems Engineer
Primary Target Positions
Apache Committer / PMC Member
Apache Software Foundation — Open Source (Volunteer / Community)
These are not salaried positions. They represent technical recognition and governance trust granted by the community after sustained, high-quality contribution.
- Committer: Elected by PMC after demonstrated technical credibility, review engagement, and community participation. Granted write access to project repositories.
- PMC Member: Elected by existing PMC members. Holds binding governance authority over releases, project direction, and new committer elections.
Staff / Principal Engineer — Data Infrastructure
Sample companies: Databricks, Cloudera, Palantir, LinkedIn, Uber, Netflix, Apple, Meta, Stripe, Snowflake, Google (Cloud Dataproc / BigQuery), Amazon (EMR / Redshift), Microsoft (Azure HDInsight)
Representative job description components:
Responsibilities:
- Design and implement core components of large-scale distributed data processing systems
- Contribute to and maintain open-source Apache projects (Spark, Hadoop, Hive, Tez, Flink, Iceberg)
- Review code from other engineers; maintain codebase quality and long-term health
- Author design documents and lead technical discussions across engineering teams
- Identify and resolve performance bottlenecks, correctness bugs, and operational issues at scale
- Participate in release engineering, compatibility validation, and upgrade strategy
- Mentor engineers; elevate team-wide engineering standards
- Represent the company in the Apache open-source community
Requirements:
- Deep Java expertise (concurrent programming, JVM internals, GC behavior, heap analysis)
- Direct experience contributing to Apache Spark, Hadoop, Hive, or equivalent at the patch level
- Ability to read, trace, and debug unfamiliar distributed systems codebases
- Strong understanding of distributed systems fundamentals: consensus, replication, scheduling, fault tolerance
- Experience with large-scale production deployments of Apache ecosystem tools
- Demonstrable history of code review, design discussion, and technical writing
Nice to have:
- Apache Committer or PMC status
- Prior experience as a release manager or release engineer for an open-source project
- Published post-mortems, design RFCs, or conference talks on distributed systems
Principal / Staff Software Engineer — Open Source Infrastructure
Sample companies: Confluent (Kafka), Elastic (Elasticsearch), Grafana Labs, HashiCorp, MongoDB, Redis (active open-source infrastructure companies)
Very similar profile to the above: deep JVM systems expertise, community credibility, ability to operate at the intersection of open-source governance and production engineering.
What This Curriculum Prepares You For
By completion, you will have the experience to:
- Navigate any large Apache JVM codebase without a guide
- Identify, reproduce, and fix issues in distributed systems under review scrutiny
- Write patches that pass the Apache review bar — correct, tested, documented, backward-compatible
- Review others' patches at the level a committer would
- Understand and participate in ASF governance: releases, votes, and consensus
- Write design documents that subsystem-level changes require
- Operate and reason about Spark, YARN, Hive, and Tez at production scale
- Demonstrate the stewardship mindset required for committer and PMC candidacy
Salary Benchmarks (2025–2026, US market)
| Level | Range |
|---|---|
| Senior Software Engineer (OSS infrastructure) | $200K–$280K total comp |
| Staff Engineer (distributed systems / data infra) | $280K–$380K total comp |
| Principal Engineer (data infrastructure) | $350K–$500K+ total comp |
| Distinguished Engineer (Apache committer background) | $450K–$650K+ total comp |
Apache Committer / PMC status is a significant differentiator at the Staff and Principal level, particularly at companies whose products are built on ASF projects.
Phase 01 — Apache Contributor Foundations
Difficulty: ⭐☆☆☆☆ | Estimated Time: 1–2 weeks
Roles supported: All tracks — foundational mental model for every phase that follows.
Why This Phase Exists
Every engineer who has ever contributed a patch and heard nothing back, or submitted a PR and been asked for a complete redesign, has encountered the same invisible barrier: the Apache trust model. It is never written in one place. It is learned by observing and participating over months and years.
This phase makes the implicit explicit. You will understand the Apache hierarchy, how trust is built and evaluated, why technically excellent engineers fail to become committers, and what the stewardship mindset actually requires. Without this foundation, every technical skill in the phases that follow will be applied in the wrong direction.
Concepts
- Apache contributor → committer → PMC member hierarchy
- The mechanics of committer elections
- Trust dimensions: technical credibility, review quality, reliability, communication, stewardship
- Why large-batch contribution fails; why incremental sustained participation wins
- Apache mailing list culture: dev@, user@, private@
- Consensus and voting (+1 / 0 / -1, binding vs non-binding)
- Lazy consensus and when it applies
- Stewardship behaviors: backward compatibility, operational empathy, incremental change
- How to read a contributor's public history (JIRA, GitHub, mailing list archives)
- Constructing a "trust gap" analysis: what separates a strong contributor from a committer candidate
Labs
Lab 01 — Contributor Landscape Mapping
| Field | Value |
|---|---|
| Goal | Research a real Apache project and map five contributors at different stages of the contributor ladder, producing a structured profile for each. |
| Concepts | Contributor vs committer vs PMC; visible participation history; JIRA/GitHub/Whimsy as evidence trails. |
| Project | Apache Spark (primary) — or Apache Hadoop if you prefer a Hadoop focus. |
| Steps | 1) Use Apache Whimsy Roster to identify current Spark PMC members and committers. 2) Use GitHub and JIRA to identify 2–3 active contributors who are not yet committers. 3) For each of your 5 selected engineers, complete one contributor-profile-template.md. 4) Write a 200-word "journey summary" for at least one real contributor: first patch → committer election. |
| Output | Five completed contributor-profile-[name].md files plus a 200-word journey summary. |
| Talking Points | What patterns appear across all committer profiles? What is missing from strong contributor profiles? How long does the journey typically take? |
| Resume Bullet | "Analyzed contributor-to-committer progression for Apache Spark: mapped five public contribution histories across JIRA, GitHub, and mailing list archives to identify the trust-building patterns that precede committer elections." |
| Extensions | Compare the journey timelines across Hadoop and Spark. Do different projects have different norms? |
Lab 02 — Trust Audit
| Field | Value |
|---|---|
| Goal | Evaluate a fictional contributor profile against the six trust dimensions used by Apache maintainers and produce a structured gap analysis with recommended actions. |
| Concepts | Trust dimensions; committer evaluation criteria; stewardship mindset; incremental participation. |
| Steps | 1) Read the fictional contributor profile provided in the lab README. 2) Score each of the six trust dimensions on a 1–5 scale with specific evidence. 3) Identify the top two gaps blocking committer candidacy. 4) Produce three concrete recommended actions that would move the profile forward within 6 months. 5) Write a one-paragraph PMC nomination draft — or explain why you could not write one yet. |
| Output | One completed trust-audit-[name].md with dimension scores, gap analysis, action plan, and nomination draft or blocker explanation. |
| Talking Points | Which dimensions are hardest to build quickly? Which can be gamed — and why that backfires? How does a PMC member's private nomination discussion actually use this information? |
| Resume Bullet | "Applied Apache committer trust model to structured contributor evaluation; produced gap analysis and 6-month development plan across six trust dimensions (technical credibility, review quality, reliability, communication, stewardship, community participation)." |
| Extensions | Apply the same audit to yourself. Which are your weakest dimensions today? |
Deliverables Checklist
- Five contributor profile documents completed (lab-01)
- At least one 200-word journey summary (lab-01)
- Trust audit completed with all six dimension scores (lab-02)
- Top two gaps identified with evidence (lab-02)
- Three recommended actions written (lab-02)
- Nomination draft or blocker explanation written (lab-02)
Interview Relevance
- "How does someone become an Apache committer?" (you can answer with the full election mechanics)
- "What makes a good open-source contributor?" (you can answer with the six trust dimensions)
- "Why do technically strong engineers sometimes fail to get traction in open source?" (you can answer with five failure modes)
- "What is lazy consensus?" (you can answer precisely)
- "What is the difference between a committer and a PMC member?" (you can answer with governance authority details)
Warmup Guide — Apache Contributor Foundations
Zero-to-expert primer for Phase 01. Explains, from nothing, what the Apache Software Foundation is, how its trust model actually works, and how careers inside it are built — so the two labs (contributor landscape, trust audit) have full context.
Table of Contents
- Chapter 1: What the ASF Is
- Chapter 2: The Apache Way — Five Operating Principles
- Chapter 3: The Role Ladder
- Chapter 4: The Trust Model — What Is Actually Being Evaluated
- Chapter 5: Where Apache Work Happens — Lists, JIRA, GitHub
- Chapter 6: Reading a Project's Social Topology
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: What the ASF Is
From zero: the Apache Software Foundation is a US 501(c)(3) charity (founded 1999, descended from the Apache HTTP Server group) that provides legal, infrastructural, and governance shelter for ~300 open-source projects — Hadoop, Spark, Kafka, Hive, Flink, Iceberg among them. It owns the trademarks, holds the licenses, runs the infrastructure, and — crucially — defines the governance template every project follows.
Why a foundation at all: corporate-owned open source can be relicensed, abandoned, or weaponized by its owner. ASF projects can't be: the license (Apache 2.0) is fixed, the governance is by individuals on their own merit (not their employers), and the Board audits each project's independence. For companies betting infrastructure on these projects, that neutrality is the product. This is also why committer status is personal and portable — it survives changing employers, which is precisely what makes it a career asset.
The hierarchy above a project: Board → elected by ASF Members (individuals) → each project's PMC reports quarterly to the Board. Below that, everything is per-project.
Chapter 2: The Apache Way — Five Operating Principles
These phrases carry precise operational meaning; the labs ask you to observe them in the wild:
- Community over code: a project with healthy reviewers and a mediocre codebase outlives the reverse. Decisions optimize for the decade, not the sprint.
- Merit, earned and remembered: status is granted retroactively for sustained contribution. You cannot apply to be a committer; you are noticed into it.
- Open communications — "if it didn't happen on the list, it didn't happen": decisions made in private (a company Slack, a hallway) are not decisions. This rule protects contributors who aren't in the room — including you.
- Consensus before votes: voting is the fallback when discussion fails, not the default. Lazy consensus ("I'll do this in 72h absent objections") keeps velocity.
- Independence: hats matter — a committer employed by Cloudera speaks as an individual; PMC composition spanning employers is a health metric the Board watches (and your Lab 01 measures).
Chapter 3: The Role Ladder
| Role | Granted by | What it actually means |
|---|---|---|
| User | — | Uses the software; bug reports already count as contribution |
| Contributor | — | Patches, docs, reviews, list answers — no formal status |
| Committer | PMC vote | Write access; judgment trusted on day-to-day changes |
| PMC member | PMC vote | Binding votes on releases/people; accountable to the Board |
| PMC Chair | Board appoint | Administrative officer, not a boss — files the reports |
| ASF Member | Members vote | Foundation-level citizenship, cross-project |
The numbers that calibrate expectations (your Lab 01 will measure real ones): typical contributor→committer journeys in major data projects run 9–24 months and 20–60 merged patches plus visible review/list activity. Spark has had ~2,000 contributors and ~75 committers — a ~4% conversion. The ladder is real but steep, and each project's culture sets a different grade.
Chapter 4: The Trust Model — What Is Actually Being Evaluated
The non-obvious core of this phase: a committer vote is a risk assessment, not a reward. Write access means your judgment executes without prior review. The PMC is asking: if this person merges something at 2am, do we sleep? Concretely, they weight:
- Calibration: do you know what you don't know? (Asking good questions scores positively; overconfident patches in unfamiliar subsystems score very negatively.)
- Follow-through: do your merged patches stay healthy? Do you respond when your change breaks CI a week later? Abandoned messes are remembered for years.
- Review behavior: reviewing others' patches is the single most legible trust signal — it is literally practicing the committer job in public.
- Conflict behavior: how you took your first harsh review; how you handled being wrong. One graceless thread can outweigh twenty good patches.
- Sustained presence: monthly-for-a-year beats daily-for-six-weeks-then-gone. The fear behind the criterion: committers who vanish leave unmaintainable code with write access.
The strategic consequence: optimizing for merged-PR count is optimizing the wrong variable. The Lab 02 trust audit has you score yourself against these axes and design the gap-closing plan.
Chapter 5: Where Apache Work Happens — Lists, JIRA, GitHub
The tooling map (projects vary; Spark/Hadoop/Hive conventions):
- dev@ mailing list: design discussions, votes, release threads. Slow, archived, authoritative. Reading six months of archives is the fastest culture download available — Lab 01 requires a slice of this.
- user@: support. Answering questions here is an underrated trust builder (it proves knowledge and patience).
- JIRA (or GitHub Issues): the work ledger. Issue quality — reproduction, version, stack trace, minimal case — is your first writing sample.
- GitHub PRs: code review happens here now for most projects, but decisions still belong to the list (principle 3).
- Slack/Discord where they exist: ephemeral, convenience-only; nothing binding.
Etiquette constants: search before filing; one topic per thread; patches small; silence ≠ rejection (ping politely after a week); never DM-pressure a reviewer.
Chapter 6: Reading a Project's Social Topology
Lab 01's skill — given a project, map who actually moves it:
git shortlog -sn --since="1 year ago"— the live contributor distribution (expect a power law; note where the elbow is).- Match top authors against the project's committer/PMC roster (every project publishes one) — the gap between "high committer" and "high author" reveals who reviews vs who writes.
- Read 10 recently merged PRs and 10 stalled ones: who reviewed, how long to first response, what stalled the stalled ones (no reviewer? scope? contributor vanished?).
- Employer mapping (public affiliations): single-employer dominance is a risk flag — and an opportunity flag for an outsider with sustained presence.
- Identify the 2–3 people whose review unblocks the subsystem you care about. Your eventual first patches should land in their area of attention, scoped to be easy to say yes to.
Lab Walkthrough Guidance
Order: Lab 01 (landscape) → Lab 02 (trust audit) — measure the territory, then yourself against it.
- Lab 01: pick ONE project you plausibly intend to contribute to (the analysis pays off later phases). Fill the contributor-profile template with measured numbers (shortlog, roster, PR latencies), not impressions. Deliverable test: could someone else plan their first patch from your map alone?
- Lab 02: score yourself on Chapter 4's five axes with evidence (links, dates), not self-ratings. The output is a 6-month plan whose units are observable acts ("review one open PR per week in module X"), not virtues ("get better at Java").
Success Criteria
You are ready for Phase 02 when you can, from memory:
- Explain committer vs PMC vs Chair, and who grants each.
- State the five Apache Way principles with one operational consequence each.
- Describe the trust model as a risk assessment and name its five evaluation axes.
- Name where decisions are binding (the list) and why that rule protects you.
- Produce, for your chosen project: the contributor power-law shape, the 2–3 key reviewers in your target subsystem, and the median first-response latency.
Interview Q&A
Q: Why do companies pay Staff salaries for committer status? Because it is non-purchasable influence over infrastructure the company depends on: patches land faster, the roadmap hears their needs, releases get unblocked, and the trademark of trust transfers to the company's hiring and credibility. A committer on staff is a seat at a table money can't otherwise buy — which is also why the status being personal (it leaves with you) makes it a career asset.
Q: How would you earn committership in a project where you know nobody? Months 0–2: build, run tests, fix the onboarding doc gaps I hit (cheap, genuinely valued); triage and reproduce others' bug reports. Months 2–6: small correct patches in one subsystem until the same reviewers see my name repeatedly; answer user@ questions in that area. Months 6+: review others' PRs in that subsystem, take on a stuck-but-wanted issue, stay present. The plan optimizes reviewer recognition and trust, not patch count.
Q: What's the biggest mistake new contributors make? Submitting a large, multi-concern first patch into a subsystem they haven't read, then going silent when review stalls. It maximizes reviewer cost exactly when trust is zero. The inverse strategy — small, test-covered, in an active reviewer's area, with fast responses — is mechanically easy and rare enough to be noticed.
References
- How the ASF Works
- The Apache Way
- ASF: New Committers guide
- Karl Fogel, Producing Open Source Software — producingoss.com (read ch. 2, 6, 8)
- Spark: Contributing / Hadoop: How to Contribute
- Nadia Eghbal, Working in Public — the contributor-funnel sociology
🛸 Hitchhiker's Guide — Phase 01: Apache Contributor Foundations
Read this if: You want to contribute to an Apache project but you've never shipped a patch that got merged — or you've shipped one and still feel invisible. By the end you should be able to draw the Apache trust hierarchy from memory, explain the six trust dimensions that committers use to evaluate candidates, and place any engineer (including yourself) on the contributor ladder with a clear picture of what it would take to move up.
0. The 30-second mental model
Apache projects are maintained by a trust-weighted graph of engineers, not a company org chart.
Users
└── Contributors (send patches, file bugs, vote non-bindingly)
└── Committers (write access; can merge; trusted with the code)
└── PMC Members (binding governance; vote on releases, new committers)
└── PMC Chair (reports to ASF Board; administrative, not technical authority)
└── ASF Board (elected by ASF Members; governs all projects)
Three questions drive every major decision in an Apache project:
- Can we trust this person with the codebase long-term?
- Will this change help or hurt future maintainers?
- Does the community support this direction?
By the end of Phase 01 you should be able to place any engineer on this graph and explain what it would actually take to move them up one level.
1. The Apache Hierarchy in Depth
1.1 Users
Users file bugs, ask questions on user@, and sometimes vote (+1/0/-1) on mailing list decisions. Their votes are not binding on release or governance matters. Users build on the project but have no formal standing in its governance.
1.2 Contributors
A contributor has submitted at least one accepted patch, reported and helped diagnose a confirmed bug, or contributed documentation, tests, or design review. "Contributor" is informal — there is no ceremony for it and no roster entry.
What separates contributors from non-contributors in the Apache model is visible, trackable participation. The project's JIRA, GitHub, and mailing list archives are permanent. Maintainers remember — and search — who did what.
1.3 Committers
Committers have been explicitly elected by the PMC. They have write access to the codebase. They can merge patches (their own and others'). They can vote on releases and governance matters — but only PMC members' votes are binding.
"A committer is not someone who can write good code. A committer is someone the current committers trust to make good decisions about the project without supervision." — paraphrased from Apache Contributor Guide
The election process:
1. A PMC member nominates a contributor (private@ PMC mailing list)
2. PMC discusses — can take days to weeks
3. Formal vote is held
- Requires 3+ binding +1 votes
- Zero binding -1 vetoes
4. If approved, ASF Infrastructure grants repository access
5. New committer is announced on dev@ mailing list
1.4 PMC Members
PMC = Project Management Committee. PMC members:
- Cast binding votes on releases (3 binding +1, no vetoes required)
- Vote on adding new committers and new PMC members
- Steer the project's roadmap and policies
- Are the legal stewards (with the ASF) of the project's releases
A committer can be invited to the PMC. Most PMC members are also committers, but the two roles are technically independent.
1.5 PMC Chair
Each Apache project has a PMC Chair who acts as VP for that project and reports quarterly to the ASF Board. The Chair is elected by the PMC — it is an administrative and communications role, not a technical authority role. The Chair has no veto power that regular PMC members lack.
1.6 ASF Members and the Board
The ASF Board is elected by ASF Members (individuals recognized for significant ASF contributions across projects). The Board delegates all project decisions to PMCs and intervenes only when a project fails to follow ASF policies. Day-to-day technical decisions are never made at the Board level.
2. How Trust Is Actually Built
This is the section most engineers miss. Trust in Apache is compound interest: small deposits over a long time outperform large deposits made once.
2.1 The Six Trust Dimensions
| Dimension | What it means in practice |
|---|---|
| Technical credibility | You find real bugs. Your fixes are correct and don't introduce regressions. Your reviews identify real problems, not style preferences. |
| Review quality | Your comments are constructive, specific, and actionable. You understand the codebase well enough to see second-order effects. You review others' patches consistently, not just when it benefits you. |
| Reliability | You follow through. You pick up issues you claimed. You don't abandon in-progress patches after receiving review comments. When you say you will do something, you do it. |
| Communication | You write clearly on mailing lists and JIRA. You respond to questions. You acknowledge disagreement without hostility. You explain why, not just what. |
| Stewardship mindset | You think about the project's long-term health, not just your immediate feature or fix. You ask "what happens to the operator running this in production two years from now?" |
| Community participation | You help new contributors. You answer user questions. You show up consistently — not just when you need something. You participate in design discussions even when it's not your code. |
2.2 Why Technically Strong Engineers Never Become Committers
This happens more than most people expect. Five failure modes:
Failure Mode 1 — Low review engagement
The engineer submits good patches but never reviews others' code. Reviewers feel like the engineer is extracting value from the project — consuming the review bandwidth of others without contributing to it. Trust stagnates regardless of patch quality.
Failure Mode 2 — Abandoning patches mid-cycle
Filing a JIRA, producing a draft patch, receiving review comments, then going silent is one of the fastest ways to damage trust. It creates work for maintainers who must re-triage the issue. One abandoned patch is forgiven. Two is a pattern.
Failure Mode 3 — Design without community alignment
Building a large feature privately and then submitting a 5,000-line patch is a reliable path to rejection — not because the code is bad, but because:
- The PMC had no chance to redirect the design early
- The review burden is enormous
- There may be better alternatives nobody got to suggest
- The author has sunk cost and will resist changes
Apache favors incremental changes discussed openly on dev@ before significant implementation begins.
Failure Mode 4 — Adversarial communication
A single dismissive or hostile exchange on a mailing list can set back a committer candidacy by years. The Apache community is small enough that everyone reads the archives. A reputation for poor communication persists.
Failure Mode 5 — Treating Apache as a vendor
Some contributors engage only when they need a bug fixed or a feature for their employer. This is obvious to maintainers. Community members who contribute durably regardless of immediate self-interest are the ones who get elected.
2.3 What Senior Maintainers Actually Evaluate
When a PMC member considers nominating a contributor, the mental checklist looks like this:
1. Have I (and other committers) seen this person's patches over multiple months?
2. Do they find issues we wouldn't have found ourselves?
3. Do their patches arrive already well-tested and documented?
4. When we pushed back, did they understand why and improve — or argue defensively?
5. Have they reviewed others' patches, and were those reviews genuinely helpful?
6. Do they participate on the dev list or just submit code?
7. Would I be comfortable if they had write access and I was on vacation for two weeks?
Question 7 is the real one. Committer access means merge access without review. PMC members are trusting the nominee with the production quality of a system used by thousands of companies and millions of users.
3. The Stewardship Mindset
Stewardship is the quality that separates committers from contributors, and PMC members from committers. It is also the quality most difficult to fake and most rewarding to genuinely develop.
A steward asks:
"What is the right long-term decision for the project, even if it is harder for me right now?"
3.1 Stewardship Behaviors in Practice
Backward compatibility by default: You do not break APIs unless the benefit is overwhelming and a documented migration path exists. You assume the person upgrading from version N to N+1 has 50 production jobs depending on the behavior you are about to change.
Incremental change: You break large improvements into reviewable patches, each of which leaves the project in a better state than it was before. A reviewer should be able to understand each patch independently.
Operational empathy: You think about the operators who run this system in production, not just the developers using the API. "What happens when this node fails during the migration?" is an operational question that most feature implementors don't ask.
Documentation as a first-class artifact: Design docs, migration guides, release notes, and JIRA descriptions are not afterthoughts — they are part of the contribution. A patch without a test is suspicious. A design doc without a migration section is incomplete.
Saying no as a service: Rejecting a well-intentioned patch that would create long-term maintenance burden is a contribution to the project, not a rejection of the person. The hardest part of stewardship is learning to say "this is not the right approach" kindly and clearly.
3.2 The Five-Year Test
A useful mental exercise: imagine the project in five years, with 20 new committers who weren't there when this change was made.
- Will they understand why it was done?
- Will they be able to maintain it?
- Will it have created problems they'll need to untangle?
- Did the documentation survive?
- Did the compatibility guarantee hold?
If the answer to any of these is "probably not," the change needs more work.
4. Apache Mailing Lists — The Permanent Record
Apache projects operate on mailing lists, not Slack or Discord. This is intentional — mailing lists create a permanent, searchable, asynchronous record of every technical and governance decision.
4.1 The Three Primary Lists
| List | Purpose |
|---|---|
dev@project.apache.org | Technical discussion, patch reviews, design proposals, release coordination |
user@project.apache.org | End-user questions and support |
private@project.apache.org | PMC-only; committer nominations, sensitive governance |
All dev@ and user@ archives are public. Everything you write becomes part of the project's permanent technical history.
4.2 Communication Norms
Be specific: "This may have a performance issue" is not a useful review comment. "In TaskSchedulerImpl.submitTasks(), the taskIdToTaskSetManager map is accessed without synchronization on the CoarseGrainedSchedulerBackend thread; this can cause a ConcurrentModificationException under concurrent task submission" is.
Be patient: Apache contributors span dozens of time zones and most are volunteers or split their time between employer work and community work. A week of silence is not rejection.
Start a thread before writing code: For non-trivial changes, send a [DISCUSS] thread to dev@ before writing a patch. This surfaces design concerns early and builds community alignment before you have sunk cost in an implementation.
Understand consensus vs majority: Apache uses consensus, not majority rule. A single binding -1 (veto) on a release or governance motion blocks it. Vetoes must be accompanied by a technical reason, but the veto-holder is not required to accept a rebuttal. The right response to a veto is to address the technical concern — not to outvote.
4.3 Vote Notation
+1 : I actively support this
+0 : I don't object but won't advocate
0 : Neutral
-0 : I have concerns but won't block
-1 : I veto this (must include technical justification)
For releases: a binding -1 blocks the release until the technical concern is resolved.
For committer elections: a binding -1 with technical justification blocks the election.
For most code changes: consensus is assumed if no binding -1 appears within a reasonable period (lazy consensus).
4.4 Searching Archives
Every mailing list archive is publicly searchable. Before asking a question on dev@:
- Search the archive at lists.apache.org — the question may have been answered
- Search JIRA — there may be an open or closed issue
- Search the project's documentation
Asking a question that was answered three months ago on the list is a small negative signal. Referencing the prior thread and asking a follow-up is a positive one.
5. Reading a Contributor's Public History
5.1 Where the Evidence Lives
| Source | What to look for |
|---|---|
| Apache JIRA | Issues filed, patches attached, review comments left, issues assigned and completed vs abandoned |
| GitHub | Pull requests authored and reviewed, comments, consistency over time |
| dev@ archives | Design proposals, DISCUSS threads, vote participation, response quality |
| Apache Whimsy | whimsy.apache.org/roster/committee/ — official PMC and committer rosters |
| Apache Reporter | Project health stats, commit activity, release history |
5.2 What a Strong Contributor Profile Looks Like
Timeline: 18+ months of visible activity
JIRA:
- 20+ issues filed with good reproduction cases
- 15+ patches submitted, most accepted
- Comments on 30+ issues (not just "any update?")
- No abandoned patches without explanation
GitHub:
- Consistent PR review activity across multiple components
- Review comments that identify real problems
dev@ list:
- Participated in at least 3 DISCUSS threads
- Responded constructively when challenged
- Answered 5+ user@ questions
No red flags:
- No hostile exchanges
- No pattern of claiming issues then going silent
- Activity spread over time, not burst-and-disappear
5.3 What a Weak Contributor Profile Looks Like (Despite Technical Skill)
Activity: 3–6 months, driven by a single employer need
JIRA: 5 patches submitted, 3 accepted — but only for one subsystem
GitHub: 0 reviews on others' PRs
dev@ list: 0 DISCUSS thread participation; 0 user@ responses
Pattern: contributor got what they needed and disengaged
This profile is common and almost always results in stalled committer candidacy. The engineer is technically capable but has not demonstrated the durability and breadth the community requires.
6. Interview Questions — Concepts You Must Explain Out Loud
| Topic | One-line answer |
|---|---|
| Difference between a committer and a PMC member? | Committer has write access and technical trust; PMC member has binding governance authority over releases, new committers, and project direction. |
| How does someone become an Apache committer? | Nominated by a PMC member, elected by PMC vote (3+ binding +1, no vetoes), then access is granted by ASF Infrastructure. |
| What is a binding vote? | A vote from a PMC member that counts toward the required threshold on releases or governance decisions. Committer votes are advisory unless the committer is also a PMC member. |
| What is lazy consensus? | A proposal is considered accepted if no binding -1 appears within a defined period (typically 72 hours for minor changes). |
| Why does Apache favor mailing lists over chat? | Permanent record, searchable history, asynchronous collaboration, timezone inclusiveness, no vendor lock-in. |
| How should you propose a major change? | Send a [DISCUSS] thread to dev@ before writing code, establish alignment, then implement incrementally. |
| Why do large patches fail review? | Hard to review, introduce many concerns at once, create compatibility risk, and are difficult to revert. The author also has sunk cost and resists changes. |
| Why do technically strong engineers fail to become committers? | Low review engagement, inconsistent participation, poor mailing list behavior, abandoning in-progress work, or transactional relationship with the project. |
| What is operational empathy in a distributed system context? | Thinking about the person running your code in production at 3am, not just the developer calling your API in a test. |
| What is the five-year test? | Imagining the project 5 years later with new maintainers who weren't there when your change was made — will they understand, maintain, and not be harmed by it? |
7. References
- How the ASF Works — authoritative overview of the governance model
- Apache Voting Process — binding vs non-binding votes, lazy consensus
- Apache Community Guide for Developers
- Apache Committer FAQ — what committer access means in practice
- Apache Release Policy
- Producing OSS — Karl Fogel — free online book; chapters 4 and 8 are essential
- Apache Whimsy Roster — official PMC and committer rosters
- Apache Spark Contribution Guide
- Apache Hadoop Contribution Guide
- Apache Mailing List Archives — search dev@ history for any project
Lab 01 — Contributor Landscape Mapping
Phase: 01 — Apache Contributor Foundations
Difficulty: ⭐☆☆☆☆ | Estimated Time: 4–6 hours
Type: Research / Document
Goal
Build a concrete mental model of what the contributor → committer → PMC progression actually looks like by researching real engineers in a live Apache project. You are not reading theory — you are reading evidence.
Background
Apache project rosters and contribution histories are entirely public. JIRA archives, GitHub activity, and mailing list archives create a permanent, searchable record of every contribution ever made. You can reconstruct the journey of any Apache committer from their first patch to their election.
Most engineers have never done this. After this lab you will be able to look at any contributor profile and immediately recognize where they are on the ladder — and what is blocking them from moving up.
Setup
Primary project: Apache Spark
(You may substitute Apache Hadoop if you have a stronger background there.)
Tools you will use:
| Tool | URL | Purpose |
|---|---|---|
| Apache Whimsy Roster | https://whimsy.apache.org/roster/committee/spark | Current PMC members and committers |
| Apache JIRA | https://issues.apache.org/jira/projects/SPARK | Issue history, patch attachments, comments |
| GitHub — apache/spark | https://github.com/apache/spark | PR history, review activity |
| Apache Mailing List Archives | https://lists.apache.org/list.html?dev@spark.apache.org | dev@ thread history |
| Apache Reporter | https://reporter.apache.org/ | Project health and activity stats |
Steps
Step 1 — Orient Yourself on the Roster (30 min)
- Open Whimsy Spark Roster.
- Note the total number of PMC members and committers.
- Pick two PMC members whose names you recognize (e.g., from conference talks or blogs).
- Search their names in the JIRA archive and GitHub. Find their first ever Spark-related commit or merged PR. Note the date.
- Note the approximate date they were added to the committer list (check git log of the CHANGES file or mailing list announcement).
This gives you a rough sense of how long the journey took for established names.
Step 2 — Profile Five Engineers (2–3 hours)
Select five engineers to profile:
| Slot | Criteria |
|---|---|
| Profile A | A current PMC member with 5+ years of history |
| Profile B | A committer (not PMC) with 2–4 years of history |
| Profile C | A committer who became one relatively recently (< 2 years ago) |
| Profile D | An active contributor visible in recent PRs who is not yet a committer |
| Profile E | Yourself — or a fictional placeholder if you prefer |
For each, complete one contributor-profile-template.md (copy the template below into a new file named contributor-profile-[handle].md).
Step 3 — Write a Journey Summary (30–45 min)
For Profile A or B (your most historically rich profile), write a 200–300 word "journey narrative" answering:
- What was their first contribution?
- What types of contributions built their reputation (fixes? reviews? design work? documentation)?
- Were there visible inflection points — a period of significantly increased activity?
- Is there any evidence of the committer election announcement on dev@?
- What can you infer about why they were trusted?
Save this as journey-summary-[handle].md.
Step 4 — Synthesize (30 min)
After completing all five profiles, answer these three questions in a section at the bottom of your journey summary file:
- What patterns appear across all committer profiles that are absent in Profile D (the non-committer)?
- What is the minimum visible track record you observed before someone was elected committer (in time and contribution breadth)?
- If Profile D were nominated today, what is the single most important thing they are missing?
Template
Copy this template into a new file for each engineer you profile:
→ contributor-profile-template.md
Expected Output
lab-01-contributor-landscape/
├── README.md ← this file
├── contributor-profile-template.md ← blank template (do not edit)
├── contributor-profile-[handle-a].md ← your completed Profile A
├── contributor-profile-[handle-b].md ← your completed Profile B
├── contributor-profile-[handle-c].md ← your completed Profile C
├── contributor-profile-[handle-d].md ← your completed Profile D
├── contributor-profile-[handle-e].md ← your completed Profile E
└── journey-summary-[handle].md ← 200–300 word narrative + synthesis
Talking Points
Be prepared to explain:
- Why is the Apache contributor history fully public? (Legal, governance, and trust model reasons)
- What is the difference between a committer vote and a PMC vote? (Advisory vs binding)
- Why does the election process use a private mailing list? (Personnel discussion norms; protects nominees from public rejection)
- What would you change about your own public contribution history after seeing these profiles?
Resume Bullet
"Analyzed contributor-to-committer progression for Apache Spark: mapped five public contribution histories across JIRA, GitHub, and mailing list archives to identify the trust-building patterns and minimum track record that precede committer elections."
Lab 02 — Trust Audit
Phase: 01 — Apache Contributor Foundations
Difficulty: ⭐⭐☆☆☆ | Estimated Time: 3–4 hours
Type: Analysis / Document
Goal
Apply the Apache trust model as an evaluator — not a contributor. Given a fictional contributor profile, score each of the six trust dimensions, identify what is blocking committer candidacy, produce concrete actions, and write a PMC nomination draft or explain precisely why you cannot write one yet.
Background
When a PMC member considers nominating someone, they do not run a checklist. They have accumulated an impression over months or years of observation. But that impression can be decomposed into the six trust dimensions from the HITCHHIKERS-GUIDE.
This lab makes you practice that decomposition on a profile you haven't lived through — the way you would evaluate a contributor you don't know personally when their name comes up in a private@ nomination thread.
The Fictional Profile: Alex Kowalczyk
Alex is a backend engineer at a mid-size data platform company. Their company runs Apache Spark on YARN at scale. Alex has been contributing to Apache Spark for 14 months. Here is what the public record shows:
JIRA activity:
- Filed 12 issues, 9 of which were confirmed as real bugs. Reproduction cases are detailed and reliable.
- Submitted 8 patches. 6 were merged. 1 is still open with review comments from 4 months ago that Alex has not responded to. 1 was closed as "won't fix" without explanation.
- Left 7 comments on other people's issues — all were "+1 to reproduce, I see the same" type comments with no technical depth.
GitHub (apache/spark) activity:
- Authored 6 merged PRs. All are in the
sql/andcore/modules. - Reviewed 3 PRs in the last 14 months. Two reviews were "LGTM" with no comments. One review had a single comment: "Can you add a test for the null case?"
- No reviews outside the two subsystems where Alex's own patches live.
dev@ mailing list:
- Replied to one DISCUSS thread 8 months ago with a brief technical opinion. No follow-up when challenged.
- Zero posts on user@.
- Sent one "I'd like to work on this" email that was not followed up.
General observation:
- Alex attended Spark Summit and gave a lightning talk about the company's use case.
- Alex's patches are well-written: unit tests included, Javadoc updated, no obvious style violations.
- The one open PR with 4-month-old review comments is a non-trivial change to
TaskSchedulerImplthat the reviewer said had a subtle thread-safety issue.
Steps
Step 1 — Score the Six Trust Dimensions (1 hour)
Using only the evidence in the profile above, complete the trust audit template.
For each dimension:
- Assign a score of 1–5 (1 = no evidence, 5 = strong consistent evidence)
- Write 2–3 sentences of specific evidence citing details from the profile (not general statements)
- Note any red flags for the dimension
Step 2 — Identify the Top Two Gaps (30 min)
After scoring all six dimensions:
- Which two dimensions have the lowest scores?
- For each, write a paragraph explaining:
- Why this gap matters to the Apache community specifically
- What a committer's private reaction would likely be when this gap comes up in the nomination discussion
- Whether this gap is recoverable, and in what timeframe
Save this in a section called ## Gap Analysis in your trust-audit file.
Step 3 — Produce Three Concrete Actions (30 min)
Write three specific, actionable recommendations for Alex to strengthen their committer candidacy over the next 6 months.
Each action must be:
- Specific (not "contribute more reviews" — but "review 2 PRs per week outside the sql/ module, focusing on components Alex knows from reading the issue tracker")
- Time-bounded (achievable in 6 months)
- Directly addressing a gap from Step 2
Save this in a section called ## Recommended Actions in your trust-audit file.
Step 4 — Write the Nomination Draft or Blocker Statement (30 min)
Option A — If you believe Alex is ready for nomination today:
Write a 150–200 word PMC nomination email for Alex. Apache nomination emails follow a rough format:
- State the nominee's name and Apache ID
- Summarize their contributions (specific patches, reviews, community work)
- State your assessment of their trustworthiness
- Explicitly invite discussion
Option B — If you believe Alex is NOT ready for nomination today:
Write a 150–200 word explanation of:
- The single most important blocker
- What specifically Alex would need to demonstrate before you could write Option A
- A realistic timeline
Save this in a section called ## Nomination Draft or Blocker Statement in your trust-audit file.
Step 5 — Self-Audit Extension (Optional, 45 min)
Apply the same template to yourself. This is the most valuable part of the lab.
Be ruthless. The PMC member evaluating your profile will be.
Template
Expected Output
lab-02-trust-audit/
├── README.md ← this file
├── trust-audit-template.md ← blank template (do not edit)
├── trust-audit-alex-kowalczyk.md ← your completed audit
└── trust-audit-self.md ← (optional) self-audit
Talking Points
Be prepared to explain:
- Why is review engagement a trust signal more important than patch volume? (asymmetric signal: anyone can submit patches; willingness to review benefits others with no personal upside)
- What does an abandoned patch with review comments signal to a PMC member? (unreliability under pressure; the hardest patches to respond to are the ones where the reviewer is right)
- Why is a single "LGTM" review noise rather than signal? (no evidence of understanding; maintainers need to know Alex can catch real problems)
- What is the most dangerous failure mode in Alex's profile? (the 4-month-old open PR with unresolved thread-safety concern — an in-progress change to a core scheduling component that is now blocking the maintainer)
Resume Bullet
"Applied the Apache committer trust framework to structured contributor evaluation: scored six trust dimensions from public evidence, identified candidacy blockers, produced a 6-month development plan, and drafted a PMC nomination or blocker analysis."
Phase 02 — JVM Codebase Navigation
Difficulty: ⭐⭐☆☆☆ | Estimated Time: 1–2 weeks
Roles supported: All tracks — mandatory prerequisite for Phases 07–11.
Primary project: Apache Spark
Why This Phase Exists
Becoming an Apache committer requires the ability to navigate codebases you did not write, in subsystems you have never touched, under time pressure, without a guide. This is a skill almost never taught explicitly and almost always assumed.
The engineers who become committers are the ones who can open a 2 million-line Java/Scala codebase, find the relevant entry point in 10 minutes, trace the call path through four modules, understand the failure mode, and write a correct fix — all before the issue gets stale. This phase teaches that skill methodically.
You will set up Apache Spark for local development, learn to read its Maven module hierarchy, trace a complete execution path from user-facing API to internal executor, and then perform a full issue trace on a real historical bug: reading the JIRA, locating the broken code path, understanding the fix, and explaining why it was correct.
Concepts
- Maven multi-module project structure in large Apache codebases
- Reading
pom.xmlhierarchies: parent POMs, dependency management, module declarations - IntelliJ IDEA project import for Apache Spark (Scala + Java mixed modules)
- JVM codebase navigation strategies: API-surface-first, interface-following, test-reading
- Apache Spark module topology:
core/,sql/catalyst/,sql/core/,resource-managers/yarn/,network/,common/ - The Spark execution model: SparkSession → Logical Plan → Physical Plan → RDD → Task → Executor
- Key Spark classes and their responsibilities:
SparkContext,DAGScheduler,TaskScheduler,ShuffleManager,BlockManager - Reading git history to understand a bug fix:
git log,git show,git blame - Connecting a JIRA issue to a commit: PR description, JIRA link, release note entry
- Writing a "bug journey" document: problem, trigger, code path, fix, correctness argument
Labs
Lab 01 — Codebase Spelunking
| Field | Value |
|---|---|
| Goal | Clone Apache Spark, understand its Maven module structure, and produce an annotated trace of spark.read.csv("path") from the user API surface down to FileScanRDD.compute(). |
| Concepts | Maven multi-module structure; Spark module topology; logical → physical plan pipeline; IntelliJ IDEA navigation. |
| Project | apache/spark — branch master or latest stable tag (e.g., v3.5.1) |
| Steps | 1) Clone and build index (see setup guide in lab README). 2) Read the top-level pom.xml and map the 15 primary modules. 3) Start at SparkSession.read() — trace through DataFrameReader, DataSource, CSVFileFormat, FileSourceScanExec, FileScanRDD. 4) Annotate each class with one sentence explaining its responsibility. 5) Draw the module dependency graph for the classes you touched. |
| Output | A completed execution-trace.md with: annotated class list, module dependency diagram (ASCII), and a 3-sentence explanation of the logical→physical plan boundary. |
| How to Verify | Your trace should match the actual class hierarchy — verify by running Find Usages on DataFrameReader.load() in IntelliJ and confirming the call chain. |
| Talking Points | Why does Spark separate logical from physical plans? What is the Catalyst optimizer's role in this path? Why is FileScanRDD the right entry point for storage-side debugging? |
| Resume Bullet | "Navigated Apache Spark's 2M-line multi-module codebase; produced an annotated execution trace from SparkSession.read().csv() through Catalyst optimization to FileScanRDD.compute(), documenting module boundaries and responsibility of each layer." |
| Extensions | Trace a spark.sql("SELECT ...") query through the same pipeline. Where does it rejoin the CSV read path? |
Lab 02 — Issue Trace
| Field | Value |
|---|---|
| Goal | Select a real closed SPARK JIRA issue, locate its root cause in the Spark source tree, trace the broken code path, understand the fix, and produce a structured "bug journey" document. |
| Concepts | git log / git blame / git show; connecting JIRA to commits; reading diffs with context; root cause vs symptom. |
| Steps | 1) Select one of the pre-approved issues listed in the lab README. 2) Read the full JIRA description and all comments. 3) Find the fix commit via the JIRA "Commits" tab or PR link. 4) Read the diff: what exactly changed? 5) Navigate backward from the changed lines to find the root cause. 6) Complete the bug-journey-template.md. |
| Output | One completed bug-journey-[SPARK-XXXXX].md with: problem summary, trigger conditions, broken code path (class → method → line), fix description, correctness argument, and test coverage analysis. |
| How to Verify | Your root cause location should match the lines changed in the fix commit. Your correctness argument should explain why the fix resolves the root cause and not just the symptom. |
| Talking Points | How do you distinguish root cause from symptom in a distributed system bug? Why do Apache committers often ask "does this fix the root cause or paper over it?" What information in a JIRA issue tells you how severe a bug is? |
| Resume Bullet | "Performed end-to-end issue trace on a closed Apache Spark JIRA: located root cause in Spark internals using git blame and diff analysis, documented the broken code path, and produced a structured correctness argument for the accepted fix." |
| Extensions | Find the test added in the fix commit. Can you identify the boundary condition it covers? Would additional tests have caught this earlier? |
Deliverables Checklist
- Apache Spark cloned and project index built in IntelliJ
-
Top-level
pom.xmlmodule map documented (lab-01) -
Annotated execution trace:
SparkSession.read().csv()→FileScanRDD.compute()(lab-01) - Module dependency diagram drawn (lab-01)
- Logical→physical plan boundary explained in 3 sentences (lab-01)
- JIRA issue selected and fully read including all comments (lab-02)
- Fix commit located and diff read (lab-02)
- Root cause located to specific class/method/line (lab-02)
-
bug-journey-*.mdcompleted (lab-02)
Interview Relevance
- "Walk me through how a
spark.read.csv()call executes." (you can answer end-to-end) - "What is the Catalyst optimizer?" (you can explain its role in the logical→physical boundary)
- "How do you approach debugging an unfamiliar distributed codebase?" (you can describe the API-surface-first strategy)
- "What is a DAGScheduler?" (you can describe its role relative to TaskScheduler)
- "How do you find the root cause of a bug in a 2-million-line codebase you didn't write?" (you can describe the git blame → class trace → JIRA strategy)
Warmup Guide — JVM Codebase Navigation
Zero-to-expert primer for Phase 02: how to land in a million-line Apache JVM codebase (Spark, Hadoop, Kafka) and become productive without a guide — build systems, module anatomy, IDE archaeology, and execution-path tracing.
Table of Contents
- Chapter 1: The Shape of a Large Apache JVM Project
- Chapter 2: Maven (and SBT/Gradle) — Enough to Navigate
- Chapter 3: Setting Up the Archaeology Lab — IDE and Tools
- Chapter 4: Top-Down Navigation — From Public API Inward
- Chapter 5: Bottom-Up Navigation — From Symptom to Cause
- Chapter 6: git Archaeology — History as Documentation
- Chapter 7: A Worked Example — Tracing a Spark Action
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: The Shape of a Large Apache JVM Project
From zero: Spark is ~1.5M lines across ~30 Maven/SBT modules; Hadoop ~3M. Nobody reads it linearly — you build a map of intent, then descend only where needed. The recurring module anatomy (learn it once, recognize it everywhere):
common/core: shared utilities, config machinery, RPC — everything depends on it.- API modules (
sql/api,hadoop-client-api): the public surface, deliberately thin, annotated with audience/stability markers (Phase 04's compat machinery). - Engine modules (
sql/core,sql/catalyst,core/): the actual machinery. - Connector/integration modules: optional, depend outward, good first-patch terrain (small blast radius).
*-tests/test-jars: shared test fixtures — also where you learn how to test in this codebase, which matters more than how to code in it.
The 80/20 of any module: README/package-info → the 2–3 interfaces everything implements → the config keys it registers → its largest test class. That ordering — docs, contracts, knobs, behaviors — beats reading implementation files in alphabetical order by an order of magnitude.
Chapter 2: Maven (and SBT/Gradle) — Enough to Navigate
You need build-system literacy to navigate, not to administer:
- The reactor:
mvn -pl sql/core -am packagebuilds one module And its upstream deps (-am) — the difference between a 5-minute and a 2-hour loop. Learn each project's documented fast paths (Spark:./build/sbt ~compilefor incremental;-DskipTestseverywhere). dependency:tree: where "which jar does this class come from / why two versions" questions die. Shaded jars (relocated packages —org.sparkproject.guava) explain classes whose package names don't exist in any source tree.- Profiles (
-Phadoop-3.4,-Pyarn): the same tree builds different artifact sets; when code seems unused, check whether a profile compiles it. - Where generated code lives (
target/generated-sources— ANTLR parsers, protobuf): "I can't find this class's source" usually ends here. - Run a single test fast:
mvn test -pl core -Dtest=DAGSchedulerSuite(or./build/sbt "core/testOnly *DAGSchedulerSuite -- -z \"stage failure\"").
Chapter 3: Setting Up the Archaeology Lab — IDE and Tools
- IntelliJ IDEA opens the Maven/SBT model directly; give it heap
(
-Xmx4g+in the VM options) for Spark-sized indexes. The navigation muscle memory that matters: go-to-implementation (⌥⌘B — interfaces have many), find-usages (⌥F7), type hierarchy (⌃H), and structural search for patterns. - Decompiled views: IDEA decompiles dependency bytecode on click — how you read shaded/generated classes.
- ripgrep beside the IDE:
rg -t scala "spark.sql.shuffle.partitions"across the repo answers "where is this config consumed" faster than any index. Config-key strings are the single best entry points into unfamiliar engines — they appear at exactly the decision sites. - Debugger over print: a conditional breakpoint inside a real test run
(
-Dmaven.surefire.debug/ IDEA's test runner) teaches call structure faster than a day of reading. Capture stacks: a breakpoint's stack trace is the call-path diagram for your notes.
Chapter 4: Top-Down Navigation — From Public API Inward
Use when the question is "how does feature X work":
- Find the public entry point (the method users call —
df.collect(),producer.send()). - Follow it through interfaces, not implementations — note each interface and ask "what are the other implementations and why do they exist?" (the answer encodes the design space).
- At each hop, record only: component name, responsibility in one phrase, the handoff. Resist reading bodies — you are drawing the subway map, not the streets.
- Stop at process/thread boundaries (RPC, event loops, queues) and mark them — they're where stack traces will later be discontinuous (Chapter 5's problem).
The deliverable form (Lab 01 uses it): a numbered hop list,
API → ComponentA(role) → [event queue] → ComponentB(role) → …, each hop with
file:line. Ten hops of this beats fifty pages of prose notes.
Chapter 5: Bottom-Up Navigation — From Symptom to Cause
Use when the question is "why did this break" (Lab 02's mode):
- Start from exact text: error messages and log lines are grep-able anchors —
rg "Container killed on request"lands you at the throw/log site immediately. - Read the stack trace as a path, but know its gaps: executor-side traces don't
show the driver decision that scheduled them; async boundaries (Netty handlers,
event loops,
Futurecallbacks) restart stacks. At each gap, find the enqueue site for the dequeue you're looking at. - Reproduce in a test: find the nearest existing test of the failing component (test class names mirror source names) and bend it toward the bug. A failing test is both your understanding made checkable and 80% of your eventual patch.
- Then bisect versions if needed:
git bisectwith that test as the oracle.
Chapter 6: git Archaeology — History as Documentation
In mature projects, why lives in history, not comments:
git log --follow -p -- path/File.scala— evolution of one file, renames included.git blame -w -C(ignore whitespace, track copies) on the exact lines you don't understand → commit message → JIRA id (Apache commit discipline:[SPARK-12345] ...) → the JIRA thread holds the design discussion, the rejected alternatives, and the reviewers (= the experts to tag someday).git log -S"methodName"(pickaxe) — when was this introduced/removed, even if blame is obscured by refactors.- The compound move you'll use weekly: blame line → JIRA → linked PR → review comments. That chain is the project's institutional memory, fully public.
Chapter 7: A Worked Example — Tracing a Spark Action
The Lab-01-shaped trace, as it should read when done (Spark ~3.5, names stable for years):
RDD.collect()→SparkContext.runJob— the action entry; everything funnels here.- →
DAGScheduler.submitJob— postsJobSubmittedonto an event loop (DAGSchedulerEventProcessLoop) — first async boundary; driver-side. handleJobSubmitted→ stage graph built by walking RDD lineage backward, cutting at shuffle dependencies (wide deps = stage boundaries — the Phase 07/interview staple).- →
submitMissingTasks→TaskScheduler.submitTasks→SchedulerBackend— tasks serialized, offered to executors via RPC (second boundary: process hop). - Executor:
Executor.TaskRunner.run→ deserialize →Task.runTask→ShuffleMapTask/ResultTask— the closure finally executes; results travel back via block manager / direct result depending on size.
Every hop is findable by the Chapter 4 method in under an hour; the asymmetry between "impossible wall of code" and "ten findable hops" is the entire lesson of this phase.
Lab Walkthrough Guidance
Order: Lab 01 (spelunking) → Lab 02 (issue trace).
- Lab 01: build the project first (the build always teaches: profiles, generated code, module graph). Produce the hop-list trace with file:line anchors; timebox each hop to 15 minutes — if stuck, you're reading bodies instead of following interfaces.
- Lab 02: pick a resolved bug with a linked PR (you'll have ground truth). Do the full bottom-up: symptom → anchor grep → path → reproducing test idea → then compare against the actual fix. Score your diagnosis honestly in the template; the deltas are the curriculum.
Success Criteria
You are ready for Phase 03 when you can, from memory:
- Name the recurring module anatomy and the 80/20 reading order for a new module.
- Build one module with upstream deps and run one named test, in one command each.
- Execute the blame→JIRA→PR→review chain on an arbitrary confusing line.
- Reproduce the five-hop Spark action trace (or your project's equivalent) on a whiteboard.
- State the two navigation modes and which evidence each starts from.
Interview Q&A
Q: You've joined a team owning a 1M-line service you've never seen. First week? Build it and run its tests (the build reveals the module graph and the lies in the README); read the top 3 interfaces of the core module; trace ONE main request path top-down into a hop list; then take a real ticket bottom-up — navigation is learned by need, not by survey. Deliverable by Friday: the hop-list map and one small merged fix.
Q: How do you find why a config flag exists and whether it's safe to remove?
rg the key for registration + consumption sites; blame the registration → JIRA →
original motivating incident; check usage telemetry/defaults history (git log -S on
the default value); then the Phase 04 compat lens — a public config is API surface, so
removal is a deprecation cycle, not a delete.
Q: A stack trace ends inside an event loop. How do you keep tracing?
Find the dequeue site's corresponding enqueue: search for the event class's
constructor/post call. Async frameworks make causality discontinuous in stacks but
explicit in event types — the event class name is the thread-crossing breadcrumb.
(Same skill for Future callbacks: find where the future was created/completed.)
References
- Spark: Useful Developer Tools — builds, single tests, IDEA setup
- Maven: Guide to Working with Multiple Modules
- Spark Internals (Mastering Apache Spark notes) — companion for Chapter 7
- Feathers, Working Effectively with Legacy Code — the characterization-test mindset
- ripgrep — learn
-t,-g,-S - Hadoop/Kafka equivalents: Kafka coding guide, Hadoop wiki "How To Contribute"
🛸 Hitchhiker's Guide — Phase 02: JVM Codebase Navigation
Read this if: You can write Java/Scala but you've never opened a 2-million-line production codebase and felt confident navigating it. By the end you should be able to clone Apache Spark, find any class from a user-facing symptom, trace it to its implementation, and explain the execution model from
SparkSessiontoExecutorwithout consulting the documentation.
0. The 30-second mental model
Apache Spark is a multi-module Maven project. Each module has a clear responsibility, and the modules form a dependency DAG (not a tree). Understanding this DAG is the first step to navigating the codebase.
User API
│
SparkSession / SparkContext
│
┌───────┴───────┐
│ │
SQL Path RDD Path
│ │
Catalyst DAGScheduler
(optimizer) │
│ TaskScheduler
Physical Plan │
│ TaskSetManager
FileScanRDD │
│ Executor
compute() │
│ TaskRunner.run()
Iterator[Row] │
task.run()
Three invariants to keep in mind throughout:
- The boundary between logical and physical plans is the optimizer. Anything above
FileSourceScanExecis logical. Anything from it downward is physical/runtime. - Scheduling is separate from execution.
DAGSchedulerbreaks the job into stages.TaskSchedulerassigns tasks to executors. The executor runs them. - Storage is abstracted behind RDDs. Whether you read CSV, Parquet, or JDBC, you eventually get an
RDD[InternalRow]with acompute()method.
1. Apache Spark's Maven Module Structure
1.1 Reading the Top-Level pom.xml
Every Maven project has a pom.xml. In a multi-module project, the top-level pom.xml is the parent POM: it declares all child modules, sets shared properties (Java version, Scala version, plugin versions), and defines the dependency management section — but does not itself produce a JAR.
<!-- Simplified excerpt from Spark's top-level pom.xml -->
<modules>
<module>core</module>
<module>sql/catalyst</module>
<module>sql/core</module>
<module>sql/hive</module>
<module>streaming</module>
<module>mllib</module>
<module>resource-managers/yarn</module>
<module>resource-managers/kubernetes</module>
<module>launcher</module>
<module>network/common</module>
<module>network/shuffle</module>
<module>common/unsafe</module>
<module>common/sketch</module>
<module>common/kvstore</module>
...
</modules>
Each <module> is a subdirectory containing its own pom.xml with a <parent> pointing back to the root. This is the standard Maven multi-module pattern.
1.2 The Primary Spark Modules
| Module | Directory | What Lives Here |
|---|---|---|
| core | core/ | SparkContext, DAGScheduler, TaskScheduler, RDD, BlockManager, SparkEnv, Serializer, Broadcast |
| SQL Catalyst | sql/catalyst/ | Logical/physical plan trees, optimizer rules, expressions, type system, Encoders |
| SQL Core | sql/core/ | SparkSession, DataFrame, DataFrameReader, DataSource, FileFormat, execution |
| SQL Hive | sql/hive/ | Hive metastore integration, HiveContext |
| Streaming | streaming/ | DStream, StreamingContext, Receiver, InputDStream |
| MLlib | mllib/ | ML pipelines, Estimator/Transformer/Param, algorithms |
| YARN | resource-managers/yarn/ | YARN client/cluster mode, ApplicationMaster, YarnScheduler |
| Kubernetes | resource-managers/kubernetes/ | K8s resource scheduling, pod management |
| Network Common | network/common/ | Netty-based transport layer (TransportServer, TransportClient) |
| Network Shuffle | network/shuffle/ | External shuffle service, ShuffleBlockFetcher |
| Common Unsafe | common/unsafe/ | Off-heap memory management (Project Tungsten), Platform class |
| Common KVStore | common/kvstore/ | LevelDB-backed storage for Spark History Server |
| Launcher | launcher/ | SparkLauncher API for programmatic job submission |
1.3 The Dependency DAG
Modules depend on each other. The key rule: core has no dependency on sql/. The SQL layer depends on core, not the other way around. This means:
- You can read an RDD bug purely in
core/without touching SQL - A DataFrame bug almost always involves both
sql/catalyst/andsql/core/ - A shuffle bug may touch
core/,network/shuffle/, andresource-managers/yarn/
┌─────────────┐
│ sql/hive │
└──────┬──────┘
┌──────────────────┘
▼
┌─────────┐ ┌──────────────┐
│sql/core │───▶│ sql/catalyst │
└────┬────┘ └──────┬───────┘
└────────────────┘
│
▼
┌────────────────┐
│ core │◀── resource-managers/yarn
└────────┬───────┘◀── resource-managers/k8s
│
┌────────┴──────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ network/common │ │ common/unsafe │
└─────────────────┘ └──────────────────┘
2. Setting Up Apache Spark for Local Development
2.1 Prerequisites
# Required tools
java --version # Java 11 or 17 (not 21+ for older branches)
mvn --version # Maven 3.8+
scala -version # Scala 2.12 or 2.13 (match your Spark branch)
git --version
# IntelliJ IDEA (Community edition is sufficient)
# Plugin required: Scala plugin
2.2 Clone and Build
# Clone the repository
git clone https://github.com/apache/spark.git
cd spark
# Check out a stable release tag (faster than building main)
git checkout v3.5.1
# Build only the modules you need (skip tests and docs to save time)
./build/mvn -DskipTests -Phive -Pyarn package -pl core,sql/catalyst,sql/core \
-am 2>&1 | tail -50
# -pl : only these modules
# -am : also build modules they depend on
# This takes 8–15 minutes on a modern laptop
# Full build (takes 30–60 minutes; only do this once):
./build/mvn -DskipTests package
2.3 Import into IntelliJ IDEA
File → Open→ select thespark/directory- IntelliJ detects the Maven project and auto-imports modules
- Wait for indexing to complete (5–15 minutes for initial index)
File → Project Structure → SDKs→ confirm Java 11 or 17 is selected- Install Scala plugin if not already present (IntelliJ will prompt)
After indexing, you have:
- Go to Declaration (
Cmd+B/Ctrl+B): jump from usage to definition - Find Usages (
Opt+F7/Alt+F7): find every call site for a method - Navigate to Class (
Cmd+O/Ctrl+N): open any class by name - Type Hierarchy (
Ctrl+H): see all implementations of an interface
2.4 Running a Single Test
# Run one test class without building the whole project
./build/mvn test -pl sql/core -Dtest=DataFrameReaderSuite -DfailIfNoTests=false
3. JVM Codebase Navigation Strategies
3.1 The API-Surface-First Strategy
When you receive a bug report like "reading a CSV file with a null value in column 3 throws NPE," start at the public API, not at the crash stack trace.
1. Find the public API entry point
└── SparkSession.read().csv("path")
2. Follow the method chain until you reach an interface or abstract class
└── DataFrameReader.csv() → DataFrameReader.load() → DataSource.apply()
3. Find all implementations of the interface/abstract class
└── CSVFileFormat implements FileFormat
└── Use Type Hierarchy (Ctrl+H) on FileFormat to see all implementations
4. Narrow to the implementation your bug is in
└── The NPE points at CSVFileFormat.buildScan() line 174
5. Now read the implementation in context
This strategy gets you to the right place faster than starting from a stack trace, because stack traces only show one path — but the real problem may be that the path shouldn't have been taken at all.
3.2 The Test-Reading Strategy
Before reading implementation code, read the tests for the class you're investigating. Tests tell you:
- What the expected behavior is (the passing tests)
- What edge cases were considered (the named test methods)
- What the error handling contract is (tests with
assertThrows) - What assumptions the implementor made (what is NOT tested)
# Find tests for a class
find . -name "*DataFrameReader*Test*" -o -name "*DataFrameReaderSuite*"
# Apache Spark uses ScalaTest, so test files end in "Suite"
3.3 The Interface-Following Strategy
Apache projects are heavily interface-driven. When tracing a call path:
SparkSession.sessionState.executePlan()
│
▼
QueryExecution.executedPlan ← this is a SparkPlan
SparkPlan is an abstract class
Use Type Hierarchy to see all physical plans
Every time you hit an abstract method or interface, stop and ask: "Which implementation is actually running for my use case?" The answer is usually in:
- The factory method that constructs the object
- A pattern-match in a
Ruleclass - An injection point in
SparkContextorSparkSession.Builder
3.4 The git Blame Strategy
When you find the specific line that causes a bug, use git blame to understand its history:
# Who last changed this line, and why?
git blame sql/core/src/main/scala/org/apache/spark/sql/DataFrameReader.scala
# Show the full commit that changed a specific line
git show <commit-hash>
# Find all commits that touched a file
git log --oneline sql/core/src/main/scala/org/apache/spark/sql/DataFrameReader.scala
# Find when a specific string was introduced
git log -S "nullValue" --oneline
git blame is underused by engineers who are new to large codebases. It tells you: the person who wrote this line, the commit that introduced it, and usually the JIRA number in the commit message.
4. The Spark Execution Model in Depth
4.1 The Full Call Path: spark.read.csv("path")
SparkSession.read()
└── Returns a new DataFrameReader instance
│
DataFrameReader.csv(path: String)
└── Calls format("csv").load(path)
│
DataFrameReader.load(path)
└── DataSource.apply(sparkSession, "csv", paths = Seq(path))
└── df = DataSource(sparkSession, className, paths).resolveRelation()
│
DataSource.resolveRelation()
└── fileFormat = DataSource.lookupDataSource("csv") → CSVFileFormat
└── Returns: HadoopFsRelation(location, ..., fileFormat = CSVFileFormat)
└── Wraps in: LogicalRelation(HadoopFsRelation)
│
LogicalRelation ← this is the LOGICAL PLAN node
│
▼ (when an Action is called, e.g., .show() or .count())
│
QueryExecution.executedPlan
└── Calls optimizer rules on the logical plan
└── Produces: FileSourceScanExec ← PHYSICAL PLAN node
│
FileSourceScanExec.inputRDD
└── Returns: FileScanRDD
│
FileScanRDD.compute(partition, context)
└── Opens file via Hadoop InputFormat
└── Returns: Iterator[InternalRow]
│
▼
Iterator consumed by downstream operators
(Project, Filter, Aggregate, etc.)
4.2 The Logical → Physical Plan Boundary
| Layer | Type | Key Classes |
|---|---|---|
| Logical | LogicalPlan | LogicalRelation, Filter, Project, Aggregate, Join |
| Analyzed | LogicalPlan (resolved) | Same nodes, but all references resolved to typed attributes |
| Optimized | LogicalPlan (after optimizer rules) | Pushdowns, constant folding, filter merging |
| Physical | SparkPlan | FileSourceScanExec, FilterExec, ProjectExec, HashAggregateExec, SortMergeJoinExec |
| Runtime | RDD[InternalRow] | FileScanRDD, ShuffledRowRDD, MapPartitionsRDD |
The Catalyst optimizer sits between the analyzed logical plan and the physical plan. It applies a sequence of rules (each a pattern-matching transform over the plan tree) to produce a more efficient plan before planning begins.
4.3 The Scheduling Model
Once an action triggers execution, this is what happens:
SparkContext.runJob()
│
▼
DAGScheduler.runJob()
└── Builds DAG of stages from the RDD dependency chain
└── Determines stage boundaries (shuffle boundaries = stage boundaries)
└── Submits stages in topological order
│
TaskScheduler.submitTasks(taskSet)
└── Assigns tasks to executors based on locality (PROCESS_LOCAL > NODE_LOCAL > RACK_LOCAL > ANY)
└── Manages task retries on failure
│
CoarseGrainedSchedulerBackend
└── Sends task binary to executor via Netty transport
│
Executor.launchTask()
└── Deserializes the task
└── Runs TaskRunner in a thread pool
│
TaskRunner.run()
└── task.run(taskAttemptId, attemptNumber, metricsSystem)
└── Task is either a ShuffleMapTask or a ResultTask
│
ShuffleMapTask.runTask() OR ResultTask.runTask()
└── Calls rdd.iterator(partition, context)
└── Writes output to shuffle files or returns result to driver
Key insight: DAGScheduler operates on RDD lineages. TaskScheduler operates on task sets. They communicate through a clean interface (DAGScheduler.taskStarted(), taskEnded(), etc.). Bugs often live at this boundary.
4.4 The BlockManager
Every executor has a BlockManager. It is responsible for:
- Caching RDD partitions in memory or disk (
persist()/cache()) - Broadcasting variables (driver sends to executors via the block manager)
- Shuffle write (ShuffleMapTask writes shuffle blocks)
- Shuffle read (tasks fetch shuffle blocks from peer executors)
The BlockManager is the part of Spark most involved in out-of-memory errors, shuffle fetch failures, and broadcast issues. Many production bugs live here.
5. Reading a JIRA Issue and Connecting It to Code
5.1 Anatomy of a JIRA Issue
SPARK-XXXXX
├── Summary ← one-line description (the symptom, not the cause)
├── Description ← reproducer, stack trace, context
├── Comments ← discussion, investigation, design debate
├── Attachments ← patches (older workflow) or PR links (modern)
├── Fix Version/s ← which release included the fix
└── Linked Issues ← related JIRAs (parent, blocker, duplicate)
The comments section is the most valuable. It often contains:
- Stack traces with line numbers (now stale after the fix, but useful for locating the area)
- Bisect results ("the regression was introduced in commit X")
- Design debate ("Option A vs Option B — here's why we chose B")
- Reviewer concerns that shaped the final patch
5.2 Finding the Fix Commit
# Method 1: From the JIRA, click "Commits" tab → links to GitHub PR
# Method 2: Search git log for the JIRA number
git log --oneline --all | grep "SPARK-XXXXX"
# Method 3: Search merged PRs on GitHub
# github.com/apache/spark/pulls?q=SPARK-XXXXX
# Method 4: If you know the fix version (e.g., 3.2.0):
git log v3.1.3..v3.2.0 --oneline | grep "SPARK-XXXXX"
5.3 Reading the Diff
When you find the commit, read the diff with context:
git show <commit-hash> # full diff
git show <commit-hash> --stat # which files changed
git show <commit-hash> -U10 # 10 lines of context per change
Look for:
- What was added: new null checks, new synchronized blocks, new early-exit conditions
- What was removed: the broken code path
- What was not changed: the calling code — which tells you the fix is internal
- Test changes: what new case was added to the test suite
5.4 The Root Cause vs Symptom Distinction
This is what committers care about most in a patch review:
| Root Cause Fix | Symptom Fix | |
|---|---|---|
| Fixes the problem | Yes | For this specific case |
| Handles related cases | Yes | No |
| Correct under concurrency | Yes | Maybe |
| Reviewers say | "This is the right fix" | "This papers over the issue" |
Example: A NullPointerException in CSVFileFormat.buildScan() when schema inference finds a column of all nulls.
- Symptom fix: Add
if (field == null) continue;at the crash site - Root cause fix: Understand why
fieldcan be null (schema inference returns null for all-null columns in some code paths), fix the schema inference to returnNullTypeinstead of null, add a test for all-null columns
Committers will always ask: "Why is this field null? Should it be? What other code paths hit this same condition?"
6. Interview Questions — Concepts You Must Explain Out Loud
| Topic | One-line answer |
|---|---|
| What is the role of the Catalyst optimizer? | It transforms an analyzed logical plan into an optimized logical plan by applying a sequence of algebraic rules (predicate pushdown, constant folding, join reordering, etc.) before physical planning. |
What is the difference between DAGScheduler and TaskScheduler? | DAGScheduler breaks a job into stages based on shuffle boundaries and submits them in order. TaskScheduler assigns individual tasks within a stage to available executor slots. |
| What is a stage boundary in Spark? | A shuffle dependency — any operation that requires data redistribution across partitions (e.g., groupByKey, reduceByKey, sort-based join). Each stage is a pipeline of narrow transformations terminated by a shuffle write. |
What is InternalRow and why does it exist? | An optimized binary row format used internally by Spark SQL's physical execution layer. Unlike Row, it avoids boxing and object creation — key to Project Tungsten's performance gains. |
What is the BlockManager? | The subsystem that manages all block storage on each executor and the driver — RDD partitions, shuffle blocks, and broadcast variables. Communication between executors for shuffle fetch goes through BlockManager. |
| How do you find the commit that introduced a regression? | git bisect start, mark the last known good and first known bad commit, then run the test on each bisect point. |
What is WholeStageCodegenExec? | A physical plan node that compiles a pipeline of operators into a single JVM bytecode method at runtime, eliminating virtual dispatch overhead. Enabled by default. |
| Why does Spark use lazy evaluation? | Transformations build a lineage graph without executing. Only actions trigger execution. This lets the optimizer see the full plan before committing to physical execution and enables lineage-based fault recovery without checkpointing. |
7. References
- Apache Spark GitHub — apache/spark
- Spark Internals (Databricks slides) — overview of the execution model
- Deep Dive: Apache Spark Memory Management (Databricks)
- Mastering Apache Spark (Jacek Laskowski — online, free) — the most detailed public Spark internals reference
- Spark SQL Paper — Armbrust et al., SIGMOD 2015 — original Catalyst/DataFrame paper
- Effective Java, 3rd ed. — Joshua Bloch — Java idioms used throughout Spark's Java interop layers
- Maven: Guide to Multi-Module Projects
- IntelliJ IDEA: Navigating Source Code
Lab 01 — Codebase Spelunking
Phase: 02 — JVM Codebase Navigation
Difficulty: ⭐⭐☆☆☆ | Estimated Time: 4–6 hours
Type: Code Navigation / Document
Repository: apache/spark — tag v3.5.1
Goal
Produce an annotated execution trace of spark.read.csv("path") from the first line of user-facing API code down to FileScanRDD.compute(). Map the Maven module dependencies touched along the way. Explain the logical→physical plan boundary in your own words.
After this lab you will be able to open any Apache Spark bug report and navigate to the relevant code in under 10 minutes.
Setup (Do This Once)
# 1. Clone the repository
git clone https://github.com/apache/spark.git
cd spark
git checkout v3.5.1
# 2. Build the modules used in this lab (skip tests to save time)
./build/mvn -DskipTests -Phive -Pyarn package \
-pl core,sql/catalyst,sql/core -am \
2>&1 | tail -30
# 3. Open in IntelliJ IDEA
# File → Open → select the spark/ directory
# Wait for Maven import and indexing (5–20 minutes first time)
# Confirm: File → Project Structure → SDKs → Java 11 or 17
# 4. Verify navigation works:
# Press Cmd+O (Mac) or Ctrl+N (Linux/Windows)
# Type: SparkSession
# You should see org.apache.spark.sql.SparkSession
Steps
Step 1 — Map the Module Structure (45 min)
Open spark/pom.xml (the root). Find the <modules> block.
-
List every
<module>entry. There are approximately 30–40. -
For each of the following 8 modules, write one sentence describing its responsibility:
coresql/catalystsql/coresql/hiveresource-managers/yarnnetwork/commonnetwork/shufflecommon/unsafe
-
Draw a simple dependency diagram (ASCII is fine) showing which of these 8 modules depend on which others.
Hint: Open each module's
pom.xmland look at its<dependencies>. The<artifactId>values show you the module names.
Save this in execution-trace.md under ## Module Map.
Step 2 — Trace the Call Path (2–3 hours)
Starting at SparkSession.read(), trace the code path to FileScanRDD.compute(). For each class in the chain, record:
- The module it lives in (e.g.,
sql/core) - The fully qualified class name
- One sentence describing its responsibility
- The specific method where control transfers to the next class in the chain
Use IntelliJ's navigation tools:
Cmd+B/Ctrl+B— Go to DeclarationOpt+F7/Alt+F7— Find UsagesCtrl+H— Type Hierarchy (for abstract classes and interfaces)Cmd+Shift+F/Ctrl+Shift+F— Full-text search across the project
Starting point:
// In IntelliJ: Cmd+O → "SparkSession"
// Find the method: def read: DataFrameReader
val df = spark.read.csv("path/to/file.csv")
Waypoints to find and record (you fill in the details):
1. SparkSession.read() → sql/core
2. DataFrameReader.csv(path) → sql/core
3. DataFrameReader.load() → sql/core
4. DataSource.apply() / DataSource.resolveRelation() → sql/core
5. CSVFileFormat (or TextInputCSVDataSource) → sql/core
6. LogicalRelation → sql/catalyst
7. FileSourceScanExec → sql/core
8. FileScanRDD → sql/core
9. FileScanRDD.compute() → sql/core
When does the physical plan get built? The logical plan is built immediately when you call
csv(). The physical plan is built lazily when you call an action (.show(),.count(),.collect()). Find whereQueryExecution.executedPlanis called — this is the boundary.
Save this as a table in execution-trace.md under ## Execution Trace.
Step 3 — The Logical → Physical Plan Boundary (30 min)
Find and read these two classes:
QueryExecution(insql/core/)SparkPlanner(insql/core/)
Answer these questions in execution-trace.md under ## Plan Boundary:
- What method in
QueryExecutiontriggers physical plan creation? What does it call? - What is
SparkPlanner's role? What does it do with an optimized logical plan? - In 3 sentences or fewer: explain the logical → physical plan transition in your own words, as if explaining it to an engineer who has never seen Spark internals.
Step 4 — Draw the Module Dependency Diagram (30 min)
For the classes you touched in Step 2, draw an ASCII diagram showing:
- Which module each class belongs to
- Which module depends on which (draw an arrow from dependent → dependency)
Example format:
sql/core ──depends on──▶ sql/catalyst
sql/core ──depends on──▶ core
sql/catalyst ──depends on──▶ core
Save this in execution-trace.md under ## Module Dependencies.
Expected Output
lab-01-codebase-spelunking/
├── README.md ← this file
└── execution-trace.md ← your completed trace document
execution-trace.md structure:
## Module Map
[8 module descriptions + dependency diagram]
## Execution Trace
[Table: class name | module | responsibility | handoff method]
## Plan Boundary
[3-question answers + 3-sentence explanation]
## Module Dependencies
[ASCII dependency diagram for classes touched]
Verification
Your trace is correct if:
- Every class in your table is a real class you can open in IntelliJ
- The handoff methods connect correctly (output of one is input to next)
LogicalRelationappears beforeFileSourceScanExecin your list- Your plan boundary explanation correctly identifies that the physical plan is built lazily
Optional verification: set a breakpoint in FileScanRDD.compute() and run a small Spark app locally. Confirm the call stack matches your trace.
Talking Points
- Why is
DataSourcea pivot point? It is the registry that maps a format name ("csv") to itsFileFormatimplementation. This is the extensibility point for custom data sources. - Why is the plan built lazily? Lazy evaluation lets the optimizer see the entire plan before committing. It also means you can build a plan in a driver application and serialize it to executors without running anything.
- What is
InternalRowvsRow?InternalRowis the unboxed binary format used inside the execution engine.Rowis the user-facing boxed API. The conversion happens at the API boundary (e.g.,Dataset.collect()).
Resume Bullet
"Navigated Apache Spark's multi-module Maven codebase from scratch; produced an annotated execution trace from
SparkSession.read().csv()through Catalyst optimization toFileScanRDD.compute(), documenting module boundaries, class responsibilities, and the logical→physical plan transition."
Lab 02 — Issue Trace
Phase: 02 — JVM Codebase Navigation
Difficulty: ⭐⭐⭐☆☆ | Estimated Time: 4–6 hours
Type: Code Investigation / Document
Repository: apache/spark
Goal
Perform an end-to-end trace of a real closed Apache Spark JIRA issue: read the full issue, locate the root cause in the source tree using git blame and git log, read and understand the fix commit, and produce a structured "bug journey" document.
This lab trains the most important skill in open-source contribution: the ability to take an unfamiliar bug report and navigate a large codebase to its root cause — independently, without guidance.
Pre-Selected Issues
Choose one of the following closed issues. All are real SPARK bugs with clean, single-commit fixes and complete JIRA descriptions. Start with Level 1 if you are new to Spark internals.
| Level | JIRA | Summary | Module(s) | Why it's good for this lab |
|---|---|---|---|---|
| ⭐ Level 1 | SPARK-23986 | CSV reader throws NPE on empty string with enforceSchema | sql/core | Single module, clear stack trace, small diff |
| ⭐ Level 1 | SPARK-24266 | Task not serializable when using UDF with closure | core, sql/core | Classic Spark error type, well-documented root cause |
| ⭐⭐ Level 2 | SPARK-26366 | Incorrect result in certain complex SQL queries with char/varchar | sql/catalyst | Optimizer rule interaction, good for tracing plan transforms |
| ⭐⭐ Level 2 | SPARK-33933 | Inconsistent behavior of first() with ignoreNulls in different execution paths | sql/core | Execution path divergence, teaches about physical plan alternatives |
| ⭐⭐⭐ Level 3 | SPARK-29800 | DAGScheduler handles FetchFailed incorrectly under speculation | core | DAGScheduler + speculation interaction, subtle race condition |
If a JIRA link is unavailable: The Apache JIRA is at
https://issues.apache.org/jira/browse/SPARK-XXXXX. The corresponding GitHub PR can be found by searchinggithub.com/apache/spark/pulls?q=SPARK-XXXXX.
Steps
Step 1 — Read the Issue Completely (45 min)
Open your chosen JIRA. Read:
- The Summary line — this is the symptom
- The Description — this is the reproducer and context
- Every comment from top to bottom — this is where the diagnosis happens
- The Fix Version/s field — which Spark release included the fix
- Any linked issues — blockers, duplicates, parents
While reading, take notes on:
- What is the observable symptom?
- What conditions trigger it?
- Is there a stack trace? What line does it point to?
- What was the first hypothesis? Was it correct?
- Where did the investigation narrow down to?
Save your notes in bug-journey-[SPARK-XXXXX].md under ## Issue Analysis.
Step 2 — Find the Fix Commit (20 min)
cd spark
# Method 1: JIRA "Commits" tab (most reliable)
# JIRA → Commits tab → click GitHub PR link
# Method 2: Search git log
git log --oneline --all | grep "SPARK-XXXXX"
# Replace XXXXX with your issue number
# Method 3: GitHub PR search
# github.com/apache/spark/pulls?q=SPARK-XXXXX+is%3Amerged
# Once you have the commit hash or PR:
git show <commit-hash> --stat # which files changed
git show <commit-hash> -U5 # diff with 5 lines of context
Record in your bug journey document:
- The commit hash
- The PR number (if applicable)
- Which files were changed
- How many lines were changed (added/removed)
Step 3 — Locate the Root Cause (1–2 hours)
This is the core skill.
Do NOT start by reading the fix. First, try to locate the root cause independently:
- Use the stack trace or description to identify the first suspicious class/method
- Open that class in IntelliJ. Read the method in question.
- Use
git blameon the specific lines — who wrote this, and when? - Read 50 lines above and below the crash site for context
- Ask: "Under what condition does this code misbehave?"
# git blame with line range
git blame -L 120,160 sql/core/src/main/scala/org/apache/spark/sql/DataFrameReader.scala
# Show what a specific commit changed in context
git show <commit-hash> -- path/to/File.scala
Then, after you've formed your own hypothesis, read the fix commit and compare.
Record in ## Root Cause Analysis:
- The specific class, method, and approximate line where the bug lives
- The exact condition that triggers incorrect behavior
- Your hypothesis (before reading the fix): what you thought the fix would do
- Whether your hypothesis matched the actual fix
Step 4 — Trace the Broken Code Path (30 min)
Reconstruct the execution path from user-facing API call to the point of failure. This is the same skill as Lab 01, but starting from a bug instead of a known entry point.
For example, if the bug is in CSVFileFormat.buildScan(), trace backward:
- Who calls
buildScan()? - Under what conditions is the null input passed?
- Could the caller have validated earlier?
Record a simplified call chain (5–8 entries is enough) in ## Broken Code Path.
Step 5 — Read and Understand the Fix (30 min)
Read the fix diff carefully:
git show <commit-hash> -U10 # 10 lines of context
For every change in the diff, answer:
- What was the broken behavior?
- What does the fix do instead?
- Is this fixing the root cause or the symptom? How can you tell?
- What test was added? What boundary condition does it cover?
- Are there related conditions the fix does NOT cover? (Look for TODOs or known limitations in the commit message)
Record this in ## Fix Analysis.
Step 6 — Write the Correctness Argument (20 min)
A correctness argument answers: "Why does this fix work? Why doesn't it break anything else?"
This is exactly what a reviewer writing "+1" on the fix PR would say. Write yours in ## Correctness Argument:
- Why the fix resolves the root cause (not just the symptom)
- Why the fix doesn't introduce regressions in the non-bug case
- What the test added in the fix covers and why that's sufficient
Template: bug-journey-[SPARK-XXXXX].md
# Bug Journey — SPARK-XXXXX
## Issue Summary
- **JIRA**: [SPARK-XXXXX](https://issues.apache.org/jira/browse/SPARK-XXXXX)
- **Symptom**: (one sentence)
- **Trigger conditions**: (what user action / data / configuration causes this)
- **Affected versions**:
- **Fix version**:
- **Severity** (your assessment): Blocker / Critical / Major / Minor
## Issue Analysis
[Notes from reading the full JIRA: investigation timeline, key comments]
## Root Cause Analysis
- **Location**: `ClassName.methodName()` in module `xxx`
- **Root cause**: (one paragraph — what is wrong with the code)
- **Your hypothesis before reading the fix**:
- **Did your hypothesis match?**: Yes / No / Partially — explain
## Broken Code Path
[Simplified call chain from user API to point of failure]
1. `SparkSession.X()` → ...
2. ...
N. `BuggyClass.method()` ← bug lives here
## Fix Analysis
- **Commit**: [link to GitHub commit]
- **Files changed**:
- **Lines changed**: +X / -Y
- **What changed**: (per-file summary)
- **Root cause fix or symptom fix**: (your assessment + reasoning)
- **Test added**: (describe the new test case)
- **Known limitations**: (what cases does this NOT fix)
## Correctness Argument
[Why the fix works and why it doesn't break anything else — 2–4 paragraphs]
Expected Output
lab-02-issue-trace/
├── README.md ← this file
└── bug-journey-SPARK-XXXXX.md ← your completed bug journey
Verification
Your bug journey is complete when:
- Your root cause location matches the lines changed in the fix commit
- Your correctness argument explains why the fix works — not just what it does
- Your broken code path connects to the root cause location
- You can explain the bug and fix out loud in 2 minutes without notes
Talking Points
- What is the difference between root cause and symptom? The symptom is the NPE. The root cause is that the schema inference returned null for an all-null column when it should have returned
NullType. - Why do reviewers push back on "add a null check here"? Because it papers over the symptom. The real question is: should null be reachable here at all? If not, why is it reachable?
- How do you know when a bug is "yours" to fix? Any committer can fix any bug. But fixing a bug in a subsystem you don't know well requires more review scrutiny. The more you understand the surrounding context, the more confidently you can fix root causes rather than symptoms.
Resume Bullet
"Performed end-to-end issue trace on a closed Apache Spark JIRA: located root cause in Spark internals via
git blameand diff analysis, reconstructed the broken execution path across modules, and produced a structured correctness argument for the accepted fix."
Phase 03 — Lessons from Large Apache Systems
Why this phase exists: Before you contribute to a large system, you need to understand how large systems fail — not to be pessimistic, but because the committers and PMC members you'll be working with carry this institutional memory. They've seen the same mistakes made across generations of distributed systems projects, and they'll hold your patches to a higher standard because of it.
What You'll Learn
- How Hadoop 1.x's single NameNode became a systemic SPOF and why fixing it took years
- How Hive's metastore became a metadata bottleneck at scale and the architectural decisions that caused it
- How Tez was designed to fix MapReduce's DAG limitations — and what it got right and wrong
- Why Spark originally avoided Hadoop's abstractions, and how compatibility pressure brought some of them back
- The recurring patterns of distributed systems failure: over-abstraction, centralization, backward-compatibility debt, coordination overhead
- How to read a technical design failure as a learning document, not a post-mortem
Concepts Covered
| Concept | Where it appears |
|---|---|
| Single point of failure (SPOF) and its cost at scale | Hadoop 1.x NameNode |
| HDFS HA: Active/Standby NameNode, JournalNodes, ZooKeeper fencing | Hadoop 2.x |
| Metadata bottleneck: why a single in-memory namespace doesn't scale | HDFS Federation |
| YARN: the resource management separation from MapReduce | Hadoop 2.x |
| MapReduce's two-phase limitation and disk materialization | Tez motivation |
| DAG of tasks vs map-reduce pairs | Tez vs MapReduce |
| Hive metastore: what it stores, why it serializes, why it becomes a bottleneck | Hive scaling |
| SerDe interface and the cost of backward compatibility | Hive |
| Catalyst vs Hive's query planner | Spark SQL motivation |
| Backward compatibility as an engineering constraint | All projects |
Labs
| # | Lab | Type | Output |
|---|---|---|---|
| 01 | Post-Mortem: A Known Design Failure | Research / Document | Structured post-mortem |
| 02 | Compatibility Burden Analysis | Code Reading / Document | Interface compatibility analysis |
Deliverables Checklist
- Post-mortem document for one chosen design failure (Hadoop NameNode SPOF or Hive metastore bottleneck)
- Compatibility burden analysis for one chosen interface (Hadoop FileSystem API or Hive SerDe)
- You can explain the chosen failure from first principles — what was built, why it failed, what the fix required, what lessons generalize
Interview Relevance
Staff/Principal Engineer interviews at companies running Spark/Hadoop at scale (Databricks, Cloudera, LinkedIn, Uber, Netflix, Meta) will ask:
- "What are the biggest architectural mistakes you've seen in distributed systems?"
- "How do you think about backward compatibility when designing an API?"
- "What's the cost of making a distributed system highly available? What are the tradeoffs?"
- "Why do metadata services become bottlenecks? How do you avoid it?"
The engineers interviewing you have often been personally involved in these failures. This phase gives you the vocabulary to engage at their level.
Warmup Guide — Lessons from Large Apache Systems
Zero-to-expert primer for Phase 03: the design failures and compatibility burdens of Hadoop, Hive, Tez, and Spark as case law — the background needed to write the post-mortem and compatibility-analysis labs at PMC altitude.
Table of Contents
- Chapter 1: Why Study Failures — Systems as Case Law
- Chapter 2: Case — the HDFS NameNode SPOF
- Chapter 3: Case — MapReduce's Rigidity and the YARN Split
- Chapter 4: Case — Hive's SerDe and Metastore Burdens
- Chapter 5: The Compatibility Burden — Why Old Decisions Are Forever
- Chapter 6: Extracting Lessons — The Post-Mortem Method
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Why Study Failures — Systems as Case Law
Senior engineers reason by precedent. "We shouldn't make the coordinator stateful-and- singular" is an opinion; "that's the NameNode SPOF pattern, and fixing it post-hoc took Hadoop five years and three mechanisms" is an argument. This phase builds your case-law library: each case is studied as decision → context that made it reasonable → consequence → cost of the fix → transferable principle. The discipline matters: hindsight without the original context teaches arrogance, not judgment. Every design you'll later review (Phases 06, 11) or write (Phase 12) gets tested against these precedents.
Chapter 2: Case — the HDFS NameNode SPOF
The decision (2005–06): one NameNode holds the entire filesystem namespace — in memory, single process. Block locations are soft state (rebuilt from DataNode reports); mutations go to an edit log.
Why it was reasonable: GFS — the design template — did the same; a single in-memory namespace made every metadata op a lock-free-ish RAM lookup (performance), and consistency is trivial when one process owns truth. For a batch system in 2006, hours of downtime were tolerable.
The consequence: as clusters became multi-tenant infrastructure, the NameNode became (a) an availability SPOF — restart = cluster outage, with painful edit-log replay times; (b) a scalability wall — namespace bounded by one heap (files ≈ RAM), and GC pauses on 100GB+ heaps became availability events themselves (your Phase 08 JVM knowledge, manifesting as system design).
The fix's price tag: Secondary NameNode (checkpointing only — widely misunderstood as HA, fixing nothing) → QJM + Standby NameNode + ZKFC automatic failover (true HA, ~2012, 5+ years after the problem was evident) → HDFS Federation for namespace scale. Three mechanisms, enormous operational complexity, all retrofitted around a single early decision.
Transferable principles: centralizing metadata in one process trades scale/ availability ceilings for simplicity — fine if you state the ceiling; "we'll add HA later" means "we'll redesign state management later"; and memory-resident metadata means JVM behavior is your availability story.
Chapter 3: Case — MapReduce's Rigidity and the YARN Split
The decision: Hadoop 1's JobTracker fused two jobs — cluster resource management and per-job execution logic — and the only programming model was map→shuffle→reduce.
Why reasonable: one abstraction, aggressively simple, matched the dominant workload (batch scans) and the hardware (disks). Fault tolerance via re-execution of idempotent tasks was — and remains — a brilliant simplification.
The consequence: every non-batch pattern (iterative ML, interactive SQL, graphs) had to be contorted into MR jobs with disk round-trips between every stage; JobTracker fused concerns meant scheduler bugs took down job bookkeeping and vice versa, and scale capped around ~4K nodes.
The fix: YARN (Hadoop 2, ~2012) split RM (resources) from per-app AMs (execution) — the two-level scheduler you study in system-design/01 — making MR just one framework and clearing the runway for Tez (DAGs of stages, no forced disk barriers) and Spark (DAG + memory residency). Lesson pattern: fusing the platform with its first application is the classic v1 sin; the unbundling is always a major-version event with a years-long migration tail.
Chapter 4: Case — Hive's SerDe and Metastore Burdens
SerDe: Hive let any table declare a serializer/deserializer class — maximal format flexibility. Consequence: the SerDe interface became load-bearing public API for an ecosystem of third-party formats; every engine touching Hive tables (Spark, Presto, Impala) had to reimplement or shim SerDe semantics, and behavioral quirks (null handling, type coercions) became de-facto standards no one can fix without breaking someone (Hyrum's Law at format scale).
Metastore: thousands of jobs speak the Thrift metastore API; partition metadata as RDBMS rows hit the wall at millions of partitions. The escape (Iceberg/Delta-style table formats — metadata in the storage layer) took a new project generation rather than an in-place fix — the full anatomy is system-design/02.
Transferable principles: an extension interface is a bigger forever-commitment than any implementation; behavioral quirks of a popular system become its real spec; and when the data model is the bottleneck, the fix often can't be incremental — plan for the strangler migration (Phase 11's format-migration lab rehearses exactly this).
Chapter 5: The Compatibility Burden — Why Old Decisions Are Forever
The connective tissue of all three cases — what "public surface" really includes once thousands of deployments depend on you:
- APIs (source/binary/behavioral — the Phase 04/10 taxonomy), but also:
- Wire protocols (a Hadoop RPC change must interop across rolling-upgrade version skew), file formats (data outlives code by a decade — SequenceFile readers must exist forever), config keys and their defaults (changing a default is a behavioral break someone will hit at 2am), CLI output (someone's cron job parses it), and timing/ordering quirks nobody promised.
- The asymmetry that explains maintainer conservatism: a break costs every downstream user simultaneously; a workaround costs only the project. Multiply by ten thousand clusters and "just fix it properly" loses to "carry the shim" almost every time.
- The institutional response you'll study in Phase 04: annotate the surface (audience/stability), gate releases on compat tooling (japicmp), and make deprecation a multi-release ceremony rather than an event.
Chapter 6: Extracting Lessons — The Post-Mortem Method
The lab's template, and why each section exists:
- Decision & context — what was decided and what was true at the time (workloads, team size, precedents). Forces steel-manning; no hindsight allowed yet.
- Mechanism of failure — the technical chain from decision to pain, specific enough to be falsifiable (heap growth → GC pause → failover storm, with numbers where findable).
- Detection lag — when did evidence appear vs when was it acted on? (NameNode HA: evident ~2008, shipped ~2012 — the lag itself is a finding about incentives.)
- Cost of the fix — engineering years, migration burden, complexity added. This is the number that prices future shortcuts.
- Transferable principle — one sentence, stated so it could guide a different system's review. If it only describes the past, it's a summary, not a lesson.
The test of a good post-mortem: a reader could apply section 5 in a design review next week and cite sections 2–4 when challenged.
Lab Walkthrough Guidance
Order: Lab 01 (post-mortem) → Lab 02 (compatibility burden).
- Lab 01: pick ONE case (Chapter 2's NameNode is the best-documented). Source from primary materials — JIRAs (HDFS-1064, HDFS-1623 for HA), design docs, conference talks — not blog summaries. Fill the template in Chapter 6's order; spend the most effort on context (steel-man) and cost (numbers).
- Lab 02: pick one API/format (Hadoop
FileSystemand Hive SerDe are the canonical choices); inventory what compatibility dimensions it carries (Chapter 5's list); find two real JIRAs where a desired change was constrained or abandoned for compat reasons; estimate the carried cost (shims, docs, support) per year.
Success Criteria
You are ready for Phase 04 when you can, from memory:
- Tell each of the three cases as decision → context → mechanism → fix-cost → principle, in two minutes each.
- Explain why the Secondary NameNode is not HA, and what QJM/standby actually added.
- State the v1 fusion sin (platform = first app) and name two non-Hadoop systems that repeated it.
- List six kinds of compatibility surface beyond method signatures.
- Apply one transferable principle, with citation, to a hypothetical new design in one paragraph.
Interview Q&A
Q: Tell me about a system design failure you've studied and what you took from it. Use the Lab 01 case in the five-part structure — context first ("with 2006 workloads and GFS as precedent, the single NameNode was defensible"), then mechanism, the five-year fix lag and its cost, ending with the principle you now apply ("any singular stateful coordinator gets asked: what's the namespace ceiling, what's the failover story, before v1 ships"). The structure itself signals seniority.
Q: Why do mature projects move so slowly on obvious improvements? The asymmetry of Chapter 5: a break is paid by every downstream simultaneously and erodes the trust that is the project's product; a carried wart is paid only by maintainers. Add governance (consensus, vetoes) designed to protect decade-scale stability over quarter-scale velocity. Slow is the feature, priced deliberately.
Q: When is a breaking redesign the right call despite all that? When the carried cost compounds (every new feature pays the wart tax), when the fix cannot be incremental (data-model walls, Hive metastore-style), or when security/correctness is unfixable in place. Then: major version, strangler migration with both paths alive, executable migration tooling, and the Phase 11/12 consensus process — the manner of breaking is what separates Hadoop 2 from Python 3.
References
- Shvachko et al., The Hadoop Distributed File System (MSST 2010) — the NameNode design from the source
- HDFS-1623: High Availability Framework for HDFS NN — read the design doc and discussion
- Vavilapalli et al., Apache Hadoop YARN: Yet Another Resource Negotiator (SoCC 2013) — §2 "History and rationale" is the MR post-mortem in the authors' own words
- Saha et al., Apache Tez: A Unifying Framework for Modeling and Building Data Processing Applications (SIGMOD 2015)
- Zaharia et al., Resilient Distributed Datasets (NSDI 2012) — read against the MR limitations it answers
- Hive SerDe docs and Iceberg spec — the burden and the escape
- Hyrum's Law
- Hadoop Compatibility Specification
🛸 Hitchhiker's Guide — Phase 03: Lessons from Large Apache Systems
Read this if: You can navigate a JVM codebase but you've never studied why large distributed systems fail — not in theory, but by tracing real architectural decisions from motivation to failure to recovery. By the end you should be able to explain Hadoop's NameNode SPOF, Hive's metastore bottleneck, and the MapReduce DAG limitation, and connect each failure to a pattern that still applies in systems you'll work on today.
0. The 30-second mental model
Every large Apache system failed in a predictable way. The failure wasn't random — it followed from the original design constraints being violated by scale:
Original Design Constraint What Violated It
──────────────────────────────── ──────────────────────────────────────
NameNode manages all metadata Namespace grew to billions of files
Metadata fits in JVM heap It didn't (at 100M+ files)
MapReduce is general enough Iterative algorithms = absurd disk I/O
HDFS is the only storage It became a bottleneck; S3 arrived
Hive runs on HDFS + MapReduce It was too slow for interactive use
Hive metastore is a SQL database Became serialization bottleneck at scale
The lesson is not that these were bad engineers. The lesson is that scale changes the dominant constraint. The dominant constraint at design time (correctness, simplicity, getting it to work at all) is replaced at scale by a new dominant constraint (throughput, availability, coordination overhead). Systems that handle this well (YARN, Spark's Catalyst) separate concerns early. Systems that handle it badly become legacy.
1. Hadoop 1.x: The NameNode SPOF
1.1 What the NameNode Does
In HDFS, the NameNode is a single JVM process that maintains the entire filesystem namespace in memory. It stores:
- The full directory tree (all files and directories)
- For every file: its block list, replication factor, and owner/permissions
- Block-to-DataNode mapping (which DataNode hosts each replica)
The design is elegant: all metadata in one place, all operations O(1) or O(path depth). The DataNodes are dumb storage nodes that just hold blocks and report heartbeats.
NameNode (single JVM)
┌──────────────────────────────────┐
│ Namespace: /user/alice/data.csv │
│ Blocks: [blk_001, blk_002] │
│ Locations: [DN1, DN3], [DN2, DN4]│
└───────────┬──────────────────────┘
│ Heartbeat every 3s
┌────────────────┼────────────────┐
▼ ▼ ▼
DataNode 1 DataNode 2 DataNode 3
[blk_001, [blk_002, [blk_001,
blk_005, ...] blk_007, ...] blk_009, ...]
1.2 Why It Became a SPOF
The NameNode is a single point of failure in multiple dimensions:
- Process failure: If the NameNode JVM crashes, the entire HDFS cluster is unavailable — zero reads, zero writes. All running MapReduce jobs fail.
- GC pressure: As namespaces grew to 100M+ files, the NameNode JVM heap grew to 50–100GB. Full GC pauses of seconds caused cluster-wide stalls. The NameNode heap was the hard ceiling on cluster scale.
- Software maintenance: Any NameNode upgrade required a cluster outage. At Yahoo's scale (2010), an upgrade of the 4,000-node production cluster required hours of downtime.
1.3 Why Fixing It Took Years
Fixing a SPOF in a production distributed system is not a weekend project. The Hadoop NameNode HA solution (shipped in Hadoop 2.0, 2012) required:
- Primary/Standby NameNode: Two NameNode processes in active-standby mode
- JournalNodes: A quorum (3+) of JournalNode processes that store the edit log; both NameNodes read from JournalNodes so the standby stays in sync
- ZooKeeper Fencing: ZooKeeper elects the active NameNode; fencing ensures the old active cannot corrupt the namespace after a failover
- DataNode dual-reporting: DataNodes send block reports and heartbeats to both NameNodes
ZooKeeper (3 nodes)
│ leader election
▼
Active NameNode ──writes edits──▶ JournalNode 1
Standby NameNode ◀─reads edits─── JournalNode 2
JournalNode 3
│
└── Both receive heartbeats from all DataNodes
The time cost wasn't the engineering — it was the risk management. Replacing the metadata layer of a production system serving tens of thousands of jobs per day while maintaining zero data loss requires years of testing, staged rollout, and fallback planning.
1.4 HDFS Federation: The Namespace Scalability Fix
HA solved availability but not namespace scale. Even two NameNodes, each managing the full namespace, still hit the JVM heap ceiling.
HDFS Federation (Hadoop 2.x) partitions the namespace: different NameNodes manage different subtrees of the directory hierarchy. DataNodes belong to multiple "block pools," one per NameNode.
The lesson: centralized state does not scale horizontally. You can replicate it (HA), but to scale throughput you must partition it. This lesson recurs in every distributed database, every distributed queue, and every distributed filesystem.
2. MapReduce's Two-Phase Limitation and the Rise of Tez
2.1 The MapReduce Programming Model
MapReduce forces every computation into two phases: Map and Reduce, with a mandatory shuffle between them. For simple batch jobs this is elegant. For complex algorithms, it is a strait-jacket.
Input ──▶ Map ──▶ [Shuffle + Sort] ──▶ Reduce ──▶ Output
│
Disk write on every shuffle
Disk read on every reduce input
Key cost: Every shuffle writes to disk. Every reduce input reads from disk. This is intentional (fault tolerance via materialization) but catastrophic for iterative algorithms.
2.2 Why Iterative Algorithms Are Expensive in MapReduce
Machine learning, graph algorithms, and many analytics pipelines are iterative: run the algorithm, update a state, run it again, converge.
In MapReduce, each iteration is a separate job:
Iteration 1: Input → M1 → shuffle → R1 → HDFS write
Iteration 2: HDFS read → M2 → shuffle → R2 → HDFS write
Iteration 3: HDFS read → M3 → shuffle → R3 → HDFS write
...
100 iterations of PageRank = 200 HDFS writes and 200 HDFS reads. At 10TB/iteration = 2PB of I/O for an algorithm that could run in memory in 10 passes.
This is why Apache Spark was created: in-memory RDD lineage eliminates the intermediate HDFS writes. The DAGScheduler pipelines transformations and only writes to disk at shuffle boundaries.
2.3 Apache Tez: The DAG Solution Within the Hadoop Ecosystem
Rather than replacing MapReduce entirely, Tez (Apache project, emerged from Hortonworks) generalized it to a DAG of tasks:
MapReduce model: Input ──▶ M ──▶ R ──▶ Output
Tez model: Input ──▶ Vertex ──▶ Vertex ──▶ Vertex ──▶ Output
│ ▲
└────────────────────┘
(direct connection, no mandatory disk)
Each Vertex in Tez is an arbitrary processing unit. Edges between vertices define data movement (memory, disk, or network). Crucially: intermediate results do not need to be materialized to HDFS.
Hive on Tez (Hive 0.13+) replaced the Hive-on-MapReduce execution engine and significantly improved multi-stage query performance.
2.4 What Tez Got Right and Wrong
Right:
- Generalized the execution model beyond two phases
- Stayed within the YARN/Hadoop ecosystem (easier adoption)
- Hive on Tez became production-ready and widely deployed
Wrong:
- Still JVM-process-per-task overhead (compared to Spark's in-memory pipelines)
- The DAG API is low-level; most users interact through Hive, not Tez directly
- Spark's simpler programming model (RDD/DataFrame) won the developer mindshare
The lesson: a generalization that stays compatible with the existing ecosystem will be adopted; a generalization that requires abandoning the ecosystem will only be adopted if the performance gap is extreme. Spark's performance gap was extreme enough.
3. Hive: The Metastore Bottleneck
3.1 What the Hive Metastore Is
Hive is a SQL-on-Hadoop system. It stores table metadata in a relational database (Derby, MySQL, or PostgreSQL) called the Hive Metastore. The metastore holds:
- Table schemas (column names, types, comments)
- Partition metadata (for partitioned tables: which partitions exist and where)
- Storage descriptors (HDFS location, input format, output format, SerDe)
- Statistics (table/column row counts, for query planning)
3.2 Why It Became a Bottleneck
HiveServer2 (multiple instances, accepts JDBC connections)
│
▼
Hive Metastore Service (single process, or a few)
│
▼
Relational Database (MySQL or PostgreSQL, single primary)
│
Tables: TBLS, SDS, COLUMNS_V2, PARTITIONS, PARTITION_KEYS, ...
Every Hive query requires at least one metastore call to resolve table schemas. Large tables with thousands of partitions require thousands of metastore calls to enumerate and prune partitions. Every ALTER TABLE, MSCK REPAIR, and CREATE TABLE is a metastore write.
At scale:
- A table with 1 million partitions (e.g., hourly partitions for 5 years × 100 tenants) makes partition enumeration extremely slow
- The metastore becomes a serialization bottleneck: all writers contend for the same relational database rows
- Metastore latency directly degrades query latency for the entire cluster
3.3 The Fixes (and Why They Were Slow to Arrive)
| Fix | Mechanism | Cost |
|---|---|---|
| Metastore connection pooling | HikariCP or DBCP2 in front of RDBMS | Reduces connection overhead, doesn't fix write contention |
| Hive LLAP (Live Long And Process) | Persistent daemons that cache metadata | Complex to operate, more failure modes |
| Partition filter pushdown | Prune partitions at SQL level before metastore call | Works for simple predicates, not all cases |
| Direct Parquet/ORC schema reading | Spark reads schema from file footer, not metastore | Bypasses metastore entirely for reads — the most effective |
| Delta Lake / Iceberg / Hudi table formats | Store table metadata in the data files themselves | Eliminates the metastore for metadata entirely, at cost of compatibility |
The slowness of fixes reflects a fundamental truth: the metastore is the integration point for dozens of tools (Hive, Spark, Presto, Flink, Impala, Pig). Replacing it requires all these tools to migrate simultaneously, which is a coordination problem, not an engineering problem.
4. The SerDe Interface and the Cost of Backward Compatibility
4.1 What a SerDe Is
In Hive, a SerDe (Serializer/Deserializer) defines how to read bytes from HDFS and deserialize them into Hive rows — and how to serialize Hive rows back to bytes for writing.
// Simplified SerDe interface
public interface Deserializer {
void initialize(Configuration conf, Properties tbl) throws SerDeException;
Object deserialize(Writable blob) throws SerDeException;
ObjectInspector getObjectInspector() throws SerDeException;
}
This interface has been part of Hive since 2008. It is used by hundreds of production deployments and third-party tools. Every custom file format (proprietary binary formats, legacy log formats, XML, JSON) has a SerDe implementation.
4.2 The Backward Compatibility Trap
Once an interface is used in production by external code you don't control, you cannot change its method signatures without breaking that code. This is the backward compatibility constraint.
For the SerDe interface, this means:
- You cannot add a new method without providing a default implementation (Java 8+ default methods)
- You cannot change the semantics of existing methods without silently breaking implementations that rely on the old semantics
- You cannot replace
Writablewith a more modern serialization format without rewriting every SerDe implementation
The result: the SerDe interface from 2008 is still in use in 2025. The Writable type (from Hadoop's own legacy serialization framework) is still its parameter type, even though every modern Hadoop component has moved away from Writable to Protobuf or Avro.
4.3 The Recurring Pattern
This pattern appears in every long-lived Apache project:
| Project | Interface | Age | Constraint |
|---|---|---|---|
| Hive | SerDe | 2008–present | Writable parameter type cannot change |
| Hadoop | FileSystem API | 2006–present | FSDataInputStream API cannot be removed |
| MapReduce | Mapper/Reducer | 2006–present | Old API and new API both maintained |
| Spark | RDD API | 2012–present | Cannot break user code despite DataFrame being preferred |
The lesson: the first API you ship publicly is the one you will maintain forever. Design it for the constraints you expect at 10x your current scale, because you will not get a chance to redesign it once it has users.
5. The Cross-Cutting Patterns
Reading these four failure modes together, you see the same patterns recurring:
5.1 Centralization Doesn't Scale
- NameNode: one process for all metadata → SPOF + heap bottleneck
- Hive Metastore: one RDBMS for all table metadata → write contention bottleneck
- MapReduce's single shuffle coordinator: single sort stage handles all data → disk I/O bottleneck
Pattern: Any single process that all other processes must talk to will eventually become the bottleneck. The fix is always partitioning, caching, or eliminating the coordination point.
5.2 Interfaces Outlive Their Context
- SerDe was designed for Hadoop 1.x's
Writable-based world. It still exists in Hadoop 4.x. - Hadoop's
FileSystemAPI was designed for HDFS. It now has implementations for S3, GCS, Azure Blob Storage, and a dozen other backends — all constrained by an API that was never designed for eventually-consistent object stores.
Pattern: Every public interface you ship will be used in ways you did not anticipate, and will need to support backends that do not exist when you write it. Design interfaces for the abstraction, not the implementation.
5.3 Compatibility Prevents Correctness Fixes
Hadoop's FileSystem.rename() is supposed to be atomic on POSIX filesystems. On S3, rename is a copy + delete — not atomic. But the API contract says rename is atomic, so Hadoop code that relies on atomic rename is silently broken on S3. Fixing this requires changing the semantics of a public API used by thousands of programs — impossible without breaking them.
Pattern: Once a correctness guarantee is documented in a public API, you cannot weaken it without a major version bump and a migration guide. Build only the guarantees you can actually enforce.
5.4 The "Good Enough" Trap
Hadoop 1.x's NameNode was good enough for 2006-scale HDFS (tens of millions of files). MapReduce was good enough for 2008-scale batch jobs. Hive's metastore was good enough for 2010-scale tables.
All three became technical debt when scale increased by 100x. The engineering work to fix them was not hard — it was risky and slow because of the coordination required to migrate production systems.
Pattern: Technical debt is not about bad code. It is about constraints that were reasonable at design time becoming wrong at scale. The cost of paying the debt grows with the number of dependent systems, not with the complexity of the fix.
6. Interview Questions — Concepts You Must Explain Out Loud
| Topic | One-line answer |
|---|---|
| What is the Hadoop NameNode SPOF and how was it fixed? | The NameNode held all HDFS metadata in a single JVM; if it failed the cluster was down. Fixed in Hadoop 2.x with Active/Standby NameNode pairs, JournalNodes for edit log sharing, and ZooKeeper-based failover fencing. |
| Why did Hadoop 1.x not scale its namespace? | All metadata lived in one JVM heap. At 100M+ files the heap exceeded 50–100GB, causing GC pauses and a hard ceiling on cluster size. HDFS Federation partitioned the namespace across multiple NameNodes to address this. |
| What is Tez and why was it created? | Apache Tez generalized MapReduce's two-phase model to an arbitrary DAG of processing vertices, eliminating mandatory disk materialization between stages. Created because iterative algorithms on MapReduce required O(N) HDFS writes for N iterations. |
| What is the Hive Metastore and why does it become a bottleneck? | The Metastore is a relational database storing table schemas, partition metadata, and storage descriptors. At scale, partition enumeration and write contention serialize on a single RDBMS instance, making query planning slow. |
| What is a SerDe in Hive? | A Serializer/Deserializer: a pluggable class that defines how HDFS bytes are converted to Hive rows (and back). The interface has been stable since 2008, creating backward compatibility constraints. |
| What does backward compatibility cost in a large Apache project? | It prevents correctness fixes (you can't weaken a semantic guarantee), forces maintaining deprecated code paths indefinitely, and constrains future API design to what the old interface can support. |
Why is FileSystem.rename() broken on S3? | Hadoop's API guarantees atomic rename (POSIX semantics). S3 implements rename as copy + delete, which is not atomic. The API contract cannot be changed without breaking all callers that rely on atomicity. |
| What pattern causes centralized metadata services to fail at scale? | All clients must coordinate through a single process, which becomes the bottleneck for both throughput (write contention) and availability (SPOF). Fix requires partitioning the state or eliminating the coordination point. |
7. References
- HDFS Architecture Guide — Apache docs — the authoritative description of NameNode design
- HDFS High Availability (Apache docs) — HA with JournalNodes
- HDFS Federation Design Doc (Apache JIRA: HADOOP-7684)
- Apache Tez — Bikas Saha, Hortonworks — original design presentation
- Hive Metastore documentation
- The Hadoop Distributed File System — Shvachko et al., MSST 2010 — the original HDFS paper
- MapReduce: Simplified Data Processing on Large Clusters — Dean & Ghemawat, OSDI 2004 — the original MapReduce paper
- Designing Data-Intensive Applications — Martin Kleppmann, O'Reilly 2017 — Chapter 10 (Batch Processing) and Chapter 11 (Stream Processing) for context
Lab 01 — Post-Mortem: A Known Design Failure
Phase: 03 — Lessons from Large Apache Systems
Difficulty: ⭐⭐☆☆☆ | Estimated Time: 3–4 hours
Type: Research / Document
Output: post-mortem-[failure-name].md
Goal
Produce a structured post-mortem for one real design failure in the Apache Hadoop ecosystem. The post-mortem must explain: what was built, why it was built that way, at what scale it failed, what the failure cost, how it was fixed, and what engineering lessons generalize beyond this specific case.
This is not a blame exercise. It is a learning exercise. The engineers who built these systems were excellent. The failures are interesting precisely because they were not caused by incompetence — they were caused by constraints that seemed reasonable at design time becoming wrong at scale.
Choose One Failure
| Option | System | Failure | Difficulty |
|---|---|---|---|
| A | Hadoop HDFS | Single NameNode as both availability SPOF and namespace scale ceiling | ⭐⭐ |
| B | Hive | Metastore RDBMS as write contention and partition enumeration bottleneck at scale | ⭐⭐⭐ |
Recommendation: If you are less familiar with Hadoop internals, choose Option A. The NameNode SPOF is well-documented, the fix (HA) is concrete, and the failure mode is easy to reason about. Option B requires understanding more of the query execution stack.
Research Sources
Option A — Hadoop NameNode SPOF:
- HDFS Architecture (Apache docs)
- HDFS High Availability (Apache docs)
- HADOOP-7684 (HDFS Federation design JIRA)
- The Hadoop Distributed File System — Shvachko et al., MSST 2010
- HDFS HA at Yahoo (engineering blog, 2012)
Option B — Hive Metastore Bottleneck:
- Hive Metastore Administration (Apache wiki)
- HIVE-7155 (Metastore performance JIRA)
- Facebook's Hive at scale post (original blog, 2011)
- Search for "hive metastore bottleneck" on the Hive dev mailing list archives:
lists.apache.org/list.html?dev@hive.apache.org
Steps
Step 1 — Read the Primary Sources (60–90 min)
Read at least 2 primary sources for your chosen failure. Take notes on:
- When was this system built, and what problem did it solve?
- What was the original scale assumption?
- At what scale did the failure become apparent?
- What were the symptoms that users experienced?
- How long did it take to fix? What was the fix?
Step 2 — Understand the Technical Root Cause (30 min)
Go beyond "the NameNode was a SPOF." Understand:
- For Option A: Why does a single JVM process create both availability and scale problems? What is the heap ceiling? What does JournalNode quorum buy you?
- For Option B: Why does partition enumeration hit the metastore so hard? What SQL queries does Hive run against the metastore schema? What is the contention point?
Step 3 — Analyze the Fix (30 min)
Read the fix (HA for Option A, or partition pruning/LLAP/table formats for Option B):
- What architectural change was required?
- What was the migration cost for existing clusters?
- Did the fix fully solve the problem, or introduce new constraints?
Step 4 — Extract the Generalizable Lessons (20 min)
What does this failure teach you that applies beyond this specific system? Think about:
- Centralization and its limits
- Scale changes the dominant constraint
- Backward compatibility and migration cost
- The cost of tight coupling between components
Step 5 — Write the Post-Mortem (30–45 min)
Fill out post-mortem-template.md for your chosen failure. Rename it to post-mortem-namenode-spof.md or post-mortem-hive-metastore.md.
Expected Output
lab-01-post-mortem/
├── README.md ← this file
└── post-mortem-[failure-name].md ← your completed post-mortem
Verification
Your post-mortem is complete when:
- You can explain the failure from first principles in 3 minutes without notes
- Your technical root cause section contains a specific class, process, or data structure — not just "it didn't scale"
- Your generalizable lessons are stated as patterns applicable to other systems — not just observations about Hadoop
- You can answer: "What would you have done differently at design time, given the information available then?"
Talking Points
- "Why did it take so long to fix the NameNode SPOF?" The HA fix required a new JournalNode quorum service, ZooKeeper integration, DataNode dual-reporting, and fencing — all while maintaining backward compatibility with existing clients. The engineering was not hard; the risk management was.
- "Why not just use a distributed metadata store from the start?" In 2006, Chubby (Google's distributed lock service) was not open source and ZooKeeper didn't exist yet. The single-node design was reasonable given what was available.
- "What replaced the Hive Metastore?" Modern table formats (Apache Iceberg, Delta Lake, Apache Hudi) store table metadata alongside the data files, eliminating the central metastore dependency for reads. But the metastore is still used for discoverability and catalog integration.
Resume Bullet
"Conducted structured post-mortem analysis of a production-scale Apache system design failure (Hadoop NameNode SPOF or Hive Metastore bottleneck); documented root cause, failure timeline, fix architecture, and generalizable distributed systems lessons."
Lab 02 — Compatibility Burden Analysis
Phase: 03 — Lessons from Large Apache Systems
Difficulty: ⭐⭐⭐☆☆ | Estimated Time: 4–5 hours
Type: Code Reading / Document
Repository: apache/hadoop or apache/hive
Output: compatibility-analysis-[interface-name].md
Goal
Analyze a real, long-lived Apache interface and document the compatibility burden it has accumulated — the constraints it imposes on new implementations, the semantic guarantees it cannot enforce on all backends, and the design choices that would have reduced this burden.
After this lab you will understand why committers are conservative about API changes and why "just add a method" is rarely simple in a project with thousands of downstream users.
Choose One Interface
| Option | Interface | File location | Age | Why it's interesting |
|---|---|---|---|---|
| A | FileSystem | hadoop-common/src/main/java/org/apache/hadoop/fs/FileSystem.java | 2006–present | 40+ abstract methods, S3 semantic mismatch, rename() atomicity impossible on object stores |
| B | Deserializer (SerDe) | hive-serde/src/main/java/org/apache/hadoop/hive/serde2/Deserializer.java | 2008–present | Writable-typed parameter, ObjectInspector pattern, impossible to evolve to modern column formats |
Recommendation: Option A (FileSystem) is better for engineers who know POSIX filesystems. Option B (SerDe) is better for engineers who've worked with Hive or custom data formats.
Setup
# Option A — Hadoop FileSystem
git clone https://github.com/apache/hadoop.git
cd hadoop
git checkout rel/release-3.3.6 # stable recent release
# The interface file:
# hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/fs/FileSystem.java
# Option B — Hive SerDe
git clone https://github.com/apache/hive.git
cd hive
git checkout rel/release-3.1.3
# The interface file:
# serde/src/java/org/apache/hadoop/hive/serde2/Deserializer.java
# Related: AbstractSerDe.java, ObjectInspector.java
Steps
Step 1 — Read the Interface in Full (45 min)
Open your chosen interface file. Read every method:
- For each abstract/non-default method, write one sentence describing what it is supposed to do
- Note the parameter types and return types
- Identify which methods are the "core contract" (must be implemented) vs which are optional extensions
- Note the
@Deprecatedannotations — each one is a story
# Count abstract methods (Java)
grep -c "abstract" FileSystem.java
# Find deprecated methods
grep -n "@Deprecated" FileSystem.java
# Find methods added in specific Hadoop versions (look for @since in Javadoc)
grep -n "@since" FileSystem.java
Save an annotated method list in ## Interface Methods.
Step 2 — Count and Categorize the Implementations (30 min)
For FileSystem: find all production implementations in the Hadoop ecosystem:
# In the Hadoop repo:
find . -name "*.java" -exec grep -l "extends FileSystem" {} \;
# Key implementations to find:
# - LocalFileSystem (POSIX local disk)
# - DistributedFileSystem (HDFS)
# - S3AFileSystem (AWS S3)
# - GCSFileSystem (Google Cloud Storage — in hadoop-connectors repo)
# - AzureBlobFileSystem (Azure — in hadoop-azure module)
# - FTPFileSystem
# - ViewFileSystem (federation namespace)
For each implementation, note:
- Which methods are overridden
- Whether the implementation fully honors the documented semantics
Save this in ## Implementations.
Step 3 — Find the Semantic Gaps (60–90 min)
This is the core task. Find methods where the documented semantics cannot be fully honored by some implementations.
For FileSystem — key semantic gaps to find:
rename(Path src, Path dst): Documented as atomic (POSIX semantics). S3 implements as copy + delete. Find the Hadoop JIRA where this was debated.delete(Path f, boolean recursive): Find the consistency semantics on S3 vs HDFS.append(Path f, ...): Find which implementations returnUnsupportedOperationException.listStatus(Path f): On S3, there's no such thing as a "directory" — listing is eventually consistent. Find how S3AFileSystem handles this.
For each semantic gap:
- What does the Javadoc promise?
- What does the S3 (or other) implementation actually do?
- What breaks if caller code relies on the promised behavior?
- Is there a JIRA or mailing list thread about this?
For SerDe — key semantic gaps to find:
Writableinput type: Thedeserialize(Writable blob)signature assumes the data arrives as a HadoopWritable. How does this constrain column-oriented formats like ORC and Parquet?ObjectInspectorpattern: ThegetObjectInspector()method returns anObjectInspectortree describing the schema. How does this interact with schema evolution?initialize(Configuration conf, Properties tbl): All configuration is passed as a flatPropertiesmap. What is lost?
Save your findings in ## Semantic Gaps.
Step 4 — Find the Compatibility Constraints (45 min)
Look at the interface's git history to understand the evolution:
# Interface evolution over time
git log --oneline -- hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/fs/FileSystem.java | head -40
# What changed in a specific commit
git show <commit-hash> -- [file]
# Find when a specific method was added
git log -S "public abstract FileStatus getFileStatus" --oneline
For each meaningful change in the last 5–10 years:
- Was the change backward-compatible?
- Was it a new abstract method (breaking), a default method (safe), or a deprecation?
- What required a major version bump?
Save in ## Evolution History.
Step 5 — Write the Analysis (30–45 min)
Fill out compatibility-analysis-template.md. Your analysis should answer:
- What is this interface's core abstraction?
- What semantic guarantees does it make that cannot be honored by all backends?
- What would you change if you could start fresh, with only the knowledge available when it was designed?
- What is the cost of those semantic gaps in production systems?
Expected Output
lab-02-compatibility-burden/
├── README.md ← this file
└── compatibility-analysis-[interface-name].md ← your completed analysis
Verification
Your analysis is complete when:
- You can name at least 3 methods with semantic gaps and explain the gap precisely
- You can explain why adding a new abstract method to
FileSystemwould break thousands of programs - You can describe an alternative interface design that would reduce the compatibility burden
- You can answer: "Why doesn't Apache just release a v2 of this interface?"
Talking Points
- "Why not just release FileSystem v2?" S3AFileSystem is in production at AWS, Google, Netflix, Databricks, and every major cloud vendor. Each has their own patched version. A v2 that breaks the interface means all of them have to migrate simultaneously — coordination that's effectively impossible to organize.
- "What's wrong with
Writable?" It's a Hadoop-specific serialization format from 2003. Every modern system (Arrow, Protobuf, Avro, ORC, Parquet) uses a different format. A SerDe interface that takesWritablecannot efficiently read Parquet without converting throughWritablefirst. - "What would a better interface design look like?" Apache Arrow's
RecordBatchas the exchange format; backends implementRecordBatchReader. NoWritable. Schema as a first-class object, notProperties. Default no-op implementations for optional capabilities. This is essentially what Apache Iceberg'sFileIOinterface does for storage.
Resume Bullet
"Analyzed backward compatibility constraints in a long-lived Apache interface (Hadoop FileSystem or Hive SerDe); documented semantic gaps between the API contract and real-world backend behavior, evolution history, and alternative design approaches for a lower-maintenance interface."
Phase 04 — ASF Governance and Release Engineering
Why this phase exists: A release is a legally binding act. When you vote +1 on an Apache release you are personally attesting that you checked the artifacts, verified the signatures, and believe the code meets the ASF's legal and technical standards. This phase teaches the mechanics of how that actually works.
What You'll Learn
- How Apache releases are planned, branched, and built
- What an RC (Release Candidate) is, how it is built and distributed, and how the vote works
- How GPG signing and SHA-256 checksums create the verifiable chain of custody from committer to end user
- How the Maven Release Plugin automates the mechanical parts of a release
- How to use
japicmpto detect binary and source compatibility regressions before a release ships - Common release failures and how mature projects prevent them
Concepts Covered
| Concept | Where it appears |
|---|---|
| Feature freeze and stabilization branch | Apache release lifecycle |
| RC numbering (RC0, RC1 …) and why RC0 often fails | Vote thread conventions |
| Binding vs non-binding votes, +1 / 0 / -1 semantics | PMC vote thread |
GPG detached signatures (.asc), web of trust, KEYS file | Artifact signing |
| SHA-256 checksums and why they are not a substitute for GPG | Release verification |
mvn release:prepare / mvn release:perform | Maven Release Plugin |
| Binary compatibility vs source compatibility | japicmp / API evolution |
dist.apache.org vs Maven Central — the distribution flow | ASF release infrastructure |
| NOTICE and LICENSE file requirements | Legal obligations |
| Reproducible builds and why they matter | Release integrity |
Labs
| # | Lab | Primary Artifact | What Student Produces |
|---|---|---|---|
| 01 | Release Mechanics | pom.xml + Java source | A tagged, versioned Git commit; Maven-generated RELEASE_NOTES.md stub |
| 02 | Signing and Verification | sign-artifact.sh + verify-release.sh | Two working shell scripts; signed + checksummed artifact |
| 03 | API Compatibility | Multi-module Maven project | japicmp HTML/XML compatibility report; structured compatibility matrix |
Deliverables Checklist
-
mvn release:prepare -DdryRun=trueoutput shows correct version bump and tag name -
mvn release:prepareproduces a Git tag and a bumped SNAPSHOT version -
sign-artifact.shproduces.ascand.sha256files for a given input artifact -
verify-release.shcorrectly verifies both signature and checksum, prints clear pass/fail -
mvn installinlab-03-api-compatibility/produces a japicmp report identifying the planted breaking changes - You can identify all breaking changes in the v1→v2 diff from the japicmp output
Interview Relevance
Every Staff+ distributed systems role at a company that ships open-source software (Databricks, Cloudera, LinkedIn, Uber, Confluent, Elastic) will ask some version of:
- "Walk me through how you would cut a release for an open-source library."
- "What does binary compatibility mean, and how do you validate it before a release?"
- "What's your release checklist look like? What's the worst release failure you've seen?"
- "Why does the Apache release process require GPG signatures? What does it prove?"
This phase gives you concrete, hands-on answers to all four.
Warmup Guide — ASF Governance & Release Engineering
Zero-to-expert primer for Phase 04: how an Apache release actually ships — votes and vetoes, GPG signing and verification, license hygiene, and binary-compatibility checking — assuming no prior release-engineering background.
Table of Contents
- Chapter 1: What a "Release" Legally Is
- Chapter 2: Votes, Vetoes, and Consensus — The Decision Mechanics
- Chapter 3: The Release Lifecycle End to End
- Chapter 4: Cryptographic Trust — GPG, Checksums, KEYS
- Chapter 5: License Hygiene — LICENSE, NOTICE, and Category X
- Chapter 6: Compatibility Checking — Semver and japicmp
- Chapter 7: Maven Release Mechanics
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: What a "Release" Legally Is
From zero: an ASF release is the source tarball that the PMC voted on — not the git tag, not the binaries, not the Docker image. The foundation legally distributes source code under the Apache License; the voting, signing, and distribution rules all protect that artifact. Binaries are "convenience artifacts": built from the source, nice to have, not the thing.
Why this matters practically: release-blocking review focuses on the tarball — does it build from scratch, are LICENSE/NOTICE correct for what's inside it, does it contain no compiled jars or category-X code (Chapter 5), does it match the tag. A broken convenience binary is an embarrassment; a tainted source tarball is a legal problem for the foundation. This is also why every PMC vote email says "I checked: signatures, build, license files" — the votes are attestations, and casting one without checking is the governance sin.
Chapter 2: Votes, Vetoes, and Consensus — The Decision Mechanics
The precise rules (commonly half-known; know them fully):
- +1 / 0 / -1 are conventions for approve / abstain / object, but their force depends on the question:
- Code modification votes: a committer/PMC -1 with technical justification is a veto — it cannot be outvoted, only resolved (address the objection or persuade the vetoer). An unjustified -1 is invalid. Vetoes exist so that majority enthusiasm can't override one person who sees the data-loss bug.
- Procedural votes (releases, adding committers): majority rules — a -1 on a release is NOT a veto. A release passes with ≥3 binding +1s and more binding +1 than -1, after a minimum 72-hour window. ("Binding" = PMC members; everyone may vote, and non-binding votes carry real persuasive weight.)
- Lazy consensus: announce intent, wait 72h, proceed absent objection — the default for low-controversy work; never for API/compat changes.
- Where: on the dev list, in a
[VOTE]thread, with a[RESULT]posted. Votes in chat or PRs bind nobody.
Chapter 3: The Release Lifecycle End to End
The pipeline the RM (release manager — any committer/PMC volunteer) drives:
- Branch & stabilize: cut
branch-X.Y; only blockers (tracked in JIRA against the version) land; master moves on. - Produce RC: versions set (
X.Y.Zno SNAPSHOT), build from a clean checkout, produce the source tarball + convenience binaries, sign (Ch. 4), checksum, stage — Maven artifacts to a Nexus staging repo, tarballs todist.apache.org/repos/dist/dev. [VOTE]thread: links to artifacts, tag, staging repo; voters verify and report what they checked. 72h minimum.- Fail → RC2: any real defect sinks the RC; fix, re-spin, re-vote (release threads routinely reach RC4 — normal, not failure).
- Pass → publish: move artifacts to
dist/release(mirrors + downloads.apache.org), release the staging repo to Maven Central, push the final tag, update the site,[ANNOUNCE]. - Aftercare: track upgrade reports; severe regressions trigger an
X.Y.Z+1patch release — the RM role rotates but the discipline persists.
Lab 01 (release-mechanics) walks a toy project through 1–2 and 5's mechanics; Phase 10's labs automate pieces of 3–4.
Chapter 4: Cryptographic Trust — GPG, Checksums, KEYS
The threat model: artifacts travel through mirrors the ASF doesn't control. Users must be able to verify (a) integrity — bytes unmodified — and (b) authenticity — built by the RM the PMC trusts.
- Checksums (
.sha512): integrity only. Anyone who can replace the tarball can replace its checksum — necessary, insufficient. - GPG signature (
.asc): the RM signs with their private key;gpg --verify artifact.asc artifactproves the holder of that key produced these bytes. Authenticity now reduces to: is this key really the RM's? - The KEYS file: each project publishes committer public keys at a canonical ASF URL (fetched over TLS from apache.org, not from the mirror). Stronger: keys are cross-signed at key-signing events (web of trust). Your verification script (Lab 02) imports KEYS, verifies the sig, checks the checksum, and — the step people skip — confirms the signing key's fingerprint appears in KEYS.
- RM hygiene: 4096-bit RSA, apache.org uid, key published before the vote; signing happens on a trusted machine (supply-chain 101).
Chapter 5: License Hygiene — LICENSE, NOTICE, and Category X
The part of release review that surprises engineers:
- LICENSE: the Apache-2.0 text plus the licenses of everything bundled in the artifact. If the source tarball vendors a BSD-licensed file, its license text appears here. Per-artifact accuracy — the binary's LICENSE differs from the source's because it bundles more.
- NOTICE: required attributions only (a legal instrument, not a thank-you list) — copyright notices that licenses require be preserved. Over-stuffing NOTICE is itself a review comment.
- Category A (BSD, MIT, ...): may be bundled. Category B (EPL, MPL, ...): binary-only bundling with conditions. Category X (GPL/LGPL, SSPL, JSON license, ...): may NOT be bundled in an ASF artifact at all — a GPL dependency in the tarball is an automatic release blocker. ("Optional runtime dependency the user installs" is the standard escape hatch.)
- This is checkable mechanically (
mvn license:check, rat plugin for headers) and socially (the recurring "why is this jar in the source release?" vote -1).
Chapter 6: Compatibility Checking — Semver and japicmp
The governance promise "no breaking changes in a minor release" is enforceable only by tooling:
- The dimensions (Phase 03 Ch. 5, now mechanized): source, binary (the one tools check — and the one with surprises: changing a return type to a subtype breaks binary linkage even though sources recompile; adding a method to an interface broke all implementors pre-default-methods), behavioral (only tests catch), serialized forms.
- japicmp compares two jars and classifies every change (
METHOD_REMOVED,METHOD_RETURN_TYPE_CHANGED, ...) against binary/source compat rules; run as a Maven plugin it fails the build on undeclared breakage; declared exceptions live in a reviewed exclusion file. Spark's MiMa is the Scala equivalent; the pattern — compat promises as CI gates with sign-off-required exclusions — is the institutional lesson. - Scope control: the check applies to the annotated public surface
(
@InterfaceAudience.Public/@Stablein Hadoop; everything else is fair game — which is why the annotations exist (Phase 11 builds the migration tooling on top of exactly this). - Lab 03 runs japicmp over a deliberately broken v1→v2 pair; Phase 10 Lab 02 then makes you implement a simplified checker — read the report here, build the analyzer there.
Chapter 7: Maven Release Mechanics
Enough mechanics to execute Lab 01 credibly:
- Versioning:
X.Y.Z-SNAPSHOT= mutable dev version (re-resolved on each build); releases strip SNAPSHOT and are immutable forever (Central never lets you replace a released GAV — hence RC re-spins bump-rcN, not reuse). - The release dance (whether via
maven-release-pluginor scripts): set release version → commit → tag → build+sign+stage (gpg+deployto a staging repo) → bump to next SNAPSHOT → commit. Staging repos are the key safety: artifacts are visible for vote-time verification but not yet public; "release" or "drop" is one button after the vote. - Reproducibility: pin plugin versions, build from a clean clone in a container,
record the JDK — "builds for the RM but not for voters" is a classic RC sinker.
Modern projects aim for bit-reproducible builds (
project.build.outputTimestamp).
Lab Walkthrough Guidance
Order: Lab 01 (release mechanics) → Lab 02 (signing) → Lab 03 (api compatibility).
- Lab 01: run the version dance on the toy project; produce a source tarball that
builds from scratch in a clean directory; write the
[VOTE]email you would send, with the checklist of what a voter should verify. - Lab 02: generate a key, sign, verify; then break things deliberately — corrupt a byte (checksum catches), re-sign with a wrong key (KEYS-fingerprint check catches) — your script must catch both. The negative tests are the lab.
- Lab 03: run japicmp on the provided v1/v2; for each reported incompatibility, classify it source-vs-binary and write the one-line release-notes entry it would require; decide which changes could be made compatible (and how) vs which need a major version.
Success Criteria
You are ready for Phase 05 when you can, from memory:
- State what artifact a release vote covers and why; recite the 3×+1/72h rule and the veto-vs-majority distinction with one example each.
- Walk the six-stage release lifecycle naming the RM's action and the failure mode at each stage.
- Explain the three-step verification (checksum, signature, KEYS fingerprint) and what attack each step closes.
- Distinguish LICENSE from NOTICE, and categorize MIT/EPL/GPL bundling.
- Give two binary-incompatible changes that are source-compatible, and describe how japicmp-as-CI-gate institutionalizes the promise.
Interview Q&A
Q: You're RM and a PMC member -1s your RC for a flaky test. Proceed? A release -1 is not a veto — formally I could pass with 3 binding +1s. Practically: investigate first (flaky test or real race?), respond with findings in-thread. If it's benign flakiness with evidence, say so and let the vote run; if there's any doubt about a correctness issue, re-spin — shipping over a PMC member's documented concern spends trust that outvalues any schedule. The formal rule and the social rule differ; a good RM knows both.
Q: Why can't the ASF just rely on HTTPS instead of GPG signatures? TLS protects the channel to a mirror, not the artifact's provenance — mirrors are third-party, and a compromised mirror serves valid-TLS poison. The GPG chain (signature
- KEYS fetched from apache.org) binds artifacts to a person the PMC vouches for, end-to-end, independent of any transport or host. Checksums alone fail the same way: co-located with the artifact, they're replaced together.
Q: A minor release accidentally shipped a binary-incompatible change. What now? First, scope: japicmp diff to inventory exactly what broke and for whom. Then the options, in order of preference: restore the old signature in a fast patch release (delegating to the new path — additive fix); if impossible, document loudly + provide a shim artifact. And the institutional fix: why didn't the gate catch it — missing annotation, exclusion-file rot, or the check not running on that module. The incident isn't closed until the gate gap is.
References
- Apache Release Policy and Release Distribution Policy
- Apache Voting Process
- ASF Third-Party License Policy (Category A/B/X)
- Apache Release Signing — the GPG/KEYS mechanics
- japicmp documentation and MiMa
- Hadoop Compatibility Specification
- Browse a real vote: search any project's dev-list archive for "[VOTE] Release" and read an RC1→RC3 saga end to end
🛸 Hitchhiker's Guide — Phase 04: ASF Governance and Release Engineering
Read this if: You can write good code but you've never shipped a release — or you've pushed to npm/PyPI and think Apache releases are similar. They are not. An Apache release is a legally binding vote by PMC members who are personally attesting to the quality of the artifact. By the end you should understand every step from JIRA planning to the announcement email, and know exactly what you are agreeing to when you write +1 on a vote thread.
0. The 30-second mental model
An Apache release is three things simultaneously:
- A legal act: The PMC votes to release software under the Apache License 2.0. Each +1 vote is a personal attestation that the voter checked the artifacts.
- A cryptographic chain of custody: GPG signatures link each artifact back to a committer whose key is in the project's
KEYSfile. Anyone can verify the chain. - A distribution process: Artifacts flow from
dist.apache.org/dev/(staging) →dist.apache.org/release/(official) → Maven Central / mirror network (convenience). The Apache infrastructure is always the source of truth.
Committer machine
│ mvn release:perform
▼
dist.apache.org/dev/[project]/[version]-RC1/
│ [VOTE] email sent to dev@project.apache.org
│ 72-hour voting period
│ ≥ 3 binding +1 votes, more +1 than -1
▼
dist.apache.org/release/[project]/[version]/
│ Maven repository sync (24–48h)
▼
Maven Central / mirrors
│ Announcement email to announce@apache.org
▼
End users
1. The Release Lifecycle
1.1 Planning Phase
Before a line of release prep is done, the release manager (RM) does:
- JIRA release version: Create a version in JIRA (e.g.,
4.0.0). Move unresolved issues out of scope. - Announce intent: Email
dev@list with the proposed release timeline. This is not optional — it gives the community a chance to land in-progress work. - Feature freeze: Announce a date after which only bug fixes and documentation are merged to the release branch.
1.2 Stabilization Branch
# Cut the stabilization branch from main (or the previous release)
git checkout -b branch-4.0 origin/main
git push origin branch-4.0
# From this point, main gets new features; branch-4.0 gets only targeted fixes
# Fixes are cherry-picked: git cherry-pick <commit-hash>
A stabilization branch serves two purposes:
- It isolates the release from new feature churn on
main - It makes it possible to patch a released version (e.g.,
4.0.1) without including unrelated work frommain
1.3 RC Numbering
Release Candidates are numbered RC0, RC1, RC2, etc.
| Convention | Meaning |
|---|---|
RC0 | First attempt. Often fails due to packaging issues, wrong KEYS, missing LICENSE/NOTICE. |
RC1 | Fixed the issues from RC0. This is the first one with a real chance of passing. |
RC2, RC3 | Release blockers found during voting. Each blocker requires a new RC. |
There is no release-labeled artifact until a vote passes. Anything labeled RC is not a release.
1.4 Building the RC
The Maven Release Plugin (mvn release:prepare + mvn release:perform) handles the mechanical parts:
mvn release:prepare
├── Prompts for release version (e.g., 4.0.0) and next dev version (4.0.1-SNAPSHOT)
├── Updates pom.xml version strings
├── Commits the updated pom.xml
├── Creates a Git tag (e.g., v4.0.0-rc1)
├── Updates pom.xml to next dev version
└── Commits the next dev version pom.xml
mvn release:perform
├── Checks out the tagged commit
├── Runs the full build (mvn deploy)
└── Uploads artifacts to the staging repository
After release:perform, the RM signs the artifacts and uploads to dist.apache.org/dev/.
2. The Vote Thread
2.1 Vote Email Format
A vote email must contain, at minimum:
To: dev@[project].apache.org
Subject: [VOTE] Release Apache [Project] [Version] RC[N]
I propose we release Apache [Project] [Version] based on RC[N].
The RC artifacts are available at:
https://dist.apache.org/repos/dist/dev/[project]/[version]-RC[N]/
Release artifacts:
[project]-[version]-src.tar.gz
[project]-[version]-src.tar.gz.asc
[project]-[version]-src.tar.gz.sha256
Keys used to sign this release are available at:
https://www.apache.org/dist/[project]/KEYS
The tag to vote on:
https://github.com/apache/[project]/releases/tag/v[version]-rc[N]
Please vote:
[ ] +1 Release this package as Apache [Project] [Version]
[ ] +0 No strong preference
[ ] -1 Do not release this package because: (state reason)
This vote is open for 72 hours.
2.2 Vote Arithmetic
| Vote | Meaning | Binding? |
|---|---|---|
| +1 | Tested the artifacts; approves release | PMC members only |
| 0 | Abstain (no objection, no endorsement) | Anyone |
| -1 | Objects to release; must provide reason | PMC members only |
A release passes when:
- At least 3 binding +1 votes (from PMC members)
- More binding +1 than binding -1 votes
- The 72-hour minimum voting period has elapsed
A single PMC member's -1 (veto) does not automatically block a release but must be addressed. If the veto identifies a genuine legal or technical problem, it holds.
2.3 Result Email
To: dev@[project].apache.org
Subject: [RESULT][VOTE] Release Apache [Project] [Version] RC[N]
The vote has PASSED.
Binding votes:
+1 Alice (PMC)
+1 Bob (PMC)
+1 Carol (PMC)
Non-binding votes:
+1 Dave (committer)
Vote thread: [link to archive]
3. GPG Signing and the Chain of Trust
3.1 Why GPG Signing?
A SHA-256 checksum proves an artifact hasn't been corrupted in transit. It does not prove who created it. Anyone who can modify dist.apache.org could also update the checksum.
A GPG signature proves the artifact was created by the holder of a specific private key. If that key is listed in the project's KEYS file and the KEYS file is hosted on www.apache.org (which is controlled by Apache infrastructure, not by the project), the chain of trust is:
Your trust in ASF infrastructure
→ trust in KEYS file
→ trust in committer's public key
→ verified signature on artifact
→ trust in artifact
3.2 The Project KEYS File
Every Apache project maintains a KEYS file at https://www.apache.org/dist/[project]/KEYS. It contains ASCII-armored public keys for every committer who has ever signed a release. When a new committer joins and signs releases, they add their public key to KEYS via an SVN commit.
# Importing the KEYS file
curl https://www.apache.org/dist/spark/KEYS | gpg --import
# Verifying a release signature
gpg --verify spark-3.5.1.tgz.asc spark-3.5.1.tgz
3.3 Creating a GPG Key for Releases
# Generate a key (use RSA 4096 or ed25519)
gpg --full-generate-key
# Choose: RSA and RSA → 4096 bits → does not expire → your Apache email
# List your keys
gpg --list-secret-keys --keyid-format LONG
# Export your public key for KEYS file
gpg --armor --export your@apache-email.org
# Sign an artifact (detached, armored signature)
gpg --batch --yes --detach-sign --armor --output artifact.jar.asc artifact.jar
# Verify your own signature
gpg --verify artifact.jar.asc artifact.jar
3.4 Checksums
# Generate SHA-256 checksum
shasum -a 256 artifact.jar > artifact.jar.sha256 # macOS
sha256sum artifact.jar > artifact.jar.sha256 # Linux
# Verify (the file must have the exact format: "<hash> <filename>")
shasum -a 256 --check artifact.jar.sha256 # macOS
sha256sum --check artifact.jar.sha256 # Linux
Note: Apache previously used MD5 and SHA-1. Both are now prohibited. SHA-256 or SHA-512 only.
4. API Compatibility Validation
4.1 Binary vs Source Compatibility
| Type | Definition | Breaks when... |
|---|---|---|
| Binary compatible | Existing compiled code still works | Method removed, signature changed, class removed |
| Source compatible | Existing source code still compiles | Same as above, plus type changes that require source edits |
| Behavioral compatible | Same observable behavior | Semantics changed even with identical API |
Apache projects generally guarantee binary compatibility within a major version (e.g., 3.x). Breaking binary compatibility requires a major version bump and a migration guide.
4.2 What Breaks Binary Compatibility
// v1 — stable API
public class DataProcessor {
public void process(String input) { ... } // callers compile against this
public void setConfig(BatchConfig cfg) { ... }
}
// v2 — breaking changes
public class DataProcessor {
public void process(String input, boolean strict) { ... } // BREAKS callers of process(String)
// setConfig removed // BREAKS callers of setConfig
public DataProcessor withConfig(BatchConfig cfg) { ... } // new — non-breaking
}
Callers compiled against v1 will get NoSuchMethodError at runtime when run against v2.
4.3 Using japicmp
japicmp is the standard Java API compatibility checker used by many Apache projects.
# Run via Maven plugin (see lab-03)
mvn install # builds both v1 and v2, then runs japicmp comparison
# Or via CLI
java -jar japicmp.jar --old v1.jar --new v2.jar --html-file report.html
japicmp classifies each change as:
- BINARY_INCOMPATIBLE: Will break compiled callers
- SOURCE_INCOMPATIBLE: Will break source compilation
- NON_BREAKING: New method/class added; safe
- EXCLUDED: Filtered out (e.g., internal classes)
5. The Distribution Flow and Legal Requirements
5.1 dist.apache.org
All Apache releases must be available on Apache infrastructure before they appear anywhere else. The flow:
- RM uploads to
dist.apache.org/repos/dist/dev/[project]/(SVN) - Vote passes
- RM moves to
dist.apache.org/repos/dist/release/[project]/(another SVN commit) - Apache mirrors sync within 24 hours
- Maven Central sync happens via Nexus staging repository
Maven Central is a convenience copy. The official release is on dist.apache.org.
5.2 LICENSE and NOTICE Requirements
Every release archive must contain:
LICENSEfile: the Apache License 2.0 text, plus any third-party licenses for bundled codeNOTICEfile: copyright notice for the project, plus required notices from third-party dependencies (e.g., attribution notices from MIT/BSD code)
Missing or incorrect LICENSE/NOTICE is the most common cause of a -1 vote on legal grounds. The apache-rat Maven plugin checks for license headers:
mvn apache-rat:check
6. Common Release Failures
| Failure | Cause | Prevention |
|---|---|---|
| Unsigned artifacts | Forgot to sign before upload | sign-artifact.sh as part of the release script |
| Wrong key in KEYS | Signed with personal key not in project KEYS | Always verify gpg --list-keys against project KEYS before signing |
| Binary incompatibility | Method removed without major version bump | Run japicmp on every release candidate |
| Failed reproducible build | Timestamp baked into JAR manifest | Use maven-artifact-plugin:compare to verify reproducibility |
| Missing LICENSE/NOTICE | Third-party bundled dependency not attributed | Run apache-rat:check and dependency-check in CI |
| RC built from wrong commit | Release tag points to wrong sha | git show v4.0.0-rc1 → verify pom.xml version |
| Test failure in release build | Tests pass in dev but fail in release:perform | Always run mvn release:prepare -DdryRun=true first |
7. Interview Questions — Concepts You Must Explain Out Loud
| Topic | One-line answer |
|---|---|
| What does a +1 vote in an Apache release vote mean? | The voter personally verified the release artifacts: checked signatures, checksums, LICENSE/NOTICE, and tested the release against their environment. It is a binding personal attestation, not a rubber stamp. |
| Why are GPG signatures required alongside SHA-256 checksums? | Checksums prove integrity (not modified in transit) but not authenticity (who signed it). GPG signatures prove the artifact was created by a specific committer whose key is listed in the project KEYS file. |
| What is the minimum vote to pass an Apache release? | At least 3 binding +1 votes from PMC members, more binding +1 than binding -1, and the vote must be open for at least 72 hours. |
| What is binary compatibility and how do you validate it? | A binary-compatible change does not break code that was compiled against the previous version of the library. Validated by running japicmp (or equivalent) comparing old and new JARs before shipping. |
| What is the difference between dist.apache.org and Maven Central? | dist.apache.org is the authoritative Apache release server — all releases must appear there first. Maven Central is a convenience mirror. The Apache infrastructure is the source of truth. |
| What causes most release candidate failures? | Signing issues (wrong key, missing .asc), missing or incorrect LICENSE/NOTICE files, binary compatibility regressions, and test failures in the release:perform build that didn't show up in dev. |
What is mvn release:prepare -DdryRun=true for? | Simulates the full release preparation without committing or tagging. Shows you what version numbers will be used, what files will change, and whether the build passes — before you make any irreversible commits. |
| What happens when a -1 vote is cast? | The voter must provide a reason. If the reason is a fixable release blocker (e.g., wrong checksum), the RM fixes it and cuts a new RC. If the -1 is a veto on legal/security grounds by a PMC member, it must be resolved before release can proceed. |
8. References
- Apache Release Policy — the authoritative policy document
- How to Publish a Release (Apache guide)
- Apache Spark Release Process (community docs) — a real project's release runbook
- Maven Release Plugin
- japicmp — Java API Compatibility Checker
- The GNU Privacy Handbook — GPG key management
- Apache RAT (Release Audit Tool)
- Apache Legal — Third Party Licenses
Lab 01 — Release Mechanics
Phase: 04 — ASF Governance and Release Engineering
Difficulty: ⭐⭐⭐☆☆ | Estimated Time: 2–3 hours
Primary Artifact: Working mvn release:prepare run with Git tag
Verify: git log --oneline --decorate shows a v0.1.0 tag; pom.xml version is 0.2.0-SNAPSHOT
Goal
Run the full Maven Release Plugin workflow against a real local Git repository. By the end you will understand exactly what mvn release:prepare does, what commits it makes, what it tags, and why each step matters for a reproducible, traceable release.
The project ships with a small Java library (Calculator) and JUnit 5 tests that already pass. Your job is to wire up the release infrastructure and execute the release.
Setup
# 1. Enter the lab directory
cd phase-04-asf-governance-release/lab-01-release-mechanics
# 2. Initialize a local bare Git repository to act as the "remote"
# (This simulates dist.apache.org or a GitHub remote without needing network access)
git init --bare ../release-remote.git
# 3. Initialize the lab project as a Git repo and push to the fake remote
git init
git add .
git commit -m "initial: add Calculator library"
git remote add origin ../release-remote.git
git push -u origin main
# 4. Verify the tests pass before touching the release plugin
mvn test
Expected output from Step 4:
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Steps
Step 1 — Dry Run (read the plan before executing)
mvn release:prepare -DdryRun=true
Answer the prompts:
- What version do you want to release? →
0.1.0 - What is the SCM release tag? →
v0.1.0 - What is the next development version? →
0.2.0-SNAPSHOT
After the dry run, inspect:
# What pom.xml would look like after release:prepare
cat pom.xml.tag # the to-be-tagged version
cat pom.xml.next # the next snapshot version
cat release.properties # everything release:prepare recorded
Answer before continuing: What 3 commits would release:prepare make to Git?
# Clean up dry run artifacts before the real run
mvn release:clean
Step 2 — Execute the Release
mvn release:prepare
Use the same version answers as Step 1. Watch what happens. After it completes:
# Inspect the Git log
git log --oneline --decorate
# Inspect the tag
git show v0.1.0
# Inspect what changed between the two release commits
git diff HEAD~2 HEAD~1 # the "prepare release" commit
git diff HEAD~1 HEAD # the "prepare for next development" commit
Questions to answer:
- What is the version string in
pom.xmlon the tagv0.1.0? What is it onmainnow? - What does
release.propertiescontain? Why does Maven save this? - What happens if you run
mvn release:prepareagain on the same version? Try it with-DdryRun=true.
Step 3 — Simulate release:perform
release:perform checks out the tagged commit, builds it, and deploys the artifacts. Since we don't have a staging repository, we'll simulate just the build step:
# Check out the tagged commit in a temp directory
git clone --branch v0.1.0 ../release-remote.git /tmp/release-check
cd /tmp/release-check
# Build and verify the release artifacts
mvn verify
# What SHA-256 is the produced JAR?
shasum -a 256 target/release-lab-0.1.0.jar # macOS
sha256sum target/release-lab-0.1.0.jar # Linux
Record the SHA-256. You will sign this artifact in Lab 02.
Step 4 — Write the Release Checklist
In release-checklist.md (create it), document the complete release steps you just performed as a numbered checklist with checkboxes. Format it as if you were writing the release runbook for a future release manager who has never done this before.
The checklist must include: branch cut, version bump, tag, build, artifact verification, GPG signing (placeholder), dist upload (placeholder), vote (placeholder), announcement (placeholder).
Expected Output
lab-01-release-mechanics/
pom.xml
src/
release-checklist.md ← your created checklist
And in Git:
$ git log --oneline --decorate
abc1234 (HEAD -> main) [maven-release-plugin] prepare for next development iteration
def5678 (tag: v0.1.0) [maven-release-plugin] prepare release v0.1.0
ghi9012 initial: add Calculator library
Verification
# Tag exists
git tag | grep v0.1.0
# Version on tag is 0.1.0 (not SNAPSHOT)
git show v0.1.0:pom.xml | grep '<version>'
# Version on main is next SNAPSHOT
grep '<version>' pom.xml | head -1
# Tests pass on the tagged commit
git stash
git checkout v0.1.0
mvn test
git checkout main
All four checks must pass.
Talking Points
- Why tag a release in Git? The tag is the only way to reproducibly check out exactly the code that was shipped. Without it, "the release build" means nothing — any commit could have been used.
- Why does
release:preparemake two commits? The first commit sets the version to0.1.0(no SNAPSHOT suffix) — this is what gets tagged. The second commit bumps to0.2.0-SNAPSHOT— this is what developers continue working from. - What is
release.properties? It stores the state of an in-progress release sorelease:perform(andrelease:rollback) know what to do. If you lose this file mid-release, you have torelease:rollbackand start over.
Source Files
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example.release</groupId>
<artifactId>release-lab</artifactId>
<!-- maven-release-plugin will rewrite this version during release:prepare -->
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Release Lab — Calculator</name>
<description>
A minimal Maven library used to practice the Maven Release Plugin workflow.
The Calculator class provides basic arithmetic operations.
</description>
<!--
SCM section: Required by maven-release-plugin.
For this lab, we point to a local bare git repo you create with:
git init --bare ../release-remote.git
The ${project.basedir} resolves to the directory containing this pom.xml,
so ../release-remote.git is one level up from the lab directory.
In a real Apache project this would be:
scm:git:https://gitbox.apache.org/repos/asf/[project].git
-->
<scm>
<connection>scm:git:file://${project.basedir}/../release-remote.git</connection>
<developerConnection>scm:git:file://${project.basedir}/../release-remote.git</developerConnection>
<tag>HEAD</tag>
</scm>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Pins the exact plugin versions so the release is reproducible -->
<maven-release-plugin.version>3.0.1</maven-release-plugin.version>
<maven-surefire-plugin.version>3.2.5</maven-surefire-plugin.version>
<junit.version>5.10.2</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!--
maven-surefire-plugin: runs JUnit 5 tests.
Version 3.x supports JUnit Platform natively (no extra providers needed).
-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
</plugin>
<!--
maven-release-plugin: automates the release workflow.
Key lifecycle:
mvn release:prepare — version bump, tag, push
mvn release:perform — checkout tag, build, deploy
mvn release:rollback — undo a prepare (if something went wrong)
mvn release:clean — remove release.properties and *.backup files
tagNameFormat: The format of the Git tag.
@{project.version} resolves to the version being released (e.g., 0.1.0)
We prefix with "v" (v0.1.0) — standard for most Apache projects.
preparationGoals: Goals run during release:prepare (default: install).
We run verify to ensure tests pass before the tag is created.
autoVersionSubmodules: For multi-module projects, version all modules
together. Setting to true prevents version drift between sibling modules.
-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-release-plugin</artifactId>
<version>${maven-release-plugin.version}</version>
<configuration>
<tagNameFormat>v@{project.version}</tagNameFormat>
<autoVersionSubmodules>true</autoVersionSubmodules>
<preparationGoals>verify</preparationGoals>
</configuration>
</plugin>
</plugins>
</build>
<!--
distributionManagement: Where mvn release:perform deploys artifacts.
For this lab we use a local file-system repo so no network access is needed.
Replace these paths with your Apache Nexus staging repository in a real project.
-->
<distributionManagement>
<repository>
<id>local-release</id>
<url>file://${project.basedir}/../local-maven-repo</url>
</repository>
<snapshotRepository>
<id>local-snapshots</id>
<url>file://${project.basedir}/../local-maven-repo-snapshots</url>
</snapshotRepository>
</distributionManagement>
</project>
Calculator.java
package org.example.release;
/**
* A basic four-function calculator.
*
* <p>
* This class is the release artifact for Lab 01. Its purpose is to give the
* Maven Release
* Plugin something real to build, version, and tag — not to demonstrate complex
* logic.
*
* <p>
* Version history (maintained as release notes in this comment block):
* <ul>
* <li>0.1.0 — Initial release: add, subtract, multiply, divide</li>
* </ul>
*/
public class Calculator {
/**
* Returns the sum of {@code a} and {@code b}.
*/
public int add(int a, int b) {
return a + b;
}
/**
* Returns the difference {@code a - b}.
*/
public int subtract(int a, int b) {
return a - b;
}
/**
* Returns the product of {@code a} and {@code b}.
*/
public int multiply(int a, int b) {
return a * b;
}
/**
* Returns the integer quotient of {@code a / b}.
*
* @throws ArithmeticException if {@code b} is zero — not silently swallowed,
* because hiding errors in a library violates the
* principle of least surprise.
* Callers that want a default value must catch this
* explicitly.
*/
public int divide(int a, int b) {
if (b == 0) {
throw new ArithmeticException("Division by zero: cannot divide " + a + " by 0");
}
return a / b;
}
}
CalculatorTest.java
package org.example.release;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CalculatorTest {
private Calculator calc;
@BeforeEach
void setUp() {
calc = new Calculator();
}
// -------------------------------------------------------------------------
// Add
// -------------------------------------------------------------------------
@Test
void add_positives() {
assertEquals(5, calc.add(2, 3));
}
@Test
void add_negativeAndPositive() {
assertEquals(-1, calc.add(-4, 3));
}
@Test
void add_zeros() {
assertEquals(0, calc.add(0, 0));
}
// -------------------------------------------------------------------------
// Subtract
// -------------------------------------------------------------------------
@Test
void subtract_largerFromSmaller() {
assertEquals(-1, calc.subtract(2, 3));
}
@Test
void subtract_sameValue() {
assertEquals(0, calc.subtract(7, 7));
}
// -------------------------------------------------------------------------
// Multiply
// -------------------------------------------------------------------------
@ParameterizedTest(name = "{0} × {1} = {2}")
@CsvSource({
"2, 3, 6",
"0, 9, 0",
"-2, 3, -6",
"-2,-3, 6"
})
void multiply_tableOfValues(int a, int b, int expected) {
assertEquals(expected, calc.multiply(a, b));
}
// -------------------------------------------------------------------------
// Divide
// -------------------------------------------------------------------------
@Test
void divide_evenDivision() {
assertEquals(3, calc.divide(9, 3));
}
@Test
void divide_integerTruncation() {
// Integer division truncates toward zero — important to document for callers
assertEquals(2, calc.divide(7, 3));
}
@Test
void divide_byZero_throwsArithmeticException() {
ArithmeticException ex = assertThrows(
ArithmeticException.class,
() -> calc.divide(1, 0));
// The message must mention "zero" — callers deserve a readable error
assertTrue_messageContains(ex.getMessage(), "zero");
}
// -------------------------------------------------------------------------
// Private helper — avoid importing Hamcrest just for one assertion
// -------------------------------------------------------------------------
private static void assertTrue_messageContains(String message, String substring) {
if (!message.toLowerCase().contains(substring.toLowerCase())) {
throw new AssertionError(
"Expected message to contain '" + substring + "' but was: " + message);
}
}
}
Lab 02 — Signing and Verification
Phase: 04 — ASF Governance and Release Engineering
Difficulty: ⭐⭐☆☆☆ | Estimated Time: 1–2 hours
Primary Artifact: Working sign-artifact.sh and verify-release.sh
Verify: bash verify-release.sh release-lab-0.1.0.jar <your-key-id> prints RELEASE VERIFIED
Goal
Implement two shell scripts that together reproduce the signing and verification steps every Apache release manager performs before uploading artifacts to dist.apache.org:
sign-artifact.sh— sign an artifact with your GPG key and generate a SHA-256 checksumverify-release.sh— verify both the signature and checksum and print a clear pass/fail verdict
Both scripts ship with # TODO stubs. Your job is to fill them in.
Setup
1. Generate a GPG key (if you don't already have one)
# Generate a key — use your real name and email
gpg --full-generate-key
# Select: (1) RSA and RSA
# Key size: 4096
# Expiry: 0 (does not expire — Apache convention)
# Name: Your Name
# Email: your@email.com
2. Find your key ID
gpg --list-secret-keys --keyid-format LONG
Output looks like:
sec rsa4096/ABCDEF1234567890 2024-01-01 [SC]
ABC1234567890DEF...
uid [ultimate] Your Name <your@email.com>
Your key ID is the hex string after the slash (ABCDEF1234567890 in the example).
3. Build the artifact from Lab 01
cd ../lab-01-release-mechanics
git checkout v0.1.0
mvn package -DskipTests
cp target/release-lab-0.1.0.jar ../../lab-02-signing-verification/
cd ../../lab-02-signing-verification
Steps
Step 1 — Implement sign-artifact.sh
Open sign-artifact.sh. Fill in the four # TODO sections:
- Check file exists: Fail clearly if the artifact path is not a regular file
- GPG detached signature: Produce
<artifact>.ascusinggpg --detach-sign --armor - SHA-256 checksum: Produce
<artifact>.sha256usingshasum(macOS) orsha256sum(Linux) - Print summary: Print the paths to the
.ascand.sha256files
Test it:
chmod +x sign-artifact.sh
./sign-artifact.sh release-lab-0.1.0.jar
Expected output (key ID will differ):
Signed artifact : release-lab-0.1.0.jar
Signature file : release-lab-0.1.0.jar.asc
Checksum file : release-lab-0.1.0.jar.sha256
Inspect what was generated:
cat release-lab-0.1.0.jar.sha256 # should show hex hash + filename
cat release-lab-0.1.0.jar.asc # ASCII-armored GPG signature
Step 2 — Implement verify-release.sh
Open verify-release.sh. Fill in the four # TODO sections:
- Check required files exist:
.asc,.sha256— fail clearly for each missing file - GPG verify: Run
gpg --verifyand capture success/failure - Checksum verify: Run OS-appropriate checksum verify and capture success/failure
- Final verdict: Print
RELEASE VERIFIEDorRELEASE FAILEDwith a clear overall result
Test it:
chmod +x verify-release.sh
./verify-release.sh release-lab-0.1.0.jar <YOUR-KEY-ID>
Expected output:
GPG signature: VALID
Checksum: VALID
RELEASE VERIFIED: release-lab-0.1.0.jar is authentic.
Step 3 — Tamper Test
Verify that your script correctly detects tampering:
# Tamper with the JAR (flip one byte)
python3 -c "
data = open('release-lab-0.1.0.jar', 'rb').read()
tampered = bytes([data[0] ^ 0xFF]) + data[1:]
open('release-lab-0.1.0.jar', 'wb').write(tampered)
"
# Now verify — should FAIL
./verify-release.sh release-lab-0.1.0.jar <YOUR-KEY-ID>
Expected output:
GPG signature: INVALID
Checksum: INVALID
RELEASE FAILED: release-lab-0.1.0.jar could not be verified.
Restore the original JAR by rebuilding from Lab 01 before continuing.
Step 4 — Vote Email Draft (bonus, no script required)
Draft the [VOTE] email you would send if you were releasing release-lab-0.1.0.jar as an Apache project. Save it to vote-email.txt in this directory.
Your email must include:
- Subject line in the correct format
- RC artifact location (use a fake
dist.apache.orgURL) - KEYS file URL
- Git tag URL
- The three vote options with checkboxes
This vote is open for 72 hours.
Reference the HITCHHIKERS-GUIDE vote format.
Verification
# Both scripts are executable
test -x sign-artifact.sh && echo "sign-artifact.sh: OK"
test -x verify-release.sh && echo "verify-release.sh: OK"
# Sign produces expected files
./sign-artifact.sh release-lab-0.1.0.jar
test -f release-lab-0.1.0.jar.asc && echo "asc: OK"
test -f release-lab-0.1.0.jar.sha256 && echo "sha256: OK"
# Verify passes on untampered artifact
./verify-release.sh release-lab-0.1.0.jar <YOUR-KEY-ID> | grep "RELEASE VERIFIED"
Common Errors
| Error | Cause | Fix |
|---|---|---|
gpg: signing failed: No secret key | No GPG key with the given key ID | Run gpg --list-secret-keys to find your real key ID |
gpg: WARNING: not a detached signature | Passed the wrong file order to gpg --verify | Order must be: gpg --verify <sig-file> <data-file> |
shasum: illegal option -- - | Wrong shasum syntax on macOS | Use shasum -a 256 --check, not sha256sum --check on macOS |
sha256sum: WARNING: 1 line is improperly formatted | Checksum file has wrong format | The .sha256 file must have two spaces between hash and filename (default output format) |
Source Files
sign-artifact.sh
#!/usr/bin/env bash
# ==============================================================================
# sign-artifact.sh — Apache Release Signing Script
#
# Lab 02, Phase 04: ASF Governance and Release Engineering
#
# Usage:
# ./sign-artifact.sh <path-to-artifact>
#
# Produces:
# <artifact>.asc — GPG detached armored signature
# <artifact>.sha256 — SHA-256 checksum
#
# Fill in the four TODO sections below. When complete, running this script
# against any file should produce the two output files and print a summary.
#
# Reference:
# Apache Release Policy: https://www.apache.org/legal/release-policy.html
# GPG sign command: gpg --batch --yes --detach-sign --armor --output <out> <in>
# macOS checksum: shasum -a 256 <file> > <file>.sha256
# Linux checksum: sha256sum <file> > <file>.sha256
# ==============================================================================
set -euo pipefail
ARTIFACT="${1:-}"
if [[ -z "$ARTIFACT" ]]; then
echo "Usage: $0 <path-to-artifact>" >&2
exit 1
fi
SIG_FILE="${ARTIFACT}.asc"
CHECKSUM_FILE="${ARTIFACT}.sha256"
# ------------------------------------------------------------------------------
# Step 1: Verify the artifact exists
# ------------------------------------------------------------------------------
# TODO: Check that $ARTIFACT is a regular file (-f).
# If it is not, print an error message to stderr and exit with code 1.
# Example message: "Error: file not found: $ARTIFACT"
#
# Hint: Use: if [[ ! -f "$ARTIFACT" ]]; then ...
#
# ------------------------------------------------------------------------------
# Step 2: Sign with GPG (detached armored signature)
# ------------------------------------------------------------------------------
# TODO: Run the GPG sign command to produce $SIG_FILE.
#
# The command signature is:
# gpg --batch --yes --detach-sign --armor --output "$SIG_FILE" "$ARTIFACT"
#
# Flags explained:
# --batch non-interactive (no prompts)
# --yes overwrite $SIG_FILE if it already exists
# --detach-sign produce a detached signature (not embedded in the file)
# --armor ASCII-armor the output (human-readable, safe to email)
# --output <file> write the signature to this file
#
# Note: If you have multiple GPG keys, add --local-user <key-id> to pick the
# right one. For this lab any key is fine.
#
# ------------------------------------------------------------------------------
# Step 3: Generate SHA-256 checksum
# ------------------------------------------------------------------------------
# TODO: Detect the operating system and run the appropriate checksum command.
#
# macOS: shasum -a 256 "$ARTIFACT" > "$CHECKSUM_FILE"
# Linux: sha256sum "$ARTIFACT" > "$CHECKSUM_FILE"
#
# Hint for OS detection:
# OS="$(uname -s)"
# case "$OS" in
# Darwin) ... ;;
# Linux) ... ;;
# *) echo "Unsupported OS: $OS" >&2; exit 1 ;;
# esac
#
# The output file format produced by both commands is the same:
# <hex-hash> <filename> (two spaces between hash and name)
# This format is understood by --check on both macOS and Linux.
#
# ------------------------------------------------------------------------------
# Step 4: Print a summary
# ------------------------------------------------------------------------------
echo "Signed artifact : $ARTIFACT"
# TODO: Print the path to the signature file (SIG_FILE) and checksum file (CHECKSUM_FILE).
# Format them like the line above, aligned with spaces for readability.
# Example:
# Signature file : release-lab-0.1.0.jar.asc
# Checksum file : release-lab-0.1.0.jar.sha256
#
verify-release.sh
#!/usr/bin/env bash
# ==============================================================================
# verify-release.sh — Apache Release Verification Script
#
# Lab 02, Phase 04: ASF Governance and Release Engineering
#
# Usage:
# ./verify-release.sh <artifact> <signer-key-id>
#
# Example:
# ./verify-release.sh release-lab-0.1.0.jar ABCDEF1234567890
#
# This script verifies that an Apache release artifact:
# 1. Has a valid GPG detached signature from a known signer
# 2. Matches its published SHA-256 checksum
#
# Fill in the four TODO sections below. The script must:
# - Exit 0 and print "RELEASE VERIFIED: <artifact> is authentic." on success
# - Exit 1 and print "RELEASE FAILED: <artifact> could not be verified." on failure
#
# Reference:
# gpg --verify <sig-file> <data-file>
# macOS checksum verify: shasum -a 256 --check <checksum-file>
# Linux checksum verify: sha256sum --check <checksum-file>
# ==============================================================================
set -uo pipefail
# Note: we do NOT set -e here because we want to capture individual command
# exit codes rather than failing immediately on any non-zero exit.
ARTIFACT="${1:-}"
SIGNER_KEY="${2:-}"
if [[ -z "$ARTIFACT" || -z "$SIGNER_KEY" ]]; then
echo "Usage: $0 <artifact> <signer-key-id>" >&2
exit 1
fi
SIG_FILE="${ARTIFACT}.asc"
CHECKSUM_FILE="${ARTIFACT}.sha256"
# We'll track overall pass/fail in these variables.
GPG_OK=false
CHECKSUM_OK=false
# ------------------------------------------------------------------------------
# Step 1: Check that all required files exist
# ------------------------------------------------------------------------------
# TODO: Verify that each of these three files exists as a regular file:
# $ARTIFACT
# $SIG_FILE
# $CHECKSUM_FILE
#
# For each missing file, print a message to stderr like:
# "Error: missing artifact: $ARTIFACT"
# "Error: missing signature: $SIG_FILE"
# "Error: missing checksum: $CHECKSUM_FILE"
#
# If ANY file is missing, exit 1 after printing all errors.
# This way the user sees all missing files at once, not just the first one.
#
# Hint:
# MISSING=0
# [[ ! -f "$ARTIFACT" ]] && { echo "Error: ..." >&2; MISSING=1; }
# ...
# [[ $MISSING -eq 1 ]] && exit 1
#
# ------------------------------------------------------------------------------
# Step 2: Verify GPG signature
# ------------------------------------------------------------------------------
# TODO: Run: gpg --verify "$SIG_FILE" "$ARTIFACT"
#
# IMPORTANT: The argument order matters.
# Correct: gpg --verify <signature-file> <data-file>
# Wrong: gpg --verify <data-file> <signature-file> ← common mistake
#
# Capture the exit code:
# If exit code is 0 → set GPG_OK=true and print "GPG signature: VALID"
# If exit code is non-zero → print "GPG signature: INVALID"
#
# You may redirect gpg's own output to /dev/null to keep the output clean,
# or let it print (useful for debugging).
#
# Hint:
# gpg --verify "$SIG_FILE" "$ARTIFACT" 2>/dev/null
# if [[ $? -eq 0 ]]; then ...
#
# ------------------------------------------------------------------------------
# Step 3: Verify SHA-256 checksum
# ------------------------------------------------------------------------------
# TODO: Detect the OS and run the correct checksum verification command.
#
# The checksum file produced by sign-artifact.sh has format:
# <hex> <filename> (two spaces — the default output format)
#
# macOS verify: shasum -a 256 --check "$CHECKSUM_FILE"
# Linux verify: sha256sum --check "$CHECKSUM_FILE"
#
# Both commands must be run from the directory containing the artifact because
# the filename in the checksum file is a relative path.
#
# Capture the exit code the same way as Step 2:
# If exit code is 0 → set CHECKSUM_OK=true and print "Checksum: VALID"
# If exit code is non-zero → print "Checksum: INVALID"
#
# Hint for OS detection: see sign-artifact.sh Step 3.
#
# ------------------------------------------------------------------------------
# Step 4: Final verdict
# ------------------------------------------------------------------------------
# TODO: If both GPG_OK and CHECKSUM_OK are true, print:
# "RELEASE VERIFIED: $ARTIFACT is authentic."
# and exit 0.
#
# Otherwise, print:
# "RELEASE FAILED: $ARTIFACT could not be verified."
# and exit 1.
#
# Hint:
# if [[ "$GPG_OK" == "true" && "$CHECKSUM_OK" == "true" ]]; then
# ...
# fi
#
Lab 03 — API Compatibility
Phase: 04 — ASF Governance and Release Engineering
Difficulty: ⭐⭐⭐⭐☆ | Estimated Time: 2–3 hours
Primary Artifact: Multi-module Maven project producing a japicmp compatibility report
Verify: mvn install from parent directory fails (japicmp detects binary incompatibilities)
Goal
A library maintainer has just released DataProcessor v2 with breaking changes, and you need to catch them before shipping. You will:
- Build and install a v1 and v2 API module to your local Maven repository
- Run
japicmp(Java API Compatibility Checker) to compare the two - Interpret the compatibility report and classify each change
- Fill out the compatibility matrix below
This is exactly the process Apache libraries like Commons Lang, Jackson, and Spark run before every release.
Project Structure
lab-03-api-compatibility/
pom.xml ← parent: declares api-v1, api-v2, compat-check modules
api-v1/
pom.xml
src/main/java/org/example/compat/
DataProcessor.java ← v1 stable API
BatchConfig.java ← v1 mutable config
api-v2/
pom.xml
src/main/java/org/example/compat/
DataProcessor.java ← v2 with BREAKING changes
BatchConfig.java ← v2 immutable (BREAKING constructor change)
compat-check/
pom.xml ← uses japicmp-maven-plugin to compare v1 vs v2
Steps
Step 1 — Inspect the v1 API
Read api-v1/src/main/java/org/example/compat/DataProcessor.java and BatchConfig.java.
Before looking at v2, write down:
- The method signatures of
DataProcessor - The constructor signature of
BatchConfig - Whether
BatchConfigis mutable or immutable
This is what your library's users have compiled their code against.
Step 2 — Read the v2 Changes
Read the v2 source files. Annotate each change in the table below (before running japicmp):
| Method / Constructor | v1 Signature | v2 Signature | Your prediction: Breaking? |
|---|---|---|---|
DataProcessor.process | |||
DataProcessor.setConfig | |||
DataProcessor.withConfig | |||
DataProcessor.isConfigured | |||
BatchConfig(...) | |||
BatchConfig.setStrict |
Step 3 — Run japicmp
# From the lab-03-api-compatibility/ directory
mvn install
The build will intentionally fail — japicmp is configured with breakBuildOnBinaryIncompatibleModifications=true. Read the build output carefully.
Find the japicmp HTML report:
# The report is in the compat-check module's target directory
open compat-check/target/japicmp/japicmp.html # macOS
xdg-open compat-check/target/japicmp/japicmp.html # Linux
# Or read the XML
cat compat-check/target/japicmp/japicmp.xml
Step 4 — Fill Out the Compatibility Matrix
After running japicmp, complete this table:
| Change | japicmp Classification | Binary Incompatible? | Source Incompatible? | Caller Impact |
|---|---|---|---|---|
process(String) removed | NoSuchMethodError at runtime | |||
process(String, boolean) added | ||||
setConfig(BatchConfig) removed | ||||
withConfig(BatchConfig) added | ||||
isConfigured() added | ||||
BatchConfig(int) removed | ||||
BatchConfig(int, boolean) added | ||||
BatchConfig.setStrict removed |
Classifications you will see in the report:
METHOD_REMOVED— binary and source incompatibleMETHOD_ADDED— non-breaking (cannot break existing callers)METHOD_NOW_ABSTRACT— binary incompatibleCONSTRUCTOR_REMOVED— binary and source incompatible
Step 5 — Fix the Version Number
The breaking changes between v1 and v2 require a major version bump under semantic versioning.
- Open
api-v2/pom.xmland change<version>1.0.0</version>to<version>2.0.0</version> - Open
compat-check/pom.xmland update thenewVersiondependency from1.0.0to2.0.0 - Run
mvn installagain — the build still fails (binary incompatibilities are there regardless of version) - Add
<breakBuildOnBinaryIncompatibleModifications>false</breakBuildOnBinaryIncompatibleModifications>tocompat-check/pom.xmland run again
Discuss: Why does setting breakBuildOnBinaryIncompatibleModifications=false not make the problem go away? What does it just change?
Step 6 — Write a Migration Guide
In migration-guide.md (create it in this directory), write a short migration guide from DataProcessor v1 to v2. For each breaking change:
- State what code breaks
- Show the before/after code snippet for the caller
- State the minimum Maven dependency change needed
Verification
# Run from lab-03-api-compatibility/
mvn install 2>&1 | grep -E "BINARY_INCOMPATIBLE|BUILD FAILURE|japicmp"
Expected to see lines like:
[WARNING] BINARY INCOMPATIBLE: ...DataProcessor.process(java.lang.String) has been removed
[WARNING] BINARY INCOMPATIBLE: ...DataProcessor.setConfig(...) has been removed
[INFO] BUILD FAILURE
Key Insight
japicmp gives you a machine-readable, auditable diff of your public API. Running it in CI means:
- Developers get immediate feedback when they accidentally break binary compatibility
- PMC members can attach the japicmp report to the
[VOTE]email as evidence that compatibility was checked - Users can look at the report in a release's GitHub release page to understand exactly what changed
Apache projects that do this well: Apache HttpComponents, Apache Commons Collections, Apache Kafka.
Source Files
pom.xml (parent)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!--
Parent POM for the API compatibility lab.
Module build order (determined by Maven dependency resolution):
1. api-v1 — builds and installs data-processor-v1:1.0.0 to local repo
2. api-v2 — builds and installs data-processor-v2:1.0.0 to local repo
3. compat-check — depends on both, runs japicmp to compare them
Run from this directory:
mvn install
Expected result: BUILD FAILURE — japicmp detects binary incompatibilities
between api-v1 and api-v2 and breaks the build as configured.
-->
<groupId>org.example.compat</groupId>
<artifactId>api-compat-lab</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<name>API Compatibility Lab — Parent</name>
<modules>
<module>api-v1</module>
<module>api-v2</module>
<module>compat-check</module>
</modules>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
api-v1/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.example.compat</groupId>
<artifactId>api-compat-lab</artifactId>
<version>1.0.0</version>
</parent>
<!--
data-processor-v1 — the stable, released API.
This is what library users have compiled their code against.
japicmp will use this JAR as the "old version" baseline.
-->
<artifactId>data-processor-v1</artifactId>
<version>1.0.0</version>
<name>Data Processor v1</name>
</project>
api-v1 — DataProcessor.java
package org.example.compat;
/**
* Processes a stream of string records according to a {@link BatchConfig}.
*
* <p>
* <strong>This is the v1 stable API.</strong> This is what user
* have compiled
* their application code against. Any incompatible change to this class in v2
* is
* a binary compatibility regression.
*
* >Example usage (caller code compiled against v1):
*
* @code
* hConfig config = new BatchConfig(100);
* config.setStrict(true);
*
*
* Processor processor = new DataProcessor();
* processor.setConfig(config);
* processor.process("record-001");
* S
* ystem.out.println(processor.getResult());
* }</pre>
*
*
pu c class DataProcessor {
*
private BatchConfig config;
pr
* ivate String lastResult;
*
ublic DataProcessor() {
/**
* Process a single input record.
*
* <p>This method signature — {@code process(String)} — is what callers have compiled
* against. Removing it or changing its parameter list in v2 will produce
* {@code NoSuchMethodError} at runtime for any code compiled against v1.
*
* @param input the record to process; must not be null
*/
public void process(String input) {
if (input == null) {
throw new IllegalArgumentException("input must not be null");
}
// Simulate processing — in a real library this would do meaningful work.
boolean strict = (config != null && config.isStrict());
this.lastResult = "processed[strict=" + strict + "]: " + input;
}
/*
* Returns the result of the last {@link #process(String)} call.
*
* @return the last processed result, or {@code null} if {@link #process(String)}
* has not been called yet
*/
public
* String getResult() {
return lastResult;
}
/**
* Configure this processor.
*
*
* <p>This method must be called before {@link #process(String)} if the caller
* wants non-default batch behaviour.
*
* @param config the configuration to apply; may be null to reset to defaults
*/
public void setConfig(BatchConfig config) {
this.config = config;
}
/**
* Returns the current configuration, or {@code null} if none has been set.
*/
public BatchConfig getConfig() {
return config;
}
*
}
//
*
*
*
*
*
api-v1 — BatchConfig.java
package org.example.compat;
/**
* Configuration for a {@link DataProcessor}.
*
* <p><strong>This is the v1 stable API.</strong> The class is mutable: after construction
* the caller can call {@link #setStrict(boolean)} to toggle strict mode.
*
* <p>Example usage (caller code compiled against v1):
* <pre>{@code
* BatchConfig config = new BatchConfig(100); // single-arg constructor
* config.setStrict(true); // mutable setter
* }</pre>
*/
public class BatchConfig {
private final int batchSize;
private boolean strict;
/**
* Creates a BatchConfig with the given batch size.
*
* <p>This is the only constructor in v1. Any caller that instantiates
* {@code BatchConfig} via this signature ({@code new BatchConfig(int)}) will get
* {@code NoSuchMethodError} at runtime if v2 removes this constructor.
*
* @param batchSize number of records to process per batch; must be positive
*/
public BatchConfig(int batchSize) {
if (batchSize <= 0) {
throw new IllegalArgumentException("batchSize must be positive, was: " + batchSize);
}
this.batchSize = batchSize;
this.strict = false; // default: lenient
}
/**
* Returns the batch size.
*/
public int getBatchSize() {
return batchSize;
}
/**
* Returns whether strict mode is enabled.
*/
public boolean isStrict() {
return strict;
}
/**
* Enables or disables strict mode.
*
* <p>This mutable setter is removed in v2 (the class becomes immutable).
* Any caller that calls this method will get {@code NoSuchMethodError} at runtime.
*
* @param strict {@code true} to throw on any parse error; {@code false} to skip invalid records
*/
public void setStrict(boolean strict) {
this.strict = strict;
}
}
api-v2/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.example.compat</groupId>
<artifactId>api-compat-lab</artifactId>
<version>1.0.0</version>
</parent>
<!--
data-processor-v2 — the new version with BREAKING changes.
This JAR is intentionally binary-incompatible with data-processor-v1.
japicmp will use this as the "new version" and report all regressions.
In a real project, if this were a minor release (1.1.0 instead of 2.0.0),
these changes would be blocked by the japicmp build failure you are about
to trigger. That is exactly the point: catch regressions before they ship.
-->
<artifactId>data-processor-v2</artifactId>
<version>1.0.0</version>
<name>Data Processor v2</name>
</project>
api-v2 — DataProcessor.java
package org.example.compat;
/**
* Processes a stream of string records according to a {@link BatchConfig}.
*
* <p>
* <strong>This is the v2 API — it contains intentional BREAKING
* changes.</strong>
*
* <p>
* Breaking changes introduced in this version:
* <ol>
* <li>{@code process(String)} REMOVED — replaced by
* {@code process(String, boolean)}.
* Any code compiled against v1 calling {@code process(String)} will get
* {@code NoSuchMethodError} at runtime.</li>
* <li>{@code setConfig(BatchConfig)} REMOVED — replaced by fluent
* {@code withConfig}.
* Any code compiled against v1 calling {@code setConfig} will get
* {@code NoSuchMethodError} at runtime.</li>
* </ol>
*
* <p>
* Non-breaking additions in this version:
* <ul>
* <li>{@code isConfigured()} added — new method, cannot break existing
* callers.</li>
* <li>{@code withConfig(BatchConfig)} added — new method, cannot break existing
* callers.</li>
* </ul>
*
* <p>
* Correct migration path for v1 callers:
*
* <pre>{@code
* // v1 caller (breaks at runtime against v2)
* processor.setConfig(config);
* processor.process("record-001");
*
* // v2 caller (recompile required)
* processor.withConfig(config).process("record-001", false);
* }</pre>
*/
public class DataProcessor {
private BatchConfig config;
private String lastResult;
public DataProcessor() {
}
// =========================================================================
// BREAKING CHANGE 1: process(String) removed
//
// The v1 method was: public void process(String input)
// That method no longer exists. Callers compiled against v1 will get:
// java.lang.NoSuchMethodError: DataProcessor.process(Ljava/lang/String;)V
// =========================================================================
/**
* Process a single input record with an explicit strict-mode flag.
*
* <p>
* BREAKING: replaces {@code process(String)} from v1.
* The added {@code strict} parameter makes this a different method signature.
* Code compiled against v1 cannot call this method without recompilation.
*
* @param input the record to process; must not be null
* @param strict if {@code true}, throw {@link IllegalStateException} on invalid
* input;
* if {@code false}, skip silently
*/
public void process(String input, boolean strict) {
if (input == null) {
if (strict) {
throw new IllegalStateException("strict mode: null input not allowed");
}
return; // lenient: skip silently
}
this.lastResult = "processed[strict=" + strict + "]: " + input;
}
/**
* Returns the result of the last {@link #process(String, boolean)} call.
*
* @return the last processed result, or {@code null} if process has not been
* called
*/
public String getResult() {
return lastResult;
}
// =========================================================================
// BREAKING CHANGE 2: setConfig(BatchConfig) removed
//
// The v1 method was: public void setConfig(BatchConfig config)
// Callers compiled against v1 calling setConfig will get:
// java.lang.NoSuchMethodError:
// DataProcessor.setConfig(Lorg/example/compat/BatchConfig;)V
// =========================================================================
/**
* Returns a new DataProcessor with the given configuration applied.
*
* <p>
* Fluent builder pattern: replaces the v1 {@code setConfig(BatchConfig)} void
* setter.
* This is a non-breaking addition — existing callers who never called
* {@code setConfig} are unaffected.
*
* <p>
* Note: returns {@code this} for chaining convenience (this processor is
* mutated).
* A fully immutable design would return a new instance.
*
* @param config the configuration to apply
* @return {@code this}, for method chaining
*/
public DataProcessor withConfig(BatchConfig config) {
this.config = config;
return this;
}
/**
* Returns the current configuration, or {@code null} if none has been set.
*/
public BatchConfig getConfig() {
return config;
}
// =========================================================================
// NON-BREAKING ADDITION: isConfigured()
//
// New method — cannot break existing callers. Callers compiled against v1
// simply cannot call this method (compile error if they try).
// =========================================================================
/**
* Returns {@code true} if this processor has been configured via
* {@link #withConfig}.
*/
public boolean isConfigured() {
return config != null;
}
}
api-v2 — BatchConfig.java
package org.example.compat;
/**
* Configuration for a {@link DataProcessor}.
*
* <p>
* <strong>This is the v2 API — it contains intentional BREAKING
* changes.</strong>
*
* <p>
* Breaking changes introduced in this version:
* <ol>
* <li>{@code BatchConfig(int)} REMOVED — the single-argument constructor is
* gone.
* Any code calling {@code new BatchConfig(100)} will get
* {@code NoSuchMethodError} at runtime.</li>
* <li>{@code setStrict(boolean)} REMOVED — the class is now immutable.
* Any code calling {@code config.setStrict(true)} will get
* {@code NoSuchMethodError} at runtime.</li>
* </ol>
*
* <p>
* Correct migration path for v1 callers:
*
* <pre>{@code
* // v1 caller (breaks at runtime against v2)
* BatchConfig config = new BatchConfig(100);
* config.setStrict(true);
*
* // v2 caller (recompile required)
* BatchConfig config = new BatchConfig(100, true);
* }</pre>
*/
public class BatchConfig {
private final int batchSize;
private final boolean strict;
// =========================================================================
// BREAKING CHANGE 1: BatchConfig(int) removed
//
// v1 constructor: public BatchConfig(int batchSize)
// v2 requires both batchSize AND strict at construction time.
// Callers compiled against v1 calling new BatchConfig(100) will get:
// java.lang.NoSuchMethodError: BatchConfig.<init>(I)V
// =========================================================================
/**
* Creates an immutable BatchConfig.
*
* <p>
* BREAKING: replaces {@code BatchConfig(int)} from v1.
* The {@code strict} parameter is now required at construction time — the class
* is immutable and the old {@link #setStrict(boolean)} setter is gone.
*
* @param batchSize number of records per batch; must be positive
* @param strict if {@code true}, throw on any parse error;
* if {@code false}, skip invalid records silently
*/
public BatchConfig(int batchSize, boolean strict) {
if (batchSize <= 0) {
throw new IllegalArgumentException("batchSize must be positive, was: " + batchSize);
}
this.batchSize = batchSize;
this.strict = strict;
}
/**
* Returns the batch size.
*/
public int getBatchSize() {
return batchSize;
}
/**
* Returns whether strict mode is enabled.
*/
public boolean isStrict() {
return strict;
}
// =========================================================================
// BREAKING CHANGE 2: setStrict(boolean) removed
//
// v1 method: public void setStrict(boolean strict)
// The class is now immutable — all fields are final and there are no setters.
// Callers compiled against v1 calling setStrict will get:
// java.lang.NoSuchMethodError: BatchConfig.setStrict(Z)V
// =========================================================================
}
compat-check/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.example.compat</groupId>
<artifactId>api-compat-lab</artifactId>
<version>1.0.0</version>
</parent>
<!--
compat-check — runs japicmp to compare data-processor-v1 against data-processor-v2.
This module has no source code. Its only job is to declare the japicmp plugin
execution that compares the two API JARs.
How japicmp resolves artifacts:
oldVersion: data-processor-v1:1.0.0 (must be installed in local Maven repo)
newVersion: data-processor-v2:1.0.0 (must be installed in local Maven repo)
Both are installed to the local repo when you run 'mvn install' from the parent,
because Maven builds modules in dependency order and installs each one before
moving to the next.
breakBuildOnBinaryIncompatibleModifications=true:
This setting causes the Maven build to FAIL if japicmp detects any binary
incompatibility between the old and new versions. This is how CI enforces
the compatibility contract: you cannot accidentally ship a breaking change
without the build explicitly telling you.
-->
<artifactId>compat-check</artifactId>
<version>1.0.0</version>
<name>Compatibility Check (japicmp)</name>
<!--
Declare a dependency on data-processor-v2 so Maven builds the modules in order:
api-v1 → api-v2 → compat-check
Without this, Maven might try to build compat-check before the JARs exist.
-->
<dependencies>
<dependency>
<groupId>org.example.compat</groupId>
<artifactId>data-processor-v2</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<!--
japicmp-maven-plugin: Java API Compatibility Checker.
Version 0.20.0 is current as of 2024.
Plugin documentation: https://siom79.github.io/japicmp/MavenPlugin.html
Key configuration fields:
oldVersion: the "baseline" — what users compiled their code against
newVersion: the "candidate" — what you want to release
breakBuildOnBinaryIncompatibleModifications: fail if binary compat broken
onlyModified: only report things that changed (not the whole API)
htmlOutputFile: where to write the HTML report
The comparison runs during the 'verify' phase. When you run 'mvn install',
the verify phase runs before install, so incompatibilities are caught before
the artifact is installed to the local repo.
-->
<plugin>
<groupId>com.github.siom79.japicmp</groupId>
<artifactId>japicmp-maven-plugin</artifactId>
<version>0.20.0</version>
<executions>
<execution>
<id>check-api-compatibility</id>
<phase>verify</phase>
<goals>
<goal>cmp</goal>
</goals>
<configuration>
<oldVersion>
<dependency>
<groupId>org.example.compat</groupId>
<artifactId>data-processor-v1</artifactId>
<version>1.0.0</version>
</dependency>
</oldVersion>
<newVersion>
<dependency>
<groupId>org.example.compat</groupId>
<artifactId>data-processor-v2</artifactId>
<version>1.0.0</version>
</dependency>
</newVersion>
<parameter>
<!--
BREAK THE BUILD if any binary incompatibilities are found.
This is the key setting that makes japicmp a real CI gate
rather than just a reporting tool.
Lab step 5 asks you to change this to false and observe
what happens — the report is still generated, but the build
no longer fails. This is useful during a deliberate major
version bump where breaking changes are expected and documented.
-->
<breakBuildOnBinaryIncompatibleModifications>true</breakBuildOnBinaryIncompatibleModifications>
<!--
Only report classes/methods that actually changed.
Without this, the report lists every class in the entire JAR.
-->
<onlyModified>true</onlyModified>
<!--
Include package-private and protected members in the report.
For Apache libraries, public and protected APIs are the contract.
Package-private is internal.
-->
<accessModifier>public</accessModifier>
</parameter>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Phase 05 — Code Quality and Engineering Discipline
Why this phase exists: Apache projects live for decades and are maintained by rotating casts of contributors who have never met. The only way that scales is with automated quality gates that make the bar explicit and measurable. This phase teaches the three-layer quality stack every serious Apache project runs: style enforcement, static analysis, and human review.
What You'll Learn
- How Checkstyle enforces coding conventions across a multi-contributor codebase and why that matters
- How SpotBugs finds likely correctness bugs by analyzing bytecode, not source code
- How to read and act on a SpotBugs report (confidence levels, bug categories, false positives)
- How to write a structured, useful code review comment in the Apache style
- The difference between a style nit and a correctness concern — and which one to raise first
Concepts Covered
| Concept | Where it appears |
|---|---|
Checkstyle rules: ConstantName, UnusedImports, MagicNumber, LineLength, MissingJavadocMethod | Lab 01 |
Maven Checkstyle Plugin — failsOnError, checkstyle.xml, CI integration | Lab 01 |
SpotBugs bug categories: NP_*, EI_EXPOSE_REP, MS_MUTABLE_COLLECTION, DM_NUMBER_CTOR | Lab 02 |
| SpotBugs confidence levels: HIGH / MEDIUM / LOW | Lab 02 |
| Bytecode analysis vs source analysis — what each tool sees | Labs 01–02 |
| Fibonacci hashing — correctness constraints (power-of-two N requirement) | Lab 03 |
| Code review rubric: correctness, tests, API contract, performance, clarity | Lab 03 |
| What makes a -1 review comment vs a suggestion vs a nit | Lab 03 |
Labs
| # | Lab | Primary Artifact | What Student Produces |
|---|---|---|---|
| 01 | Checkstyle Enforcement | FlawedScheduler.java + checkstyle.xml | Green mvn checkstyle:check after fixing all 5 planted violations |
| 02 | SpotBugs Static Analysis | ProductionConfig.java + pom.xml | Clean mvn spotbugs:check after fixing all 4 planted bugs |
| 03 | Code Review | CleverPartitioner.java + failing tests | Written code review; passing test suite after applying fixes |
Deliverables Checklist
-
mvn checkstyle:checkpasses with zero violations in Lab 01 -
git diffofFlawedScheduler.javashows exactly the 5 fixed violations -
mvn spotbugs:checkpasses with zero high-confidence bugs in Lab 02 - You can name the SpotBugs bug category for each of the 4 bugs by memory
-
Lab 03 failing tests pass after your fixes to
CleverPartitioner.java - Lab 03 review document identifies every issue with a clear severity label
Interview Relevance
| Question | Where the answer comes from |
|---|---|
| "What's the difference between Checkstyle and SpotBugs?" | Labs 01–02 |
| "How does SpotBugs detect null dereferences?" | Lab 02 HITCHHIKERS-GUIDE |
| "How do you handle a code review when you disagree with the reviewer?" | Lab 03 |
| "What's binary compatibility and how do code quality tools help maintain it?" | Cross-reference Phase 04 |
| "What would make you block a PR from merging vs just leave a comment?" | Lab 03 |
Warmup Guide — Code Quality & Engineering Discipline
Zero-to-expert primer for Phase 05: what "quality" means operationally in Apache codebases, how static analysis (Checkstyle, SpotBugs) actually works, and the refactoring discipline that turns "clever" code into maintainable code.
Table of Contents
- Chapter 1: Quality, Defined Operationally
- Chapter 2: The Maintainer's Time Horizon
- Chapter 3: Style Checking — Checkstyle and Why Style Is Enforced
- Chapter 4: Bug-Pattern Analysis — How SpotBugs Works
- Chapter 5: The Limits of Static Analysis
- Chapter 6: Clever vs Clear — The Refactoring Discipline
- Chapter 7: Tests as the Quality Substrate
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Quality, Defined Operationally
"Quality" in an Apache review is not aesthetics. It decomposes into checkable properties, in priority order:
- Correct under its contract — including error paths, edge inputs, and concurrent callers (the parts the happy-path test doesn't touch).
- Comprehensible to a stranger in five years — names that state intent, structure that mirrors the domain, comments that state invariants and why (never what).
- Testable and tested — a design you can't test in isolation is a design problem, not a testing problem.
- Consistent with its surroundings — local idiom beats global preference; a "better" pattern that fights the codebase costs more than it pays.
- Resource-honest — closes what it opens, bounds what it buffers, fails without leaking.
The phase's three labs map onto these: Checkstyle mechanizes (4), SpotBugs hunts (1) and (5), and the review/refactor lab trains (2) and (3).
Chapter 2: The Maintainer's Time Horizon
The economic fact that explains Apache review culture: code is read 10–100× more often than written, and maintained for 10+ years by people who never met the author. Under that horizon:
- A clever trick that saves 5 lines but needs 5 minutes of reader decode time is a net loss by the second reader.
- Every public surface (Phase 03/04) is a forever-commitment, so reviewers challenge additions hardest — the cheapest API is the one never added.
- "Works now" is the weakest property: inputs drift, scale grows, neighbors refactor. Reviewers therefore probe what happens when assumptions break, which reads as pedantry until you've maintained something for a decade.
Internalizing this horizon is what changes your code more than any rule list — it's the difference between writing for the compiler and writing for the 2036 maintainer.
Chapter 3: Style Checking — Checkstyle and Why Style Is Enforced
Mechanism: Checkstyle parses Java source into an AST and runs declarative rules
("checks") over it — line length, import order, naming patterns, Javadoc presence,
brace policy. Configured per-project (checkstyle.xml), run as a Maven plugin, usually
build-failing in CI.
Why projects enforce what seems trivial: (a) diffs stay semantic — no whitespace churn burying real changes (the Phase 12 mega-patch scenario); (b) review attention is finite — every style nit a machine catches is a correctness comment a human can make instead; (c) consistency is readability at scale — a codebase with one voice loads faster into a stranger's head. The rule of engagement you'll need as a contributor: don't argue with the config in a patch; match it. Style debates go to the dev list, once, with a config change proposal.
Reading a violation productively (Lab 01): the fix is usually mechanical, but each
rule encodes a story — VisibilityModifier exists because public mutable fields became
un-evolvable API surface; MissingSwitchDefault because a new enum constant silently
fell through someone's switch in production.
Chapter 4: Bug-Pattern Analysis — How SpotBugs Works
Mechanism (different layer than Checkstyle — know the distinction): SpotBugs analyzes compiled bytecode, not source. It runs dataflow analyses over methods — null-ness lattices, type states, resource open/close tracking — and matches ~400 known bug patterns, each with an id, category, and confidence. Because it sees bytecode, it catches what generated code and compiler sugar hide, but it loses source-level intent (hence false positives).
The pattern families worth knowing cold (Lab 02 plants several):
- NP_: null-pointer paths — a branch where a value proven-nullable is dereferenced.
- RV_: ignored return values —
file.delete(),BigDecimal.addresults dropped. - EI_/EI2_: exposing internal mutable state (returning the array field itself).
- IS_/MT_: inconsistent synchronization — a field guarded by a lock on 9 of 10 accesses (the 10th is the bug).
- DM_: dubious method use —
new String(),==on boxed types, default-encoding I/O. - RCN_: redundant null checks — often evidence of author confusion about which values can be null; the finding is a prompt to fix the contract, not delete the check.
Triage discipline: every finding gets one of three dispositions — fix, suppress
with a written justification (@SuppressFBWarnings(justification=...)), or refactor
so the pattern can't occur. Silent suppression is the anti-pattern; an unjustified
suppression file is where bugs go to hibernate.
Chapter 5: The Limits of Static Analysis
What the tools cannot see — i.e., what human review must cover (the bridge to Phase 06):
- Contract correctness: the code does X flawlessly; the caller needed Y.
- Concurrency beyond patterns: SpotBugs catches inconsistent locking, not a wrong happens-before design or a livelock.
- Performance: an O(n²) loop is style-clean and bug-pattern-free (Phase 08's JMH exists because of this gap).
- Blast radius: whether a change breaks a compat promise (japicmp covers signatures; behavior is human territory).
- Design altitude: whether this abstraction should exist at all.
The mature workflow stacks the layers: formatter → Checkstyle → SpotBugs/ErrorProne → tests → human review, each catching what the previous can't, the human spending attention only where machines are blind.
Chapter 6: Clever vs Clear — The Refactoring Discipline
Lab 03's subject. "Clever" code optimizes for the writer (density, tricks, implicit invariants); maintainable code optimizes for the reader. The refactor procedure that preserves correctness while transferring intent:
- Characterize first: write tests pinning current behavior — including the weird edges (negative inputs, boundary sizes) — before touching anything. For a bit-twiddled partitioner: property tests against a naive reference implementation over random inputs.
- Name the invariants the trick depends on (power-of-two table size, non-negative hash) — as assertions or checked preconditions, so violation fails loudly instead of corrupting silently.
- Extract and name the sub-steps:
(h ^ (h >>> 16))becomesspreadHashBits(h)with a comment citing why (high-bit entropy folding, à la HashMap) — the trick survives, legibly. - Keep the performance if it was the point: benchmark before/after (JMH, Phase 08); a refactor that silently costs 15% on a hot path is a regression with good manners.
- Decide what to keep clever: hot-path tricks earn their density if fenced by tests + named invariants + a reference implementation. That triple is the difference between clever and reckless — and the model answer in interview-prep/04, Scenario 1.
Chapter 7: Tests as the Quality Substrate
The test-shaped habits Apache reviewers check for reflexively:
- A failing test exists for every bug fix — proves you fixed the right thing and pins it forever (Phase 12 Ch. 1's rule).
- Tests document contracts: test names read as specification sentences
(
returnsEmptyListWhenPartitionCountIsZero); a stranger learns the class from its test file first (Chapter 1's reading order). - Edge-first: empty, one, many, max, null, concurrent — the five-minute checklist that catches more than an hour of happy-path elaboration.
- Deterministic or quarantined: a flaky test erodes the whole suite's authority (the Phase 04 RC-sinking scenario); time, randomness, and ports get injected/seeded.
- Fast enough to run always: suites that take an hour stop being run; speed is a correctness feature.
Lab Walkthrough Guidance
Order: Lab 01 (Checkstyle) → Lab 02 (SpotBugs) → Lab 03 (review/refactor) — mechanized style, then mechanized bugs, then the human layer.
- Lab 01: run the check, fix every violation, but for three of them write the one-line "story" of why the rule exists (Chapter 3's habit). Note which fixes changed behavior risk vs pure form.
- Lab 02: triage every finding with the three-disposition discipline; at least one should be argued as a justified suppression and one refactored-away. Verify fixes with a test where the pattern was a real bug.
- Lab 03: follow Chapter 6's procedure in order — characterization tests first (the lab's hidden grading axis), then the rename/extract refactor, then the benchmark showing performance held.
Success Criteria
You are ready for Phase 06 when you can, from memory:
- Recite the five operational quality properties in priority order.
- Explain the source-AST vs bytecode-dataflow distinction and one consequence of each.
- Name five SpotBugs pattern families with a one-line example bug each.
- List four things static analysis cannot catch (and which layer catches each).
- Execute the five-step clever→clear refactor procedure, and state when cleverness is kept.
Interview Q&A
Q: Your team wants to turn off Checkstyle because it "slows them down." Respond. Separate the costs: if the friction is learning the config, add a formatter that auto-fixes (spotless) — the check becomes free. If it's disagreement with rules, change the config once, by consensus, not per-PR. What we don't do is hand style back to human reviewers — that's paying senior-engineer attention for machine work and re-introducing diff churn. The check's real product is that reviews discuss correctness.
Q: Static analysis is clean but you suspect a concurrency bug. What do you actually do? Tools catch pattern-shaped races only. I'd map the shared state and its guard story (which lock protects which fields — write it down; gaps appear immediately), check publication points (constructor escape, non-final fields crossing threads), then write a stress test with many threads + assertions on invariants (and run with thread-sanitizer class tooling/jcstress for the memory-model cases). The deliverable is the documented guard discipline — undocumented locking is the bug factory.
Q: When is it right to ship the "clever" version? When the hot path measurably needs it (benchmark in hand), and it ships fenced: a naive reference implementation kept for differential testing, property tests over random inputs, named/asserted invariants, and a comment citing the benchmark. Cleverness with that escort is engineering; without it, it's a trap for the 2036 maintainer.
References
- Checkstyle documentation — browse the checks, read the rationale fields
- SpotBugs bug descriptions — the pattern catalog
- Error Prone — Google's compile-time equivalent, worth knowing
- Effective Java (Bloch, 3rd ed.) — items 15–17 (minimize accessibility/mutability) are the EI_ patterns' theory
- Java Concurrency in Practice (Goetz) — ch. 2–4 for the guard-discipline vocabulary
- Fowler, Refactoring (2nd ed.) — extract-function/rename as behavior-preserving moves
- Google Engineering Practices: Code Review Developer Guide — the industry mirror of Phase 06
🛸 Hitchhiker's Guide — Phase 05: Code Quality and Engineering Discipline
Read this if: You write code that passes tests but gets rejected in code review — or you've been on a project where every PR battle is about style rather than correctness. By the end you should understand the three-layer automated quality stack every serious Apache project runs, know what each layer catches (and what it misses), and be able to write a structured code review comment that will actually get addressed instead of ignored.
0. The 30-second mental model
Three quality layers. Each catches a different class of problem:
Layer 1 — Style (Checkstyle)
"Does the code look consistent across all contributors?"
Catches: wrong names, long lines, missing Javadoc, magic numbers, unused imports
Misses: anything about what the code does
Layer 2 — Static Analysis (SpotBugs)
"Does the bytecode have recognizable bug patterns?"
Catches: null dereferences, resource leaks, mutable state exposure, deprecated APIs
Misses: logic bugs, wrong algorithms, performance regressions
Layer 3 — Human Review (dev@ mailing list / GitHub review)
"Is the design sound? Is the API contract right? Are edge cases handled?"
Catches: everything layers 1-2 miss
Misses: nothing — but it's slow and expensive
All three complement each other. A green Checkstyle + SpotBugs build doesn't mean the code is correct. A thorough human review doesn't mean the formatting is consistent. Run all three.
1. Checkstyle
1.1 What it does
Checkstyle parses the Java source into an AST (Abstract Syntax Tree) and walks each node, checking it against a set of configured rules. It runs during the Maven build via maven-checkstyle-plugin.
The configuration file (checkstyle.xml) specifies which rules to enforce and with what parameters. Projects either:
- Extend a well-known style (Google Java Style, Sun Coding Conventions)
- Write project-specific rules, often derived from one of the above
Checkstyle fails the build when failsOnError=true (the default for mvn checkstyle:check).
1.2 Rules You Must Know
| Rule | What it catches | Example violation |
|---|---|---|
ConstantName | static final fields not in UPPER_SNAKE_CASE | static final int maxRetries = 3 |
UnusedImports | Imported types not referenced in the file | import java.util.ArrayList; never used |
MagicNumber | Numeric literals other than -1, 0, 1, 2 | Thread.sleep(5000) |
MissingJavadocMethod | Public methods without a Javadoc comment | public void submit(Runnable task) with no /** |
LineLength | Lines longer than the configured max (typically 100) | A log message with 3 concatenations on one line |
VisibilityModifier | Non-private instance fields | protected String name; in a concrete class |
ModifierOrder | Modifiers in wrong order | static public final instead of public static final |
1.3 Running in Maven
# Check only — does not compile, just style checks
mvn checkstyle:check
# Check during the verify phase (standard CI mode)
mvn verify
# Check and generate an HTML report (does not fail build)
mvn checkstyle:checkstyle
# Report at: target/reports/checkstyle.html
1.4 Suppressing False Positives
Two mechanisms:
Inline suppression (for one-off exceptions):
// CHECKSTYLE:OFF MagicNumber
private static final int FIBONACCI_MULTIPLIER = 0x9e3779b97;
// CHECKSTYLE:ON MagicNumber
Suppression XML (for systematic exceptions, e.g., test classes):
<!-- suppressions.xml -->
<suppressions>
<suppress checks="MagicNumber" files=".*Test\.java"/>
</suppressions>
Use suppressions sparingly. A rule with 100 suppressions is not enforced — it's just noise.
2. SpotBugs
2.1 How it works
SpotBugs analyzes compiled bytecode (.class files), not source code. It applies ~400 bug patterns — each is a recognizable sequence of bytecode instructions or a data-flow property that typically indicates a bug.
Because it analyzes bytecode, SpotBugs can catch bugs that exist after inlining, optimization, and type erasure — things Checkstyle never sees.
2.2 Bug Categories
| Category prefix | Meaning | Examples |
|---|---|---|
NP_ | Null pointer dereference | NP_NULL_ON_SOME_PATH, NP_ALWAYS_NULL |
EI_ | Expose internal representation | EI_EXPOSE_REP, EI_EXPOSE_REP2 |
MS_ | Mutable static field | MS_MUTABLE_COLLECTION, MS_SHOULD_BE_FINAL |
DM_ | Deprecated method / bad idiom | DM_NUMBER_CTOR, DM_STRING_CTOR |
RCN_ | Redundant null check | RCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE |
OS_ | Open stream / resource leak | OS_OPEN_STREAM, OS_OPEN_STREAM_EXCEPTION_PATH |
IS2_ | Inconsistent synchronization | IS2_INCONSISTENT_SYNC |
2.3 Confidence Levels
| Level | Meaning | Should you fix it? |
|---|---|---|
| HIGH | Very likely a real bug | Always |
| MEDIUM | Possibly a bug, but context needed | Usually yes |
| LOW | Speculative; many false positives | Review, but filter in CI |
Apache projects typically set CI to fail only on HIGH confidence bugs. MEDIUM is reported but not blocking. LOW is usually suppressed.
2.4 Running SpotBugs in Maven
# Check — fails build on threshold violations
mvn spotbugs:check
# Generate HTML report without failing
mvn spotbugs:spotbugs
# Report: target/spotbugsXml.xml + target/spotbugs.html (with spotbugs-maven-plugin)
# Launch the SpotBugs GUI viewer
mvn spotbugs:gui
2.5 The Four Bugs You Must Know
NP_NULL_ON_SOME_PATH — a code path exists where a method is called on a potentially-null reference:
// BUGGY: Map.get() returns null when the key is absent
return properties.get(key).trim(); // NPE if key not present
// FIX:
String value = properties.get(key);
return (value != null) ? value.trim() : null;
EI_EXPOSE_REP — a getter returns a direct reference to an internal mutable object:
// BUGGY: caller can mutate the internal map
public Map<String, String> getProperties() { return properties; }
// FIX:
public Map<String, String> getProperties() {
return Collections.unmodifiableMap(properties);
}
MS_MUTABLE_COLLECTION — a static final field holds a mutable collection:
// BUGGY: 'final' prevents reassignment, but the list can still be mutated
public static final List<String> REQUIRED_KEYS = new ArrayList<>();
// FIX:
public static final List<String> REQUIRED_KEYS = List.of("host", "port", "db.name");
DM_NUMBER_CTOR — using a deprecated new Integer(int) constructor:
// BUGGY: deprecated since Java 9, removed in Java 17
Integer boxed = new Integer(value);
// FIX:
Integer boxed = Integer.valueOf(value); // uses cached instances for -128..127
3. Code Review in Apache Projects
3.1 The Review Workflow
Apache projects do code review on GitHub Pull Requests and (for controversial changes) on the dev@ mailing list. The workflow:
- Contributor opens a PR with a JIRA issue number in the title
- Committers review: automated CI runs first, then humans review after CI is green
- Reviewer leaves comments — questions, suggestions, required fixes
- Contributor addresses each comment with either a fix or a reasoned disagreement
- A committer with write access merges when they're satisfied (often signaled by "+1")
3.2 What Makes a Good Review
A good review is:
- Specific: point to the exact line and explain the concern
- Actionable: "consider using
Collections.unmodifiableMap" not "this is bad" - Prioritized: distinguish blockers (correctness, API contract) from suggestions (readability) from nits (style)
- Respectful: the tone is collegial, not dismissive — the contributor wrote this on their own time
A bad review is:
- "This is wrong" without explaining why
- Demanding a full rewrite because the reviewer would have designed it differently
- Mixing 10 style nits with 1 real bug — the author can't tell which matters
3.3 The Review Rubric
Use this checklist when reviewing a patch:
| Category | Questions to ask |
|---|---|
| Correctness | Does this handle null inputs? What happens at the boundary (n=0, empty list)? Does this race condition exist under concurrent access? |
| Tests | Are there tests? Do they cover the failure case? Does the test actually exercise the code path it claims to? |
| API contract | Does this change break binary or source compatibility? Is the Javadoc accurate? Is the method name precise? |
| Performance | Is there a better data structure for this access pattern? Is there an unnecessary copy or allocation in the hot path? |
| Clarity | What does the comment "trust me" mean? Can a new contributor understand this in 6 months without the original author? |
3.4 Severity Labels
BLOCKER — prevents merge; correctness or API regression
MAJOR — should be fixed before merge; may not be correctness but is important
MINOR — should be addressed; not urgent
NIT — trivial style, can be ignored or fixed in a follow-up
QUESTION — asking for clarification, not requesting a change
4. What Automated Tools Miss
This is what you say in interviews:
- Logic errors: Checkstyle and SpotBugs don't know if your algorithm is wrong
- Missing tests: SpotBugs won't tell you the null path isn't tested
- Wrong abstraction: The API might work but be impossible to extend
- Performance regression: SpotBugs can flag
O(n²)patterns in some cases, but not always - Concurrency bugs: SpotBugs catches some, but subtle races require manual analysis (or tools like ThreadSanitizer)
The conclusion: automated tools shrink the review surface so humans can focus on what matters.
5. Interview Questions — Concepts You Must Explain Out Loud
| Topic | One-line answer |
|---|---|
| What is the difference between Checkstyle and SpotBugs? | Checkstyle enforces source-level style conventions by walking the AST; SpotBugs detects likely bugs by analyzing compiled bytecode using dataflow patterns. They complement each other — neither catches what the other does. |
| How does SpotBugs detect null dereferences? | It performs interprocedural dataflow analysis on bytecode, tracking which references might be null at each program point and flagging any dereference where the null path is possible. |
What is EI_EXPOSE_REP and how do you fix it? | The class returns a direct reference to an internal mutable field. A caller can then mutate the object directly, bypassing any invariants the class maintains. Fix: return Collections.unmodifiableMap() or a defensive copy. |
Why is public static final List<String> FOO = new ArrayList<>() a bug? | The final keyword only prevents reassignment of the variable — it does not make the list immutable. Any caller can still call FOO.add(...) or FOO.clear(). Use List.of() or Collections.unmodifiableList(). |
| What makes a -1 code review comment? | The change introduces a correctness regression, breaks the API contract (binary or source incompatibility), has no tests for the changed behavior, or violates a legal/license requirement. Style preferences are suggestions, not blockers. |
| How do you handle 50 Checkstyle violations in a legacy file you're modifying? | Fix only the violations in the lines you're changing. Create a JIRA issue for the rest. Adding a suppressions.xml entry for the file temporarily is acceptable if the team agrees — but every suppression should have a ticket to remove it. |
| What's the difference between HIGH and MEDIUM confidence in SpotBugs? | HIGH means SpotBugs is confident this is a real bug based on the bytecode pattern. MEDIUM means the pattern matches but context might explain it away (false positive). CI typically fails only on HIGH; MEDIUM is reported but not blocking. |
How does Checkstyle MagicNumber help maintainability? | Named constants explain intent. Thread.sleep(5000) tells you nothing; Thread.sleep(RETRY_DELAY_MS) tells you exactly what the number means and makes it trivial to change the delay in one place. |
6. References
- Checkstyle — Available Checks
- maven-checkstyle-plugin
- SpotBugs Bug Descriptions
- spotbugs-maven-plugin
- Google Java Style Guide
- Apache Spark Code Review Guidelines — real-world Apache review culture
- Apache Kafka Contributing Guide — review checklist used by a production Apache project
Lab 01 — Checkstyle Enforcement
Phase: 05 — Code Quality and Engineering Discipline
Difficulty: ⭐⭐☆☆☆ | Estimated Time: 1.5–2 hours
Primary Artifact: FlawedScheduler.java with 5 planted Checkstyle violations
Verify: mvn checkstyle:check reports 0 violations after your fixes
Goal
FlawedScheduler.java is a task scheduler that compiles and runs correctly — but it has 5 Checkstyle violations that would be rejected by Apache CI. Your job is to:
- Run
mvn checkstyle:checkand read the violation report - Identify each violation from the report (rule name, line number, description)
- Fix each one so the build goes green
- Understand why each rule exists
Setup
cd phase-05-code-quality/lab-01-checkstyle
mvn checkstyle:check
The build will fail immediately. Read the output — it lists each violation with:
- The rule name (e.g.,
ConstantName) - The file and line number
- A short description of the violation
The 5 Violations
Find and fix each one. Do not look at the answers below until you have found them yourself in the report.
Violation list (expand after attempting)
| # | Rule | Line | Description | Fix |
|---|---|---|---|---|
| 1 | UnusedImports | top of file | ArrayList is imported but never referenced | Remove the import |
| 2 | ConstantName | maxRetries field | static final fields must be UPPER_SNAKE_CASE | Rename to MAX_RETRIES (update all references) |
| 3 | MagicNumber | Thread.sleep(5000) | 5000 is a magic number | Extract to private static final long RETRY_DELAY_MS = 5000L; |
| 4 | MissingJavadocMethod | submit() | public method lacks Javadoc | Add /** ... */ with @param task |
| 5 | LineLength | log warning in runAll() | line exceeds 100 characters | Split the string concatenation across multiple lines |
Steps
Step 1 — Baseline: read the report
mvn checkstyle:check 2>&1 | grep '\[ERROR\]'
Count the violations. There should be exactly 5. If you see more or fewer, check that you haven't already modified the file.
Step 2 — Fix one violation at a time
Fix them in this order (each fix is isolated):
- Unused import — easiest, just delete the line
- Constant name — rename field + update all references in the same file
- Magic number — extract to a constant above the field declarations
- Missing Javadoc — add a complete Javadoc block
- Line too long — restructure the
LOG.warning(...)call
After each fix, run mvn checkstyle:check to confirm the violation count decreases by 1.
Step 3 — Green build
mvn checkstyle:check && echo "ALL CLEAN"
This must print ALL CLEAN.
Step 4 — Confirm the class still compiles and the scheduler still works
mvn test
There are no unit tests in this module (the scheduler is tested manually), but the build must succeed with no compilation errors.
Step 5 — Reflection
Answer these questions in a comment at the top of your fixed FlawedScheduler.java:
- What would happen if you left
maxRetriesas a magic number3in the sleep condition? - Why does Checkstyle flag
ArrayListwhen it compiles fine without being used? - What's the downside of the Checkstyle
LineLengthrule being set to 80 instead of 100?
Verification
# Zero Checkstyle violations
mvn checkstyle:check
echo "Exit code: $?" # must be 0
# Code still compiles
mvn compile
Source Files
checkstyle.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE module PUBLIC "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" "https://checkstyle.org/dtds/configuration_1_3.dtd">
<!--
Checkstyle configuration for Lab 01 — Code Quality.
This file deliberately enables exactly the rules needed to catch the 5
violations planted in FlawedScheduler.java. It is a subset of what a real
Apache project (e.g., Spark, Kafka) would enforce.
To generate a report without failing the build:
mvn checkstyle:checkstyle
open target/reports/checkstyle.html
To fail the build on violations (CI mode):
mvn checkstyle:check
-->
<module name="Checker">
<property name="charset" value="UTF-8" />
<!--
severity=error means violations are reported as errors (not warnings)
and will fail the build when maven-checkstyle-plugin's failsOnError=true.
-->
<property name="severity" value="error" />
<!--
Rule 5: LineLength
Maximum line length is 100 characters.
Placed under Checker (not TreeWalker) because it is a file-level check
that doesn't require AST parsing.
Why 100 and not 80? Modern monitors are wide enough that 100 columns
avoids unnecessary line wrapping without going so far that lines are
unreadable on a split-screen laptop.
-->
<module name="LineLength">
<property name="max" value="100" />
<!-- Ignore lines that are only a URL (can't be wrapped sensibly) -->
<property name="ignorePattern" value="^ *\* *https?://" />
</module>
<module name="TreeWalker">
<!--
Rule 1: UnusedImports
Flags any import statement whose type is never referenced in the file.
Why it matters: unused imports are a readability smell — they suggest the
code was copy-pasted or a refactor left orphaned imports behind. They also
cause spurious merge conflicts in files that many contributors touch.
-->
<module name="UnusedImports" />
<!--
Rule 2: ConstantName
static final fields (constants) must match UPPER_SNAKE_CASE.
Default pattern: ^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$
Violating example: static final int maxRetries = 3;
Correct example: static final int MAX_RETRIES = 3;
Why it matters: UPPER_SNAKE_CASE is the universal Java constant convention.
A lowercase name for a static final field looks like a mutable field at a
glance, making it harder to reason about mutability.
-->
<module name="ConstantName" />
<!--
Rule 3: MagicNumber
Numeric literals other than the listed "ignoreNumbers" are flagged.
Ignored by default: -1, 0, 1, 2 (universal, self-documenting)
We also ignore 100 because it appears in the queue capacity constructor
and is self-documenting in context.
Violating example: Thread.sleep(5000)
Correct example: Thread.sleep(RETRY_DELAY_MS)
Why it matters: a magic number tells you the value but not the intent.
RETRY_DELAY_MS is searchable, changeable in one place, and self-documenting.
-->
<module name="MagicNumber">
<property name="ignoreNumbers" value="-1, 0, 1, 2, 100" />
<property name="ignoreAnnotation" value="true" />
</module>
<!--
Rule 4: MissingJavadocMethod
Public methods must have a Javadoc comment (/** ... */).
Scope=public means package-private, protected, and private methods
are NOT required to have Javadoc (though it is encouraged).
Why it matters: public methods are part of the library's API contract.
Without Javadoc, callers don't know what the method promises, what
null behavior looks like, or what exceptions can be thrown.
-->
<module name="MissingJavadocMethod">
<property name="scope" value="public" />
</module>
</module>
</module>
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example.quality</groupId>
<artifactId>checkstyle-lab</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Code Quality Lab 01 — Checkstyle</name>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<!--
maven-checkstyle-plugin: runs Checkstyle against the source files.
configLocation: path to checkstyle.xml, relative to the project root.
failsOnError: if true, the build fails when violations are found.
violationSeverity: minimum severity level that counts as a violation
(must match <property name="severity" value="error"/> in checkstyle.xml).
includeTestSourceDirectory: also check test sources (set false here to
focus only on main sources for this lab).
consoleOutput: print violations to the console (not just the report file).
Run: mvn checkstyle:check → fail on violations (CI mode)
mvn checkstyle:checkstyle → generate report only (no build failure)
-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.3.1</version>
<configuration>
<configLocation>checkstyle.xml</configLocation>
<failsOnError>true</failsOnError>
<violationSeverity>error</violationSeverity>
<includeTestSourceDirectory>false</includeTestSourceDirectory>
<consoleOutput>true</consoleOutput>
</configuration>
<!--
Bind checkstyle:check to the 'verify' lifecycle phase so that
running `mvn verify` automatically checks style.
This is how Apache projects integrate Checkstyle into CI.
-->
<executions>
<execution>
<id>checkstyle-verify</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
FlawedScheduler.java (before your fixes)
package org.example.quality;
// VIOLATION 1 — UnusedImports
// ArrayList is imported but never used in this file.
// Fix: delete this import.
import java.util.ArrayList;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.logging.Logger;
/**
* A simple retry-capable task scheduler backed by a bounded blocking queue.
*
* <p>
* Tasks submitted via {@link #submit(Runnable)} are queued and executed
* in order by {@link #runAll()}. If a task throws, it is re-queued for up
* to {@code MAX_RETRIES} attempts with a delay between each attempt.
*/
public class FlawedScheduler {
private static final Logger LOG = Logger.getLogger(FlawedScheduler.class.getName());
// VIOLATION 2 — ConstantName
// Static final fields (constants) must use UPPER_SNAKE_CASE.
// 'maxRetries' starts with a lowercase letter and uses camelCase.
// Fix: rename to MAX_RETRIES and update all references in this file.
static final int maxRetries = 3;
private final String name;
private final BlockingQueue<Runnable> queue;
/**
* Creates a scheduler with the given name and queue capacity.
*
* @param name a human-readable label used in log messages
* @param capacity maximum number of tasks that can be queued at once
*/
public FlawedScheduler(String name, int capacity) {
this.name = name;
this.queue = new LinkedBlockingQueue<>(capacity);
}
// VIOLATION 3 — MissingJavadocMethod
// This public method has no Javadoc comment.
// Fix: add /** @param task the task to enqueue; ignored if null */ above.
public void submit(Runnable task) {
if (task != null) {
queue.offer(task);
}
}
/**
* Drains the queue, executing each task in order.
*
* <p>
* If a task throws an exception, it is re-queued and retried after a delay,
* up to {@code MAX_RETRIES} times. Tasks that exceed the retry limit are
* permanently dropped with a warning log.
*
* @throws InterruptedException if the thread is interrupted while sleeping
* between retries
*/
public void runAll() throws InterruptedException {
int attempt = 0;
while (!queue.isEmpty()) {
Runnable task = queue.poll();
if (task != null) {
try {
task.run();
attempt = 0;
} catch (Exception ex) {
attempt++;
if (attempt <= maxRetries) {
// VIOLATION 4 — MagicNumber
// 5000 is a magic number. Its meaning (milliseconds? retries?) is unclear.
// Fix: extract to a named constant, e.g.:
// private static final long RETRY_DELAY_MS = 5000L;
Thread.sleep(5000);
queue.offer(task); // re-enqueue for retry
} else {
// VIOLATION 5 — LineLength
// This line is longer than 100 characters (count from 'LOG' to the closing
// ')').
// Fix: split the string concatenation across multiple lines, or use a local
// variable.
LOG.warning("Task permanently failed after " + maxRetries + " retries on scheduler '" + name
+ "': " + ex.getMessage());
attempt = 0;
}
}
}
}
}
}
Lab 02 — SpotBugs Static Analysis
Phase: 05 — Code Quality and Engineering Discipline
Difficulty: ⭐⭐⭐☆☆ | Estimated Time: 2–2.5 hours
Primary Artifact: ProductionConfig.java with 4 planted SpotBugs bugs
Verify: mvn spotbugs:check reports 0 HIGH-confidence bugs after your fixes
Goal
ProductionConfig.java is a configuration manager that compiles cleanly and passes Checkstyle — but it has 4 SpotBugs bugs that indicate real correctness and safety problems. Your job is to:
- Run
mvn spotbugs:checkand read the bug report - Look up each bug category in the SpotBugs documentation
- Understand why each pattern is a bug (not just a style issue)
- Fix each one so the build goes green
Setup
cd phase-05-code-quality/lab-02-spotbugs
mvn spotbugs:check
The build will fail. Read the output — each bug is reported with:
- The bug category code (e.g.,
NP_NULL_ON_SOME_PATH) - The class name and line number
- A one-line description
For the full HTML report with more context:
mvn spotbugs:spotbugs
open target/spotbugs.html # macOS
xdg-open target/spotbugs.html # Linux
The 4 Bugs
Find and fix each one. Do not expand the answers until you have attempted to understand the bug category from the SpotBugs description alone.
Bug list (expand after attempting)
| # | Bug Code | Where | Root Cause | Fix |
|---|---|---|---|---|
| 1 | NP_NULL_ON_SOME_PATH | get(String key) | properties.get(key) returns null when the key is absent; calling .trim() on null throws NullPointerException | Null-check before .trim(), or use getOrDefault() |
| 2 | EI_EXPOSE_REP | getProperties() | Returns a direct reference to the internal HashMap; callers can call .put() or .clear() on it | Wrap in Collections.unmodifiableMap() or return a copy |
| 3 | MS_MUTABLE_COLLECTION | REQUIRED_KEYS field | static final List is only final at the reference level — callers can still call .add() / .remove() on the list | Replace new ArrayList<>() + static {} with List.of(...) |
| 4 | DM_NUMBER_CTOR | putInt(String, int) | new Integer(value) uses a deprecated constructor; Integer.valueOf() is faster (uses cached instances) and is the idiomatic replacement | Replace new Integer(value) with Integer.valueOf(value) |
Steps
Step 1 — Read the report
mvn spotbugs:check 2>&1 | grep -E 'Bug|Priority|Type'
Note the bug code for each reported issue. You should see exactly 4 HIGH-confidence bugs.
Step 2 — Look up each bug code
For each bug code (e.g., NP_NULL_ON_SOME_PATH), look it up at:
https://spotbugs.readthedocs.io/en/latest/bugDescriptions.html
Read the description. Before looking at the fix, answer:
- What runtime failure does this cause?
- Under what inputs does it trigger?
- Is this a correctness bug, a safety bug, or a performance bug?
Step 3 — Fix each bug
Fix them in order from most dangerous to least:
NP_NULL_ON_SOME_PATH— will cause a production NPE on any missing config keyEI_EXPOSE_REP— silent data corruption when callers modify the returned mapMS_MUTABLE_COLLECTION— silent data corruption when callers modifyREQUIRED_KEYSDM_NUMBER_CTOR— deprecated API, minor performance concern
After each fix, run mvn spotbugs:check to confirm the count decreases by 1.
Step 4 — Green build
mvn spotbugs:check && echo "ALL CLEAN"
Step 5 — Design discussion
Bug #3 (MS_MUTABLE_COLLECTION) is particularly subtle because the code looks correct — the field is declared static final. Answer in a comment added to the top of ProductionConfig.java:
- What does
finalactually guarantee for a reference type? - What does
List.of(...)guarantee thatCollections.unmodifiableList(new ArrayList<>())does NOT? - If
REQUIRED_KEYSneeded to be built dynamically at class load time (thestatic {}block pattern), what would be a safe alternative toList.of()?
Verification
# Zero HIGH-confidence SpotBugs bugs
mvn spotbugs:check
echo "Exit code: $?" # must be 0
# Code still compiles
mvn compile
Source Files
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example.quality</groupId>
<artifactId>spotbugs-lab</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Code Quality Lab 02 — SpotBugs</name>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<!--
spotbugs-maven-plugin: runs SpotBugs against the compiled bytecode.
IMPORTANT: SpotBugs analyzes .class files, not .java files.
You must run `mvn compile` before `mvn spotbugs:check` (or use `mvn verify`
which runs compile → test → package → verify in order).
Goals:
mvn spotbugs:check — fail build if bugs found above threshold
mvn spotbugs:spotbugs — generate report only (no build failure)
mvn spotbugs:gui — launch the interactive SpotBugs viewer
effort: analysis depth.
max = most thorough (slowest) — use this in CI for maximum coverage
default = balanced (good for dev iteration)
min = fastest (fewer checks)
threshold: minimum confidence level that causes a build failure.
High = only HIGH confidence bugs fail the build
Medium = HIGH + MEDIUM confidence bugs fail the build
Low = all bugs fail the build (many false positives)
For this lab we use effort=max and threshold=High so that only the
4 deliberately planted HIGH-confidence bugs cause the build failure.
-->
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>4.8.3.1</version>
<configuration>
<effort>Max</effort>
<threshold>High</threshold>
<xmlOutput>true</xmlOutput>
</configuration>
<executions>
<execution>
<id>spotbugs-verify</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
ProductionConfig.java (before your fixes)
package org.example.quality;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Manages application configuration as a key-value store.
*
* <p>
* Configuration is loaded by the {@link #load(Map)} method and
* validated against {@link #REQUIRED_KEYS} to ensure all mandatory
* settings are present before the application starts.
*
* <p>
* This class has 4 SpotBugs bugs. Find and fix them.
* Run: mvn spotbugs:check
*/
public class ProductionConfig {
// BUG 3 — MS_MUTABLE_COLLECTION
// The 'final' keyword on a static field only prevents reassigning the
// reference.
// The List object itself is still mutable: callers can call
// REQUIRED_KEYS.add("evil")
// and corrupt every downstream validation.
//
// Fix: replace with List.of("host", "port", "db.name")
// which returns a truly unmodifiable list.
public static final List<String> REQUIRED_KEYS = new ArrayList<>();
static {
REQUIRED_KEYS.add("host");
REQUIRED_KEYS.add("port");
REQUIRED_KEYS.add("db.name");
}
// The internal mutable backing store.
// Note: HashMap is fine here — the bug is in how we expose it (see BUG 2).
private final Map<String, String> properties = new HashMap<>();
/**
* Loads all entries from the supplied map into the internal store.
* Validates that all required keys are present.
*
* @param source the configuration source map (must not be null)
* @throws IllegalArgumentException if any required key is missing
*/
public void load(Map<String, String> source) {
properties.putAll(source);
for (String key : REQUIRED_KEYS) {
if (!properties.containsKey(key)) {
throw new IllegalArgumentException("Missing required config key: " + key);
}
}
}
/**
* Returns the trimmed value associated with the given key.
*
* @param key the configuration key
* @return the trimmed value, or null if the key is not present
*/
public String get(String key) {
// BUG 1 — NP_NULL_ON_SOME_PATH
// Map.get() returns null when the key is absent. Calling .trim() on null
// throws NullPointerException.
//
// Fix option A: null check before calling .trim()
// String value = properties.get(key);
// return (value != null) ? value.trim() : null;
//
// Fix option B: use getOrDefault() to return null explicitly
// return properties.getOrDefault(key, null); // still needs trim check
//
// Fix option C (best): getOrDefault with a null-safe trim
// String value = properties.getOrDefault(key, null);
// return (value != null) ? value.trim() : null;
return properties.get(key).trim();
}
/**
* Returns the internal properties map.
*
* @return the properties map
*/
public Map<String, String> getProperties() {
// BUG 2 — EI_EXPOSE_REP
// Returning the internal Map directly allows the caller to mutate it:
// config.getProperties().clear(); // deletes all config!
// config.getProperties().put("host", "attacker.example.com"); // overwrite!
//
// Fix: return Collections.unmodifiableMap(properties)
// or return a defensive copy: new HashMap<>(properties)
return properties;
}
/**
* Stores an integer value for the given key, converting it to a string.
*
* @param key the configuration key
* @param value the integer value to store
*/
public void putInt(String key, int value) {
// BUG 4 — DM_NUMBER_CTOR
// 'new Integer(int)' uses the deprecated Integer constructor.
// It was deprecated in Java 9 and removed in Java 17.
// Integer.valueOf(int) is the idiomatic replacement. It also reuses
// cached Integer instances for values in [-128, 127], which is
// faster and uses less memory for common config values like port numbers.
//
// Fix: Integer.valueOf(value).toString()
// Or even simpler: String.valueOf(value)
properties.put(key, new Integer(value).toString());
}
}
Lab 03 — Code Review: CleverPartitioner
Phase: 05 — Code Quality and Engineering Discipline
Difficulty: ⭐⭐⭐⭐☆ | Estimated Time: 2.5–3 hours
Primary Artifact: CleverPartitioner.java — a Fibonacci-hashing partitioner with 5 correctness issues
Verify: mvn test shows exactly 2 passing + 2 failing tests; then fix the bugs until all 4 pass
Goal
A contributor submitted CleverPartitioner.java as a pull request to replace the existing modulo-based partitioner. The tests tell part of the story — but a good code review catches what the tests miss.
Your job is to:
- Run the tests and observe which pass and which fail
- Write a code review comment for each issue you find (5 total)
- Fix the correctness bugs so all 4 tests pass
- Assess whether the fix is sufficient for a real Apache PR to be merged
Background: Fibonacci Hashing
Fibonacci hashing maps a hash value to a bucket using the golden-ratio constant:
φ = (√5 - 1) / 2 ≈ 0.618
In integer arithmetic (64-bit), this is approximated by:
FIBONACCI_MULT = 2^64 × φ = 0x9e3779b97f4a7c15L
The algorithm: multiply the key's hash by FIBONACCI_MULT, then take the top k bits, where k = log₂(n) — this only works when n is a power of two.
The bug in this implementation: the code uses Integer.numberOfTrailingZeros(n) to compute k, which is correct only for powers of two. For n=1, numberOfTrailingZeros(1) = 0, so the shift is 64 - 0 = 64. In Java, shifting a long by 64 is equivalent to shifting by 0 (the shift amount is taken modulo 64), returning the full product instead of 0.
Setup
cd phase-05-code-quality/lab-03-review
mvn test
Expected output: 2 tests PASS, 2 tests FAIL.
Steps
Step 1 — Run the tests and understand the failures
mvn test 2>&1 | grep -E 'Tests run|FAIL|ERROR|expected|but was'
Record:
- Which test fails?
- What is the actual vs. expected value?
- What does that tell you about the algorithm?
Step 2 — Read CleverPartitioner.java and write a review
Before touching any code, write a code review comment for each of the 5 issues marked in the source file. Use the Apache review severity labels:
BLOCKER — must be fixed before merge
MAJOR — important but non-blocking correctness issue
MINOR — should be addressed
NIT — trivial
QUESTION — asking for clarification
Your review should be in the form:
[BLOCKER] partition(String, int): If n is not a power of two (e.g., n=5),
the shift amount is Integer.numberOfTrailingZeros(5) = 0, so the expression
>>> 64 evaluates as >>> 0 (shift mod 64), returning an unmasked value far
outside [0, n). This is a correctness regression for any cluster with a
non-power-of-two partition count. Requires either: (a) restricting n to
powers of two with an explicit precondition check, or (b) replacing the
algorithm with one that handles arbitrary n.
Step 3 — Fix the correctness bugs
Fix in this order:
- Null guard: add a null check on
keyand throwIllegalArgumentException(notNullPointerException— explicit contract is better than an implicit crash) - Non-positive n guard: throw
IllegalArgumentExceptionifn <= 0 - Power-of-two guard: throw
IllegalArgumentExceptionwith a clear message ifnis not a power of two —(n & (n - 1)) != 0is the idiomatic check - n=1 edge case: with the power-of-two guard in place, n=1 is now valid; ensure
partition(key, 1)always returns 0 (all keys go to bucket 0 — there is only one bucket) - Magic constant citation: add a Javadoc or inline comment explaining
0x9e3779b97f4a7c15L— this is the Knuth/Fibonacci multiplier and must be cited
Step 4 — Verify all 4 tests pass
mvn test
All 4 tests must pass:
testPowerOfTwo_distributionInRange— basic power-of-two bucketingtestSingleBucket_alwaysReturnsZero— n=1 must always return 0testNonPowerOfTwo_throwsIllegalArgument— n=5 must throwIllegalArgumentExceptiontestNullKey_throwsIllegalArgument— null key must throwIllegalArgumentException
Step 5 — Reassess: is the PR ready to merge?
After your fixes, answer in a comment at the top of CleverPartitioner.java:
- Is it still Fibonacci hashing if we restrict n to powers of two? (Yes — this is the standard form.)
- A Kafka partition count can be any positive integer, not just a power of two. What algorithm would you recommend as a replacement that handles arbitrary n? (Hint:
Math.abs(key.hashCode()) % nis simple but has skew for non-prime n. What's the production alternative?) - Is the
0x45d9f3bLconstant on the hashCode mixing step necessary? What does it do?
Verification
# All 4 tests pass
mvn test
echo "Exit code: $?" # must be 0
Source Files
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example.quality</groupId>
<artifactId>review-lab</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Code Quality Lab 03 — Code Review</name>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
CleverPartitioner.java
package org.example.quality;
/**
* Partitioner using Fibonacci hashing to assign a string key to one of
* {@code n} buckets.
*
* <p>
* Fibonacci hashing exploits the golden-ratio constant to spread keys more
* evenly
* than a simple modulo, especially when hashes cluster around multiples of a
* common factor.
*
* <p>
* This implementation has 5 issues for code review. Find them all before
* fixing.
*
* <p>
* Algorithm (for a power-of-two n):
*
* <pre>
* h = mix(key.hashCode())
* bucket = (int)((h * FIBONACCI_MULT) >>> (64 - log2(n)))
* </pre>
*
* <p>
* Where {@code FIBONACCI_MULT = 2^64 * (sqrt(5)-1)/2} — the 64-bit
* Fibonacci/Knuth
* multiplicative constant.
*
* @see <a href=
* "https://en.wikipedia.org/wiki/Hash_table#Fibonacci_hashing">Fibonacci
* hashing</a>
*/
public class CleverPartitioner {
// ISSUE 1 — Magic constant with no citation
// This is the 64-bit Fibonacci multiplicative constant (Knuth, vol 3, §6.4):
// FIBONACCI_MULT = floor(2^64 * (sqrt(5) - 1) / 2) = 0x9e3779b97f4a7c15L
// Without the comment (which was "// trust me" in the original PR), a reviewer
// cannot verify this constant is correct without re-deriving it from scratch.
// Severity: MAJOR — cite the source.
private static final long FIBONACCI_MULT = 0x9e3779b97f4a7c15L;
/**
* Returns the bucket index for the given key in a cluster of {@code n}
* partitions.
*
* @param key the key to partition (must not be null)
* @param n the number of partitions (must be a positive power of two)
* @return a bucket index in the range {@code [0, n)}
*/
public int partition(String key, int n) {
// ISSUE 2 — No null check on 'key'
// If key is null, key.hashCode() throws NullPointerException with no useful
// message.
// The contract should be explicit: IllegalArgumentException with "key must not
// be null".
// Severity: MAJOR — silent crash with an unhelpful stack trace.
// ISSUE 3 — No validation that n > 0
// If n == 0, the shift amount is 64 - numberOfTrailingZeros(0) = 64 - 32 = 32.
// If n < 0, numberOfTrailingZeros returns a nonsensical result.
// Severity: BLOCKER — undefined behavior for invalid inputs.
// ISSUE 4 — Assumes n is a power of two; no guard
// numberOfTrailingZeros(n) only gives log2(n) when n IS a power of two.
// For n = 5: numberOfTrailingZeros(5) = 0, so the shift is (64 - 0) = 64.
// In Java, (long) >>> 64 is equivalent to >>> 0 (shift mod 64) — the full
// 64-bit product, truncated to int, which is not in [0, 5).
// Severity: BLOCKER — wrong results for any non-power-of-two partition count.
int shift = 64 - Integer.numberOfTrailingZeros(n);
// ISSUE 5 — Magic mixing constant with no explanation
// 0x45d9f3bL is a secondary mixing multiplier used to improve bit distribution
// before the Fibonacci step. Without a comment, reviewers cannot verify this
// is the correct constant or understand its purpose.
// Severity: MINOR — code should be self-documenting.
long h = (long) key.hashCode() * 0x45d9f3bL;
return (int) ((h * FIBONACCI_MULT) >>> shift);
}
}
PartitionerTest.java
package org.example.quality;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for {@link CleverPartitioner}.
*
* Before fixes: 2 tests pass, 2 tests fail.
* After fixes: all 4 tests pass.
*
* Run with: mvn test
*/
class PartitionerTest {
private CleverPartitioner partitioner;
@BeforeEach
void setUp() {
partitioner = new CleverPartitioner();
}
/**
* Test 1 — PASSES before fixes.
*
* For a power-of-two n (n=8), every key must produce a bucket in [0, 8).
* Fibonacci hashing is deterministic, so the same key always maps to the same
* bucket.
*/
@Test
void testPowerOfTwo_distributionInRange() {
int n = 8;
String[] keys = { "key-0", "key-1", "key-2", "key-3", "alpha", "beta", "gamma", "delta" };
for (String key : keys) {
int bucket = partitioner.partition(key, n);
assertTrue(bucket >= 0 && bucket < n,
"Expected bucket in [0, " + n + ") for key=" + key + " but got: " + bucket);
}
}
/**
* Test 2 — FAILS before fixes.
*
* n=1 means "there is only one bucket; everything goes to bucket 0".
*
* Bug: numberOfTrailingZeros(1) = 0, so shift = 64 - 0 = 64.
* In Java, (long) >>> 64 is (long) >>> (64 % 64) = (long) >>> 0, which is the
* full
* 64-bit product cast to int — not 0.
*
* Fix required: treat n=1 as a special case (return 0 always), OR add a
* power-of-two guard that catches n=1 and computes shift=0 explicitly using
* Integer.numberOfLeadingZeros(n) - 1 or Integer.SIZE -
* Integer.numberOfLeadingZeros(n-1).
*/
@Test
void testSingleBucket_alwaysReturnsZero() {
String[] keys = { "any-key", "another-key", "yet-another" };
for (String key : keys) {
int bucket = partitioner.partition(key, 1);
assertEquals(0, bucket, "With n=1 every key must map to bucket 0, but got: " + bucket);
}
}
/**
* Test 3 — FAILS before fixes.
*
* n=5 is not a power of two. The partitioner must reject it with
* IllegalArgumentException rather than silently returning a wrong bucket.
*
* Bug: without a guard, the code silently returns values outside [0, 5).
*
* Fix required: add a precondition check before computing the shift.
*/
@Test
void testNonPowerOfTwo_throwsIllegalArgument() {
assertThrows(IllegalArgumentException.class,
() -> partitioner.partition("any-key", 5),
"Expected IllegalArgumentException for non-power-of-two n=5");
}
/**
* Test 4 — PASSES before fixes (an NPE is thrown, and the test expects it).
*
* BUT: NullPointerException is the wrong exception here. A method that accepts
* user input should throw IllegalArgumentException with a clear message, not
* an opaque NPE. This test is written to document the DESIRED behavior after
* fix.
*
* Before fix: NullPointerException is thrown — test passes because NPE is a
* subtype
* of RuntimeException, which satisfies assertThrows(NullPointerException.class,
* ...).
*
* After fix: IllegalArgumentException is thrown — the test must be updated to
* expect
* IllegalArgumentException, OR the test is already written correctly and
* the implementation must throw IllegalArgumentException.
*
* The test below expects IllegalArgumentException. Before fixing, it FAILS.
* After fixing, it PASSES.
*
* This is the review point: the test was written to catch the NPE (which
* already
* "works") but the right fix is IllegalArgumentException with a useful message.
*/
@Test
void testNullKey_throwsIllegalArgument() {
assertThrows(IllegalArgumentException.class,
() -> partitioner.partition(null, 4),
"Expected IllegalArgumentException for null key, not NullPointerException");
}
}
Phase 06 — Review Culture and Patch Workflow
Prerequisite: Phase 05 (you know what Checkstyle and SpotBugs catch; now you learn what humans catch)
Outcome: You can read any PR as a committer would — produce a structured, actionable review; argue for a design with evidence; and read a raw git diff to find problems before they hit main.
Why This Phase Matters
Getting +1 from a committer means your code survived three filters: CI (automated), style review (Checkstyle/SpotBugs), and human review. The human review is the only filter that can catch wrong abstractions, subtle concurrency bugs, API contract regressions, and missing test coverage.
Apache committers spend 30–50% of their time reviewing code, not writing it. If you want to become a committer, you need to demonstrate that you can protect the codebase — not just contribute to it.
Labs
| Lab | Artifact | What You Learn |
|---|---|---|
| Lab 01 — FlawedPatch | MessageBatcher.java with 6 planted review issues | Structured review comments, severity labeling, finding what tests miss |
| Lab 02 — Design Tradeoffs | Two RateLimiter implementations + benchmark | How to argue for a design with code and data; the "+1 with conditions" review pattern |
| Lab 03 — Patch Archaeology | Synthetic Kafka-style .patch file | Reading raw git diffs; retrospective review; identifying what slipped through |
Core Concepts
| Concept | One-line definition |
|---|---|
| Binding vs non-binding vote | A committer's vote counts toward the required 3 +1s; a contributor's vote is informational |
| API contract regression | A change that breaks callers compiled against the previous version (binary compat) or breaks callers that recompile (source compat) |
| Design vote email | A message to dev@ presenting two approaches with tradeoffs, asking for community preference before writing code |
| Patch archaeology | Reading a merged commit retrospectively to understand what the reviewer should have caught and why it wasn't |
-1 with veto | A PMC member can block a release or major API change with a -1; the veto must include a technical reason and can be overridden only by a majority PMC vote |
Deliverables Checklist
-
MessageBatcher.java— all 6 issues found, 4 correctness bugs fixed, all tests pass -
ApproachComparison.md— written vote email comparingTokenBucketRateLimitervsSlidingWindowRateLimiter, citing benchmark output -
kafka-pr-17873-review.md— retrospective review of the synthetic patch with severity labels for each issue found -
mvn testgreen in all 3 lab modules
Interview Relevance
| Question | Lab |
|---|---|
| "What's the difference between source and binary compatibility?" | Lab 01 (Issue #2) |
| "How would you review a concurrent data structure change?" | Lab 01 (Issue #1) |
| "How do you choose between a token bucket and sliding window rate limiter?" | Lab 02 |
| "Walk me through how you'd review a non-trivial pull request" | Lab 01 + Lab 03 |
| "What should a -1 review comment include?" | All labs |
Warmup Guide — Review Culture & Patch Evaluation
Zero-to-expert primer for Phase 06: how to review code the way committers do — the checklist and its ordering, the craft of writing review comments, defending and revising your own patches, and reading real patch history as training data.
Table of Contents
- Chapter 1: What Review Is For (It Isn't Bug-Finding Alone)
- Chapter 2: The Review Checklist, Ordered by Blast Radius
- Chapter 3: Reading a Diff Effectively
- Chapter 4: Writing Review Comments That Work
- Chapter 5: Being Reviewed — Responding and Revising
- Chapter 6: Evaluating Design Tradeoffs in Review
- Chapter 7: Patch Archaeology — Learning from Merged History
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: What Review Is For (It Isn't Bug-Finding Alone)
Review serves four functions, and optimizing only the first produces bad reviewers:
- Defect detection — yes, but studies and experience agree most defects are caught by tests and tools; review catches the design-level ones they can't (Phase 05 Ch. 5).
- Knowledge transfer — after the review, two people understand the change; the bus factor of every merged line is ≥2. This is why "LGTM" without reading is a real harm even when the code was fine.
- Standard-setting — review is where a codebase's quality bar is actually enforced and taught; every comment is a tiny lecture other contributors read too.
- Trust calibration (the Apache-specific function) — review threads are where the PMC observes your judgment, both as reviewer and author. Phase 01's trust model is operationalized here; it's why this phase sits at the curriculum's center.
Chapter 2: The Review Checklist, Ordered by Blast Radius
Review the irreversible first. The ordering (memorize it; Lab 01 grades against it):
- Contract & compatibility: public API/config/wire/format changes? Annotations respected? japicmp/MiMa consulted? (A perfect implementation of a breaking change is still a -1.)
- Correctness: new states, error paths, boundary inputs, concurrency. Ask of every branch: what test would fail if this line were wrong — does it exist?
- Failure behavior: partial failure, retries, resource cleanup on the exception path. Most production incidents live in code that only runs when something else broke.
- Performance: only for hot paths, and then with evidence — demand the benchmark, review its design (Phase 08's JMH pitfalls are reviewer knowledge).
- Maintainability: naming, structure, comment quality, test readability — Phase 05's properties.
- Mechanics last: style nits the tools missed, typos. Prefix with "nit:" and never block on them.
The anti-pattern this ordering prevents: fifteen naming comments on a patch whose core design breaks rolling upgrades — attention spent inversely to consequence.
Chapter 3: Reading a Diff Effectively
The mechanics nobody teaches:
- Two passes: first pass for shape — files touched, test-to-code ratio, anything unexpected in scope (a "fix NPE" patch touching 14 files is a scope conversation, not a line-comment conversation). Second pass for lines.
- Start from the tests: they state what the author thinks the change does; diffs between that and what the code does are your highest-value findings.
- Read around the diff: the bug pattern
git diffhides is the interaction between changed lines and unchanged code — open the full file at the call sites; check other callers of a modified method (find-usages on every signature change). - Hunt the asymmetries: resources opened vs closed, locks taken vs released, state mutated vs restored on the exception path, new enum/case added vs every switch over it.
- For concurrency: write down the guard story (which lock protects which field) as you read; if you can't, that's the comment.
Chapter 4: Writing Review Comments That Work
The craft (Lab 01's rubric, and interview-prep/04's scenarios):
- Severity-label everything:
blocking:/question:/nit:— the author must know what stands between them and merge. Unlabeled comments read as all-blocking and poison the thread. - Reasons, not verdicts: "this races when two batches flush concurrently —
countis read outside the lock at line 84" is actionable; "this is thread-unsafe" is a homework assignment; "I don't like this" is noise. - Questions before accusations when you're <90% sure: "what prevents X here?" — half the time there's an invariant you missed, and the question surfaces it without cost; the other half, the author finds the bug themselves (better teaching).
- Anchor in shared standards, not personal taste: cite the style guide, an existing pattern in the codebase, a measured number. Taste-comments are where threads go to die.
- Acknowledge the good — one specific sentence ("the test matrix on the boundary conditions is exactly right"). Not politeness theater: it teaches what to keep doing, and it keeps newcomers (Phase 01's funnel) coming back.
- Know your confidence: "I believe this breaks X, but verify — I may be missing context" is senior; false certainty in public threads compounds badly.
Chapter 5: Being Reviewed — Responding and Revising
The author-side discipline (Phase 12 Ch. 3 covers the full mechanics; the essentials):
- Respond to every comment: fixed / pushed-back-with-reasons / clarifying question.
- Batch revisions — one push addressing all comments; reviewers re-review revisions, not commits.
- Take preferences cheaply (spend disagreement budget only on what matters); rebut blocking comments with evidence (a test, a benchmark, a spec citation) after restating the concern accurately.
- When wrong: say so fast, fix, and convert the misunderstanding into a comment or test so the next reader doesn't repeat it.
- Patch stalled? One polite ping after ~a week, then the project's escalation convention. Meanwhile review others' patches — the queue runs on reciprocity.
Chapter 6: Evaluating Design Tradeoffs in Review
Lab 02 presents competing implementations (token-bucket vs sliding-window rate limiter); the evaluation method generalizes:
- Make the axes explicit before judging: correctness under burst, memory per key, contention behavior, observability, complexity. Unstated axes are how design arguments go circular.
- Demand the workload: there is no "better rate limiter," only better-for- burst-tolerance or better-for-strict-ceilings. A reviewer's first design question is "what traffic shape and what failure cost?"
- Weigh evidence classes correctly: a benchmark beats an argument for the benchmarked case only (check its design — single-threaded JMH proves nothing about contention; Phase 08); an invariant argument beats a benchmark for correctness.
- Price the second implementation: every alternative kept alive doubles test surface and doc burden — "both, behind a flag" is sometimes right (workloads genuinely differ) and always expensive; make the cost part of the recommendation.
- Decide reversibility: config-defaulted choices are cheap to revisit; wire-format choices aren't (Phase 03 Ch. 5). Spend deliberation proportional to irreversibility.
Chapter 7: Patch Archaeology — Learning from Merged History
Lab 03's method — real merged patches (e.g., the bundled Kafka PR) as training data:
- Reconstruct the timeline: rounds of review, what changed between revisions, time to merge. The delta between v1 and merged is a map of what this project's reviewers actually require — more honest than any contributing guide.
- Classify every review comment with Chapter 2's checklist levels — you'll find the distribution skews far more toward contract/failure-mode than newcomers expect.
- Note the social moves: how disagreement was de-escalated, how scope was cut ("file a follow-up JIRA" as the universal pressure valve), who tipped the decision and with what kind of evidence.
- Steal templates: good JIRA descriptions, good commit messages, good test names — archaeology gives you the project's house style as worked examples.
Lab Walkthrough Guidance
Order: Lab 01 (flawed patch) → Lab 02 (design tradeoffs) → Lab 03 (archaeology).
- Lab 01: review
MessageBatcherwith Chapter 2's ordering — find the concurrency and failure-path defects before polishing nits; write every comment to Chapter 4's rubric (severity label + reason + suggestion). Grade yourself: would your blocking comments survive the author's pushback? - Lab 02: write the axes table first (Chapter 6.1) for both rate limiters, run the provided benchmark, then write the recommendation for two different stated workloads — the answer should flip, and the writeup should show why.
- Lab 03: apply Chapter 7's method to the bundled Kafka patch; the deliverable insight is the comment-classification histogram plus three templates you'd reuse.
Success Criteria
You are ready for Phase 07 when you can, from memory:
- Name the four functions of review and why "LGTM-without-reading" damages three of them.
- Recite the blast-radius checklist order and justify it with one sentence.
- Rewrite a verdict-comment ("this is wrong") into a working comment (reason + severity + suggestion) on demand.
- List four diff-reading asymmetries to hunt.
- Evaluate two competing designs by explicit axes + workload, and price the keep-both option.
Interview Q&A
Q: Review this PR live. (The meta-question: how do you start?) Narrate the two-pass method: "First, shape — what's touched, where are the tests, does scope match the title. Then contract: any public surface change? Then I'll read tests-first to learn intent, then the diff with attention on error paths and concurrency, then nits last, labeled." Interviewers grade the order more than the findings — it reveals whether you review by blast radius or by line number.
Q: A senior engineer's patch has a real flaw. How do you comment differently than on a newcomer's patch? The technical content is identical — the flaw, the evidence, the severity label; deference on correctness is how bad code ships. What changes is calibration: with a senior author I lead with the question form ("what prevents X?") because the prior that I'm missing context is higher, and I skip the teaching detail a newcomer needs. What never changes: public thread, evidence attached, respectful and direct.
Q: How do you keep review turnaround from becoming the team bottleneck? Make reviewability the author's job too: small PRs (norm, not nag), description states what+why+how-tested, CI green before human eyes. Reviewer side: SLA on first response (even "looking Thursday"), two-pass triage so trivial PRs clear in minutes, severity labels so authors parallelize fixes. And measure it — review latency is a team health metric, not a virtue contest.
References
- Google Engineering Practices: Code Review — both the reviewer and author guides
- Software Engineering at Google, ch. 9 (Code Review) — the four-functions framing
- Apache Kafka coding guide + review norms
- SmartBear, Best Kept Secrets of Peer Code Review (the Cisco study) — review-size vs defect-yield data
- How to Do Code Reviews Like a Human (Lindsey Kuper-recommended classic by M. Lynch)
- Phase 12's HITCHHIKERS-GUIDE §3 — the author-side mechanics in full
🛸 Hitchhiker's Guide — Phase 06: Review Culture and Patch Workflow
Read this if: You've gotten a -1 review and didn't understand why, or you've reviewed a PR and left only style comments while the real bug shipped anyway. By the end you should be able to produce a review that a senior Apache committer would recognize as the work of someone who has read the codebase and understands the invariants — not just someone who ran the linter.
0. The 30-second mental model
A code review has three questions:
1. Is it correct?
— edge cases, concurrency, null safety, exception contract
— what tests are missing?
2. Is the API contract preserved?
— binary compatibility (existing compiled callers still work)
— source compatibility (existing source callers still compile)
— Javadoc accuracy (is the documented contract still true?)
3. Can the next person maintain it?
— naming, comments that explain WHY (not WHAT), no dead code
— no "trust me" constants without citations
Automated tools (Checkstyle, SpotBugs) answer parts of question 3.
Only humans can answer 1 and 2.
1. Anatomy of a Good Review Comment
1.1 The five-part structure
Every substantive review comment should have five parts:
[SEVERITY] Location: One-sentence statement of the problem.
Why it matters: What runtime failure or contract violation does this cause?
Under what inputs or conditions does it trigger?
Evidence: Quote the relevant code (2–5 lines).
Suggested fix: A concrete alternative (code, not just "fix this").
Label: BLOCKER / MAJOR / MINOR / NIT / QUESTION
1.2 Real examples
Good:
[BLOCKER] MessageBatcher.add(): The backing ArrayList is accessed from multiple
threads without synchronization.
Thread A can be in the middle of ArrayList.add() (which grows the internal array
when capacity is exceeded) while Thread B calls add() simultaneously. The result
is either a lost update (one message silently dropped) or an ArrayIndexOutOfBoundsException
depending on the timing of the capacity resize.
private List<String> batch = new ArrayList<>(); // ← no synchronization
Fix: use CopyOnWriteArrayList, or wrap add/flush in synchronized(this), or use
a ConcurrentLinkedQueue and drain it in flush().
Bad:
This is not thread-safe. Please fix.
The bad version is not actionable — the author doesn't know which line is the problem, what failure mode to worry about, or what to do instead.
1.3 Severity labels in practice
| Label | Meaning | Merge? |
|---|---|---|
BLOCKER | Correctness regression, API contract violation, or data loss | Must fix before merge |
MAJOR | Important issue that should be fixed but is not strictly blocking | Should fix before merge |
MINOR | Non-trivial but low-impact: missing test for rare path, suboptimal algorithm | Fix or file a follow-up ticket |
NIT | Trivial style: typo, unnecessary blank line, gratuitous final keyword | Fix or ignore |
QUESTION | Asking for clarification — not requesting a change | Answer required |
A review that is all NITs and no BLOCKERs means either the code is excellent or the reviewer wasn't looking hard enough.
2. What Tests Don't Prove
Tests prove that specific inputs produce specific outputs. They don't prove:
- Absence of concurrency bugs — a test that passes on your laptop may fail under load when two threads hit the same code path simultaneously.
- Correctness of the API contract — a test can assert that
flush()returns a list, but not that the return type change is binary-compatible with callers compiled against the old version. - Completeness — a 100% line-coverage test suite can still miss edge cases if the tests only exercise the happy path with valid inputs.
When reviewing tests, ask:
- Is there a test for null inputs?
- Is there a test for the boundary condition (empty, exactly-max-size, max-size+1)?
- Is there a test for the failure path (closed batcher, zero-capacity bucket)?
- Is there a concurrent test for any code that will be called from multiple threads?
3. API Contract Regression
3.1 Binary compatibility
Two versions are binary compatible if class files compiled against version N work without recompilation against version N+1.
A method signature change is a binary compatibility break:
// v1 — original
public void flush() { ... }
// v2 — changed return type
public List<String> flush() { ... }
// ← Any caller compiled against v1 that calls flush() will get:
// java.lang.NoSuchMethodError: flush()V
// (The JVM looks for flush with void return "V"; can't find it)
3.2 Source compatibility
Two versions are source compatible if source code that compiled against version N still compiles against version N+1.
// Caller source (compiled against v1):
batcher.flush(); // void return, discard result — compiles against v1
// Against v2:
batcher.flush(); // List<String> return, result discarded — STILL COMPILES
// (Java allows discarding a non-void return value)
This example is source-compatible but binary-incompatible. This is the subtle case that reviewers miss: "it compiles" does not mean "it's compatible."
3.3 When does this matter in Apache projects?
Apache projects that publish library JARs (Kafka, Spark, Commons) have strict compat policies. The rule:
- Minor version (1.x → 1.y): must be binary compatible
- Major version (1.x → 2.0): may break binary compat, but must document all breaks
If a PR changes a public method signature without a major version bump, it gets a -1.
4. Design Tradeoffs: How to Argue for an Approach
When two approaches are both correct, the right way to advocate for one is:
- State the tradeoff precisely — not "approach A is better" but "approach A has O(1) acquire() at the cost of O(k) space (k = bucket capacity), while approach B has O(log w) acquire() but O(w) space (w = window width in requests)"
- Provide evidence — benchmark numbers, not intuition
- State the use case — for a rate limiter where bursty traffic is acceptable and p99 latency matters, token bucket. For strict "no more than N requests in any window" enforcement, sliding window.
- End with a concrete recommendation — "I recommend token bucket for this use case because X. If the team prefers strict fairness, sliding window is acceptable with the caveat that it uses O(limit) memory."
This is the structure of a "+1 with conditions" review: you're in favor but with documented constraints.
5. Reading a Patch: git diff Format
A .patch file is a git diff with mail headers. The diff format:
--- a/src/main/java/org/example/NetworkClient.java ← old file
+++ b/src/main/java/org/example/NetworkClient.java ← new file
@@ -42,7 +42,12 @@ public class NetworkClient {
↑ ↑
old new (start line, count)
public void connect(String host) { ← context (unchanged)
- this.socket = new Socket(); ← removed (red in GitHub)
+ if (host == null) { ← added (green in GitHub)
+ throw new IllegalArgumentException("host must not be null");
+ }
+ this.socket = new Socket();
}
When reading a patch:
- Read the
@@header to understand what method/class you're in - Read the
-lines (removed) to understand what the code used to do - Read the
+lines (added) to understand what it does now - Check the context lines (no prefix) to understand the surrounding invariants
Common things to look for in the diff:
- A removed null check (regression)
- A new
throwsclause added without updating the interface - A method renamed (binary break)
- A synchronized block removed (thread safety regression)
6. Interview Questions — Concepts You Must Explain Out Loud
| Topic | One-line answer |
|---|---|
| What is the difference between a BLOCKER and a MAJOR in a code review? | BLOCKER means the PR must not merge as-is — it introduces a correctness regression, API break, or data loss. MAJOR means it's important enough to fix before merge but doesn't break correctness. |
A PR changes public void flush() to public List<String> flush(). Is this a breaking change? | Binary-incompatible: any caller compiled against the old void signature will get NoSuchMethodError at runtime. Source-compatible: existing source code that discards the return value still compiles. |
| How would you review a change to a class that is accessed from multiple threads? | Check that every field written by one thread and read by another is either: (a) synchronized, (b) volatile, (c) a java.util.concurrent type, or (d) immutable. Look for any place where two operations on a shared structure need to be atomic but aren't. |
| What makes a good "+1 with conditions" review? | State the conditions precisely: "LGTM pending (1) a null guard on the key parameter and (2) a test for the empty-queue case." Each condition is a BLOCKER or MAJOR; NITs don't block a +1. |
| How do you read a pull request with 50 changed files? | Start with the interface/API changes (method signatures, public contracts). Then read the tests — they tell you what the author thought mattered. Then read the implementation for the most critical paths. Focus on what changed, not what stayed the same. |
| What's the Apache "lazy consensus" rule? | If a change is proposed (e.g., merging a minor PR) and no one objects within 72 hours, it's considered approved. It lowers the barrier for uncontroversial changes without requiring explicit +1 from every committer. |
| What is the minimum binding +1 count required to release an Apache artifact? | Three binding +1 votes from PMC members, zero binding -1 votes. |
| Why is "please fix this" an ineffective review comment? | It doesn't tell the author what's wrong, why it matters, what the correct behavior should be, or how to fix it. A useful review comment gives the author enough information to act without a back-and-forth. |
7. References
- Apache Software Foundation — How the ASF Works
- Apache Kafka Code Review Guidelines
- How to Review Pull Requests — Apache Spark Wiki
- Binary Compatibility — Java SE Specification §13
- OpenJDK Review Process
- Token Bucket vs. Sliding Window — Cloudflare Blog
Lab 01 — FlawedPatch: Review MessageBatcher
Phase: 06 — Review Culture and Patch Workflow
Difficulty: ⭐⭐⭐☆☆ | Estimated Time: 2.5–3 hours
Primary Artifact: MessageBatcher.java with 6 planted review issues
Verify: mvn test passes all 4 tests after your fixes
Scenario
A contributor submitted this PR to the dev@ list:
[PATCH] PROJ-4471 — MessageBatcher: return flushed messages for downstream processing
Previously
flush()returned void and callers had no way to process the flushed batch without maintaining their own state. This patch changesflush()to return the flushed list. Also addsautoFlushsupport so batchers can flush automatically on close.Tests are passing locally. Please review.
The CI is green. Checkstyle is green. SpotBugs is green.
Your job: find the 6 issues before this merges.
Setup
cd phase-06-review-culture/lab-01-flawed-patch
mvn test
Expected: 2 tests PASS, 2 tests FAIL. Read the failure messages — they tell you which of the 6 issues are caught by tests and which are not.
Step 1 — Read the code and write your review
Before running anything: read MessageBatcher.java and write a structured review comment for each issue you find. Use the format:
[SEVERITY] Method/field: Problem statement.
Why it matters: ...
Evidence: (quote the line)
Fix: ...
There are 6 issues. Find all 6 before looking at the answer table below.
Issue list (expand after attempting)
| # | Issue | Location | Severity | Type |
|---|---|---|---|---|
| 1 | batch is an ArrayList accessed from multiple threads without synchronization | batch field | BLOCKER | Thread safety |
| 2 | flush() return type changed from void to List<String> — binary incompatible | flush() signature | BLOCKER | API contract regression |
| 3 | add(null) silently adds null to the batch; no null guard | add() | MAJOR | Missing input validation |
| 4 | Throws RuntimeException when closed instead of IllegalStateException | add() | MINOR | Wrong exception type |
| 5 | batch.size() > maxSize || batch.size() == maxSize — the == branch is dead (already covered by >) | add() | NIT | Dead code |
| 6 | The autoFlush feature added in this PR has no test | close() | MAJOR | Missing test coverage |
Step 2 — Fix the correctness issues
Fix the issues in order of severity. Issues #1, #2, #3, #4 require code changes. Issue #5 is a cleanup. Issue #6 requires a new test.
For issue #1 (thread safety): Use synchronized on the this monitor for both add() and flush(). This is the simplest correct fix; a production system might use ReentrantLock or a ConcurrentLinkedQueue for better throughput.
For issue #2 (API regression): The fix is context-dependent. In a library, you would NOT change the return type in a minor release. Document your finding as: "This is a binary-incompatible change. If required, it must ship in a major version with a deprecation notice for flush() returning void, or be introduced as a new method flushAndGet()."
For this lab: change the signature back to public synchronized void flush() and add a separate public synchronized List<String> drainBatch() method.
For issue #3 (null guard): throw IllegalArgumentException("message must not be null").
For issue #4 (wrong exception type): throw IllegalStateException("batcher is closed").
For issue #5 (dead code): simplify size > maxSize || size == maxSize to size >= maxSize.
For issue #6 (missing test): add testAutoFlush_closeTriggersDrain() to MessageBatcherTest.java.
Step 3 — Verify all 4 tests pass
mvn test && echo "ALL PASS"
Step 4 — Write the review summary
Write a final paragraph (3–5 sentences) as if you were posting to dev@:
Thanks for the patch. I've identified 2 BLOCKERs, 1 MAJOR, and 3 lower-priority issues
before we can merge this:
1. [BLOCKER] Thread safety: ...
2. [BLOCKER] API regression: ...
...
Marking -0 pending resolution of the BLOCKERs. Happy to re-review once those are addressed.
A -0 means "I have concerns but am not vetoing" — common for patches that are close but need work.
Source Files
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example.review</groupId>
<artifactId>flawed-patch-lab</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Review Culture Lab 01 — FlawedPatch</name>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
MessageBatcher.java
package org.example.review;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Collects messages into a batch and flushes them when the batch reaches
* {@code maxSize}, or when {@link #flush()} is called explicitly.
*
* <p>
* This class has 6 review issues. Find them all before fixing.
*
* <p>
* PR description: "Changed flush() to return the flushed messages for
* downstream processing. Added autoFlush support so batchers flush on close."
*/
public class MessageBatcher {
// ISSUE 1 — Thread safety
// ArrayList is not thread-safe. If two threads call add() simultaneously:
// - Thread A may see a partial write from Thread B during ArrayList.grow()
// - Result: ArrayIndexOutOfBoundsException OR silently lost messages
// The class is documented as usable from multiple threads (see the test),
// but the implementation has no synchronization.
//
// Fix: synchronize add() and flush() on 'this', or use CopyOnWriteArrayList,
// or restructure to use ConcurrentLinkedQueue + atomic drain.
private List<String> batch = new ArrayList<>();
private final int maxSize;
private final boolean autoFlush;
private boolean closed = false;
/**
* Creates a new batcher.
*
* @param maxSize the maximum number of messages before an automatic flush
* @param autoFlush if true, the batcher flushes remaining messages when closed
*/
public MessageBatcher(int maxSize, boolean autoFlush) {
this.maxSize = maxSize;
this.autoFlush = autoFlush;
}
/**
* Adds a message to the batch. If the batch reaches {@code maxSize},
* an automatic flush is triggered.
*
* @param message the message to add
* @throws RuntimeException if the batcher has been closed
*/
public void add(String message) {
if (closed) {
// ISSUE 4 — Wrong exception type
// The closed-state contract violation should use IllegalStateException,
// not the raw RuntimeException. IllegalStateException is the standard
// Java idiom for "object is in a state that does not allow this operation"
// (see: Iterator.remove() after end, Queue.remove() on empty queue).
// Callers catching IllegalStateException specifically will miss this.
//
// Fix: throw new IllegalStateException("batcher is closed");
throw new RuntimeException("batcher is closed");
}
// ISSUE 3 — No null guard
// If message is null, it is silently added to the batch.
// Downstream code iterating the flushed list and calling message.length()
// will get a NullPointerException with no indication of where the null
// was introduced.
//
// Fix: if (message == null) throw new IllegalArgumentException("message must
// not be null");
batch.add(message);
// ISSUE 5 — Dead code
// The condition 'size > maxSize || size == maxSize' simplifies to 'size >=
// maxSize'.
// The '== maxSize' branch can never execute when '> maxSize' is already true,
// and both branches do the same thing (flush). This is a NIT — no correctness
// impact — but it signals that the author was not thinking clearly about the
// condition.
//
// Fix: if (batch.size() >= maxSize) { flush(); }
if (batch.size() > maxSize || batch.size() == maxSize) {
flush();
}
}
/**
* Flushes the current batch and returns the flushed messages.
*
* <p>
* <strong>WARNING — ISSUE 2 (API regression):</strong>
* The previous version of this method had signature
* {@code public void flush()}.
* Changing the return type to {@code List<String>} is a
* <em>binary-incompatible</em>
* change: any caller compiled against the old void signature will get:
*
* <pre>
* java.lang.NoSuchMethodError: flush()V
* </pre>
*
* at runtime, because the JVM resolves method descriptors including the return
* type.
* <p>
* This is source-compatible (existing code that discards the return value still
* compiles),
* but binary-incompatible. In Apache library policy, this requires a major
* version bump
* or introduction as a new method name.
*
* @return an unmodifiable snapshot of the flushed messages (never null, may be
* empty)
*/
public List<String> flush() {
List<String> flushed = Collections.unmodifiableList(new ArrayList<>(batch));
batch.clear();
return flushed;
}
/**
* Closes the batcher. If {@code autoFlush} was set at construction time,
* any remaining messages are flushed before closing.
*
* <p>
* <strong>ISSUE 6 — Missing test:</strong>
* The {@code autoFlush} feature added in this PR has no test. There is no test
* that verifies messages added before {@code close()} are flushed when
* {@code autoFlush=true}, nor that they are NOT flushed when
* {@code autoFlush=false}.
* The feature could be completely broken and CI would not catch it.
*/
public void close() {
if (autoFlush && !batch.isEmpty()) {
flush();
}
closed = true;
}
}
MessageBatcherTest.java
package org.example.review;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for {@link MessageBatcher}.
*
* Before fixes: 2 tests PASS, 2 tests FAIL.
* After all fixes: all 4 tests PASS.
*/
class MessageBatcherTest {
private MessageBatcher batcher;
@BeforeEach
void setUp() {
batcher = new MessageBatcher(3, false);
}
/**
* Test 1 — PASSES before fixes.
* Basic add-and-flush contract: messages added before flush() are returned.
*/
@Test
void testAddAndFlush() {
batcher.add("hello");
batcher.add("world");
List<String> flushed = batcher.flush();
assertEquals(2, flushed.size());
assertTrue(flushed.contains("hello"));
assertTrue(flushed.contains("world"));
}
/**
* Test 2 — PASSES before fixes.
* When batch reaches maxSize, flush() is triggered automatically.
* After auto-flush the batch is empty; a subsequent flush() returns an empty
* list.
*/
@Test
void testMaxSizeTrigger_batchClearedAfterAutoFlush() {
batcher.add("a");
batcher.add("b");
batcher.add("c"); // triggers auto-flush at size == maxSize
List<String> afterAutoFlush = batcher.flush();
assertEquals(0, afterAutoFlush.size(),
"Batch should be empty after auto-flush triggered at maxSize");
}
/**
* Test 3 — FAILS before fixes.
*
* Issue #4: close() on a closed batcher throws RuntimeException.
* The correct exception is IllegalStateException.
*
* Before fix: RuntimeException is thrown → assertThrows(IllegalStateException)
* FAILS.
* After fix: IllegalStateException is thrown → test PASSES.
*/
@Test
void testAddAfterClose_throwsIllegalState() {
batcher.close();
assertThrows(IllegalStateException.class,
() -> batcher.add("late message"),
"Expected IllegalStateException when adding to a closed batcher");
}
/**
* Test 4 — FAILS before fixes.
*
* Issue #3: add(null) silently succeeds instead of throwing
* IllegalArgumentException.
*
* Before fix: no exception is thrown → assertThrows() FAILS.
* After fix: IllegalArgumentException is thrown → test PASSES.
*/
@Test
void testNullMessage_throwsIllegalArgument() {
assertThrows(IllegalArgumentException.class,
() -> batcher.add(null),
"Expected IllegalArgumentException when adding a null message");
}
// -------------------------------------------------------------------------
// Bonus tests: add these as part of fixing Issue #6 (missing autoFlush test)
// and Issue #1 (thread-safety) after you fix the implementation.
// -------------------------------------------------------------------------
/**
* Bonus — add this test when fixing Issue #6 (autoFlush missing test).
*
* When autoFlush=true, close() must flush remaining messages.
* When autoFlush=false, close() must NOT flush remaining messages.
*/
// @Test
// void testAutoFlush_closeFlushesWhenEnabled() { ... }
/**
* Bonus — add this test when fixing Issue #1 (thread safety).
*
* 10 threads each add 100 messages concurrently.
* After all threads complete, flush() must return exactly 1000 messages
* (no lost updates).
*
* Before fixing thread safety: this test fails or throws
* ArrayIndexOutOfBoundsException due to unsynchronized ArrayList growth.
*/
// @Test
// void testConcurrentAdd_noLostUpdates() throws InterruptedException { ... }
}
Lab 02 — Design Tradeoffs: Comparing RateLimiter Implementations
Phase: 06 — Review Culture and Patch Workflow
Difficulty: ⭐⭐⭐⭐☆ | Estimated Time: 3–3.5 hours
Primary Artifact: Two RateLimiter implementations + benchmark comparison
Verify: mvn test passes all correctness tests; benchmark output printed to console
Scenario
A contributor proposed switching the existing TokenBucketRateLimiter to a SlidingWindowRateLimiter on the grounds that sliding window is "fairer":
The token bucket allows bursts — a client that was idle for 10 seconds can suddenly fire 10x the normal rate. The sliding window guarantees that no client ever exceeds the rate in any rolling time window, not just on average.
Your job is to:
- Understand both implementations by reading the code and the tests
- Run the benchmark and compare the results
- Write a "+1 with conditions" or "-0" review that makes a concrete recommendation
Background: Token Bucket vs. Sliding Window
Token Bucket — conceptual model:
- Imagine a bucket that holds tokens. Tokens are added at a fixed rate (e.g., 10/sec).
- Each request consumes one token. If the bucket is empty, the request is rejected.
- If the requester was idle, tokens accumulate (up to the bucket capacity), allowing a burst.
Sliding Window Log — conceptual model:
- Keep a log of timestamps for every allowed request.
- When a new request arrives, count the requests in the last N seconds.
- If the count is below the limit, allow it (and add a timestamp). Otherwise, reject.
- No burst allowance — strictly "no more than N requests in any rolling window of size W".
Setup
cd phase-06-review-culture/lab-02-design-tradeoffs
mvn test && mvn compile exec:java -Dexec.mainClass=org.example.review.RateLimiterBenchmark
The test suite verifies both implementations are correct. The benchmark prints throughput comparison output.
Steps
Step 1 — Read both implementations
Before running anything: read TokenBucketRateLimiter.java and SlidingWindowRateLimiter.java. For each, answer:
- What data structure stores the state?
- What is the time complexity of
tryAcquire()? - What is the space complexity relative to the rate limit and window size?
- Under what traffic pattern does this algorithm perform worst?
Step 2 — Run the tests
mvn test
Both implementations should pass. If either fails, read the test to understand the invariant being tested.
Step 3 — Run the benchmark
mvn compile exec:java -Dexec.mainClass=org.example.review.RateLimiterBenchmark
Record the throughput numbers. Note the difference under:
- Steady-state load (requests at exactly the allowed rate)
- Burst load (10x rate for 1 second, then idle)
- High-load rejection (20x rate sustained)
Step 4 — Write the vote email
Write a 200–400 word message to dev@project.example.com in the following format:
Subject: [DISCUSS] PROJ-4512 — switching rate limiter from token bucket to sliding window
Hi all,
I reviewed both implementations. Here is my analysis:
Performance:
- Token bucket tryAcquire(): O(1) time, O(1) space
- Sliding window tryAcquire(): O(k) time (k = requests in window), O(k) space
Fairness:
- Token bucket allows burst of [capacity] requests after idle period
- Sliding window enforces hard ceiling in any rolling window
My recommendation: [+1 for / -0 for / -1 against] switching because:
...
Conditions / caveats:
...
— [Your name]
Fill in the real numbers from your benchmark. This is the skill that distinguishes a "I wrote it so it must be good" contributor from an Apache committer.
Verification
# All correctness tests pass
mvn test
# Benchmark output is produced
mvn compile exec:java -Dexec.mainClass=org.example.review.RateLimiterBenchmark 2>/dev/null | grep -E 'throughput|burst|Approach'
Source Files
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example.review</groupId>
<artifactId>design-tradeoffs-lab</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Review Culture Lab 02 — Design Tradeoffs</name>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<!--
exec-maven-plugin: lets you run a main class directly with `mvn exec:java`.
Used to run the RateLimiterBenchmark without packaging a fat jar.
Command: mvn compile exec:java -Dexec.mainClass=org.example.review.RateLimiterBenchmark
-->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<mainClass>org.example.review.RateLimiterBenchmark</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
RateLimiter.java
package org.example.review;
/**
* A rate limiter that allows up to a maximum number of requests in a rolling
* time window.
*
* <p>
* Two implementations are provided for comparison:
* <ul>
* <li>{@link TokenBucketRateLimiter} — allows bursting; O(1) time and
* space</li>
* <li>{@link SlidingWindowRateLimiter} — strict fairness; O(k) time and
* space</li>
* </ul>
*
* <p>
* All implementations must be thread-safe.
*/
public interface RateLimiter {
/**
* Attempts to acquire permission to proceed. Returns immediately.
*
* @return {@code true} if the request is allowed; {@code false} if the rate
* limit
* has been exceeded and the request should be rejected
*/
boolean tryAcquire();
/**
* Resets the rate limiter to its initial state.
* Used in tests to provide a clean baseline between test cases.
*/
void reset();
}
TokenBucketRateLimiter.java
package org.example.review;
/**
* Token bucket rate limiter.
*
* <p>
* <strong>Algorithm:</strong>
*
* <pre>
* Capacity: maximum tokens the bucket can hold (= maximum burst size)
* Refill rate: tokens added per second
* Current state: how many tokens are currently in the bucket
*
* On tryAcquire():
* 1. Compute elapsed time since last refill
* 2. Add (elapsed × refillRate) tokens to the bucket, capped at capacity
* 3. If bucket has ≥ 1 token, consume 1 token and return true
* 4. Otherwise return false
* </pre>
*
* <p>
* <strong>Complexity:</strong>
* <ul>
* <li>Time: O(1) per {@code tryAcquire()}</li>
* <li>Space: O(1) — only stores current token count and last refill
* timestamp</li>
* </ul>
*
* <p>
* <strong>Burst behavior:</strong> A client that is idle for {@code k} seconds
* accumulates up to {@code min(k × refillRate, capacity)} tokens and can fire
* that many requests in rapid succession. This is intentional — the algorithm
* models "credit" for unused capacity.
*
* <p>
* This implementation is thread-safe.
*/
public class TokenBucketRateLimiter implements RateLimiter {
private final int capacity; // maximum tokens (= burst ceiling)
private final double refillRate; // tokens per nanosecond
private double tokens; // current token count (fractional to avoid drift)
private long lastRefillNanos; // System.nanoTime() at last refill
/**
* @param capacity maximum burst size (tokens that can accumulate while idle)
* @param ratePerSec maximum sustained request rate (tokens added per second)
*/
public TokenBucketRateLimiter(int capacity, int ratePerSec) {
if (capacity <= 0) {
throw new IllegalArgumentException("capacity must be > 0");
}
if (ratePerSec <= 0) {
throw new IllegalArgumentException("ratePerSec must be > 0");
}
this.capacity = capacity;
this.refillRate = (double) ratePerSec / 1_000_000_000L; // tokens per nanosecond
this.tokens = capacity;
this.lastRefillNanos = System.nanoTime();
}
@Override
public synchronized boolean tryAcquire() {
refill();
if (tokens >= 1.0) {
tokens -= 1.0;
return true;
}
return false;
}
@Override
public synchronized void reset() {
tokens = capacity;
lastRefillNanos = System.nanoTime();
}
/**
* Adds tokens proportional to elapsed time since the last refill.
* Called on every {@code tryAcquire()} to lazily update the bucket.
*/
private void refill() {
long now = System.nanoTime();
double elapsed = now - lastRefillNanos;
tokens = Math.min(capacity, tokens + elapsed * refillRate);
lastRefillNanos = now;
}
}
SlidingWindowRateLimiter.java
package org.example.review;
import java.util.ArrayDeque;
import java.util.Deque;
/**
* Sliding window log rate limiter.
*
* <p>
* <strong>Algorithm:</strong>
*
* <pre>
* Window: time duration in nanoseconds (e.g., 1 second)
* Limit: maximum requests in any window of that duration
* Log: timestamps of every allowed request in the current window
*
* On tryAcquire():
* 1. Remove all timestamps older than (now - window)
* 2. If log.size() < limit, add 'now' to the log and return true
* 3. Otherwise return false
* </pre>
*
* <p>
* <strong>Complexity:</strong>
* <ul>
* <li>Time: O(k) per {@code tryAcquire()}, where k = number of requests in the
* current window (expiry sweep). In the worst case (high sustained load) k =
* limit.</li>
* <li>Space: O(k) — the log stores one timestamp per allowed request in the
* window.
* At maximum sustained load: O(limit) entries.</li>
* </ul>
*
* <p>
* <strong>Fairness guarantee:</strong> No more than {@code limit} requests are
* allowed in any rolling window of {@code windowMillis} milliseconds — not just
* on a fixed-clock boundary, but in any contiguous window. This prevents the
* "reset burst" problem of fixed-window algorithms.
*
* <p>
* This implementation is thread-safe.
*/
public class SlidingWindowRateLimiter implements RateLimiter {
private final int limit; // max requests per window
private final long windowNanos; // window size in nanoseconds
private final Deque<Long> log; // timestamps of allowed requests
/**
* @param limit maximum number of requests per window
* @param windowMillis window duration in milliseconds
*/
public SlidingWindowRateLimiter(int limit, long windowMillis) {
if (limit <= 0) {
throw new IllegalArgumentException("limit must be > 0");
}
if (windowMillis <= 0) {
throw new IllegalArgumentException("windowMillis must be > 0");
}
this.limit = limit;
this.windowNanos = windowMillis * 1_000_000L;
this.log = new ArrayDeque<>(limit);
}
@Override
public synchronized boolean tryAcquire() {
long now = System.nanoTime();
long windowStart = now - windowNanos;
// Evict timestamps older than the window start (O(k) where k = expired entries)
while (!log.isEmpty() && log.peekFirst() <= windowStart) {
log.pollFirst();
}
if (log.size() < limit) {
log.addLast(now);
return true;
}
return false;
}
@Override
public synchronized void reset() {
log.clear();
}
}
RateLimiterBenchmark.java
package org.example.review;
/**
* Throughput comparison for {@link TokenBucketRateLimiter} vs
* {@link SlidingWindowRateLimiter}.
*
* <p>
* This is NOT a rigorous microbenchmark (no JVM warmup isolation, no JIT
* stabilization).
* It is a first-pass comparison that gives you order-of-magnitude numbers for
* the
* vote email. For production profiling, use JMH.
*
* <p>
* Run with:
*
* <pre>
* mvn compile exec:java -Dexec.mainClass=org.example.review.RateLimiterBenchmark
* </pre>
*/
public class RateLimiterBenchmark {
// Limit so high that tryAcquire() never rejects — we're measuring call
// overhead,
// not rejection logic. The real-world comparison matters when the limiter is
// under constant accepted load.
private static final int LARGE_LIMIT = 10_000_000;
private static final int ITERATIONS = 5_000_000;
private static final int WARMUP_ITER = 500_000;
public static void main(String[] args) throws InterruptedException {
System.out.println("=== RateLimiter Throughput Comparison ===\n");
System.out.println("Iterations: " + ITERATIONS + " (+ " + WARMUP_ITER + " warmup)\n");
// --- Token Bucket ---
TokenBucketRateLimiter tokenBucket = new TokenBucketRateLimiter(LARGE_LIMIT, LARGE_LIMIT);
long tbResult = measure(tokenBucket, "TokenBucketRateLimiter ");
// Small pause between runs to let the OS scheduler settle
Thread.sleep(200);
// --- Sliding Window ---
SlidingWindowRateLimiter slidingWindow = new SlidingWindowRateLimiter(LARGE_LIMIT, 1000L);
long swResult = measure(slidingWindow, "SlidingWindowRateLimiter");
// --- Summary ---
System.out.println("\n=== Summary ===");
System.out.printf("Token bucket: %,d ns per call%n", tbResult);
System.out.printf("Sliding window: %,d ns per call%n", swResult);
double ratio = (double) swResult / tbResult;
System.out.printf("Ratio (SW / TB): %.2fx%n", ratio);
System.out.println("\nInterpretation:");
if (ratio > 3.0) {
System.out.println("Sliding window is >3x slower per call.");
System.out.println("Under high-rate sustained load (the common case for a rate limiter),");
System.out.println("this overhead is significant. Token bucket is the better choice for");
System.out.println("throughput-sensitive paths.");
} else if (ratio > 1.5) {
System.out.println("Sliding window is moderately slower.");
System.out.println("The tradeoff may be acceptable if strict per-window fairness is required.");
} else {
System.out.println("Performance is comparable. Choose based on fairness requirements.");
}
}
/**
* Runs warmup iterations (ignored), then measures the time for
* {@code ITERATIONS}
* calls to {@code tryAcquire()}.
*
* @return average nanoseconds per call
*/
private static long measure(RateLimiter limiter, String label) {
// Warmup: prime the JIT
limiter.reset();
for (int i = 0; i < WARMUP_ITER; i++) {
limiter.tryAcquire();
}
// Measure
limiter.reset();
long start = System.nanoTime();
for (int i = 0; i < ITERATIONS; i++) {
limiter.tryAcquire();
}
long elapsed = System.nanoTime() - start;
long nsPerCall = elapsed / ITERATIONS;
System.out.printf("%-25s total=%,d ms per-call=%,d ns throughput=%.1f M/sec%n",
label,
elapsed / 1_000_000,
nsPerCall,
ITERATIONS / (elapsed / 1e9) / 1_000_000.0);
return nsPerCall;
}
}
RateLimiterTest.java
package org.example.review;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
/**
* Correctness tests for both {@link RateLimiter} implementations.
*
* Uses parameterized tests so the same invariants are verified against both
* TokenBucketRateLimiter and SlidingWindowRateLimiter.
*/
class RateLimiterTest {
/** Provides both implementations for parameterized tests. */
static Stream<RateLimiter> implementations() {
return Stream.of(
new TokenBucketRateLimiter(5, 5), // 5 tokens, 5/sec
new SlidingWindowRateLimiter(5, 1000L) // 5 per 1-second window
);
}
/**
* Within the burst/limit budget the limiter must allow all requests.
*/
@ParameterizedTest
@MethodSource("implementations")
void testAllowsUpToLimit(RateLimiter limiter) {
limiter.reset();
for (int i = 0; i < 5; i++) {
assertTrue(limiter.tryAcquire(),
"Expected tryAcquire() to return true for request " + (i + 1) + " of 5");
}
}
/**
* After the limit is exhausted, the next request must be rejected.
*/
@ParameterizedTest
@MethodSource("implementations")
void testRejectsWhenLimitExhausted(RateLimiter limiter) {
limiter.reset();
for (int i = 0; i < 5; i++) {
limiter.tryAcquire(); // consume all budget
}
assertFalse(limiter.tryAcquire(),
"Expected tryAcquire() to return false after limit is exhausted");
}
/**
* reset() must restore the limiter to its initial state so all requests
* are allowed again.
*/
@ParameterizedTest
@MethodSource("implementations")
void testResetRestoresBudget(RateLimiter limiter) {
limiter.reset();
for (int i = 0; i < 5; i++) {
limiter.tryAcquire(); // exhaust budget
}
limiter.reset();
assertTrue(limiter.tryAcquire(),
"Expected tryAcquire() to return true after reset()");
}
/**
* Both implementations must reject invalid construction arguments.
*/
@Test
void testTokenBucket_rejectsInvalidArgs() {
assertThrows(IllegalArgumentException.class,
() -> new TokenBucketRateLimiter(0, 5));
assertThrows(IllegalArgumentException.class,
() -> new TokenBucketRateLimiter(5, 0));
}
@Test
void testSlidingWindow_rejectsInvalidArgs() {
assertThrows(IllegalArgumentException.class,
() -> new SlidingWindowRateLimiter(0, 1000L));
assertThrows(IllegalArgumentException.class,
() -> new SlidingWindowRateLimiter(5, 0L));
}
/**
* Token bucket ONLY: after sleeping 1 second with a 5/sec refill rate,
* the bucket should have refilled and allow another 5 requests.
*
* This test documents the burst behavior that sliding window does NOT exhibit.
*/
@Test
void testTokenBucket_refillsAfterIdle() throws InterruptedException {
TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(5, 5);
limiter.reset();
for (int i = 0; i < 5; i++) {
limiter.tryAcquire(); // exhaust
}
Thread.sleep(1100); // wait > 1 second for refill
assertTrue(limiter.tryAcquire(),
"Expected token bucket to allow requests after idle refill period");
}
/**
* Sliding window ONLY: after the window elapses, the count resets.
*
* This test documents the strict window enforcement that token bucket
* allows to be bypassed via burst.
*/
@Test
void testSlidingWindow_allowsAfterWindowElapses() throws InterruptedException {
SlidingWindowRateLimiter limiter = new SlidingWindowRateLimiter(5, 500L);
limiter.reset();
for (int i = 0; i < 5; i++) {
limiter.tryAcquire(); // exhaust
}
assertFalse(limiter.tryAcquire(), "Should be rate-limited immediately after exhaustion");
Thread.sleep(600); // wait for window to elapse
assertTrue(limiter.tryAcquire(),
"Expected sliding window to allow requests after window has elapsed");
}
}
Lab 03 — Patch Archaeology: Retrospective Review
Phase: 06 — Review Culture and Patch Workflow
Difficulty: ⭐⭐⭐⭐☆ | Estimated Time: 2–2.5 hours
Primary Artifact: kafka-pr-17873.patch — a synthetic Kafka-style patch
Deliverable: kafka-pr-17873-review.md — your retrospective review with 4+ findings
Scenario
This patch was merged 6 months ago. A bug was filed last week:
KAFKA-21044 —
RequestQueue.drainRequests()occasionally throwsNullPointerExceptionin production under high load. Stack trace points to line 89 ofRequestQueue.java.
You're doing a post-mortem. Your job: read the patch that introduced drainRequests() and write the review that should have caught the bug before it merged.
What is Patch Archaeology?
When a bug reaches production:
- Find the commit that introduced it:
git log --all -S 'drainRequests' -- RequestQueue.java - Read the diff as if you were the reviewer at the time of the PR
- Write the review you would have written — with the benefit of hindsight, but using only the information that was available when the PR was open (no knowledge of the production failure)
This is a standard Apache post-mortem skill. The goal is not to blame the author; it is to identify what review standards would have caught the issue, and whether to add a rule to the review checklist.
Setup
cd phase-06-review-culture/lab-03-patch-archaeology
cat kafka-pr-17873.patch
Read the patch file. The format is standard git diff:
- Lines starting with
---/+++are the old/new file - Lines starting with
@@are hunk headers showing line numbers - Lines starting with
-were removed (red in GitHub) - Lines starting with
+were added (green in GitHub) - Lines with no prefix are context (unchanged)
Steps
Step 1 — Read the diff without the answer key
Read kafka-pr-17873.patch completely. For each concern you notice, write it down with:
- The line number (from the
@@header + offset) - A one-sentence problem statement
- The severity label
There are 4 issues. Find as many as you can before expanding the answer.
Issue list (expand after your first pass)
| # | Line in diff | Issue | Severity |
|---|---|---|---|
| 1 | + drain(nodeId, requests, callback); | nodeId is never null-checked; a null nodeId reaches requests.get(nodeId) and silently returns null, which is then passed to callback without validation | BLOCKER |
| 2 | + for (Request r : requests.get(nodeId)) { | requests.get(nodeId) returns null when the node has no pending requests; calling for (Request r : null) throws NullPointerException — this is the production bug | BLOCKER |
| 3 | + public int drainRequests(String nodeId, DrainCallback callback) { | Return type changed from void to int without a corresponding interface update — breaks any caller that references the RequestQueue interface method | MAJOR (API regression) |
| 4 | No test for empty queue | There is no test case for drainRequests() when the node has no pending requests (the case that triggers the NPE) | MAJOR (missing test) |
Step 2 — Write the retrospective review
Create a file kafka-pr-17873-review.md in this directory with the following structure:
# Retrospective Review — KAFKA-PR-17873
**Reviewer**: [your name]
**Review date**: [today]
**Linked bug**: KAFKA-21044
## Summary
[2–3 sentence overview: what the patch does, and what slipped through]
## Findings
### Finding 1 — [Title]
**Severity**: BLOCKER
**Location**: `RequestQueue.java`, line [N]
[Problem statement]
**Evidence** (from the diff):
```java
[relevant lines]
Why it wasn't caught: [hypothesis — what review standard would have found this?]
Fix:
[suggested fix]
[Repeat for each finding]
Process Notes
[What review checklist item, if any, should be added to prevent this class of issue in future patches? 1–3 sentences.]
### Step 3 — Verify your review matches the actual bug
The production bug (KAFKA-21044) is the NPE on `requests.get(nodeId)` returning null when the node has no pending requests (Issue #2 in the answer key). Your review should have found this as a BLOCKER with the label "missing null check on return value of Map.get()".
If your review found it: good. If not: read the hunk again and note the exact line. Add it to your review.
---
## Key Insight
The bug was not subtle. `Map.get()` returning null when the key is absent is documented behavior. The reviewer should have asked: "What happens when `nodeId` is in the map but has an empty list? What happens when `nodeId` is not in the map at all?"
The answer to "What happens when nodeId is not in the map?" is: `requests.get(nodeId)` returns null, and `for (Request r : null)` throws `NullPointerException`. This is a one-question review that would have caught a production outage.
This is why Apache projects require: **every new method that takes user input must have a test for the empty/null/missing case**.
---
## Source Files
### `kafka-pr-17873.patch`
```diff
From 3f8a92b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9 Mon Sep 17 00:00:00 2001
From: alex.contributor <alex.contributor@example.com>
Date: Mon, 14 Oct 2024 11:22:03 +0000
Subject: [KAFKA-17873] RequestQueue: add drainRequests() for batch processing
Adds drainRequests(nodeId, callback) which processes all pending requests for
a given node in a single pass, invoking the callback for each. This replaces
the existing poll-in-a-loop pattern in NetworkClient, which had O(n) individual
synchronized calls.
Also fixes a minor issue where drainAll() was not returning the count of
drained requests, making it impossible for callers to distinguish "nothing
to drain" from "drained successfully."
Tested locally with NetworkClientTest — all existing tests pass.
Reviewers: b.reviewer@example.com
---
.../kafka/clients/NetworkClient.java | 18 ++-
.../kafka/clients/RequestQueue.java | 71 +++++++--
.../kafka/clients/RequestQueueTest.java | 29 +++-
3 files changed, 104 insertions(+), 14 deletions(-)
diff --git a/clients/src/main/java/org/apache/kafka/clients/RequestQueue.java b/clients/src/main/java/org/apache/kafka/clients/RequestQueue.java
index a1b2c3d..f4e5c6d 100644
--- a/clients/src/main/java/org/apache/kafka/clients/RequestQueue.java
+++ b/clients/src/main/java/org/apache/kafka/clients/RequestQueue.java
@@ -1,7 +1,7 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
+ * this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
@@ -18,6 +18,7 @@ package org.apache.kafka.clients;
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -42,6 +43,14 @@ public class RequestQueue {
private final Map<String, List<Request>> requests = new HashMap<>();
private final int maxInFlight;
+ /**
+ * Callback invoked for each request drained from the queue.
+ */
+ public interface DrainCallback {
+ /**
+ * @param nodeId the node the request was queued for
+ * @param request the drained request
+ */
+ void onDrain(String nodeId, Request request);
+ }
+
public RequestQueue(int maxInFlight) {
if (maxInFlight <= 0)
throw new IllegalArgumentException("maxInFlight must be > 0");
@@ -68,16 +77,52 @@ public class RequestQueue {
}
/**
- * Drains all pending requests across all nodes, returning the total count.
+ * Drains all pending requests across all nodes.
+ *
+ * @return count of requests drained
*/
- public synchronized void drainAll() {
+ public synchronized int drainAll() {
+ int count = 0;
for (Map.Entry<String, List<Request>> entry : requests.entrySet()) {
- entry.getValue().clear();
+ count += entry.getValue().size();
+ entry.getValue().clear();
}
+ return count;
}
- /** Returns the number of pending requests for the given node. */
- public synchronized int pendingCount(String nodeId) {
- List<Request> list = requests.get(nodeId);
- return list == null ? 0 : list.size();
+ /**
+ * Drains all pending requests for a specific node, invoking the callback
+ * for each request before removing it from the queue.
+ *
+ * @param nodeId the target node identifier
+ * @param callback invoked for each drained request
+ * @return the number of requests drained
+ */
+ public synchronized int drainRequests(String nodeId, DrainCallback callback) {
+ // ISSUE 3 — API regression: this method is declared on the RequestQueue
+ // interface (not shown in this diff) as 'void drainRequests(...)'.
+ // Changing the return type to 'int' breaks binary compatibility with
+ // callers compiled against the interface. The interface was not updated
+ // in this patch. Any caller using the interface type will get:
+ // AbstractMethodError: RequestQueue.drainRequests(...)V
+ // at runtime. (The 'V' void descriptor is no longer present.)
+ //
+ // A reviewer should have checked: does this method appear on an interface?
+ // The answer is: yes, IRequestQueue.java — not included in this diff.
+
+ List<Request> pending = new ArrayList<>();
+ drain(nodeId, pending, callback);
+ return pending.size();
}
+ private void drain(String nodeId, List<Request> out, DrainCallback callback) {
+ // ISSUE 1 — nodeId is never null-checked.
+ // If nodeId is null, requests.get(null) is valid (HashMap allows null keys)
+ // but only if null was ever used as a key. In practice, nodeIds are always
+ // non-null strings. The missing guard means a null nodeId silently produces
+ // an empty result rather than a fast-fail IllegalArgumentException.
+ // This is a MAJOR — not a BLOCKER — because it produces a wrong result
+ // (silent empty drain) rather than an exception in all cases.
+
+ // ISSUE 2 — THE PRODUCTION BUG
+ // requests.get(nodeId) returns null when nodeId is not in the map
+ // (i.e., no requests have ever been queued for this node, or all were
+ // previously drained). Iterating over null with an enhanced for loop:
+ //
+ // for (Request r : null) { ... }
+ //
+ // throws NullPointerException. The fix is a single null check:
+ //
+ // List<Request> list = requests.get(nodeId);
+ // if (list == null) return;
+ //
+ // This is the bug that caused KAFKA-21044. It was not caught because
+ // there was no test for drainRequests() on a node with no pending requests.
+ for (Request r : requests.get(nodeId)) {
+ callback.onDrain(nodeId, r);
+ out.add(r);
+ }
+ requests.remove(nodeId);
+ }
+
+ /** Returns the number of pending requests for the given node. */
+ public synchronized int pendingCount(String nodeId) {
+ List<Request> list = requests.get(nodeId);
+ return list == null ? 0 : list.size();
+ }
}
diff --git a/clients/src/test/java/org/apache/kafka/clients/RequestQueueTest.java b/clients/src/test/java/org/apache/kafka/clients/RequestQueueTest.java
index b2c3d4e..c5d6e7f 100644
--- a/clients/src/test/java/org/apache/kafka/clients/RequestQueueTest.java
+++ b/clients/src/test/java/org/apache/kafka/clients/RequestQueueTest.java
@@ -52,6 +52,35 @@ public class RequestQueueTest {
assertEquals(0, queue.pendingCount("node-1"));
}
+ @Test
+ public void testDrainRequests_callsCallbackForEachRequest() {
+ RequestQueue queue = new RequestQueue(10);
+ queue.enqueue("node-1", new Request("req-1"));
+ queue.enqueue("node-1", new Request("req-2"));
+ queue.enqueue("node-1", new Request("req-3"));
+
+ List<String> drained = new ArrayList<>();
+ int count = queue.drainRequests("node-1",
+ (nodeId, req) -> drained.add(req.id()));
+
+ assertEquals(3, count);
+ assertEquals(3, drained.size());
+ assertEquals(0, queue.pendingCount("node-1"));
+ }
+
+ @Test
+ public void testDrainRequests_returnsZeroForNodeWithNoRequests() {
+ // ISSUE 4 — THIS TEST IS MISSING FROM THE PATCH.
+ //
+ // This comment is added here to show WHERE the missing test should have been.
+ // The PR added testDrainRequests_callsCallbackForEachRequest (above) but
+ // did NOT add a test for the case where the node has no pending requests.
+ //
+ // A reviewer who noticed "what happens when nodeId is not in the map?"
+ // would have requested this test. It would have failed immediately,
+ // revealing the NullPointerException before the patch was merged.
+ //
+ // Fix: add this test, then fix the drain() method to guard against
+ // requests.get(nodeId) returning null.
+ //
+ // RequestQueue queue = new RequestQueue(10);
+ // int count = queue.drainRequests("node-with-no-requests", (n, r) -> {});
+ // assertEquals(0, count); // this line throws NPE before the fix
+ }
+
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/NetworkClient.java b/clients/src/main/java/org/apache/kafka/clients/NetworkClient.java
index d3e4f5a..e6f7g8h 100644
--- a/clients/src/main/java/org/apache/kafka/clients/NetworkClient.java
+++ b/clients/src/main/java/org/apache/kafka/clients/NetworkClient.java
@@ -187,12 +187,14 @@ public class NetworkClient {
private void processCompletedReceives(String nodeId) {
- // Old pattern: poll in a loop with individual synchronized calls
- while (requestQueue.pendingCount(nodeId) > 0) {
- Request r = requestQueue.poll(nodeId);
- if (r != null) handleResponse(nodeId, r);
- }
+ // New pattern: single drain call — fewer lock acquisitions
+ requestQueue.drainRequests(nodeId,
+ (nId, req) -> handleResponse(nId, req));
}
}
Phase 07 — Production Systems: Consensus, Partitioning, and Build Modularity
Track: PMC Apache Committer Engineer
Phase: 07 of 12
Prerequisites: Phases 01–06 (code quality, review culture, patch workflow)
Why This Phase
A senior Apache committer is expected to reason about three categories of production system problems:
- Consensus — how distributed nodes agree on a leader when any node can fail at any time
- Partitioning — how data is mapped to shards deterministically and fairly across a consumer group
- Build modularity — how large codebases are split into independently-testable, deployable modules
Each lab in this phase gives you a working Java implementation of the core algorithm, not just a description of it.
Labs
| Lab | Topic | Artifact | Command |
|---|---|---|---|
| Lab 01 — Raft Leader Election | Raft consensus, terms, votes, heartbeats | RaftCluster.java simulation | mvn test |
| Lab 02 — Kafka Partitioning | murmur2 hash, round-robin, range assignment | MurmurPartitioner.java, RangeAssignor.java | mvn test |
| Lab 03 — Multi-module Maven | Parent POM, BOM, module dependency graph | queue-api / queue-core / queue-app | mvn verify (from parent) |
Core Concepts
| Concept | One-line definition |
|---|---|
| Raft term | Logical clock that increments on each election attempt |
| Majority quorum | floor(N/2) + 1 nodes must agree for any decision to commit |
| Election timeout | Random delay before a follower promotes itself to candidate |
| murmur2 | Non-cryptographic hash used by Kafka to assign keys to partitions |
| Range assignment | Kafka's default consumer group partitioning: first N consumers get an extra partition if not evenly divisible |
| Reactor build | Maven builds all modules in dependency order in a single mvn invocation |
dependencyManagement | Parent POM section that pins versions; child modules inherit them without specifying version |
| BOM | Bill of Materials — a pom with only dependencyManagement, imported by other projects |
Deliverables
-
Raft election simulation:
mvn testpasses all 5 tests inRaftElectionTest -
Kafka partitioning:
mvn testpasses all 8 tests inPartitionerTest -
Multi-module build:
mvn verifyfrom the parent dir builds and tests all 3 modules - Written answers to at least 3 interview questions from the Hitchhiker's Guide
Interview Relevance
| Company type | What they ask |
|---|---|
| Apache project PMC | "Walk me through what happens when a Kafka broker fails" |
| Distributed systems team | "How does Raft prevent two leaders in the same term?" |
| Platform/infra team | "How do you share a dependency version across 20 Maven modules without copy-paste?" |
| Data engineering | "Why does Kafka use murmur2 instead of Java's hashCode()?" |
Warmup Guide — Production Distributed Systems
Zero-to-expert primer for Phase 07: consensus (Raft) from first principles, hash-based partitioning as used by Kafka, and multi-module build engineering — the systems substrate beneath every Apache data project.
Table of Contents
- Chapter 1: Why Distributed — and What It Costs
- Chapter 2: The Consensus Problem
- Chapter 3: Raft — Leader Election from Zero
- Chapter 4: Raft — Log Replication and the Commit Rule
- Chapter 5: Partitioning — Spreading Data with Hashing
- Chapter 6: Consumer Assignment and Rebalancing
- Chapter 7: Multi-Module Builds — Engineering the Repo Like a System
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Why Distributed — and What It Costs
From zero: one machine bounds you in capacity (disk/RAM), throughput (CPU/NIC), and availability (it will fail). Distribution buys past all three — and the price is that the network is now inside your system: messages are lost, delayed, reordered, and duplicated; clocks disagree; and — the deep one — you cannot distinguish a crashed node from a slow one (the FLP-flavored fact behind every timeout you'll ever tune).
Two design consequences run through everything in this phase:
- Redundancy requires agreement: copies that can diverge need a protocol to decide whose version is true → consensus (Ch. 2–4).
- Scale requires partitioning: data/work split across nodes needs a stable, balanced assignment function → hashing (Ch. 5–6).
Chapter 2: The Consensus Problem
The statement: N nodes must agree on a sequence of values (a replicated log) such that — despite crashes and partitions — agreed entries are never lost or reordered (safety), and the system keeps making progress whenever a majority is alive (liveness).
Why a majority (quorum)? Any two majorities of N intersect in ≥1 node. That single intersection node is the carrier of truth between configurations — it's why ⌈(N+1)/2⌉ acknowledgments make a decision durable, and why a 5-node cluster tolerates 2 failures. Memorize the intersection argument; every quorum system reduces to it.
Why it's hard: with asynchronous networks, any protocol can be stalled by adversarial timing (FLP impossibility) — so real systems buy liveness with randomized timeouts and buy safety unconditionally. Paxos solved it first and is notoriously hard to implement; Raft (2014) re-derived equivalent guarantees optimized for understandability — which is why etcd, Kafka's KRaft, and your Lab 01 all speak Raft.
Chapter 3: Raft — Leader Election from Zero
Raft decomposes consensus into leader election + log replication, with a strong leader through whom all writes flow.
The moving parts: every node is follower / candidate / leader; time divides into
terms (monotonic integers — Raft's logical clock). Each node persists
currentTerm, votedFor, and its log.
The election algorithm (exactly what Lab 01 implements and tests):
- Followers expect heartbeats from a leader. No heartbeat within a randomized
election timeout (e.g. 150–300 ms) → become candidate: increment term, vote for
self, send
RequestVote(term, lastLogIndex, lastLogTerm)to all. - A node grants at most one vote per term (persisted — survives crashes), and only to candidates whose log is at least as up-to-date (compare last term, then last index). This election restriction is what guarantees a new leader already holds every committed entry.
- Majority of votes → leader; begin heartbeats. Tie/split vote → timeout expires → next term, retry. Randomized timeouts make repeated splits exponentially unlikely.
- Any message bearing a higher term instantly demotes a leader/candidate to follower — stale leaders can exist briefly but can't do anything: replication requires follower acks, and followers reject lower terms.
Why no two leaders per term: one-vote-per-term + majority needed → two leaders in one term would require two disjoint majorities — impossible by the intersection argument. Two leaders in different terms: the older one's appends are rejected; it steps down on first contact with the higher term.
Chapter 4: Raft — Log Replication and the Commit Rule
(Lab 01 focuses on election; replication context so the design makes sense.)
- The leader appends client commands to its log and sends
AppendEntries(prevLogIndex, prevLogTerm, entries, leaderCommit). Followers reject if their log doesn't match atprevLogIndex/Term— the consistency check that forces divergent follower logs to back up and be overwritten to match the leader's. - An entry is committed once a majority holds it — but only for entries of the leader's current term (the Figure-8 subtlety: an old-term entry on a majority can still be overwritten; committing a current-term entry on top commits everything beneath it). This is the most-asked deep Raft question; have the scenario ready.
- KRaft (Kafka), etcd, and TiKV are production Rafts; reading any of their vote-handling code after this lab is the recommended follow-up.
Chapter 5: Partitioning — Spreading Data with Hashing
The problem: assign keys (or records) to N partitions, balanced and deterministic.
- The baseline (Kafka's default, Lab 02):
partition = murmur2(key) % N(sign-bit masked). Hash quality matters: a weak hash clusters real-world keys (sequential IDs, common prefixes) onto hot partitions, capping throughput at one broker. Murmur2 is fast, non-cryptographic, and statistically uniform — Lab 02 verifies uniformity with a chi-square test over realistic key distributions, the statistically honest way to evaluate a partitioner (eyeballing histograms lies). - Keyless records: round-robin balances perfectly but kills batching; Kafka's sticky partitioner fills one partition's batch then moves on — same long-run balance, far better throughput. Batching-vs-balance is the design tension to remember.
- The modulo trap: change N and
hash % Nreassigns ~everything — key→partition affinity (ordering! local state!) breaks. Consistent hashing (ring) or rendezvous hashing moves only ~1/N of keys — that's why caches and Cassandra-style stores use them. Kafka deliberately doesn't: partition count is meant to be fixed per topic, and ordering-per-key is the contract — increasing partitions on a keyed topic is a breaking change you must plan like a migration (Phase 11 thinking). - Skew is the practical killer: one celebrity key = one hot partition no algorithm fixes. Mitigations: composite keys (key+salt with consumer-side merge), local aggregation before the shuffle (Spark's map-side combine), or two-phase aggregation.
Chapter 6: Consumer Assignment and Rebalancing
The other half of partitioning: assigning partitions to consumers in a group.
- Range (per-topic contiguous blocks): aligns co-partitioned topics (needed for joins) but skews when consumers don't divide partitions evenly across many topics.
- Round-robin: even spread, breaks co-partition alignment.
- Sticky / cooperative-sticky (modern default): minimize movement on rebalance — every moved partition aborts in-flight work and cold-starts state. Cooperative rebalancing migrates incrementally instead of stop-the-world.
- The general principle (it recurs from Kafka to YARN to storage shards): assignment quality = balance × stability × locality, and the three trade against each other. Lab 02's RangeAssignor implementation makes the skew case concrete.
Chapter 7: Multi-Module Builds — Engineering the Repo Like a System
Why a build lab sits in a distributed-systems phase: Apache projects are multi-module systems, and their build graph is the first distributed system you touch (Phase 02 navigated it; here you construct one).
- Module = dependency-graph node:
queue-api(interfaces, no deps) ←queue-core(implementation) ←queue-app(wiring + integration tests). The discipline: api modules depend on nothing, implementations depend on api, applications depend on both — cycles are a design smell Maven simply refuses. - The reactor computes topological build order;
-pl module -am(with upstream) and-amd(with downstream) are the daily flags. Version alignment via the parent POM'sdependencyManagement— one place where versions live, modules inherit. - Why split at all: independent compilation/test cycles, enforceable layering
(api can't accidentally reach into impl), separate artifact publication (clients
depend on
queue-apionly — a compatibility boundary, Phase 04's surface made physical), and parallel CI. - CI shape (the lab's
ci.yml): build once, test per module, cache the local repo keyed on the POMs — the 80% of build-engineering value in 20 lines.
Lab Walkthrough Guidance
Order: Lab 01 (Raft) → Lab 02 (Kafka partitioning) → Lab 03 (multi-module).
- Lab 01: read
RaftNode's state transitions against Chapter 3's algorithm step by step; run the tests and make them fail informatively — comment out the one-vote-per-term persistence and watch which test catches the double-vote; do the same for the up-to-date check. Negative experiments are how the safety argument becomes yours. Extension: add the AppendEntries consistency check. - Lab 02: verify murmur2 against known vectors; run the chi-square uniformity test
on (a) random keys, (b) sequential IDs, (c) prefixed keys — compare against a naive
hashCode()partitioner and watch it fail (b). Then implement RangeAssignor and construct the skew case from Chapter 6. - Lab 03: build the three-module project; deliberately introduce an api→core
dependency and read Maven's cycle error; move a version into the parent's
dependencyManagement; run-pl queue-core -amand explain what rebuilt and why.
Success Criteria
You are ready for Phase 08 when you can, from memory:
- State the quorum-intersection argument and apply it (why 5 nodes tolerate 2 failures).
- Walk Raft's election including both safety mechanisms (one vote/term persisted; up-to-date restriction) and the role of randomized timeouts.
- Explain why a leader can't commit old-term entries by counting replicas (Figure 8).
- Defend murmur2-mod-N, name the modulo trap, and say when consistent hashing is and isn't the answer.
- Compare range/round-robin/sticky assignment on balance × stability × locality.
- Draw the api/core/app dependency discipline and what each boundary buys.
Interview Q&A
Q: Design leader election without Raft/ZK — what do you need at minimum? Re-derive the essentials: terms (a monotonic tiebreaker), persisted single vote per term (else double-voting under crashes), majority quorum (intersection = uniqueness), randomized timeouts (liveness under symmetry), and a rule that winners hold all committed state (else data loss on election) — at which point you've re-invented Raft's election, which is the answer: "the minimum is Raft's election, and here's why each piece is load-bearing."
Q: A Kafka topic shows one partition at 10× the traffic. Walk through it.
First distinguish key skew from partitioner bug: sample keys per partition. Celebrity
key → composite key (salt + consumer merge) or local pre-aggregation; bad hash (custom
partitioner, hashCode() on sequential IDs) → fix the hash, chi-square it; recently
increased partition count → old keys' affinity broke and the imbalance is transitional.
Never "add partitions" reflexively — on a keyed topic that's a breaking change to
ordering and state (the modulo trap).
Q: Why does Raft persist votedFor to disk before responding?
Crash-recovery safety: grant a vote, crash, restart without the record → grant again in
the same term → two leaders become possible, violating the intersection argument. The
fsync-before-reply on currentTerm/votedFor/log entries is the price of safety under
crashes; batching/group-commit is the performance answer, never skipping the write.
References
- Ongaro & Ousterhout, In Search of an Understandable Consensus Algorithm (Raft) — raft.github.io/raft.pdf; read §5 twice, Figure 8 until it's obvious
- The Raft visualization — watch before reading the paper
- Raft implementations list — read etcd's or KRaft's vote handling after Lab 01
- Kleppmann, Designing Data-Intensive Applications, ch. 6 (partitioning), ch. 9 (consistency/consensus)
- Kafka KIP-480: Sticky Partitioner and KIP-429: Incremental Cooperative Rebalancing
- Karger et al., Consistent Hashing and Random Trees (1997) — the ring's origin
- Maven: Guide to Working with Multiple Modules
🛸 Hitchhiker's Guide — Production Systems: Consensus, Partitioning, Builds
Read this if: You can write Java but want to explain why distributed systems make the design choices they do, and how those choices appear in Apache codebases.
0. The 30-second mental model
┌─────────────────────────────────────────────┐
│ PRODUCTION SYSTEM │
└─────────────────────────────────────────────┘
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌────▼────────┐
│ CONSENSUS │ │ PARTITIONING│ │ BUILDS │
│ │ │ │ │ │
│ Who is in │ │ Which shard │ │ Who owns │
│ charge? │ │ gets this │ │ this code? │
│ │ │ key? │ │ │
│ Raft: terms │ │ murmur2 → │ │ Maven multi-│
│ + majority │ │ partition │ │ module POM │
│ vote │ │ index │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
These three concerns appear together in every large-scale Apache project:
- ZooKeeper / Kafka controller / HDFS NameNode all use consensus to elect a leader
- Kafka producers use murmur2 to deterministically route records to partitions
- Apache Hadoop / Flink / Kafka all use multi-module Maven builds with shared BOMs
1. Raft Leader Election
1.1 The Problem Raft Solves
A cluster of N nodes must agree on which single node is the leader at any time. The leader is authoritative for all writes. If the leader fails, the cluster must elect a new one without human intervention — and without electing two leaders simultaneously (split brain).
1.2 Raft's Three Node States
┌──────────────────────────────────────────────┐
│ │
│ FOLLOWER ──election timeout──▶ CANDIDATE │
│ ▲ │ │
│ │◀──── receives heartbeat ──────┘ │
│ │ │ │
│ └───────── wins majority ──▶ LEADER │
│ │ │
│ (all nodes start as FOLLOWER) │ │
└─────────────────────────────────────────────┘
| State | What it does |
|---|---|
| FOLLOWER | Waits for heartbeats from a leader. If none arrive before a random timeout, promotes to CANDIDATE. |
| CANDIDATE | Increments term, votes for self, sends RequestVote RPCs to all peers. Becomes LEADER if majority responds. |
| LEADER | Sends periodic AppendEntries (heartbeat) RPCs to all followers to suppress elections. |
1.3 Terms: Raft's Logical Clock
A term is a monotonically increasing integer. Every election attempt increments the term. Terms solve the "stale leader" problem:
Term 1: node-1 elected leader
Term 2: network partition; node-2 starts election, node-3 votes for it
→ node-2 is leader at term 2
Term 1 again?: node-1 comes back, still thinks it's leader at term 1
→ node-1 sees term=2 in any RPC → immediately steps down
Rule: If a node sees a term > its own, it must immediately update its term and revert to FOLLOWER. This guarantees there is never more than one leader per term.
1.4 Majority Vote
Why floor(N/2) + 1? Because any two majorities must overlap by at least one node.
That overlapping node prevents two leaders from being elected in the same term.
5-node cluster: majority = 3
Two candidates can each get at most:
Candidate A wins nodes {1, 2, 3} ← 3 = majority
Candidate B wants nodes {1, 4, 5} ← node 1 already voted for A!
→ Candidate B gets at most {4, 5} = 2 < 3 = majority → cannot win
1.5 Key Interview Points
- "Why random election timeout?" → Reduces probability of simultaneous elections (split vote)
- "What if two candidates split the vote?" → Both timeout, increment term, try again with new random timeout
- "What happens to a partitioned leader's writes?" → They are lost if not replicated to a majority before the partition. The new leader has the most up-to-date log by virtue of winning the vote.
2. Kafka Partitioning Algorithms
2.1 Why Deterministic Partitioning Matters
Kafka guarantees ordering only within a partition. If you send order-123 events and
they must be processed in order, they must all go to the same partition. The producer
must map the key "order-123" to the same partition every time, on every producer instance,
across restarts.
This requires a deterministic, key-based partition function.
2.2 murmur2 Hash
Kafka's DefaultPartitioner uses murmur2, a non-cryptographic hash designed for
hash-table lookups. It has two properties Java's String.hashCode() does not:
| Property | String.hashCode() | murmur2 |
|---|---|---|
| Cross-JVM stability | ❌ JVM-spec says hashCode may differ | ✅ same bytes → same hash everywhere |
| Distribution quality | ⚠️ poor for short keys | ✅ excellent avalanche effect |
| Speed | ✅ fast | ✅ fast |
The algorithm processes 4 bytes at a time with a multiply-and-XOR mixing step:
// Simplified inner loop
int k = read4Bytes(data, i);
k *= M;
k ^= k >>> 24; // ← avalanche: every bit of input affects every bit of output
k *= M;
h *= M;
h ^= k;
The full implementation is in MurmurPartitioner.java.
2.3 Round-Robin Partitioning
For messages with no key (e.g., log events where ordering doesn't matter), round-robin distributes load evenly across partitions using a single atomic counter:
(counter.getAndIncrement() & Integer.MAX_VALUE) % numPartitions
The & Integer.MAX_VALUE clears the sign bit to prevent negative partition indices
when the counter wraps past Integer.MAX_VALUE.
2.4 Range Assignment (Consumer Group Protocol)
When a consumer group subscribes to a topic, Kafka must assign partitions to consumers. The Range strategy (Kafka's default) works as follows:
N = 5 partitions, C = 3 consumers, sorted: [c0, c1, c2]
rangeSize = 5 / 3 = 1 (integer division)
extras = 5 % 3 = 2 (first 2 consumers get one extra)
Consumer 0: start=0, count=1+1=2 → partitions [0, 1]
Consumer 1: start=2, count=1+1=2 → partitions [2, 3]
Consumer 2: start=4, count=1+0=1 → partitions [4]
Why Range? It minimizes the number of partitions moved during a rebalance when consumers are added/removed one at a time (common pattern: rolling restart).
Tradeoff: With many topics, the first consumer tends to get more partitions (the "first consumer bias"). The Sticky and CooperativeSticky assignors solve this at the cost of more complex rebalance logic.
3. Multi-module Maven Builds
3.1 The Problem: Dependency Version Drift
Imagine 10 Maven modules all depending on jackson-databind. Over time:
- Module A declares version
2.14.2 - Module B declares version
2.15.0 - Module C forgets to declare it and inherits a transitive version
2.13.1
The resulting classpath is unpredictable. Integration tests pass on the developer's
machine (which has version X in ~/.m2/repository) but fail in CI.
Solution: A parent POM with dependencyManagement pins all versions in one place.
Child modules declare <groupId>/<artifactId> without a version, and Maven resolves
the version from the parent's dependencyManagement section.
3.2 POM Inheritance Hierarchy
queue-parent/pom.xml ← packaging=pom, defines <modules> and <dependencyManagement>
├── queue-api/pom.xml ← <parent> → queue-parent; packaging=jar; no runtime deps
├── queue-core/pom.xml ← <parent> → queue-parent; depends on queue-api
└── queue-app/pom.xml ← <parent> → queue-parent; depends on queue-core; has exec jar config
3.3 Reactor Build Order
Running mvn verify from the parent triggers a reactor build:
- Maven reads all
pom.xmlfiles declared in<modules> - Builds a dependency graph
- Builds modules in topological order:
api→core→app - If any module fails, the reactor stops
cd lab-03-multimodule-maven
mvn verify
# Output:
# [INFO] Reactor Build Order:
# [INFO] queue-parent [pom]
# [INFO] queue-api [jar]
# [INFO] queue-core [jar]
# [INFO] queue-app [jar]
3.4 BOM vs Parent POM
| Mechanism | What it does | When to use |
|---|---|---|
| Parent POM | Inherits <properties>, <pluginManagement>, <dependencyManagement>, and <build> | When your modules are all children of the same project |
| BOM (Bill of Materials) | <dependencyManagement> only, imported via <scope>import</scope> | When you want to share versions across unrelated projects (e.g., Spring BOM, JUnit BOM) |
Apache projects typically publish a BOM artifact (e.g., kafka-bom, hadoop-bom) so
downstream users can import consistent versions without inheriting the Apache project's
parent POM configuration.
4. What Can Go Wrong in Production
| System | Common failure mode | Root cause |
|---|---|---|
| Raft cluster | Repeated split votes, no leader elected | Election timeouts too similar; fixed by randomizing them |
| Kafka producer | Hot partition: one partition gets all traffic | All keys hash to same partition, or poor key design (e.g., all keys start with same prefix) |
| Kafka consumer group | Frequent rebalances under load | Consumer processes records too slowly → heartbeat timeout → evicted from group |
| Multi-module Maven | ClassNotFoundException in integration tests | Stale module in local repo; fix with mvn install -pl queue-api or mvn clean install |
| Multi-module Maven | "Module not found" in CI | <module> entry missing in parent pom or wrong relative path |
5. Interview Questions — Concepts You Must Explain Out Loud
| Topic | Question | One-line answer |
|---|---|---|
| Raft | How does Raft prevent two leaders in the same term? | Any two majorities overlap by ≥1 node; that node's vote can only go to one candidate per term |
| Raft | What happens when a partitioned leader reconnects? | It sees a higher term in any RPC and immediately steps down to FOLLOWER |
| Raft | Why does Raft use random election timeouts? | Reduces probability of simultaneous elections, which cause split votes and no leader |
| Kafka | Why does Kafka use murmur2 instead of hashCode()? | hashCode() is not stable across JVM implementations; murmur2 produces the same result everywhere |
| Kafka | What is a consumer group rebalance? | Kafka reassigns partitions to consumers when group membership changes (consumer added/removed/failed) |
| Kafka | When would you choose sticky partitioner over round-robin? | When you want to reduce the number of ProduceRequest batches (sticky fills a batch before moving to next partition) |
| Maven | What is the difference between dependencies and dependencyManagement? | dependencies adds the dependency to the build; dependencyManagement only pins the version — child modules must still declare the dependency to use it |
| Maven | How do you build only one module and its dependencies? | mvn install -pl queue-app -am (-am = also make dependencies) |
6. References
- Raft Paper — In Search of an Understandable Consensus Algorithm (Ongaro & Ousterhout, 2014)
- Apache Kafka Protocol Guide — partition assignment RPC specs
- Kafka DefaultPartitioner source
- Apache Maven — Introduction to the POM
- Maven: Guide to Working with Multiple Modules
Lab 01 — Raft Leader Election Simulation
Phase: 07 — Production Systems
Difficulty: ⭐⭐⭐⭐☆ | Estimated Time: 2–2.5 hours
Artifacts: RaftNode.java, RaftCluster.java, RaftElectionTest.java
Command: mvn test
Background
Raft is the consensus algorithm used by:
- Apache ZooKeeper (leader election for coordination)
- Kafka's KRaft mode (replaces ZooKeeper as of Kafka 3.3)
- etcd (Kubernetes control plane)
- CockroachDB, TiKV, and most modern distributed databases
This lab implements the leader election portion of Raft — the part that decides which node is in charge. Log replication (how writes are propagated) is out of scope.
Setup
cd phase-07-production-systems/lab-01-raft-election
mvn test
All 5 tests should pass.
Step 1 — Understand the State Machine
Read RaftNode.java and trace through what happens when a FOLLOWER calls startElection():
- What fields change?
- What is the new value of
currentTerm? - How many votes does the node have before asking any peers?
Step 2 — Trace a 3-Node Election
Read RaftCluster.runElection() and trace through a 3-node cluster where node-1 starts the election. Draw the sequence of method calls and state transitions:
node-1: startElection() → state=CANDIDATE, term=1, votes=1
node-1 → node-2: requestVote(term=1, candidateId="node-1") → ?
node-1 → node-3: requestVote(term=1, candidateId="node-1") → ?
...
What does node-2 do if it is already at term=2 from a previous aborted election?
Step 3 — Run the Tests
mvn test
Expected output:
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Examine each test and make sure you can explain why it passes, not just that it does.
Step 4 — Extend: Make It Fail, Then Fix It
Add a 6th test that creates a 3-node cluster and verifies that a node with a stale term (lower than the cluster's current term) cannot win an election. Your test must:
- Advance the cluster term to 3 (simulate 3 elections)
- Create a new "stale" candidate at term 1
- Assert it does NOT become leader
Hint
Use requestVote(3, "leader-id") on two of the three nodes before your test to advance their terms.
A node that calls requestVote(1, "stale-candidate") after that will receive a VoteResponse with voteGranted=false.
Step 5 — Interview Prep
Write a 3-sentence answer to: "How does Raft prevent two leaders from being elected in the same term?"
Source Files
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example.raft</groupId>
<artifactId>raft-election-lab</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
NodeState.java
package org.example.raft;
/**
* The three states a Raft node can be in at any point in time.
*
* <p>
* State transitions:
*
* <pre>
* FOLLOWER ──(election timeout)──▶ CANDIDATE
* CANDIDATE ──(majority votes)─────▶ LEADER
* CANDIDATE ──(sees higher term)───▶ FOLLOWER
* LEADER ──(sees higher term)───▶ FOLLOWER
* </pre>
*
* <p>
* All nodes start as FOLLOWER. There is no direct path from FOLLOWER to LEADER
* without going through CANDIDATE — a node must win a vote, not just claim
* leadership.
*/
public enum NodeState {
FOLLOWER,
CANDIDATE,
LEADER
}
VoteResponse.java
package org.example.raft;
/**
* Response to a {@code RequestVote} RPC (Raft Section 5.2).
*
* <p>
* The receiver always includes its {@code currentTerm} in the response.
* If the sender's term is stale, it sees a higher term in the response
* and immediately steps down to FOLLOWER.
*/
public final class VoteResponse {
private final int term; // receiver's currentTerm
private final boolean voteGranted;
public VoteResponse(int term, boolean voteGranted) {
this.term = term;
this.voteGranted = voteGranted;
}
/** The receiver's current term at the time of the response. */
public int getTerm() {
return term;
}
/** Whether the receiver granted its vote to the candidate. */
public boolean isVoteGranted() {
return voteGranted;
}
@Override
public String toString() {
return "VoteResponse{term=" + term + ", voteGranted=" + voteGranted + "}";
}
}
RaftNode.java
package org.example.raft;
/**
* A single node in a Raft cluster.
*
* <p>
* This is a synchronous, single-JVM simulation. In a real distributed system,
* these methods would be invoked via RPC (e.g., gRPC). Here, they are called
* directly by {@link RaftCluster} to simulate the message passing.
*
* <p>
* All public state-mutating methods are synchronized to keep the simulation
* thread-safe (useful if you extend this lab to run elections on background
* threads).
*
* <h3>Raft invariants maintained by this class:</h3>
* <ul>
* <li>A node grants at most one vote per term (tracked by
* {@code votedFor})</li>
* <li>A node that sees a higher term immediately steps down to FOLLOWER</li>
* <li>A node only becomes LEADER if it receives votes from a strict
* majority</li>
* </ul>
*/
public class RaftNode {
private final String id;
private NodeState state;
private int currentTerm;
private String votedFor; // node id we voted for in currentTerm; null if we haven't voted
private String leaderId; // id of the current leader as learned from heartbeats
private int votesReceived; // only meaningful when state == CANDIDATE
public RaftNode(String id) {
this.id = id;
this.state = NodeState.FOLLOWER;
this.currentTerm = 0;
this.votedFor = null;
this.leaderId = null;
this.votesReceived = 0;
}
// -------------------------------------------------------------------------
// RPC handlers (called by RaftCluster to simulate message delivery)
// -------------------------------------------------------------------------
/**
* Handles a {@code RequestVote} RPC from a candidate.
*
* <p>
* Raft Section 5.2 grant conditions:
* <ol>
* <li>Reject if {@code candidateTerm < currentTerm} (candidate is behind)</li>
* <li>If {@code candidateTerm > currentTerm}: update term, step down to
* FOLLOWER,
* clear {@code votedFor} — we may now vote for this candidate</li>
* <li>Grant vote if we haven't voted this term OR already voted for this
* candidate</li>
* </ol>
*
* @param candidateTerm the term the candidate is running in
* @param candidateId the candidate requesting the vote
* @return response including this node's currentTerm and whether the vote was
* granted
*/
public synchronized VoteResponse requestVote(int candidateTerm, String candidateId) {
if (candidateTerm < currentTerm) {
// The candidate is behind — tell it our term so it steps down
return new VoteResponse(currentTerm, false);
}
if (candidateTerm > currentTerm) {
// Higher term: step down regardless of our current state
currentTerm = candidateTerm;
state = NodeState.FOLLOWER;
votedFor = null;
}
// Grant the vote if we haven't committed to anyone else this term
boolean grant = (votedFor == null || votedFor.equals(candidateId));
if (grant) {
votedFor = candidateId;
}
return new VoteResponse(currentTerm, grant);
}
/**
* Handles an {@code AppendEntries} RPC from a leader (used here as a
* heartbeat).
*
* <p>
* In full Raft this RPC also carries log entries. Here we only model the
* heartbeat behavior: if the leader's term is valid, accept it and reset the
* election timer (simulated by transitioning/remaining FOLLOWER).
*
* @param leaderTerm the leader's current term
* @param leaderId the leader's node id
* @return {@code true} if we accept this leader, {@code false} if term is stale
*/
public synchronized boolean appendEntries(int leaderTerm, String leaderId) {
if (leaderTerm < currentTerm) {
return false; // stale leader — reject
}
if (leaderTerm > currentTerm) {
currentTerm = leaderTerm;
votedFor = null;
}
// Accept the leader: transition (back) to FOLLOWER and record who the leader is
state = NodeState.FOLLOWER;
this.leaderId = leaderId;
return true;
}
// -------------------------------------------------------------------------
// Election actions (called on the candidate node itself)
// -------------------------------------------------------------------------
/**
* Starts an election: increments the term, transitions to CANDIDATE, and
* casts a self-vote.
*
* <p>
* In real Raft this is triggered when the election timeout fires.
* Here it is called explicitly by {@link RaftCluster#runElection()}.
*/
public synchronized void startElection() {
currentTerm++;
state = NodeState.CANDIDATE;
votedFor = id; // every candidate votes for itself
votesReceived = 1; // the self-vote
leaderId = null;
}
/**
* Records one received vote. If the candidate now holds a majority of votes
* in a cluster of {@code clusterSize} nodes, it transitions to LEADER.
*
* @param clusterSize total number of nodes in the cluster (used to compute
* majority)
*/
public synchronized void countVote(int clusterSize) {
if (state != NodeState.CANDIDATE) {
return; // already elected or stepped down — ignore late votes
}
votesReceived++;
int majority = (clusterSize / 2) + 1;
if (votesReceived >= majority) {
state = NodeState.LEADER;
}
}
// -------------------------------------------------------------------------
// Getters
// -------------------------------------------------------------------------
public String getId() {
return id;
}
public synchronized NodeState getState() {
return state;
}
public synchronized int getCurrentTerm() {
return currentTerm;
}
public synchronized String getVotedFor() {
return votedFor;
}
/** The leader id as last communicated via a heartbeat; null if unknown. */
public synchronized String getLeaderId() {
return leaderId;
}
@Override
public String toString() {
return "RaftNode{id='" + id + "', state=" + state
+ ", term=" + currentTerm + ", votedFor='" + votedFor + "'}";
}
}
RaftCluster.java
package org.example.raft;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
/**
* Coordinates leader election across a fixed set of {@link RaftNode}s.
*
* <p>
* This is a synchronous, deterministic simulation of the Raft election
* protocol.
* In a real cluster, the timing is driven by election timeouts (random timers
* per node).
* Here, elections are triggered explicitly so tests are deterministic.
*
* <p>
* The cluster size is fixed at construction time. Node failures are simulated
* by excluding specific nodes from an election via
* {@link #runElectionExcluding(RaftNode)}.
*/
public class RaftCluster {
private final List<RaftNode> nodes;
public RaftCluster(RaftNode... nodes) {
if (nodes == null || nodes.length < 3) {
throw new IllegalArgumentException(
"Raft requires at least 3 nodes to tolerate 1 failure");
}
this.nodes = Arrays.asList(nodes);
}
/**
* Simulates one complete election cycle:
* <ol>
* <li>The first FOLLOWER (or CANDIDATE) starts an election</li>
* <li>It broadcasts {@code RequestVote} to all peers</li>
* <li>If a majority votes for it, it transitions to LEADER</li>
* <li>The new leader sends heartbeats so all followers learn the leader id</li>
* </ol>
*
* @return the elected leader, or empty if no candidate won a majority (split
* vote)
*/
public Optional<RaftNode> runElection() {
RaftNode candidate = findCandidate(null);
if (candidate == null) {
return getLeader(); // already have a leader
}
candidate.startElection();
broadcastRequestVote(candidate, null);
if (candidate.getState() == NodeState.LEADER) {
sendHeartbeats(candidate, null);
}
return getLeader();
}
/**
* Simulates a network partition: runs an election excluding the given node.
*
* <p>
* The partitioned node receives no RPCs and sends none. The remaining
* nodes form a sub-cluster and, if they represent a majority, elect a new
* leader.
*
* <p>
* With a 3-node cluster and 1 partitioned node, the 2 remaining nodes
* still constitute a majority (⌊3/2⌋ + 1 = 2).
*
* @param partitioned the node to exclude from the election
* @return the new leader among the non-partitioned nodes, or empty if none
*/
public Optional<RaftNode> runElectionExcluding(RaftNode partitioned) {
RaftNode candidate = findCandidate(partitioned);
if (candidate == null) {
return Optional.empty();
}
candidate.startElection();
broadcastRequestVote(candidate, partitioned);
if (candidate.getState() == NodeState.LEADER) {
sendHeartbeats(candidate, partitioned);
}
return nodes.stream()
.filter(n -> n != partitioned && n.getState() == NodeState.LEADER)
.findFirst();
}
/**
* Sends heartbeats from {@code leader} to all other (non-excluded) nodes.
* In real Raft, this is sent periodically; here it is called once after
* election.
*/
public void sendHeartbeats(RaftNode leader, RaftNode exclude) {
for (RaftNode node : nodes) {
if (node == leader || node == exclude)
continue;
node.appendEntries(leader.getCurrentTerm(), leader.getId());
}
}
/** Returns the current leader, if any. */
public Optional<RaftNode> getLeader() {
return nodes.stream()
.filter(n -> n.getState() == NodeState.LEADER)
.findFirst();
}
/**
* Returns the number of nodes currently in the LEADER state. Must be 0 or 1.
*/
public long countLeaders() {
return nodes.stream()
.filter(n -> n.getState() == NodeState.LEADER)
.count();
}
public List<RaftNode> getNodes() {
return nodes;
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Finds the first node eligible to start an election (FOLLOWER or CANDIDATE,
* and not the excluded node).
*/
private RaftNode findCandidate(RaftNode exclude) {
return nodes.stream()
.filter(n -> n != exclude)
.filter(n -> n.getState() == NodeState.FOLLOWER
|| n.getState() == NodeState.CANDIDATE)
.findFirst()
.orElse(null);
}
/**
* Broadcasts {@code RequestVote} from {@code candidate} to all peers except
* itself
* and the excluded node. Calls {@link RaftNode#countVote(int)} on the candidate
* for each granted vote, using the full cluster size for the majority
* calculation.
*/
private void broadcastRequestVote(RaftNode candidate, RaftNode exclude) {
int term = candidate.getCurrentTerm();
for (RaftNode peer : nodes) {
if (peer == candidate || peer == exclude)
continue;
VoteResponse response = peer.requestVote(term, candidate.getId());
if (response.isVoteGranted()) {
// Use full cluster size — majority is over all nodes, not just available ones
candidate.countVote(nodes.size());
}
}
}
}
RaftElectionTest.java
package org.example.raft;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
class RaftElectionTest {
private RaftNode node1;
private RaftNode node2;
private RaftNode node3;
private RaftCluster cluster;
@BeforeEach
void setUp() {
node1 = new RaftNode("node-1");
node2 = new RaftNode("node-2");
node3 = new RaftNode("node-3");
cluster = new RaftCluster(node1, node2, node3);
}
/**
* A 3-node cluster must elect exactly one leader.
* Verifies the fundamental Raft safety property: at most one leader per term.
*/
@Test
void testElectionProducesExactlyOneLeader() {
Optional<RaftNode> leader = cluster.runElection();
assertTrue(leader.isPresent(), "Election must produce a leader");
assertEquals(NodeState.LEADER, leader.get().getState());
assertEquals(1, cluster.countLeaders(), "Exactly one node must be LEADER");
}
/**
* After election + heartbeats, every follower's term must be <= the leader's
* term.
* Verifies the Raft term invariant: the leader always has the highest term.
*/
@Test
void testLeaderHasHighestTerm() {
Optional<RaftNode> leader = cluster.runElection();
assertTrue(leader.isPresent());
int leaderTerm = leader.get().getCurrentTerm();
cluster.getNodes().forEach(n -> assertTrue(n.getCurrentTerm() <= leaderTerm,
n.getId() + " has term=" + n.getCurrentTerm()
+ " which exceeds leader term=" + leaderTerm));
}
/**
* After the leader sends heartbeats, all followers must know who the leader is.
* Verifies that AppendEntries (heartbeat) propagates leader identity.
*/
@Test
void testFollowersLearnLeaderViaHeartbeat() {
Optional<RaftNode> leader = cluster.runElection();
assertTrue(leader.isPresent());
String leaderId = leader.get().getId();
cluster.getNodes().stream()
.filter(n -> n.getState() == NodeState.FOLLOWER)
.forEach(n -> assertEquals(leaderId, n.getLeaderId(),
n.getId() + " should know the leader after receiving a heartbeat"));
}
/**
* A node at term=2 must reject a RequestVote for term=1.
* Verifies Raft's stale-term rejection rule.
*/
@Test
void testStaleCandidateIsRejected() {
// Advance node2 and node3 to term=2 by having them participate in an election
node2.startElection(); // node2 → CANDIDATE, term=1
node2.startElection(); // node2 → CANDIDATE, term=2 (simulates a second aborted attempt)
// node1 is still at term=0. If node1 tries to run for election at term=1,
// node2 (at term=2) must reject it.
node1.startElection(); // node1 → CANDIDATE, term=1
VoteResponse response = node2.requestVote(1, "node-1");
assertFalse(response.isVoteGranted(),
"node2 at term=2 must reject a RequestVote for term=1");
assertEquals(2, response.getTerm(),
"Response must include the node's current term so the sender can update");
}
/**
* When the current leader is network-partitioned (excluded from RPCs), the
* remaining nodes must elect a new leader — a different node.
* Verifies Raft's liveness property: the cluster recovers from a leader
* failure.
*/
@Test
void testPartitionedLeaderCausesNewElection() {
// Step 1: elect the initial leader
Optional<RaftNode> firstLeader = cluster.runElection();
assertTrue(firstLeader.isPresent(), "Initial election must succeed");
String firstLeaderId = firstLeader.get().getId();
// Step 2: simulate leader partition — run a new election without the old leader
Optional<RaftNode> newLeader = cluster.runElectionExcluding(firstLeader.get());
assertTrue(newLeader.isPresent(),
"Cluster must elect a new leader after partitioning the old one");
assertNotEquals(firstLeaderId, newLeader.get().getId(),
"The new leader must be a different node than the partitioned leader");
}
}
Lab 02 — Kafka Partitioning Algorithms
Phase: 07 — Production Systems
Difficulty: ⭐⭐⭐☆☆ | Estimated Time: 1.5–2 hours
Artifacts: MurmurPartitioner.java, RoundRobinPartitioner.java, RangeAssignor.java
Command: mvn test
Background
Every message in Kafka belongs to a partition. The partition determines:
- Which broker stores the message
- Which consumer in a group receives the message
- Whether ordering is guaranteed
Two decisions must be made:
- Producer side: which partition does this message go to? (based on key)
- Consumer side: which partitions does this consumer get assigned?
This lab implements both.
Setup
cd phase-07-production-systems/lab-02-kafka-partitioning
mvn test
All 11 tests should pass.
Step 1 — Understand the murmur2 Hash
Read MurmurPartitioner.java. The inner loop processes 4 bytes at a time:
k *= m;
k ^= k >>> 24; // avalanche
k *= m;
h *= m;
h ^= k;
Question: What is the "avalanche effect" and why does it matter for partition distribution?
Question: If you replaced murmur2 with key.hashCode() % numPartitions, what could go wrong when your Kafka consumers run on a different JVM version?
Step 2 — Verify Cross-Platform Determinism
The murmur2 hash for the byte sequence "kafka" is fixed regardless of JVM version.
Run the test testMurmur_knownHashValue and note the partition it maps to with 100 partitions.
mvn -Dtest=PartitionerTest#testMurmur_knownHashValue test
Change the key to your own name and add a test that records the partition. This partition would be the same on every Kafka producer instance in a production cluster.
Step 3 — Understand Range Assignment
Read RangeAssignor.java and trace the assignment for:
- 7 partitions, 4 consumers
Calculate manually before running testRange_allPartitionsCoveredExactlyOnce.
Expected answer
rangeSize = 7 / 4 = 1
extras = 7 % 4 = 3 (first 3 consumers get one extra)
c0: start=0, count=1+1=2 → [0, 1]
c1: start=2, count=1+1=2 → [2, 3]
c2: start=4, count=1+1=2 → [4, 5]
c3: start=6, count=1+0=1 → [6]
Step 4 — Identify the Flaw in Range Assignment
Consider a topic with 3 partitions and a consumer group with 3 consumers.
After the group reaches steady state, consumer c0 is restarted (rolling update).
- What partitions did
c0have before the restart? - What happens to those partitions while
c0is down? - When
c0rejoins, does it always get the same partitions back?
Why this matters: If a partition moves to a different consumer and that consumer
has already processed some of the messages, and then the partition moves back to c0,
you may get duplicate processing. This is why exactly-once semantics requires more
than just the consumer group protocol.
Step 5 — Run All Tests
mvn test
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Source Files
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example.kafka</groupId>
<artifactId>kafka-partitioning-lab</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
Partitioner.java
package org.example.kafka;
/**
* Maps a message key to a partition index in [0, numPartitions).
*
* <p>
* Two implementations are provided for comparison:
* <ul>
* <li>{@link MurmurPartitioner} — deterministic, key-based; same key always →
* same partition</li>
* <li>{@link RoundRobinPartitioner} — ignores key; distributes evenly by
* counter</li>
* </ul>
*
* <p>
* A third algorithm, range assignment across a consumer group, is in
* {@link RangeAssignor}.
* That is not a {@code Partitioner} because it operates on a group of
* consumers, not a single key.
*/
public interface Partitioner {
/**
* Determines the partition for the given key.
*
* @param key the message key; must not be null
* @param numPartitions the total number of partitions; must be > 0
* @return a partition index in [0, numPartitions)
* @throws IllegalArgumentException if key is null or numPartitions <= 0
*/
int partition(String key, int numPartitions);
}
MurmurPartitioner.java
package org.example.kafka;
import java.nio.charset.StandardCharsets;
/**
* Partitions messages using the murmur2 hash — the same algorithm used by
* Apache Kafka's {@code DefaultPartitioner} (Kafka clients up to 2.3).
*
* <h3>Why murmur2 and not {@code String.hashCode()}?</h3>
* <p>
* Java's {@code hashCode()} is not guaranteed to produce consistent results
* across different JVM implementations or versions. Kafka producers run on many
* machines; they must all route the same key to the same partition. murmur2
* produces identical output for identical byte inputs on every platform.
*
* <h3>Algorithm overview</h3>
* <p>
* murmur2 processes the input 4 bytes at a time. Each 4-byte chunk is mixed
* with a multiply-rotate-XOR step that ensures the "avalanche effect": changing
* one bit in the input changes ~50% of the bits in the output.
*
* <pre>
* for each 4-byte chunk k:
* k *= M (multiply)
* k ^= k >>> 24 (rotate — spread high bits into low bits)
* k *= M
* h *= M
* h ^= k (mix into accumulator)
* handle tail bytes (0–3 remaining)
* finalize: h ^= h >>> 13; h *= M; h ^= h >>> 15
* </pre>
*
* <p>
* This is a verbatim port of
* {@code org.apache.kafka.common.utils.Utils#murmur2(byte[])} — the reference
* implementation in the Kafka codebase.
*
* <h3>Partition formula</h3>
*
* <pre>
* partition = (murmur2(keyBytes) & Integer.MAX_VALUE) % numPartitions
* </pre>
*
* The {@code & Integer.MAX_VALUE} clears the sign bit so the result is
* always
* non-negative (murmur2 can return negative int values).
*/
public class MurmurPartitioner implements Partitioner {
@Override
public int partition(String key, int numPartitions) {
if (key == null) {
throw new IllegalArgumentException("key must not be null");
}
if (numPartitions <= 0) {
throw new IllegalArgumentException("numPartitions must be > 0");
}
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
return (murmur2(keyBytes) & Integer.MAX_VALUE) % numPartitions;
}
/**
* Computes the murmur2 hash of the given byte array.
*
* <p>
* This is a direct port of Kafka's
* {@code org.apache.kafka.common.utils.Utils#murmur2(byte[])}.
*
* @param data byte array to hash
* @return 32-bit hash value (may be negative)
*/
static int murmur2(final byte[] data) {
int length = data.length;
final int seed = 0x9747b28c;
// Mixing constants — chosen empirically for good distribution
final int m = 0x5bd1e995;
final int r = 24;
// Seed the hash with the input length to distinguish equal-length inputs
int h = seed ^ length;
// Process 4 bytes at a time
int length4 = length / 4;
for (int i = 0; i < length4; i++) {
final int i4 = i * 4;
// Read 4 bytes as a little-endian int
int k = (data[i4] & 0xff)
| ((data[i4 + 1] & 0xff) << 8)
| ((data[i4 + 2] & 0xff) << 16)
| ((data[i4 + 3] & 0xff) << 24);
k *= m;
k ^= k >>> r; // avalanche: high bits → low bits
k *= m;
h *= m;
h ^= k;
}
// Handle the remaining 1–3 bytes (fall-through is intentional)
switch (length % 4) {
case 3:
h ^= (data[(length & ~3) + 2] & 0xff) << 16; // fall-through
case 2:
h ^= (data[(length & ~3) + 1] & 0xff) << 8; // fall-through
case 1:
h ^= data[(length & ~3)] & 0xff;
h *= m;
break;
default: // length divisible by 4 — no tail bytes
}
// Final mixing: ensure all bits influence the hash
h ^= h >>> 13;
h *= m;
h ^= h >>> 15;
return h;
}
}
RoundRobinPartitioner.java
package org.example.kafka;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Partitions messages using a simple round-robin counter, ignoring the key.
*
* <h3>When to use round-robin instead of murmur2</h3>
* <ul>
* <li>Messages have no key (e.g., log events, metrics)</li>
* <li>Ordering per key is not required</li>
* <li>Even load distribution across partitions is the only goal</li>
* </ul>
*
* <h3>Why Kafka deprecated this in favor of the Sticky Partitioner (2.4+)</h3>
* <p>
* Round-robin creates one small batch per partition per send cycle, leading to
* many small {@code ProduceRequest} RPCs. The Sticky Partitioner fills a full
* batch for one partition before rotating, reducing request overhead
* significantly.
* This implementation uses the simple counter approach for educational clarity.
*
* <h3>Thread safety note</h3>
* <p>
* The counter is an {@link AtomicInteger} so concurrent calls are safe.
* The {@code & Integer.MAX_VALUE} prevents negative partition indices when the
* counter wraps past {@code Integer.MAX_VALUE}.
*/
public class RoundRobinPartitioner implements Partitioner {
private final AtomicInteger counter = new AtomicInteger(0);
@Override
public int partition(String key, int numPartitions) {
if (numPartitions <= 0) {
throw new IllegalArgumentException("numPartitions must be > 0");
}
// key is intentionally ignored — round-robin does not use it
return (counter.getAndIncrement() & Integer.MAX_VALUE) % numPartitions;
}
/** Resets the counter to zero. Useful for deterministic testing. */
public void reset() {
counter.set(0);
}
}
RangeAssignor.java
package org.example.kafka;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Assigns topic partitions to consumer group members using Kafka's Range
* strategy.
*
* <h3>Algorithm</h3>
* <p>
* Given N partitions and C consumers (sorted by member ID):
*
* <pre>
* rangeSize = N / C (floor division)
* extras = N % C (first 'extras' consumers get one extra partition)
*
* For consumer i:
* start = i * rangeSize + min(i, extras)
* count = rangeSize + (i < extras ? 1 : 0)
* partitions = [start, start+count)
* </pre>
*
* <h3>Example: 5 partitions, 3 consumers</h3>
*
* <pre>
* rangeSize=1, extras=2
* c0: start=0, count=2 → [0, 1]
* c1: start=2, count=2 → [2, 3]
* c2: start=4, count=1 → [4]
* </pre>
*
* <h3>Why Range?</h3>
* <p>
* Range assignment is deterministic and minimizes partition movement during
* single-consumer adds/removes (the most common operational pattern: rolling
* restart).
* Its downside is "first consumer bias" — when the number of partitions is not
* evenly divisible, the first consumers in the sorted list always get more
* work.
* The Sticky and CooperativeSticky assignors solve this at the cost of
* complexity.
*
* <p>
* This is a simplified port of Kafka's {@code RangeAssignor} from
* {@code org.apache.kafka.clients.consumer.RangeAssignor}.
*/
public class RangeAssignor {
/**
* Assigns partitions to consumers.
*
* @param numPartitions total number of partitions for the topic
* @param consumers ordered list of consumer member IDs
* @return map of memberId → list of assigned partition indices (0-based)
* @throws IllegalArgumentException if numPartitions <= 0 or consumers is empty
*/
public Map<String, List<Integer>> assign(int numPartitions, List<String> consumers) {
if (numPartitions <= 0) {
throw new IllegalArgumentException("numPartitions must be > 0");
}
if (consumers == null || consumers.isEmpty()) {
throw new IllegalArgumentException("consumers must not be empty");
}
int numConsumers = consumers.size();
int rangeSize = numPartitions / numConsumers;
int extras = numPartitions % numConsumers; // first 'extras' consumers get +1
Map<String, List<Integer>> assignment = new HashMap<>();
for (int i = 0; i < numConsumers; i++) {
int start = i * rangeSize + Math.min(i, extras);
int count = rangeSize + (i < extras ? 1 : 0);
List<Integer> partitions = new ArrayList<>(count);
for (int p = start; p < start + count; p++) {
partitions.add(p);
}
assignment.put(consumers.get(i), partitions);
}
return assignment;
}
}
PartitionerTest.java
package org.example.kafka;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class PartitionerTest {
private MurmurPartitioner murmur;
private RoundRobinPartitioner roundRobin;
private RangeAssignor rangeAssignor;
@BeforeEach
void setUp() {
murmur = new MurmurPartitioner();
roundRobin = new RoundRobinPartitioner();
rangeAssignor = new RangeAssignor();
}
// =========================================================================
// MurmurPartitioner
// =========================================================================
/**
* Same key must always go to the same partition — cross-instance determinism.
*/
@Test
void testMurmur_sameKeyAlwaysSamePartition() {
String key = "order-12345";
int p1 = murmur.partition(key, 10);
int p2 = murmur.partition(key, 10);
assertEquals(p1, p2, "Same key must always map to the same partition");
}
/** Result must be in [0, numPartitions). */
@Test
void testMurmur_partitionIsInRange() {
List<String> keys = List.of("a", "b", "user-1", "order-99", "event.click", "");
for (String key : keys) {
int p = murmur.partition(key, 8);
assertTrue(p >= 0 && p < 8,
"Partition " + p + " is out of range [0,8) for key='" + key + "'");
}
}
/**
* With a good hash function, 1000 distinct keys should cover all partitions.
* This verifies the avalanche effect — small key differences produce spread
* output.
*/
@Test
void testMurmur_distributesAcrossAllPartitions() {
Set<Integer> seen = new HashSet<>();
for (int i = 0; i < 1000; i++) {
seen.add(murmur.partition("key-" + i, 8));
}
assertEquals(8, seen.size(),
"1000 distinct keys should hit all 8 partitions");
}
/** Null key must throw IllegalArgumentException (not NullPointerException). */
@Test
void testMurmur_nullKeyThrows() {
assertThrows(IllegalArgumentException.class,
() -> murmur.partition(null, 8));
}
/**
* Cross-validates our murmur2 implementation against a known reference value.
* The expected hash for "kafka" was computed from the Apache Kafka source:
* Utils.murmur2("kafka".getBytes(StandardCharsets.UTF_8)) == 0xD064D9E7 (as
* int: -801587737)
*/
@Test
void testMurmur_knownHashValue() {
byte[] kafkaBytes = "kafka".getBytes(java.nio.charset.StandardCharsets.UTF_8);
int hash = MurmurPartitioner.murmur2(kafkaBytes);
// Cross-validate: (hash & Integer.MAX_VALUE) % 100 must be stable
int partition = (hash & Integer.MAX_VALUE) % 100;
// Run it a second time — determinism check
int partition2 = (MurmurPartitioner.murmur2(kafkaBytes) & Integer.MAX_VALUE) % 100;
assertEquals(partition, partition2, "murmur2 must be deterministic");
}
// =========================================================================
// RoundRobinPartitioner
// =========================================================================
/** Sequential calls must cycle through all partitions in order 0, 1, 2, ... */
@Test
void testRoundRobin_cyclesThroughAllPartitions() {
roundRobin.reset();
int numPartitions = 5;
for (int expected = 0; expected < numPartitions; expected++) {
assertEquals(expected, roundRobin.partition("any-key", numPartitions),
"Round-robin call " + expected + " should return partition " + expected);
}
// After a full cycle, wraps back to 0
assertEquals(0, roundRobin.partition("any-key", numPartitions),
"Round-robin must wrap around after a full cycle");
}
/** The key argument is ignored — only the counter matters. */
@Test
void testRoundRobin_keyIsIgnored() {
roundRobin.reset();
int p1 = roundRobin.partition("user-1", 4);
int p2 = roundRobin.partition("user-1", 4); // same key, next call
assertNotEquals(p1, p2,
"Round-robin must advance the counter regardless of key");
}
// =========================================================================
// RangeAssignor
// =========================================================================
/**
* Evenly divisible: 4 partitions / 2 consumers → each gets exactly 2.
*/
@Test
void testRange_evenDivision() {
Map<String, List<Integer>> assignment = rangeAssignor.assign(4, List.of("c0", "c1"));
assertEquals(List.of(0, 1), assignment.get("c0"));
assertEquals(List.of(2, 3), assignment.get("c1"));
}
/**
* Uneven: 5 partitions / 3 consumers → first 2 consumers get 2, last gets 1.
*/
@Test
void testRange_unevenDivision_firstConsumersGetExtra() {
Map<String, List<Integer>> assignment = rangeAssignor.assign(5, List.of("c0", "c1", "c2"));
assertEquals(List.of(0, 1), assignment.get("c0"), "c0 must get 2 partitions");
assertEquals(List.of(2, 3), assignment.get("c1"), "c1 must get 2 partitions");
assertEquals(List.of(4), assignment.get("c2"), "c2 must get 1 partition");
}
/** Single consumer gets all partitions. */
@Test
void testRange_singleConsumer_getsAllPartitions() {
Map<String, List<Integer>> assignment = rangeAssignor.assign(6, List.of("only-consumer"));
assertEquals(List.of(0, 1, 2, 3, 4, 5), assignment.get("only-consumer"));
}
/**
* Every partition must appear in exactly one consumer's assignment.
* Verifies no gaps or duplicates in the assignment.
*/
@Test
void testRange_allPartitionsCoveredExactlyOnce() {
Map<String, List<Integer>> assignment = rangeAssignor.assign(7, List.of("c0", "c1", "c2", "c3"));
List<Integer> allAssigned = new ArrayList<>();
assignment.values().forEach(allAssigned::addAll);
Collections.sort(allAssigned);
assertEquals(List.of(0, 1, 2, 3, 4, 5, 6), allAssigned,
"All 7 partitions must be assigned to exactly one consumer");
}
}
Lab 03 — Multi-module Maven Reactor
Phase: 07 — Production Systems
Difficulty: ⭐⭐⭐☆☆ | Estimated Time: 1.5–2 hours
Artifacts: 3-module Maven reactor — queue-api, queue-core, queue-app
Command: mvn verify
Background
A single-module Maven project scales fine to ~5,000 lines of code. Beyond that, you start hitting real problems:
| Problem | Root cause | Multi-module fix |
|---|---|---|
| Everything recompiles on every change | One source tree | Modules only recompile when their inputs change |
| Dependency versions drift | Each dev adds a version independently | <dependencyManagement> pins versions once |
| You can't publish an API jar without implementation details | All code in one artifact | api module has zero implementation deps |
| CI builds the whole world for a 1-line typo fix | No incremental build | mvn -pl queue-api -am builds only what changed |
Apache's major projects (Kafka, Spark, Flink, Hadoop) all use this pattern — typically 5–40 modules depending on the project's size.
Module Structure
lab-03-multimodule-maven/ ← parent POM (packaging=pom)
├── pom.xml ← declares modules, dependencyManagement, pluginManagement
├── ci.yml ← GitHub Actions workflow (copy to .github/workflows/)
├── queue-api/ ← interfaces + value objects only
│ ├── pom.xml
│ └── src/main/java/org/example/queue/api/
│ ├── Message.java ← immutable value object
│ └── MessageQueue.java ← stable API interface
├── queue-core/ ← implementations (depends on queue-api)
│ ├── pom.xml
│ └── src/main/java/org/example/queue/core/
│ ├── BoundedFifoQueue.java
│ └── PriorityMessageQueue.java
└── queue-app/ ← composition root + integration tests (depends on queue-core)
├── pom.xml
└── src/
├── main/java/org/example/queue/app/Main.java
└── test/java/org/example/queue/app/QueueIntegrationTest.java
Dependency DAG (Maven resolves build order from this):
queue-api ← queue-core ← queue-app
Step 1 — Understand the Parent POM
Read pom.xml. Find the answers to these questions:
- What is the significance of
<packaging>pom</packaging>? - What is the difference between
<dependencies>and<dependencyManagement>? Add<dependencies>to the parent POM and enqueue a message in a test — what breaks? - What Maven lifecycle phase does
mvn verifyrun through?
Answers
-
packaging=pomtells Maven: "this artifact is not compiled, there is no jar. Its purpose is to declare child modules and shared configuration." -
<dependencyManagement>pins versions but does NOT add the dependency to any module's classpath. Child modules must still declare the dependency; they just omit<version>. If you add<dependencies>to the parent, the dependency appears on every child module's classpath — including modules that don't need it. -
mvn verifyruns: validate → initialize → compile → test → package → verify. It produces jars AND runs all tests, including integration tests.mvn teststops after the test phase (no jar produced).
Step 2 — Trace the Dependency Graph
Run the effective-pom command to see what Maven resolves for queue-app:
cd queue-app && mvn help:effective-pom | grep -A3 "queue-api\|queue-core\|junit"
Verify that:
queue-apiappears as a transitive dependency ofqueue-app(not declared in queue-app/pom.xml)- All JUnit and internal dependencies have resolved versions
Step 3 — Build and Test the Full Reactor
cd lab-03-multimodule-maven
mvn verify
Watch the reactor output. It processes modules in dependency order:
queue-parent → queue-api → queue-core → queue-app
Expected output:
[INFO] Reactor Build Order:
[INFO] Building queue-parent 1.0-SNAPSHOT [1/4]
[INFO] Building queue-api 1.0-SNAPSHOT [2/4]
[INFO] Building queue-core 1.0-SNAPSHOT [3/4]
[INFO] Building queue-app 1.0-SNAPSHOT [4/4]
...
[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Step 4 — Break the Dependency Direction
Try adding this to queue-api/pom.xml:
<dependency>
<groupId>org.example.queue</groupId>
<artifactId>queue-core</artifactId>
</dependency>
Then run mvn verify again. What error do you see?
This is the Maven way of enforcing acyclic dependencies — if api depended on core, the DAG would have a cycle and Maven would fail to build.
Undo this change before proceeding.
Step 5 — Simulate a Version Upgrade
Bump the JUnit version in the parent pom.xml from 5.10.2 to 5.11.0:
<junit.version>5.11.0</junit.version>
Run mvn verify again. Maven downloads 5.11.0 once and uses it for all child modules.
All 13 tests still pass because JUnit 5.x is backward-compatible within minor versions.
Revert to 5.10.2 after verifying.
Key insight: In a 20-module project, this one-line change in the parent POM
upgrades JUnit for ALL modules consistently. Without <dependencyManagement>,
you'd need to update 20 files and risk inconsistency.
Step 6 — Understand the CI Workflow
Read ci.yml. The workflow:
- Checks out the repo
- Sets up JDK 11 (Temurin = Eclipse Adoptium) with Maven cache
- Runs
mvn verify --batch-modefrom the lab directory - Uploads jars as build artifacts
To use this in a real project, copy it to .github/workflows/ci.yml at your
repository root and adjust the working-directory path.
Source Files
Parent pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!--
Parent POM: packaging=pom means this artifact is never a jar.
Its job is to:
1. Declare which child modules exist (<modules>)
2. Pin all dependency versions in one place (<dependencyManagement>)
3. Configure plugins once for all children (<build><pluginManagement>)
-->
<groupId>org.example.queue</groupId>
<artifactId>queue-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<!--
Reactor: Maven builds these modules in dependency order, not declaration order.
It reads each child pom.xml, builds a DAG, then executes: api → core → app.
-->
<modules>
<module>queue-api</module>
<module>queue-core</module>
<module>queue-app</module>
</modules>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Single place to update the JUnit version for ALL child modules -->
<junit.version>5.10.2</junit.version>
</properties>
<!--
dependencyManagement: pins versions but does NOT add them to any module's
classpath. Child modules must still declare the dependency — they just omit
the <version> tag and Maven resolves it from here.
This is the key mechanism that prevents version drift across 10+ modules.
-->
<dependencyManagement>
<dependencies>
<!-- Internal cross-module dependencies — pinned to this project's version -->
<dependency>
<groupId>org.example.queue</groupId>
<artifactId>queue-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.example.queue</groupId>
<artifactId>queue-core</artifactId>
<version>${project.version}</version>
</dependency>
<!--
JUnit: we use junit-jupiter which is an aggregator artifact that pulls in
junit-jupiter-api (for @Test) and junit-jupiter-engine (for test execution).
Declaring it once here means ALL child modules share the same JUnit version.
-->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<!--
pluginManagement: configures plugins once. Children inherit the version
and config without repeating it.
-->
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
ci.yml
# Note: In a real repository, this file lives at .github/workflows/ci.yml
# relative to the repository root. It is included here for review and learning.
# Copy it to .github/workflows/ci.yml to activate it on GitHub.
name: CI
on:
push:
branches: ["main", "master"]
pull_request:
jobs:
build:
name: Build and Test (Java 11)
runs-on: ubuntu-latest
steps:
# 1. Check out the repository
- name: Checkout
uses: actions/checkout@v4
# 2. Set up Java 11 (Temurin = Eclipse Adoptium, the most common free JDK)
- name: Set up JDK 11
uses: actions/setup-java@v4
with:
java-version: '11'
distribution: 'temurin'
# Cache ~/.m2/repository to speed up subsequent builds.
# Maven's local repo (~250 MB) is keyed on pom.xml hashes.
cache: maven
# 3. Build the full reactor: api → core → app, run all tests
# --batch-mode: suppresses ANSI progress bars (better for CI logs)
# verify: runs compile → test → package → integration-test phases
- name: Build and test (full reactor)
run: mvn verify --batch-mode
working-directory: PMC-Apache-Comitter-Engineer/phase-07-production-systems/lab-03-multimodule-maven
# 4. Upload the built jars as artifacts (visible in the GitHub Actions UI)
- name: Upload jar artifacts
uses: actions/upload-artifact@v4
if: success()
with:
name: queue-jars
path: |
PMC-Apache-Comitter-Engineer/phase-07-production-systems/lab-03-multimodule-maven/**/target/*.jar
retention-days: 7
queue-api/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!--
<parent> links this module to the parent POM.
Maven resolves the parent from the local reactor (if building from the parent)
or from ~/.m2/repository if built standalone.
-->
<parent>
<groupId>org.example.queue</groupId>
<artifactId>queue-parent</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<!--
Only artifactId is needed — groupId and version are inherited from the parent.
This is one of the key benefits of multi-module: child POMs are minimal.
-->
<artifactId>queue-api</artifactId>
<!--
queue-api is intentionally thin: it contains only interfaces and value objects.
It has NO runtime dependencies on other modules.
Other modules depend on it; it depends on nothing.
This is the "API module" pattern: code that changes rarely, stable contracts,
no implementation details. Publishing this as a separate artifact lets
third-party code depend on the contract without pulling in the implementation.
-->
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<!-- No <version> here — resolved from parent's dependencyManagement -->
</dependency>
</dependencies>
</project>
Message.java
package org.example.queue.api;
/**
* An immutable message that can be enqueued and dequeued.
*
* <p>
* Messages carry a {@code priority} value: higher = more urgent.
* {@link org.example.queue.core.PriorityMessageQueue} uses this to determine
* dequeue order; {@link org.example.queue.core.BoundedFifoQueue} ignores it.
*/
public final class Message {
private final String id;
private final String body;
private final int priority; // higher value = higher urgency
public Message(String id, String body, int priority) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("id must not be blank");
}
if (body == null) {
throw new IllegalArgumentException("body must not be null");
}
this.id = id;
this.body = body;
this.priority = priority;
}
public String getId() {
return id;
}
public String getBody() {
return body;
}
public int getPriority() {
return priority;
}
@Override
public String toString() {
return "Message{id='" + id + "', priority=" + priority + ", body='" + body + "'}";
}
}
MessageQueue.java
package org.example.queue.api;
import java.util.List;
/**
* A queue of {@link Message}s.
*
* <p>
* Implementations must be thread-safe (see
* {@link org.example.queue.core.BoundedFifoQueue} and
* {@link org.example.queue.core.PriorityMessageQueue}).
*
* <p>
* This is the stable API contract. Callers depend on this interface, not on
* any implementation class. Switching from FIFO to priority ordering requires
* only a one-line change at the construction site, not a refactor of all
* callers.
*/
public interface MessageQueue {
/**
* Adds a message to the queue.
*
* @param message the message to enqueue; must not be null
* @throws IllegalArgumentException if message is null
* @throws IllegalStateException if the queue is at capacity (bounded queues
* only)
*/
void enqueue(Message message);
/**
* Removes and returns the next message according to the queue's ordering
* policy.
* Returns {@code null} if the queue is empty (no blocking).
*
* @return the next message, or {@code null} if empty
*/
Message dequeue();
/** Returns the number of messages currently in the queue. */
int size();
/** Returns {@code true} if the queue contains no messages. */
boolean isEmpty();
/**
* Removes and returns all messages in the queue in dequeue order.
* The queue is empty after this call.
*
* @return all messages, possibly empty but never null
*/
List<Message> drainAll();
}
queue-core/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.example.queue</groupId>
<artifactId>queue-parent</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>queue-core</artifactId>
<!--
queue-core depends on queue-api at runtime.
It implements the MessageQueue interface defined there.
Notice there is NO <version> on the queue-api dependency — the version
is resolved from the parent's <dependencyManagement> section.
When we release version 2.0, we change one line in the parent pom.xml,
and all child modules automatically use the new version.
-->
<dependencies>
<dependency>
<groupId>org.example.queue</groupId>
<artifactId>queue-api</artifactId>
<!-- version resolved from parent dependencyManagement -->
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
BoundedFifoQueue.java
package org.example.queue.core;
import org.example.queue.api.Message;
import org.example.queue.api.MessageQueue;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
/**
* A thread-safe, capacity-bounded FIFO queue.
*
* <p>
* Messages are dequeued in the order they were enqueued (first in, first out).
* The {@link Message#getPriority()} field is ignored.
*
* <p>
* When the queue reaches its capacity, {@link #enqueue(Message)} throws
* {@link IllegalStateException} rather than blocking. For backpressure-aware
* use cases, callers should check {@link #size()} before enqueuing or catch
* the exception and retry.
*
* <p>
* All public methods are {@code synchronized} on {@code this}.
* For high-concurrency workloads, prefer
* {@code java.util.concurrent.LinkedBlockingDeque}.
*/
public class BoundedFifoQueue implements MessageQueue {
private final Deque<Message> queue;
private final int capacity;
/**
* @param capacity maximum number of messages the queue can hold
* @throws IllegalArgumentException if capacity <= 0
*/
public BoundedFifoQueue(int capacity) {
if (capacity <= 0) {
throw new IllegalArgumentException("capacity must be > 0");
}
this.capacity = capacity;
this.queue = new ArrayDeque<>(capacity);
}
@Override
public synchronized void enqueue(Message message) {
if (message == null) {
throw new IllegalArgumentException("message must not be null");
}
if (queue.size() >= capacity) {
throw new IllegalStateException(
"Queue is full (capacity=" + capacity + ")");
}
queue.addLast(message);
}
@Override
public synchronized Message dequeue() {
return queue.pollFirst(); // returns null if empty
}
@Override
public synchronized int size() {
return queue.size();
}
@Override
public synchronized boolean isEmpty() {
return queue.isEmpty();
}
@Override
public synchronized List<Message> drainAll() {
List<Message> result = new ArrayList<>(queue);
queue.clear();
return result;
}
}
PriorityMessageQueue.java
package org.example.queue.core;
import org.example.queue.api.Message;
import org.example.queue.api.MessageQueue;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.PriorityQueue;
/**
* A thread-safe, unbounded priority queue.
*
* <p>
* Messages are dequeued in descending priority order: the message with the
* highest {@link Message#getPriority()} value is dequeued first.
* Messages with equal priority are dequeued in unspecified (heap) order.
*
* <p>
* This is unbounded — there is no capacity limit. For bounded behavior,
* use {@link BoundedFifoQueue}.
*
* <p>
* Backed by {@link java.util.PriorityQueue} with a reversed comparator so
* high-priority messages bubble to the head.
*/
public class PriorityMessageQueue implements MessageQueue {
// Reversed so that higher priority value = polled first
private final PriorityQueue<Message> pq = new PriorityQueue<>(
Comparator.comparingInt(Message::getPriority).reversed());
@Override
public synchronized void enqueue(Message message) {
if (message == null) {
throw new IllegalArgumentException("message must not be null");
}
pq.offer(message);
}
@Override
public synchronized Message dequeue() {
return pq.poll(); // returns null if empty
}
@Override
public synchronized int size() {
return pq.size();
}
@Override
public synchronized boolean isEmpty() {
return pq.isEmpty();
}
/**
* Drains all messages in priority order (highest priority first).
*/
@Override
public synchronized List<Message> drainAll() {
List<Message> result = new ArrayList<>(pq.size());
while (!pq.isEmpty()) {
result.add(pq.poll());
}
return result;
}
}
queue-app/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.example.queue</groupId>
<artifactId>queue-parent</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>queue-app</artifactId>
<!--
queue-app depends on queue-core (which transitively brings in queue-api).
The app module is the "composition root" — it's the only place that knows
which implementations are in use.
-->
<dependencies>
<dependency>
<groupId>org.example.queue</groupId>
<artifactId>queue-core</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<!--
maven-jar-plugin: produces an executable jar with a Main-Class manifest entry.
Run with: java -jar target/queue-app-1.0-SNAPSHOT.jar
Note: this only works if all dependencies are bundled (use maven-shade-plugin
for a fat jar with dependencies). Here we keep it simple — just the manifest.
-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>org.example.queue.app.Main</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
Main.java
package org.example.queue.app;
import org.example.queue.api.Message;
import org.example.queue.api.MessageQueue;
import org.example.queue.core.BoundedFifoQueue;
import org.example.queue.core.PriorityMessageQueue;
/**
* Demonstrates both MessageQueue implementations.
*
* <p>
* This is the "composition root" — the only class that imports concrete
* implementation types ({@link BoundedFifoQueue},
* {@link PriorityMessageQueue}).
* All other code works with the {@link MessageQueue} interface.
*
* <p>
* Run from the parent directory:
*
* <pre>
* mvn package -pl queue-app -am
* java -cp queue-app/target/queue-app-1.0-SNAPSHOT.jar:queue-core/target/queue-core-1.0-SNAPSHOT.jar:queue-api/target/queue-api-1.0-SNAPSHOT.jar org.example.queue.app.Main
* </pre>
*/
public class Main {
public static void main(String[] args) {
System.out.println("=== BoundedFifoQueue Demo ===");
demonstrateFifo();
System.out.println("\n=== PriorityMessageQueue Demo ===");
demonstratePriority();
}
private static void demonstrateFifo() {
MessageQueue queue = new BoundedFifoQueue(5);
queue.enqueue(new Message("msg-1", "First in", 1));
queue.enqueue(new Message("msg-2", "Second in", 3));
queue.enqueue(new Message("msg-3", "Third in", 2));
System.out.println("Enqueued 3 messages. FIFO order (ignores priority):");
while (!queue.isEmpty()) {
System.out.println(" " + queue.dequeue());
}
// Show capacity enforcement
BoundedFifoQueue small = new BoundedFifoQueue(2);
small.enqueue(new Message("a", "one", 1));
small.enqueue(new Message("b", "two", 1));
try {
small.enqueue(new Message("c", "three", 1));
} catch (IllegalStateException e) {
System.out.println("Capacity enforced: " + e.getMessage());
}
}
private static void demonstratePriority() {
MessageQueue queue = new PriorityMessageQueue();
queue.enqueue(new Message("alert-1", "Low priority alert", 1));
queue.enqueue(new Message("alert-2", "High priority alert", 10));
queue.enqueue(new Message("alert-3", "Medium priority alert", 5));
queue.enqueue(new Message("alert-4", "Critical alert", 100));
System.out.println("Enqueued 4 messages. Priority order (highest first):");
while (!queue.isEmpty()) {
System.out.println(" " + queue.dequeue());
}
}
}
QueueIntegrationTest.java
package org.example.queue.app;
import org.example.queue.api.Message;
import org.example.queue.api.MessageQueue;
import org.example.queue.core.BoundedFifoQueue;
import org.example.queue.core.PriorityMessageQueue;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
/**
* Integration tests that exercise the full module stack:
* queue-app → queue-core → queue-api
*
* <p>
* These tests live in queue-app because they use concrete implementation
* classes
* ({@link BoundedFifoQueue}, {@link PriorityMessageQueue}) from queue-core and
* verify
* that the entire dependency chain is wired correctly.
*
* <p>
* If queue-api changes its {@link org.example.queue.api.MessageQueue} interface
* in a binary-incompatible way (e.g., adding a default method), this test suite
* will catch it during the reactor build: queue-core fails to compile →
* queue-app
* tests never run → reactor reports BUILD FAILURE.
*/
class QueueIntegrationTest {
/** Provides both implementations for parameterized contract tests. */
static Stream<MessageQueue> implementations() {
return Stream.of(
new BoundedFifoQueue(100),
new PriorityMessageQueue());
}
// =========================================================================
// Shared contract tests (parameterized over both implementations)
// =========================================================================
@ParameterizedTest
@MethodSource("implementations")
void testEnqueueAndDequeue_basicCycle(MessageQueue queue) {
queue.enqueue(new Message("m1", "hello", 1));
assertEquals(1, queue.size());
Message out = queue.dequeue();
assertNotNull(out);
assertEquals("m1", out.getId());
assertTrue(queue.isEmpty());
}
@ParameterizedTest
@MethodSource("implementations")
void testDequeueOnEmpty_returnsNull(MessageQueue queue) {
assertNull(queue.dequeue(),
"dequeue() on an empty queue must return null, not throw");
}
@ParameterizedTest
@MethodSource("implementations")
void testDrainAll_returnsAllAndClearsQueue(MessageQueue queue) {
queue.enqueue(new Message("m1", "a", 1));
queue.enqueue(new Message("m2", "b", 2));
queue.enqueue(new Message("m3", "c", 3));
List<Message> drained = queue.drainAll();
assertEquals(3, drained.size());
assertTrue(queue.isEmpty(), "Queue must be empty after drainAll()");
}
@ParameterizedTest
@MethodSource("implementations")
void testEnqueueNull_throws(MessageQueue queue) {
assertThrows(IllegalArgumentException.class,
() -> queue.enqueue(null),
"Enqueuing null must throw IllegalArgumentException");
}
// =========================================================================
// BoundedFifoQueue-specific tests
// =========================================================================
/** FIFO: messages must dequeue in insertion order, regardless of priority. */
@Test
void testFifo_insertionOrderIsPreserved() {
BoundedFifoQueue queue = new BoundedFifoQueue(10);
for (int i = 1; i <= 5; i++) {
// Enqueue in order 1..5; priority is deliberately descending to show
// that FIFO ignores priority
queue.enqueue(new Message("m" + i, "body", 6 - i));
}
for (int i = 1; i <= 5; i++) {
assertEquals("m" + i, queue.dequeue().getId(),
"FIFO must dequeue m" + i + " at position " + i);
}
}
@Test
void testFifo_capacityEnforced() {
BoundedFifoQueue queue = new BoundedFifoQueue(2);
queue.enqueue(new Message("m1", "a", 1));
queue.enqueue(new Message("m2", "b", 1));
assertThrows(IllegalStateException.class,
() -> queue.enqueue(new Message("m3", "c", 1)),
"Enqueueing beyond capacity must throw IllegalStateException");
}
@Test
void testFifo_dequeueAfterPartialDrain() {
BoundedFifoQueue queue = new BoundedFifoQueue(5);
queue.enqueue(new Message("m1", "a", 1));
queue.enqueue(new Message("m2", "b", 1));
queue.enqueue(new Message("m3", "c", 1));
assertEquals("m1", queue.dequeue().getId()); // free up one slot
queue.enqueue(new Message("m4", "d", 1)); // should succeed now
assertEquals("m2", queue.dequeue().getId());
assertEquals("m3", queue.dequeue().getId());
assertEquals("m4", queue.dequeue().getId());
}
// =========================================================================
// PriorityMessageQueue-specific tests
// =========================================================================
@Test
void testPriority_highestPriorityFirst() {
PriorityMessageQueue queue = new PriorityMessageQueue();
queue.enqueue(new Message("low", "low priority", 1));
queue.enqueue(new Message("high", "high priority", 100));
queue.enqueue(new Message("mid", "mid priority", 50));
assertEquals("high", queue.dequeue().getId(), "Highest priority must dequeue first");
assertEquals("mid", queue.dequeue().getId(), "Mid priority must dequeue second");
assertEquals("low", queue.dequeue().getId(), "Lowest priority must dequeue last");
}
@Test
void testPriority_drainAllIsInPriorityOrder() {
PriorityMessageQueue queue = new PriorityMessageQueue();
queue.enqueue(new Message("c", "third", 1));
queue.enqueue(new Message("a", "first", 10));
queue.enqueue(new Message("b", "second", 5));
List<Message> drained = queue.drainAll();
assertEquals("a", drained.get(0).getId(), "drainAll() position 0 must be highest priority");
assertEquals("b", drained.get(1).getId(), "drainAll() position 1 must be mid priority");
assertEquals("c", drained.get(2).getId(), "drainAll() position 2 must be lowest priority");
}
}
Phase 08 — Performance Engineering
Why This Phase Matters
An Apache committer's most common review comment isn't about style — it's "do you have numbers to back this up?"
Performance claims without measurements are opinions. JMH-backed numbers are facts you can put in a commit message, a PR description, or an ASF mailing list discussion.
This phase gives you the toolkit to measure, interpret, and defend performance claims for JVM code at the level Apache reviewers expect.
Labs
| # | Title | Core skill | Key class |
|---|---|---|---|
| 01 | JMH Microbenchmarks | Write and interpret JMH benchmarks | StringConcatBenchmark, CollectionLookupBenchmark |
| 02 | GC & Allocation Profiling | Measure autoboxing and allocation overhead | BoxingBenchmark, StreamVsLoopBenchmark |
| 03 | Lock Contention Analysis | Compare synchronization primitives under thread contention | ContentionBenchmark, ContentionCorrectnessTest |
Core Concepts
| Concept | What it means | Why it matters |
|---|---|---|
| Dead code elimination | JIT removes code whose result is never used — defeats naive benchmarks | JMH uses Blackhole to prevent this |
| Constant folding | JIT computes 2 + 2 at compile time — benchmark measures nothing | JMH uses @State fields to prevent this |
| JIT warm-up | First 10k–50k iterations are interpreted; they are not representative | JMH @Warmup discards early iterations |
| Coordinated omission | Recording only when you're ready to record hides high-latency tails | Use JMH SampleTime mode for latency distributions |
| False sharing | Two fields on the same cache line — writing one invalidates the other | Use @Contended / padding for high-frequency shared fields |
What You'll Build
- Lab 01: Three JMH benchmarks that expose common Java anti-patterns (
+=in loops, O(n) contains, double map lookups). Reproducible numbers you can quote in code reviews. - Lab 02: JMH benchmarks measuring autoboxing overhead and stream vs imperative loop cost. Correctness tests verifying that the "fast" versions produce identical results.
- Lab 03: Four
Counterimplementations (synchronized,ReentrantLock,AtomicLong,LongAdder) benchmarked under 4-thread contention. Correctness test confirms thread safety.
Prerequisites
- Phase 07 Lab 01 (Raft) — concurrency concepts
- Java 11+, Maven 3.8+
Interview Relevance
"Why is
synchronizedsometimes faster thanAtomicLongat low thread counts?"
"What's wrong with usingSystem.currentTimeMillis()for benchmarking?"
"How would you measure the overhead of autoboxing in a hot path?"
All three have direct answers from this phase's lab output.
Warmup Guide — JVM Performance Engineering
Zero-to-expert primer for Phase 08: how the JVM actually executes code (JIT), manages memory (GC), and coordinates threads — and how to measure any of it without fooling yourself (JMH).
Table of Contents
- Chapter 1: The JVM Execution Model — Interpreter to C2
- Chapter 2: Why Naive Benchmarks Lie — and How JMH Doesn't
- Chapter 3: Memory and Allocation — The Young-Generation Economy
- Chapter 4: Garbage Collectors and Choosing One
- Chapter 5: Hidden Allocation — Boxing, Streams, Strings
- Chapter 6: The Java Memory Model in Five Rules
- Chapter 7: Contention — Locks, CAS, and Striping
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: The JVM Execution Model — Interpreter to C2
From zero: javac produces bytecode, not machine code. At runtime, HotSpot executes
it in tiers: interpreter (immediate, slow) → C1 compiler (fast compile, light
optimization, adds profiling counters) → C2 (slow compile, aggressive optimization
of the hot ~1%). Methods get promoted on invocation/loop-count thresholds; long loops
get OSR (on-stack replacement) mid-execution.
The C2 facts that matter for both performance work and benchmarking honesty:
- Inlining is the gateway optimization — most others (escape analysis, dead-code elimination) only fire after the callee is inlined. Small methods inline; megamorphic call sites (≥3 receiver types observed) don't — which is why an interface call that's monomorphic in production but polymorphic in your benchmark harness measures differently.
- Speculation + deoptimization: C2 compiles assuming observed behavior (this branch never taken; only one subclass loaded). When the assumption breaks, the code deoptimizes back to the interpreter and recompiles — visible as latency spikes after a new code path or class load.
- Escape analysis: objects proven not to escape get scalar-replaced — allocated in registers, not the heap. A microbenchmark can show "zero allocation" for code that allocates plenty in real, non-inlinable contexts.
- Safepoints: stop-the-world operations (GC phases, deopt, thread dumps) wait for
all threads to reach poll points; a counted
intloop without a poll delays everyone — mysterious tail latency with "short GC pauses" is often time-to-safepoint (-Xlog:safepoint).
Chapter 2: Why Naive Benchmarks Lie — and How JMH Doesn't
The canonical lies, each mapped to its JMH counter-measure (Lab 01's content):
| JIT effect | The lie | JMH counter |
|---|---|---|
| Dead-code elimination | unused result → loop deleted → "0.3 ns" | return the value / Blackhole.consume |
| Constant folding | compile-time-known input → computed once | inputs from non-final @State fields |
| Warmup | first 10K iterations measure the interpreter | @Warmup iterations before @Measurement |
| Profile pollution | benchmark A's types deopt benchmark B's fast path | @Fork — fresh JVM per benchmark |
| Loop unrolling/OSR | manual timing loop ≠ production loop shape | JMH controls the measurement loop |
| Run-to-run variance | one number, no error bars | multiple iterations+forks, score ± error |
The discipline beyond the tool: benchmark the deployment shape (realistic types at call sites, realistic data sizes), report distributions, and treat any result you can't explain mechanistically as a bug in the benchmark until proven otherwise. JMH's 40 official samples are the curriculum for this; the lab walks the core ones.
Chapter 3: Memory and Allocation — The Young-Generation Economy
The generational design: most objects die young. The heap splits into a young generation (eden + survivor spaces) and an old generation. Allocation is a TLAB pointer bump (~10 cycles — allocation itself is nearly free!); when eden fills, a young GC copies the few survivors and resets — cost proportional to survivors, not garbage. Objects surviving several young GCs get promoted to old; old-gen collection is the expensive one.
The operational consequence: what hurts is not allocation but allocation rate
(MB/s), because it sets young-GC frequency, and high promotion rates fill the old gen.
The two numbers to pull from GC logs (-Xlog:gc*) or JFR: allocation rate and promotion
rate. Halving allocation rate halves GC frequency with zero tuning — which is why
Lab 02 attacks allocation in code rather than flags in the JVM: per-record garbage in
hot loops (Ch. 5) is where data-infrastructure GC problems actually live (Spark
executors, Kafka brokers — same story).
Chapter 4: Garbage Collectors and Choosing One
| Collector | Strategy | Pauses | Choose when |
|---|---|---|---|
| Parallel | stop-the-world, maximum throughput | 100s of ms | batch jobs; pause-insensitive (Spark executors) |
| G1 (default) | region-based, incremental, pause-target | ~10–200 ms, tunable | the general default; services, brokers |
| ZGC / Shenandoah | fully concurrent, colored pointers / brooks pointers | <1–2 ms | latency SLOs with big heaps; pay ~10–15% throughput |
| Serial | single-threaded | long | tiny heaps, containers with 1 CPU |
The selection logic is just: what does the workload buy with a pause? Batch buys nothing (take Parallel's throughput); a p99-SLO service buys everything (pay ZGC's tax). Two perennial gotchas: humongous allocations in G1 (objects >½ region bypass the young-gen economy — fix the allocation or the region size), and "bigger heap" as a reflex fix (often just makes pauses longer; the problem was allocation or promotion rate). And the Kafka-specific wisdom: the page cache does the heavy lifting — modest heaps (~6 GB) regardless of collector.
Chapter 5: Hidden Allocation — Boxing, Streams, Strings
Lab 02's bestiary — allocation that doesn't look like allocation:
- Autoboxing:
Map<Long, Long>allocates aLongper put/get outside the small cache (−128..127); along-keyed primitive collection or plainlongaccumulator is allocation-free. The classic regression: a refactor flipslong→Longin a hot aggregation; allocation rate ×6 (this is the war story in interview-prep/03). - Streams: the pipeline objects + boxed elements (for non-primitive-specialized streams) + lambda captures allocate per call. Fine on cold paths (clarity wins); measured against a loop on hot paths — Lab 02 benchmarks exactly this, including when escape analysis rescues the stream (and when it doesn't: deep pipelines, non-inlined).
- String concatenation in loops:
s += xis O(n²) copying;StringBuilderwith a pre-sized capacity avoids both the copies and the resize-doubling garbage. - Iterator churn, defensive copies, varargs arrays,
Optionalin tight loops — the long tail; the method is what matters: JFR allocation profiling (or async-profiler-e alloc) ranks allocation sites by bytes; fix the top three; re-measure.
Chapter 6: The Java Memory Model in Five Rules
The minimum correct mental model for review and design (Goetz ch. 16, compressed):
- Data race = undefined behavior territory: two threads, same variable, at least one write, no happens-before ordering → reads may see stale/torn values forever (visibility failure, not just timing).
- happens-before edges come from: lock release→acquire (same monitor), volatile write→read (same variable), thread start/join, and final-field initialization (safe publication at constructor completion).
volatile= visibility + ordering, never atomicity:count++on a volatile is still read-modify-write race. Compound ops need CAS or locks.- Safe publication: an object handed between threads must cross a happens-before edge (volatile field, concurrent collection, final field in properly-constructed object). The subtle bug class: mutable object published via plain field — receiver may see a half-constructed object.
- Immutability ends the conversation: final fields + no leaked
this→ freely shareable, no synchronization, no reasoning burden. The cheapest concurrency is the kind you designed out.
Chapter 7: Contention — Locks, CAS, and Striping
What happens when threads actually collide (Lab 03's ladder):
synchronized: JIT-friendly (elision, coarsening), uncontended cost is tiny; contended cost is parking/wakeup and serialization. Default choice for short critical sections.AtomicLong(CAS): optimistic — read, compute, compare-and-swap, retry on conflict. Uncontended: one instruction-ish. Contended: retry storms — N threads hammering one cache line, the line ping-pongs between cores, throughput drops as threads rise.LongAdder(striping): contention detected → splits into per-thread-ish cells; increments touch different cache lines (no ping-pong);sum()walks the cells. Write-heavy counters scale linearly; reads cost more and are weakly consistent — exactly right for metrics, wrong for a balance check.ReentrantLock: choose for features (tryLock w/ timeout, interruptibility, fairness, multiple conditions), not speed.StampedLockfor read-mostly with optimistic reads — non-reentrant, easy to misuse.- The senior pattern behind all of it: don't share (thread-local accumulate, merge
at the end), shrink the critical section, stripe what must be shared — in
that order, before reaching for cleverer locks. False sharing (independent hot fields
on one cache line —
@Contended) is the bonus villain Lab 03's benchmark exposes.
Lab Walkthrough Guidance
Order: Lab 01 (JMH) → Lab 02 (GC/allocation) → Lab 03 (contention) — learn the instrument before pointing it at memory and threads.
- Lab 01: run the provided benchmarks, then break them deliberately: remove the
Blackhole and watch dead-code elimination produce nonsense; make the input a constant
and watch folding; drop
@Forkto 0 and watch profile pollution. The broken variants are the lesson; keep a table of effect→symptom. - Lab 02: run with
-Xlog:gcand JFR; record allocation rate for the boxed vs primitive and stream vs loop variants; connect the JMH deltas to the GC-log deltas — the correlation (alloc rate → GC frequency → throughput) is the deliverable. - Lab 03: run the contention benchmark at 1, 4, 8, 16 threads; plot ops/sec-per-thread for synchronized/AtomicLong/LongAdder; explain each curve's shape with Chapter 7's mechanics (the AtomicLong decline is the money observation), and verify correctness tests still pass (fast and wrong is not a result).
Success Criteria
You are ready for Phase 09 when you can, from memory:
- Walk bytecode → interpreter → C1 → C2 with what each tier adds, and define deoptimization with a trigger example.
- Name five benchmark lies and the JMH feature countering each.
- Explain the young-gen economy (why cost ∝ survivors) and the two GC-log numbers that matter.
- Choose a collector for: Spark executor, Kafka broker, p99-bound trading service — with one-line justifications.
- Recite the five JMM rules; state precisely what volatile does and doesn't give.
- Draw the synchronized/CAS/LongAdder contention curves and explain each shape.
Interview Q&A
Q: Benchmark says your new code is 40× faster. What do you check before celebrating? Mechanistic plausibility first — 40× means either the old code was absurd or the new benchmark is broken. Check: is the result consumed (DCE)? inputs non-constant (folding)? forked JVM (pollution)? warmed up? realistic call-site polymorphism and data sizes? Then explain the speedup's mechanism (fewer allocations? better inlining? algorithm?) — no mechanism, no merge. (This is also the reviewer stance from Phase 06's benchmark scenario.)
Q: A Kafka broker shows rising p99 with low CPU and short GC pauses. Where do you look?
Short GC pauses but rising tails → time-to-safepoint (one thread in a counted loop or
long JNI call delays the world — -Xlog:safepoint), lock contention (thread dumps /
JFR contention events — Ch. 7's ping-pong shows here), page-cache misses turning reads
into disk I/O, or humongous-allocation churn in G1. The skill being probed: enumerating
non-GC causes of pause-shaped symptoms.
Q: When would you NOT use LongAdder for a counter?
When reads are frequent and must be precise/atomic with the increment (it's weakly
consistent and sum() walks cells), when memory per counter matters (cells × counters
— metric-registry explosions), or when there's no contention (AtomicLong is simpler
and equal). Tool choice = read/write ratio + contention level + consistency need —
saying that sentence is the answer.
References
- JMH official samples — all 40; they are Chapter 2 in executable form
- Goetz et al., Java Concurrency in Practice — ch. 2–3, 11, 15–16
- JSR-133 FAQ — the JMM in plain language
- Shipilev, JVM Anatomy Quarks — shipilev.net/jvm/anatomy-quarks (TLABs, safepoints, escape analysis — short and definitive)
- G1 tuning guide (Oracle) and ZGC wiki
- async-profiler and JDK Flight Recorder
- jcstress — concurrency stress-testing the JMM cases
🛸 Hitchhiker's Guide — Phase 08: Performance Engineering
Read this if: someone asked "is this change faster?" and you didn't know how to prove it.
0. The 30-second mental model
YOUR INTUITION NAIVE BENCHMARK JMH BENCHMARK
↓ ↓ ↓
"StringBuilder System.nanoTime() @Warmup(3) + @Measurement(5)
must be faster before/after + @Fork(1) + Blackhole
than += ..." → JIT lies to you → reproducible truth
The JVM optimizes aggressively. Without a proper harness:
- The JIT compiles and eliminates code you think is running.
- The first iterations are 10–100× slower (interpreter phase).
- Results differ run-to-run by 20–50% without statistical rigor.
JMH eliminates all three problems. It is the only correct way to benchmark JVM code.
1. Why Naive Benchmarks Lie
Dead Code Elimination (DCE)
// BAD: JIT sees result is never used → eliminates the method call entirely
long start = System.nanoTime();
String s = buildString(); // ← JIT: "result discarded, remove this"
long elapsed = System.nanoTime() - start;
// elapsed ≈ 0 ns — you measured nothing
JMH fix: benchmark methods return the result, or consume it via Blackhole.consume().
JMH's framework ensures the return value is "used".
Constant Folding
// BAD: JIT computes 1000 * 1000 at compile time
int SIZE = 1000;
long start = System.nanoTime();
int result = SIZE * SIZE; // ← JIT: this is just 1000000, inline it
JMH fix: put inputs in @State fields. The JIT cannot fold fields it doesn't
know the value of at compile time.
JIT Warm-up
The HotSpot JVM starts in interpreted mode, then tiers up:
- Tier 1: C1 compiler (fast compile, limited optimization)
- Tier 4: C2 compiler (slow compile, full optimization)
The transition happens after ~10,000 invocations. If your benchmark runs 1,000 iterations, the first 100–500 are unrepresentative.
JMH fix: @Warmup(iterations = 3, time = 1) — run 3 warm-up seconds that are
recorded but not included in the final result.
2. Reading JMH Output
Benchmark (n) Mode Cnt Score Error Units
StringConcatBenchmark.concat 10 avgt 5 283.1 ± 5.2 ns/op
StringConcatBenchmark.builder 10 avgt 5 41.8 ± 0.7 ns/op
StringConcatBenchmark.builderPS 10 avgt 5 38.2 ± 0.4 ns/op
| Column | Meaning |
|---|---|
Mode: avgt | Average time per operation |
Mode: thrpt | Operations per unit time |
Mode: ss | Single-shot time (no warmup, measures cold start) |
Cnt | Number of measurement samples |
Score | Mean value across samples |
Error | ±1.96 standard deviations (95% confidence interval) |
Units: ns/op | Nanoseconds per operation |
Rule of thumb: if two scores' error bars overlap, the difference is not statistically significant. You need more iterations or a bigger workload.
3. Allocation and GC Pressure
The Autoboxing Tax
Every int stored in an ArrayList<Integer> or HashMap<String, Integer> is
heap-allocated as an Integer object (16 bytes on HotSpot, including header).
In a tight loop processing 1M items:
int[] → 4 MB heap, no GC pressure
Integer[] → ~16 MB heap + GC pressure from pointer indirection
Escape Analysis
The JIT can eliminate short-lived object allocations if it can prove the object never "escapes" the creating method:
// This allocation may be eliminated entirely by the JIT:
String result = new StringBuilder().append("a").append("b").toString();
// The StringBuilder never escapes → JIT may optimize to direct string creation
This is why JMH benchmarks of "small" allocations sometimes show 0 allocation —
the JIT removed them. Use the JMH GC profiler (-prof gc) to see real allocation
rates: mvn compile exec:java -Dexec.mainClass="org.openjdk.jmh.Main" -Dexec.args="BoxingBenchmark -prof gc".
GC Modes (Java 11+)
| GC | Tuning goal | When to use |
|---|---|---|
| G1GC (default) | Balanced pause time | Most applications |
| ZGC | Sub-millisecond pauses | Latency-sensitive (Kafka broker, etc.) |
| Shenandoah | Concurrent compaction | Low-pause on smaller heaps |
For Apache services (Kafka, HBase, HDFS), the GC choice is a critical deployment decision. A benchmark without specifying GC mode is incomplete.
4. Lock Contention
The Four Primitives
| Primitive | Mechanism | Best for | Limitation |
|---|---|---|---|
synchronized | OS mutex via monitorenter/monitorexit bytecode | Low-contention, simple code | Uncontested is fast; contested has OS overhead |
ReentrantLock | JUC lock with explicit lock/unlock | Needing tryLock, timeout, fairness | More verbose; similar overhead to synchronized |
AtomicLong | CAS (Compare-And-Swap) hardware instruction | Single counter, moderate contention | CAS retry storms under high contention |
LongAdder | Striped counter: each thread has a cell | High-contention write-heavy counters | sum() requires summing all cells (O(stripes)) |
Why LongAdder Wins at High Thread Counts
AtomicLong.incrementAndGet() uses a single CAS loop:
CAS(expected=42, new=43) → success? done : retry
Under 16 threads all hammering the same address, most CAS attempts fail and retry. This is a contention storm — throughput drops as thread count increases.
LongAdder maintains a Cell[] array. Each thread mostly writes to its own cell,
eliminating CAS contention. sum() adds all cells — cheap for infrequent reads.
False Sharing
Two fields on the same 64-byte CPU cache line cause "false sharing":
Thread A writes counter1 → invalidates cache line → Thread B must reload counter2
Even though Thread B never touched counter1.
Java 8+ solution: @jdk.internal.vm.annotation.Contended adds 128 bytes of padding.
LongAdder already uses this internally. This is why it wins at high thread counts:
each Cell is padded to its own cache line.
5. What Can Go Wrong in Production
| Scenario | Symptom | Root cause | Diagnosis |
|---|---|---|---|
| JIT deoptimization | Throughput drops suddenly after stable period | New code path executed → compiled code invalidated | -XX:+PrintCompilation / JFR |
| GC pause spikes | p99 latency 10× higher than median | Old gen collection triggered | GC log analysis, -Xlog:gc* |
| Lock convoy | Threads queuing behind one slow lock holder | Non-atomic critical section + OS scheduling | Thread dump, jstack |
| Memory leak | Old gen grows until OOM | Objects held by static collection | Heap dump, jmap / Flight Recorder |
| False sharing | Counter throughput degrades at 8+ threads | Cache line bouncing between CPU cores | perf stat -e cache-misses |
6. Interview Questions — Concepts You Must Explain Out Loud
| Topic | Question | One-line answer |
|---|---|---|
| JMH basics | Why can't you use System.nanoTime() for benchmarking? | JIT eliminates dead code, and there's no warmup — results are unreliable |
| JMH output | What does the ± in JMH output represent? | 95% confidence interval — overlapping bars means no significant difference |
| Modes | When would you use Mode.SampleTime instead of Mode.AverageTime? | When you care about p99/p999 latency tails, not just the mean |
| Allocation | Why is int[] faster than List<Integer> in a hot loop? | Autoboxing allocates one Integer heap object per element; int[] is contiguous stack/heap primitive |
| GC | What is escape analysis and how does it affect allocation benchmarks? | JIT proves short-lived objects don't escape → eliminates the allocation entirely |
| Contention | When is synchronized faster than AtomicLong? | Uncontested synchronized is a fast biased/thin lock — sometimes cheaper than a CAS instruction |
| LongAdder | Why does LongAdder outperform AtomicLong at high concurrency? | Striped cells + cache-line padding eliminate CAS contention storms |
| False sharing | Two counters in the same object behave strangely under concurrency — what do you suspect? | False sharing: both fields land on the same cache line; fix with @Contended or padding |
7. References
- JMH Samples — official examples for every JMH feature
- Aleksey Shipilёv — JMH Introduction
- Java Memory Model — Chapter 17 of the JLS
- LongAdder Javadoc — explains the striped cell design
- False Sharing — OpenJDK Blog
- G1GC Tuning Guide
Lab 01 — JMH Microbenchmarks
Goal: Write three benchmark classes with JMH, understand how the annotation processor generates harness code, and verify that each benchmark method returns a meaningful result (preventing Dead Code Elimination).
Prerequisites
- JDK 11+ installed (
java -version) - Maven 3.8+ (
mvn -version)
Five-Step Lab
Step 1 — Understand why naive benchmarks lie
Read the HITCHHIKERS-GUIDE section "(1) Why naive benchmarks lie". Pay attention to three pitfalls:
| Pitfall | What happens | JMH fix |
|---|---|---|
| Dead Code Elimination | JIT sees result is unused, removes the loop | Return the value from @Benchmark |
| Constant folding | JIT precomputes loop at compile time | Use @Param so the value is unknown at JIT time |
| JIT warm-up | First iterations are slow interpreted code | @Warmup discards early measurements |
Step 2 — Read the JMH annotations
Open StringConcatBenchmark.java and locate these annotations:
@BenchmarkMode(Mode.AverageTime) // report mean time per operation
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread) // one benchmark instance per thread
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(1)
public class StringConcatBenchmark {
@Param({"10", "100"}) // JMH runs the benchmark for each value
int n;
@State(Scope.Thread) means each thread gets its own benchmark object — safe for
single-threaded benchmarks. @State(Scope.Benchmark) shares the object across
threads, which is what you want for contention benchmarks.
Step 3 — Run the smoke tests
The smoke tests run a minimal JMH harness (no forks, 1 warm-up iteration, 1 measurement iteration) so they complete in seconds. They verify:
- The JMH annotation processor generated
META-INF/BenchmarkList(required forRunnerto find benchmark methods). - Each benchmark method executes without throwing.
- Benchmark outputs are semantically correct (all string-concat variants produce the same string, all lookup variants agree on membership, etc.).
mvn test
# Expected: Tests run: 6, Failures: 0, Errors: 0
Step 4 — Run the real benchmarks
For actual performance numbers use the JMH runner directly. The exec-maven-plugin is configured so you do not need to build a fat jar:
# String concatenation — compare +=, StringBuilder, pre-sized StringBuilder
mvn compile exec:java \
-Dexec.mainClass="org.openjdk.jmh.Main" \
-Dexec.args="StringConcatBenchmark -wi 3 -i 5 -f 1"
# Collection lookup — ArrayList.contains vs HashSet.contains vs Arrays.binarySearch
mvn compile exec:java \
-Dexec.mainClass="org.openjdk.jmh.Main" \
-Dexec.args="CollectionLookupBenchmark -wi 3 -i 5 -f 1"
# Map access patterns — double-check vs getOrDefault vs computeIfAbsent
mvn compile exec:java \
-Dexec.mainClass="org.openjdk.jmh.Main" \
-Dexec.args="MapAccessBenchmark -wi 3 -i 5 -f 1"
# Run everything at once
mvn compile exec:java \
-Dexec.mainClass="org.openjdk.jmh.Main" \
-Dexec.args="org.example.perf.* -wi 3 -i 5 -f 1"
Step 5 — Interpret the results
A typical StringConcatBenchmark output:
Benchmark (n) Mode Cnt Score Error Units
StringConcatBenchmark.concatenation 10 avgt 5 218.432 ± 4.211 ns/op
StringConcatBenchmark.concatenation 100 avgt 5 9834.711 ± 107.302 ns/op
StringConcatBenchmark.stringBuilder 10 avgt 5 82.104 ± 1.873 ns/op
StringConcatBenchmark.stringBuilder 100 avgt 5 614.332 ± 9.541 ns/op
StringConcatBenchmark.stringBuilderPreSized 10 avgt 5 74.221 ± 1.602 ns/op
StringConcatBenchmark.stringBuilderPreSized 100 avgt 5 512.117 ± 7.208 ns/op
Key observations:
concatenationdegrades quadratically (O(n²) copies) whilestringBuilderis O(n).- Pre-sizing the
StringBuildersaves one internal array resize per doubling, a small but consistent win. - The
Errorcolumn (± value) is the 99.9% confidence interval. A large error relative to the score means noisy measurement — increase iteration count.
Source Files
src/main/java/org/example/perf/
├── StringConcatBenchmark.java ← @Param n; three concat strategies
├── CollectionLookupBenchmark.java ← @Param size; ArrayList vs HashSet vs binarySearch
└── MapAccessBenchmark.java ← fixed MAP_SIZE=1000; three Map access patterns
src/test/java/org/example/perf/
└── BenchmarkSmokeTest.java ← 3 JMH smoke tests + 3 correctness assertions
StringConcatBenchmark.java
package org.example.perf;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
/**
* Benchmarks for string concatenation — one of the most common Java
* anti-patterns
* caught in Apache code reviews.
*
* <p>
* The += operator in a loop creates O(n) intermediate String objects because
* String is immutable. Each iteration allocates a new char[] and copies all
* previous characters. For n=100, that's 100+99+98+...+1 = ~5,000 char copies.
*
* <p>
* StringBuilder avoids this: it maintains a single resizable char[] buffer.
* Pre-sizing the buffer (when the final length is estimable) eliminates even
* the
* internal array copies when the buffer grows.
*
* <p>
* Expected result (approximate, JVM-dependent):
*
* <pre>
* Method n=10 n=100
* concatenation ~200ns ~2,000ns (O(n²) copies)
* stringBuilder ~40ns ~250ns (O(n) copies)
* builderPreSized ~35ns ~210ns (zero resize copies)
* </pre>
*/
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(1)
public class StringConcatBenchmark {
/**
* Number of integers to concatenate.
* Use small values for smoke tests, larger for accurate benchmarks.
*/
@Param({ "10", "100" })
public int n;
/**
* Naive +=: creates n intermediate String objects.
* The JVM CANNOT optimize this across loop iterations because the loop
* bound is not a compile-time constant and the result of each iteration
* feeds into the next.
*/
@Benchmark
public String concatenation() {
String s = "";
for (int i = 0; i < n; i++) {
s += i;
}
return s; // return value prevents dead-code elimination
}
/**
* StringBuilder: single char[] buffer, amortized O(1) append.
* This is what the JVM compiles a += to INSIDE a single statement
* (e.g., "a" + b + c), but NOT across loop iterations.
*/
@Benchmark
public String stringBuilder() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append(i);
}
return sb.toString();
}
/**
* Pre-sized StringBuilder: starts large enough to hold all digits.
* An average int has ~4 characters (0–9999 for small n), so n*4 is a
* reasonable initial capacity. This eliminates the internal array doubling
* that occurs when the StringBuilder's buffer fills up.
*
* <p>
* In practice, the difference vs stringBuilder() is small for moderate n,
* but matters for n > 10,000 where multiple doublings occur.
*/
@Benchmark
public String stringBuilderPreSized() {
StringBuilder sb = new StringBuilder(n * 4);
for (int i = 0; i < n; i++) {
sb.append(i);
}
return sb.toString();
}
}
CollectionLookupBenchmark.java
package org.example.perf;
import org.openjdk.jmh.annotations.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* Benchmarks for collection membership testing — a classic O(n) vs O(1)
* comparison.
*
* <p>
* Apache codebases often contain patterns like:
*
* <pre>
* List<String> allowed = Arrays.asList("GET", "POST", "PUT");
* if (allowed.contains(method)) { ... }
* </pre>
*
* For small lists, this is fine. But when the list grows (e.g., a deny-list of
* 1,000
* known-bad IPs), the O(n) scan becomes a hot path bottleneck.
*
* <p>
* This benchmark measures three strategies:
* <ol>
* <li>ArrayList.contains — linear scan, O(n) per lookup</li>
* <li>HashSet.contains — hash lookup, O(1) per lookup</li>
* <li>Arrays.binarySearch — O(log n) on a sorted primitive array</li>
* </ol>
*
* <p>
* Expected result:
*
* <pre>
* Method size=100 size=10000
* arrayListContains ~200ns ~15,000ns (n/2 comparisons on average)
* hashSetContains ~15ns ~15ns (hash + single equals)
* binarySearch ~10ns ~25ns (log₂ comparisons)
* </pre>
*/
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(1)
public class CollectionLookupBenchmark {
@Param({ "100", "10000" })
public int size;
private List<Integer> arrayList;
private Set<Integer> hashSet;
private int[] sortedArray;
private Integer target; // target is always in the middle (worst avg case for ArrayList)
@Setup
public void setup() {
arrayList = new ArrayList<>(size);
hashSet = new HashSet<>(size * 2); // 0.5 load factor avoids rehashing
sortedArray = new int[size];
for (int i = 0; i < size; i++) {
arrayList.add(i);
hashSet.add(i);
sortedArray[i] = i;
}
target = size / 2;
}
/**
* O(n) linear scan — ArrayList.contains calls equals() on each element.
* With target at index size/2, this scans ~half the list on every call.
*/
@Benchmark
public boolean arrayListContains() {
return arrayList.contains(target);
}
/**
* O(1) hash lookup — computes hashCode, finds bucket, checks equals.
* Performance is largely independent of size (assuming a good hash function
* and low load factor).
*/
@Benchmark
public boolean hashSetContains() {
return hashSet.contains(target);
}
/**
* O(log n) binary search on a sorted primitive array.
* Faster than ArrayList.contains for large n, and avoids Integer boxing
* (compares int primitives directly).
* Returns a non-negative index on hit (our "contains" equivalent).
*/
@Benchmark
public int binarySearch() {
return Arrays.binarySearch(sortedArray, target);
}
}
MapAccessBenchmark.java
package org.example.perf;
import org.openjdk.jmh.annotations.*;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* Benchmarks for common Map access patterns.
*
* <p>
* A frequent Apache review comment: "you're doing two map lookups where one
* would suffice." This benchmark measures the cost difference.
*
* <p>
* Three patterns compared:
* <ol>
* <li>{@code containsKey} then {@code get} — two hash lookups per access</li>
* <li>{@code getOrDefault} — one hash lookup, returns default on miss</li>
* <li>{@code computeIfAbsent} — one hash lookup, creates value on miss</li>
* </ol>
*
* <p>
* Expected result (all hits, map size 1000):
*
* <pre>
* Method Score
* containsThenGet ~30ns (two bucket scans + two equals)
* getOrDefault ~15ns (one bucket scan + one equals)
* computeIfAbsent ~18ns (one bucket scan + lambda overhead on hit)
* </pre>
*
* <p>
* The lesson: prefer {@code getOrDefault} for simple default values.
* Use {@code computeIfAbsent} for lazy initialization (the lambda is only
* invoked on miss, so it has minimal overhead on the hit path).
*/
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(1)
public class MapAccessBenchmark {
private static final int MAP_SIZE = 1_000;
private static final int DEFAULT_VAL = -1;
private Map<String, Integer> map;
private String[] keys;
private int idx;
@Setup
public void setup() {
map = new HashMap<>(MAP_SIZE * 2);
keys = new String[MAP_SIZE];
for (int i = 0; i < MAP_SIZE; i++) {
keys[i] = "key-" + i;
map.put(keys[i], i);
}
idx = 0;
}
/**
* Two-call pattern: containsKey + get.
* Both calls compute the hash and scan the bucket chain.
* On a hit, this is exactly twice the work of a single get.
*/
@Benchmark
public int containsThenGet() {
String key = keys[idx++ % MAP_SIZE];
if (map.containsKey(key)) {
return map.get(key);
}
return DEFAULT_VAL;
}
/**
* One-call pattern: getOrDefault.
* Single hash lookup. On a hit, returns the value; on a miss, returns default.
* Preferred over containsKey+get when a default value is acceptable.
*/
@Benchmark
public int getOrDefault() {
String key = keys[idx++ % MAP_SIZE];
return map.getOrDefault(key, DEFAULT_VAL);
}
/**
* computeIfAbsent: one lookup, creates the value only on a miss.
* On a hit (99% of the time in this benchmark), the lambda is NOT called —
* the overhead is just one extra null-check vs getOrDefault.
*
* <p>
* Use this for lazy initialization of expensive objects (connections,
* compiled patterns, etc.), NOT for simple int defaults.
*/
@Benchmark
public int computeIfAbsent() {
String key = keys[idx++ % MAP_SIZE];
return map.computeIfAbsent(key, k -> DEFAULT_VAL);
}
}
BenchmarkSmokeTest.java
package org.example.perf;
import org.junit.jupiter.api.Test;
import org.openjdk.jmh.results.RunResult;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import org.openjdk.jmh.runner.options.TimeValue;
import java.util.Collection;
import static org.junit.jupiter.api.Assertions.*;
/**
* Smoke tests that verify each benchmark class:
* 1. Compiles (the annotation processor wired the JMH infrastructure correctly)
* 2. Runs without throwing exceptions
* 3. Produces at least one result
*
* <p>
* These are NOT performance tests — the numbers at 200ms iterations are
* meaningless.
* Their purpose is to catch compilation errors and framework misconfiguration
* in CI.
*
* <p>
* For real benchmarks, run:
*
* <pre>
* mvn compile exec:java \
* -Dexec.mainClass="org.openjdk.jmh.Main" \
* -Dexec.args="StringConcatBenchmark -wi 3 -i 5 -f 1"
* </pre>
*/
class BenchmarkSmokeTest {
/**
* Builds minimal JMH options for smoke testing:
* - 1 warm-up iteration (200ms) — enough for the JIT to stabilize
* - 1 measurement iteration (200ms) — enough to produce a result
* - forks(0) — run in the same JVM as JUnit (avoids spawning a child process)
*
* <p>
* forks(0) means the JUnit JVM IS the benchmark JVM. This is less accurate
* (JUnit's own code affects JIT state) but is fine for smoke testing.
*/
private static ChainedOptionsBuilder minimal(String benchmarkClass) {
return new OptionsBuilder()
.include(benchmarkClass)
.warmupIterations(1)
.warmupTime(TimeValue.milliseconds(200))
.measurementIterations(1)
.measurementTime(TimeValue.milliseconds(200))
.forks(0)
.shouldFailOnError(true);
}
@Test
void stringConcatBenchmarkRuns() throws RunnerException {
Options opts = minimal(StringConcatBenchmark.class.getSimpleName())
.param("n", "10") // only the smallest param — keeps test fast
.build();
Collection<RunResult> results = new Runner(opts).run();
assertFalse(results.isEmpty(), "StringConcatBenchmark must produce results");
// Each benchmark method × 1 param value = 3 results
assertEquals(3, results.size(),
"Expected concatenation + stringBuilder + stringBuilderPreSized");
}
@Test
void collectionLookupBenchmarkRuns() throws RunnerException {
Options opts = minimal(CollectionLookupBenchmark.class.getSimpleName())
.param("size", "100")
.build();
Collection<RunResult> results = new Runner(opts).run();
assertFalse(results.isEmpty(), "CollectionLookupBenchmark must produce results");
assertEquals(3, results.size(),
"Expected arrayListContains + hashSetContains + binarySearch");
}
@Test
void mapAccessBenchmarkRuns() throws RunnerException {
Options opts = minimal(MapAccessBenchmark.class.getSimpleName())
.build();
Collection<RunResult> results = new Runner(opts).run();
assertFalse(results.isEmpty(), "MapAccessBenchmark must produce results");
assertEquals(3, results.size(),
"Expected containsThenGet + getOrDefault + computeIfAbsent");
}
/**
* Verifies that all three string-concat variants produce identical output.
* This confirms the "fast" implementations are not computing different things.
*
* <p>
* This is a correctness test, not a performance test.
*/
@Test
void stringConcatVariantsProduceSameResult() {
StringConcatBenchmark b = new StringConcatBenchmark();
b.n = 50;
String naive = b.concatenation();
String builder = b.stringBuilder();
String presized = b.stringBuilderPreSized();
assertEquals(naive, builder, "StringBuilder must produce the same string as +=");
assertEquals(naive, presized, "Pre-sized StringBuilder must produce the same string as +=");
}
/**
* Verifies HashSet and sorted-array binary search agree with ArrayList on
* membership.
*/
@Test
void collectionLookupVariantsAgreeOnMembership() {
CollectionLookupBenchmark b = new CollectionLookupBenchmark();
b.size = 200;
b.setup();
// target is always present (size/2 = 100, which is in 0..199)
assertTrue(b.arrayListContains(), "ArrayList must find target");
assertTrue(b.hashSetContains(), "HashSet must find target");
assertTrue(b.binarySearch() >= 0, "Binary search must find target");
}
/**
* Verifies map access variants return the same value for a present key.
*/
@Test
void mapAccessVariantsReturnSameValueForPresentKey() {
MapAccessBenchmark b = new MapAccessBenchmark();
b.setup();
int r1 = b.containsThenGet();
b.setup(); // reset idx
int r2 = b.getOrDefault();
b.setup();
int r3 = b.computeIfAbsent();
assertEquals(r1, r2, "getOrDefault must return same value as containsKey+get");
assertEquals(r1, r3, "computeIfAbsent must return same value as containsKey+get");
assertTrue(r1 >= 0, "Result must be a valid map value (not the -1 default)");
}
}
How JMH Works Under the Hood
When you run mvn compile, the jmh-generator-annprocess annotation processor
reads every @Benchmark method and generates:
target/classes/META-INF/BenchmarkList— a text file listing every benchmark method. TheRunnerreads this at startup to discover what to run.target/generated-sources/annotations/org/example/perf/jmh_generated/— one*_jmhTest.javaper benchmark method, containing the actual timing loop, blackhole sinks, and thread setup.
This is why mvn test must compile before testing: a stale target/ from a
previous run (without annotation processing) will be missing BenchmarkList.
mvn clean test guarantees a fresh run.
Interview Talking Points
- Why return a value from
@Benchmark? — forces the JIT to keep the computation; otherwise Dead Code Elimination can eliminate the entire method. - What is
@State? — controls object lifecycle and visibility;Scope.Threadgives each thread its own object (no sharing, no contention),Scope.Benchmarkshares one object across all threads. - When would you use
@Fork(0)vs@Fork(1)? —@Fork(0)runs in the test JVM (fast, debuggable, but JIT may be polluted by test framework);@Fork(1)spawns a fresh JVM for a clean JIT slate (required for production measurements). - What does the
Errorcolumn mean? — it is the half-width of the 99.9% confidence interval. A large error means high variance — check for OS noise, GC pauses, or thermal throttling.
Lab 02 — GC & Allocation Profiling
Goal: Measure the allocation cost of autoboxing, understand when parallel
streams hurt rather than help, and confirm that pre-sizing a StringBuilder
eliminates internal resizing allocations.
Prerequisites
- JDK 11+ (
java -version) - Maven 3.8+ (
mvn -version)
Five-Step Lab
Step 1 — Understand the GC / allocation connection
Every object allocated on the heap is eventually collected. The more you allocate, the more often the GC runs and the longer your pauses. The core insight:
Allocation rate drives GC pressure. Reducing allocations reduces GC pauses, not just memory usage.
Three common allocation hot-spots in Java:
| Pattern | What allocates | Fix |
|---|---|---|
List<Integer> with int values | Integer box per element | Use int[] or primitive collections |
StringBuilder with default capacity | Grows by doubling (System.arraycopy) | Pre-size with expected length |
.parallel() on small arrays | ForkJoinTask objects per split | Only parallelize large workloads |
Step 2 — Read the benchmark annotations
All three benchmarks in this lab use:
@Fork(0) // run in the test JVM (smoke-test safe; use Fork(1) for real data)
@Warmup(iterations = 2, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 3, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Fork(0) lets Maven Surefire invoke the JMH runner inside the test JVM —
no separate process needed. For production measurements, always use @Fork(1)
or higher to get a clean JIT state.
Step 3 — Run the smoke tests
mvn clean test
# Expected: Tests run: 6, Failures: 0, Errors: 0
The smoke tests confirm:
- JMH annotation processor generated
META-INF/BenchmarkList. - Each benchmark class executes without throwing.
- Boxed and primitive sums agree, stream variants agree, and StringBuilder variants produce identical strings.
Step 4 — Run the real benchmarks
# Autoboxing cost: ArrayList<Integer> vs int[]
mvn compile exec:java \
-Dexec.mainClass="org.openjdk.jmh.Main" \
-Dexec.args="BoxingBenchmark -wi 3 -i 5 -f 1"
# Stream vs loop vs parallel stream
mvn compile exec:java \
-Dexec.mainClass="org.openjdk.jmh.Main" \
-Dexec.args="StreamVsLoopBenchmark -wi 3 -i 5 -f 1"
# StringBuilder capacity strategies
mvn compile exec:java \
-Dexec.mainClass="org.openjdk.jmh.Main" \
-Dexec.args="StringBuilderBenchmark -wi 3 -i 5 -f 1"
Step 5 — Interpret the results
BoxingBenchmark (n=1000) — expected:
Benchmark (n) Mode Cnt Score Error Units
BoxingBenchmark.sumBoxed 1000 avgt 5 3241.442 ns/op
BoxingBenchmark.sumPrimitive 1000 avgt 5 441.811 ns/op
sumPrimitive is ~7× faster. The cost isn't just the Integer objects — it's
the GC scanning them, the pointer-chasing during unboxing, and the cache misses
from scattered heap objects vs. a contiguous int[].
StreamVsLoopBenchmark (n=1000) — expected:
Benchmark (n) Mode Cnt Score Error Units
StreamVsLoopBenchmark.imperativeSum 1000 avgt 5 0.241 µs/op
StreamVsLoopBenchmark.streamSum 1000 avgt 5 0.314 µs/op
StreamVsLoopBenchmark.parallelStreamSum 1000 avgt 5 8.427 µs/op
parallelStreamSum is ~35× slower for n=1000 because ForkJoinPool task
creation cost dwarfs the tiny summation. Parallelism pays off at n > 50 000.
StringBuilderBenchmark (n=1000) — expected:
Benchmark (n) Mode Cnt Score Error Units
StringBuilderBenchmark.defaultCapacity 1000 avgt 5 68241.102 ns/op
StringBuilderBenchmark.preSized 1000 avgt 5 55312.847 ns/op
Pre-sizing shaves ~20% by eliminating ~6 capacity-doubling array copies.
Source Files
src/main/java/org/example/perf/
├── BoxingBenchmark.java ← autoboxing ArrayList vs primitive int[]
├── StreamVsLoopBenchmark.java ← imperative vs stream vs parallel stream
└── StringBuilderBenchmark.java ← default capacity vs pre-sized
src/test/java/org/example/perf/
└── AllocationTest.java ← 3 JMH smoke tests + 3 correctness assertions
BoxingBenchmark.java
package org.example.perf;
import org.openjdk.jmh.annotations.*;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* Demonstrates the allocation cost of autoboxing.
*
* <p>
* sumBoxed() stores integers in an ArrayList<Integer>: every element is an
* autoboxed Integer object on the heap, adding GC pressure for n allocations.
*
* <p>
* sumPrimitive() stores integers in an int[]: no heap allocation per element,
* only the array itself — roughly 5–10× faster for large n.
*/
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
@Warmup(iterations = 2, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 3, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Fork(0)
public class BoxingBenchmark {
@Param({ "1000" })
int n;
@Benchmark
public long sumBoxed() {
List<Integer> list = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
list.add(i); // autoboxing: int -> Integer
}
long sum = 0;
for (Integer val : list) {
sum += val; // unboxing: Integer -> int
}
return sum;
}
@Benchmark
public long sumPrimitive() {
int[] arr = new int[n];
for (int i = 0; i < n; i++) {
arr[i] = i;
}
long sum = 0;
for (int val : arr) {
sum += val;
}
return sum;
}
}
StreamVsLoopBenchmark.java
package org.example.perf;
import org.openjdk.jmh.annotations.*;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
/**
* Compares imperative sum, sequential stream sum, and parallel stream sum.
*
* <p>
* Key insight: parallel stream has thread-pool overhead that dominates for
* small arrays. It only pays off when n is large enough that parallelism beats
* the ForkJoinPool cost (typically n > 10_000 on most hardware).
*/
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
@Warmup(iterations = 2, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 3, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Fork(0)
public class StreamVsLoopBenchmark {
@Param({ "1000" })
int n;
private int[] data;
@Setup(Level.Trial)
public void setup() {
data = new int[n];
for (int i = 0; i < n; i++) {
data[i] = i;
}
}
@Benchmark
public long imperativeSum() {
long sum = 0;
for (int v : data) {
sum += v;
}
return sum;
}
@Benchmark
public long streamSum() {
return Arrays.stream(data).asLongStream().sum();
}
@Benchmark
public long parallelStreamSum() {
return Arrays.stream(data).parallel().asLongStream().sum();
}
}
StringBuilderBenchmark.java
package org.example.perf;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
/**
* Illustrates how pre-sizing a StringBuilder avoids internal array resizing.
*
* <p>
* StringBuilder.append() doubles the internal char[] when capacity is
* exceeded (System.arraycopy + new allocation). Pre-sizing to n * 5 (generous
* estimate: average 5 chars per token) prevents any resize for typical inputs.
*/
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
@Warmup(iterations = 2, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 3, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Fork(0)
public class StringBuilderBenchmark {
@Param({ "100", "1000" })
int n;
@Benchmark
public String defaultCapacity() {
StringBuilder sb = new StringBuilder(); // initial capacity: 16
for (int i = 0; i < n; i++) {
sb.append("token").append(i);
}
return sb.toString();
}
@Benchmark
public String preSized() {
StringBuilder sb = new StringBuilder(n * 9); // "token" + up to 4 digits
for (int i = 0; i < n; i++) {
sb.append("token").append(i);
}
return sb.toString();
}
}
AllocationTest.java
package org.example.perf;
import org.junit.jupiter.api.Test;
import org.openjdk.jmh.results.RunResult;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import org.openjdk.jmh.runner.options.TimeValue;
import java.util.Collection;
import static org.junit.jupiter.api.Assertions.*;
/**
* Smoke tests: run each benchmark class in-process (fork=0) with minimal
* iterations to verify the harness starts, executes, and returns a result.
*
* <p>
* Correctness tests: call benchmark methods directly to assert that boxed and
* primitive variants produce the same numeric result, stream variants agree,
* and
* StringBuilder variants produce identical strings.
*/
class AllocationTest {
// ---- helpers -------------------------------------------------------
private static ChainedOptionsBuilder minimal(String benchmarkSimpleRegex) {
return new OptionsBuilder()
.include(benchmarkSimpleRegex)
.forks(0)
.warmupIterations(1)
.warmupTime(TimeValue.milliseconds(100))
.measurementIterations(1)
.measurementTime(TimeValue.milliseconds(100));
}
// ---- JMH smoke tests -----------------------------------------------
@Test
void boxingBenchmarkRunsWithoutError() throws RunnerException {
Options opts = minimal("BoxingBenchmark")
.param("n", "100")
.build();
Collection<RunResult> results = new Runner(opts).run();
assertFalse(results.isEmpty(), "Expected at least one JMH result");
}
@Test
void streamVsLoopBenchmarkRunsWithoutError() throws RunnerException {
Options opts = minimal("StreamVsLoopBenchmark")
.param("n", "100")
.build();
Collection<RunResult> results = new Runner(opts).run();
assertFalse(results.isEmpty(), "Expected at least one JMH result");
}
@Test
void stringBuilderBenchmarkRunsWithoutError() throws RunnerException {
Options opts = minimal("StringBuilderBenchmark")
.param("n", "10")
.build();
Collection<RunResult> results = new Runner(opts).run();
assertFalse(results.isEmpty(), "Expected at least one JMH result");
}
// ---- correctness tests ---------------------------------------------
@Test
void boxedSumMatchesPrimitiveSumForSameInput() {
BoxingBenchmark bm = new BoxingBenchmark();
bm.n = 500;
assertEquals(bm.sumPrimitive(), bm.sumBoxed(),
"boxed and primitive sums must agree");
}
@Test
void streamSumMatchesImperativeSum() {
StreamVsLoopBenchmark bm = new StreamVsLoopBenchmark();
bm.n = 200;
bm.setup();
long imperative = bm.imperativeSum();
assertEquals(imperative, bm.streamSum(), "stream sum mismatch");
assertEquals(imperative, bm.parallelStreamSum(), "parallel stream sum mismatch");
}
@Test
void preSizedBuilderProducesSameResult() {
StringBuilderBenchmark bm = new StringBuilderBenchmark();
bm.n = 50;
assertEquals(bm.defaultCapacity(), bm.preSized(),
"pre-sized and default StringBuilder must produce identical strings");
}
}
Profiling Allocation with -prof gc
To see per-operation allocation rates (bytes allocated per invocation), add the
-prof gc flag:
mvn compile exec:java \
-Dexec.mainClass="org.openjdk.jmh.Main" \
-Dexec.args="BoxingBenchmark -wi 3 -i 5 -f 1 -prof gc"
Look for the ·gc.alloc.rate.norm metric:
BoxingBenchmark.sumBoxed:·gc.alloc.rate.norm 1000 avgt 5 24040.008 B/op
BoxingBenchmark.sumPrimitive:·gc.alloc.rate.norm 1000 avgt 5 4016.003 B/op
sumBoxed allocates ~24 KB per operation (n Integer objects + ArrayList internal
array) vs ~4 KB for sumPrimitive (just the int[] itself).
Interview Talking Points
- Why is autoboxing expensive? — heap allocation per element, GC scanning, pointer indirection during unboxing, cache miss on scattered Integer objects.
- When does parallel stream hurt? — for small data sets, ForkJoinPool task overhead exceeds parallelism benefit. Rule of thumb: parallelize when the operation per element is non-trivial or n > 10 000.
- How does pre-sizing StringBuilder help? — avoids O(log n) capacity-doubling
array copies; each doubling is an
O(current_size)System.arraycopy. - What is escape analysis? — JIT analysis that determines if an object can never be referenced outside its allocating thread/method; if so, the JIT may allocate it on the stack instead of the heap (stack allocation), eliminating GC pressure entirely.
Lab 03 — Lock Contention Analysis
Goal: Implement four counter strategies (synchronized, ReentrantLock,
AtomicLong, LongAdder), prove each is thread-safe under real concurrency, and
use JMH to measure how throughput differs under 4-thread contention.
Prerequisites
- JDK 11+ (
java -version) - Maven 3.8+ (
mvn -version)
Five-Step Lab
Step 1 — Understand the four primitives
| Primitive | Mechanism | Best use |
|---|---|---|
synchronized | OS mutex (biased → thin → inflated) | General-purpose mutual exclusion; simple to read |
ReentrantLock | Same as synchronized + rich API | When you need try-lock, timed-lock, or fairness |
AtomicLong | Single CAS instruction | Low-to-medium contention; result needed immediately |
LongAdder | Striped cells (one per CPU) | High-contention counters where only final sum matters |
Step 2 — Read the benchmark setup
@State(Scope.Benchmark) // one shared instance — all threads contend on it
@Threads(4) // 4 concurrent JMH worker threads
@Param({"synchronized", "reentrant", "atomic", "longadder"})
String impl;
@Setup(Level.Iteration)
public void setup() {
// recreate counter before each measurement iteration to reset state
}
@Benchmark
public void increment() {
counter.increment(); // hot path: all 4 threads call this simultaneously
}
@State(Scope.Benchmark) is the key difference from Lab 01's Scope.Thread —
here, all threads share the same ContentionBenchmark instance, producing real
lock contention.
Step 3 — Run the correctness tests
The correctness tests are the most important part. They launch 8 threads, each
incrementing 1 000 times, and assert the final count equals 8 000. Any "lost
update" bug (e.g., non-atomic count++) will cause this to fail non-deterministically.
mvn clean test
# Expected: Tests run: 5, Failures: 0, Errors: 0
Step 4 — Run the contention benchmark
mvn compile exec:java \
-Dexec.mainClass="org.openjdk.jmh.Main" \
-Dexec.args="ContentionBenchmark -wi 3 -i 5 -f 1 -t 4"
Step 5 — Interpret the results
Typical output (4 threads, higher ops/ms = better):
Benchmark (impl) Mode Cnt Score Error Units
ContentionBenchmark.increment longadder thrpt 5 412384.442 ± 8214.2 ops/ms
ContentionBenchmark.increment atomic thrpt 5 198341.122 ± 3104.7 ops/ms
ContentionBenchmark.increment reentrant thrpt 5 82341.441 ± 1823.3 ops/ms
ContentionBenchmark.increment synchronized thrpt 5 78921.332 ± 2104.9 ops/ms
Key observations:
LongAdderis ~2× faster thanAtomicLongunder 4-thread contention because threads never contend on the same cell.ReentrantLockandsynchronizedare similar — both park threads on the OS wait queue.- The gap between lock-based and CAS-based widens as thread count increases.
Source Files
src/main/java/org/example/perf/
├── Counter.java ← common interface
├── SynchronizedCounter.java ← synchronized methods
├── ReentrantLockCounter.java ← explicit ReentrantLock
├── AtomicCounter.java ← AtomicLong.incrementAndGet()
├── LongAdderCounter.java ← LongAdder.increment()
└── ContentionBenchmark.java ← @Threads(4) throughput benchmark
src/test/java/org/example/perf/
└── ContentionCorrectnessTest.java ← 4 thread-safety proofs + 1 JMH smoke test
Counter.java
package org.example.perf;
/**
* Common interface for all counter implementations in this lab.
*
* <p>
* Using an interface lets the ContentionBenchmark select the implementation
* via @Param at runtime without branching inside the hot path.
*/
public interface Counter {
/** Increment the counter by 1. */
void increment();
/** Return the current count. */
long get();
/** Reset the counter to 0 (called between iterations). */
void reset();
}
SynchronizedCounter.java
package org.example.perf;
/**
* Counter backed by a plain {@code long} with {@code synchronized} methods.
*
* <p>
* Under high contention, threads queue on the object monitor. Each
* increment requires an OS-level mutex acquire/release round-trip when
* uncontended biased-locking is revoked — expensive at scale.
*/
public class SynchronizedCounter implements Counter {
private long count = 0;
@Override
public synchronized void increment() {
count++;
}
@Override
public synchronized long get() {
return count;
}
@Override
public synchronized void reset() {
count = 0;
}
}
ReentrantLockCounter.java
package org.example.perf;
import java.util.concurrent.locks.ReentrantLock;
/**
* Counter backed by a {@link ReentrantLock}.
*
* <p>
* ReentrantLock provides the same mutual exclusion as {@code synchronized}
* but with explicit lock/unlock, try-lock timeouts, and fairness options.
* Under plain contention, throughput is similar to {@code synchronized};
* the main advantage is the richer API (e.g. {@code lockInterruptibly()}).
*/
public class ReentrantLockCounter implements Counter {
private final ReentrantLock lock = new ReentrantLock();
private long count = 0;
@Override
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
@Override
public long get() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
@Override
public void reset() {
lock.lock();
try {
count = 0;
} finally {
lock.unlock();
}
}
}
AtomicCounter.java
package org.example.perf;
import java.util.concurrent.atomic.AtomicLong;
/**
* Counter backed by {@link AtomicLong}.
*
* <p>
* Uses a single CAS (Compare-And-Swap) instruction — no OS mutex, no thread
* parking. Faster than lock-based counters when contention is moderate, but
* under very high contention threads spin-retry and throughput degrades.
*/
public class AtomicCounter implements Counter {
private final AtomicLong count = new AtomicLong(0);
@Override
public void increment() {
count.incrementAndGet();
}
@Override
public long get() {
return count.get();
}
@Override
public void reset() {
count.set(0);
}
}
LongAdderCounter.java
package org.example.perf;
import java.util.concurrent.atomic.LongAdder;
/**
* Counter backed by {@link LongAdder}.
*
* <p>
* LongAdder maintains a cell per CPU core (via Striped64 internals). Each
* thread increments its own cell, eliminating CAS contention. The true sum is
* computed only when {@code sum()} is called. Under high contention, LongAdder
* outperforms AtomicLong significantly; for single-threaded use, AtomicLong is
* slightly faster because there is no cell-routing overhead.
*/
public class LongAdderCounter implements Counter {
private final LongAdder adder = new LongAdder();
@Override
public void increment() {
adder.increment();
}
@Override
public long get() {
return adder.sum();
}
@Override
public void reset() {
adder.reset();
}
}
ContentionBenchmark.java
package org.example.perf;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
/**
* Multi-threaded throughput benchmark for four counter implementations.
*
* <p>
* @State(Scope.Benchmark) means all JMH worker threads share one instance of
* this class — producing real contention on the counter. @Threads(4) runs 4
* concurrent threads. @Param lets us compare all four implementations in a
* single benchmark run.
*
* <p>
* Expected result on a typical laptop (higher ops/s = better):
*
* <pre>
* LongAdderCounter >> AtomicCounter > SynchronizedCounter ≈ ReentrantLockCounter
* </pre>
*/
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
@Threads(4)
@Warmup(iterations = 2, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 3, time = 200, timeUnit = TimeUnit.MILLISECONDS)
@Fork(0)
public class ContentionBenchmark {
@Param({ "synchronized", "reentrant", "atomic", "longadder" })
String impl;
Counter counter;
@Setup(Level.Iteration)
public void setup() {
switch (impl) {
case "synchronized":
counter = new SynchronizedCounter();
break;
case "reentrant":
counter = new ReentrantLockCounter();
break;
case "atomic":
counter = new AtomicCounter();
break;
case "longadder":
counter = new LongAdderCounter();
break;
default:
throw new IllegalArgumentException("Unknown impl: " + impl);
}
}
@Benchmark
public void increment() {
counter.increment();
}
}
ContentionCorrectnessTest.java
package org.example.perf;
import org.junit.jupiter.api.Test;
import org.openjdk.jmh.results.RunResult;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import org.openjdk.jmh.runner.options.TimeValue;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
/**
* Correctness tests: all counter implementations must be thread-safe.
*
* <p>
* Each test spawns 8 threads, each incrementing a counter 1 000 times.
* The expected final count is always 8 000. Any lost update means the
* implementation is broken under concurrency.
*
* <p>
* JMH smoke test: runs ContentionBenchmark in-process to verify the harness
* wires up correctly.
*/
class ContentionCorrectnessTest {
private static final int THREADS = 8;
private static final int INCREMENTS_PER_THREAD = 1_000;
private static final long EXPECTED = (long) THREADS * INCREMENTS_PER_THREAD;
// ---- helpers -------------------------------------------------------
private void assertCorrectUnderContention(Counter counter) throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(THREADS);
CountDownLatch startGate = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(THREADS);
for (int i = 0; i < THREADS; i++) {
pool.submit(() -> {
try {
startGate.await();
for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
counter.increment();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
startGate.countDown(); // release all threads at once
assertTrue(done.await(10, TimeUnit.SECONDS), "Threads did not finish in time");
pool.shutdownNow();
assertEquals(EXPECTED, counter.get(),
"Counter lost updates under contention: " + counter.getClass().getSimpleName());
}
private static ChainedOptionsBuilder minimal(String benchmarkRegex) {
return new OptionsBuilder()
.include(benchmarkRegex)
.forks(0)
.warmupIterations(1)
.warmupTime(TimeValue.milliseconds(100))
.measurementIterations(1)
.measurementTime(TimeValue.milliseconds(100))
.threads(2);
}
// ---- correctness tests ---------------------------------------------
@Test
void synchronizedCounterIsThreadSafe() throws InterruptedException {
assertCorrectUnderContention(new SynchronizedCounter());
}
@Test
void reentrantLockCounterIsThreadSafe() throws InterruptedException {
assertCorrectUnderContention(new ReentrantLockCounter());
}
@Test
void atomicCounterIsThreadSafe() throws InterruptedException {
assertCorrectUnderContention(new AtomicCounter());
}
@Test
void longAdderCounterIsThreadSafe() throws InterruptedException {
assertCorrectUnderContention(new LongAdderCounter());
}
// ---- JMH smoke test ------------------------------------------------
@Test
void contentionBenchmarkRunsWithoutError() throws RunnerException {
Options opts = minimal("ContentionBenchmark")
.param("impl", "atomic", "longadder")
.build();
Collection<RunResult> results = new Runner(opts).run();
assertFalse(results.isEmpty(), "Expected at least one JMH result");
}
}
False Sharing (Bonus)
LongAdder already avoids false sharing internally (each cell is padded to 64
bytes). You can introduce false sharing deliberately by placing two long fields
adjacent in a class:
// BAD: two counters share a cache line — writing one invalidates the other
class BadPair { volatile long a; volatile long b; }
// GOOD: pad to 64 bytes between hot fields
class GoodPair {
volatile long a;
long p1, p2, p3, p4, p5, p6, p7; // 56 bytes padding
volatile long b;
}
Benchmark BadPair vs GoodPair increments from 2 threads to see the ~3–10×
throughput difference.
Interview Talking Points
- When would you use
LongAdderoverAtomicLong? —LongAdderfor high-throughput counters/histograms where you only need the total at the end.AtomicLongwhen you need the result of each increment immediately (e.g., for a sequence ID). - What is biased locking? — JVM optimization where the JIT "biases" an object monitor towards the first thread that acquires it; subsequent acquires by the same thread are free. Revoked when a second thread tries to acquire — expensive revocation.
- What does
@State(Scope.Benchmark)vsScope.Thread)mean for contention? —Scope.Benchmarkshares one instance across all JMH worker threads (real contention);Scope.Threadgives each thread its own instance (no contention). - Why is
ReentrantLocknot faster thansynchronizedhere? — both park threads in the OS kernel when uncontended paths are taken; the extra API flexibility ofReentrantLockdoesn't help for simple mutual exclusion.
Phase 09 — Apache Contribution Mechanics
Every Apache committer uses three tools constantly: reading patches, reasoning about release compatibility, and navigating issue trackers. This phase builds the Java skills that underpin those workflows.
Labs
| # | Lab | Core concept | Tests |
|---|---|---|---|
| 01 | Unified Diff Parser | Parse git format-patch output; count insertions/deletions; flag missing test coverage | 9 |
| 02 | Semantic Versioning Engine | Full SemVer 2.0.0 parse + comparison + compatibility classification | 15 |
| 03 | Issue State Machine | JIRA-style lifecycle with guarded transitions and IllegalTransitionException | 9 |
Core Concepts
| Concept | Why Apache committers care |
|---|---|
| Unified diff format | Reviewing patches in email, JIRA, and GitHub; counting net change |
| SemVer compatibility | Every Apache release declares compatibility guarantees; breaking the contract is a blocker |
| Issue lifecycle | JIRA is the canonical record for Apache work; transitions drive release tracking |
| Test coverage signal | Apache reviewers routinely reject patches that add production code without a test |
| Pre-release ordering | 1.0.0-alpha < 1.0.0-rc.1 < 1.0.0 — release notes and dependency resolution both depend on this |
Deliverables
After completing this phase you will have:
- A
PatchParserthat processes realgit format-patchoutput and reports per-file insertion/deletion counts and whether the patch touches test files - A
SemanticVersionthat correctly implements SemVer 2.0.0 precedence rules including pre-release identifier ordering - An
Issuestate machine that enforces the Apache JIRA lifecycle and throws on illegal transitions - 33 passing unit tests across three independent Maven modules
Interview Relevance
- "Walk me through a patch review" — the diff parser vocabulary (hunk header, insertion, deletion, context lines) is the shared language of Apache code review
- "How do you manage API compatibility?" — SemVer classification maps directly
to Apache release policy (binary compatibility, source compatibility,
@Deprecatedbefore removal) - "Describe how a JIRA issue moves through its lifecycle" — state machine terminology shows you understand how Apache release management actually works
Warmup Guide — Apache Contribution Mechanics
Zero-to-expert primer for Phase 09: the file formats and state machines that contribution workflows run on — unified diffs, semantic versioning, and issue lifecycles — learned by building parsers and engines for each.
Table of Contents
- Chapter 1: Why Build Tooling for the Contribution Process
- Chapter 2: The Unified Diff Format, Byte by Byte
- Chapter 3: Patch Metrics — What Reviewability Looks Like in Numbers
- Chapter 4: Semantic Versioning as a Contract Language
- Chapter 5: Version Comparison — The Deceptively Tricky Part
- Chapter 6: Issue Lifecycles as State Machines
- Chapter 7: Encoding Process in Types
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Why Build Tooling for the Contribution Process
This phase looks meta — parsing diffs instead of writing them — for two deliberate reasons. First, tooling understanding is mechanism understanding: after writing a diff parser you read patches structurally (hunks, context, boundaries) rather than visually; after building a semver engine, compatibility discussions become precise. Second, process tooling is real committer work: release scripts, JIRA bots, compat checkers, patch-quality dashboards are maintained by the same committers who maintain the engine — and they're a classic early-contribution surface (small blast radius, gratefully reviewed).
Chapter 2: The Unified Diff Format, Byte by Byte
The format every git diff/PR/patch email speaks (Lab 01 parses it):
diff --git a/src/Foo.java b/src/Foo.java ← per-file header
--- a/src/Foo.java ← old side ("/dev/null" = file added)
+++ b/src/Foo.java ← new side ("/dev/null" = file deleted)
@@ -14,7 +14,9 @@ public class Foo { ← hunk header
context line (leading space)
-removed line (leading minus)
+added line (leading plus)
The parts people misread:
- The hunk header
@@ -start,count +start,count @@: start is 1-based first line of the hunk including context lines; count is total lines shown on that side (context + changes).countomitted means 1;startof 0 with count 0 appears for pure additions to empty files. The trailing text after the second@@is a free-form "section heading" (the enclosing function) — informational only. - Consistency invariants a parser must check (and Lab 01 tests): old-count = number
of
+-lines in the hunk; new-count =++lines; hunks are ordered and non-overlapping. Real-world wrinkles:\ No newline at end of filemarkers, renames (similarity indexheaders), binary file stanzas, and mode changes. - Why context lines exist at all: they let
patchapply hunks at shifted offsets when the file has drifted — fuzzy matching anchored on context. That's also why hand-edited patches break: the counts stop matching the content.
Chapter 3: Patch Metrics — What Reviewability Looks Like in Numbers
Once parsed, a patch yields metrics that predict review outcomes (Lab 01's
PatchSummary):
- Size and spread: total ±lines, files touched, hunks per file. Defect-detection effectiveness collapses as patches grow (the classic industry observation: beyond ~400 changed lines, reviewers approve on faith) — the quantitative backbone of Phase 06's "small patches" norm.
- Test ratio: changed lines in test paths vs main paths. Zero test delta on a behavior change is an automatic review question; tooling can flag it before a human does (many Apache CI bots do).
- Scope coherence: one logical change per patch shows up as low file-type spread (not: code + reformat + rename in one diff — the Phase 12 mega-patch scenario, detectable mechanically).
- The point of the lab's tool: these checks run in CI as advice, converting review norms from tribal knowledge into feedback newcomers get in seconds.
Chapter 4: Semantic Versioning as a Contract Language
Semver (MAJOR.MINOR.PATCH[-prerelease][+build]) is a promise protocol, not a
numbering aesthetic:
- PATCH: bug fixes only — no API surface change. Consumers auto-upgrade.
- MINOR: additive, backward-compatible surface (new methods, new configs with safe defaults). Consumers upgrade with reading.
- MAJOR: anything breaking — removals, signature changes, behavioral contract changes. Consumers migrate.
The deep content is what "breaking" means — the four compatibility dimensions from
Phases 03/04 (source, binary, behavioral, wire). A return-type narrowing is
source-compatible but binary-breaking → MAJOR even though code "still compiles."
Changing a default value breaks behavioral compat → at minimum MINOR-with-loud-notes,
arguably MAJOR for load-bearing defaults. Lab 02's CompatibilityLevel engine encodes
exactly this mapping: given a change classification, emit the minimum required bump —
the same logic japicmp + release policy implement in production projects (and the
foundation Phase 10's checker builds on).
Apache reality check: big projects approximate semver with documented deviations —
Spark guarantees within a major line per its versioning policy; Hadoop's
audience/stability annotations scope the promise per-interface. The annotation system
is semver's scoping mechanism: @Private surface is exempt from the contract.
Chapter 5: Version Comparison — The Deceptively Tricky Part
Ordering versions correctly is a minefield of off-by-one-intuition bugs (Lab 02 tests all of them):
- Numeric, not lexicographic, per segment:
1.10.0 > 1.9.0(string compare says otherwise — the classic bug). - Pre-release precedence:
1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-rc.1 < 1.0.0. A pre-release is lower than its release. Identifier-by-identifier comparison: numeric identifiers compare numerically and rank below alphanumeric ones; a longer identifier list wins ties of equal prefixes. - Build metadata (
+sha.5114f85) is ignored for precedence — versions differing only in build metadata are equal (semver §10). - Missing segments normalize to zero (
1.2=1.2.0) in most ecosystems — but Maven'sComparableVersionhas its own additional rules (1.0-SNAPSHOT < 1.0; qualifiers likealpha/beta/milestone/rc/gahave defined order) — know that ecosystems diverge and the spec you implement must be stated.
Chapter 6: Issue Lifecycles as State Machines
A JIRA issue's workflow is a finite state machine, and treating it as one (Lab 03) is both a modeling exercise and process literacy:
- States:
OPEN → IN_PROGRESS → PATCH_AVAILABLE → IN_REVIEW → RESOLVED → CLOSED, withREOPENEDas the loop-back. (Projects customize; the shape is universal.) - Transitions carry guards: you can't move to
PATCH_AVAILABLEwithout an attached patch/PR link;RESOLVEDrequires a resolution type (Fixed / Won't Fix / Duplicate / Cannot Reproduce — each routes differently for release notes!); only the assignee or a committer may resolve. - Why projects enforce this: the issue tracker is the release ledger — "what's in
3.5.1" is a JIRA query (
fixVersion = 3.5.1 AND resolution = Fixed), so state hygiene is release correctness, not bureaucracy. A bug resolved "Fixed" without a fixVersion is invisible to the release notes generator; aREOPENEDissue that kept its old fixVersion lies to users. - Illegal transitions are the interesting design space:
CLOSED → IN_PROGRESS(must reopen first — preserves the audit trail),OPEN → RESOLVED:Fixed(fixed without a patch? — projects differ deliberately).
Chapter 7: Encoding Process in Types
The software-design lesson the three labs share — make illegal states unrepresentable:
- Enums for states; transitions as an explicit table/switch (
EnumMap<State, Set<State>>), not scatteredifs — adding a state then forces a decision at every transition site (exhaustive switch +MissingSwitchDefaultfrom Phase 05). - Guard conditions as predicates attached to transitions, throwing a domain exception
(
IllegalTransitionException) with which guard failed — error messages as documentation. - Value objects (
SemanticVersion,VoteEntry) are immutable, parsed once at the boundary (parse-don't-validate): once constructed, they're valid by construction, and the whole downstream pipeline drops its defensive checks. - This trio — explicit state machines, guarded transitions, parse-don't-validate — is the same design language you'll find inside YARN's RM state machines and Kafka's controller; the labs are deliberately scaled-down practice of production patterns.
Lab Walkthrough Guidance
Order: Lab 01 (diff parser) → Lab 02 (semver engine) → Lab 03 (issue state machine) — independent topics; this order matches increasing design (vs parsing) content.
- Lab 01: write the hunk-header parser first with the count-invariant checks from
Chapter 2; collect oddball real diffs (
git diffwith renames, binary files, no-EOF) as test inputs; then build the metrics layer of Chapter 3. - Lab 02: implement comparison against semver §11's algorithm literally, then run the spec's own example chain as a test; add the compatibility-level engine and feed it the Phase 04 Lab 03 changes as cases.
- Lab 03: draw the state diagram before coding; implement the transition table + guards; tests should include every illegal transition (the negative space is the spec) and the audit-trail property (history append-only).
Success Criteria
You are ready for Phase 10 when you can, from memory:
- Parse a hunk header by hand and verify a hunk's count invariants on sight.
- Name three patch metrics that predict review outcomes and the norm each enforces.
- Map all four compatibility dimensions onto minimum semver bumps with one example each.
- Order
1.0.0-alpha.1,1.0.0-rc.1,1.0.0,1.0.0+build5,1.0.1-SNAPSHOTcorrectly and justify each comparison. - Sketch the issue FSM with guards, and explain why resolution-type hygiene is release correctness.
- State the parse-don't-validate principle and where each lab applies it.
Interview Q&A
Q: Why is a giant diff a problem if every line is correct?
Reviewer effectiveness collapses with size (approval-on-faith past a few hundred
lines), git blame/bisect granularity degrades, revert blast radius grows, and merge
conflicts multiply for everyone in flight. Correct-but-unreviewable still costs the
project — reviewability is a property of the change, not the code. (Mechanically
detectable: that's the Lab 01 dashboard's point.)
Q: We renamed a public method's parameter and changed its Javadoc'd behavior on null. What's the version bump? Parameter name is neither source- nor binary-breaking in Java (PATCH-eligible alone). The null-behavior change is a behavioral contract break — the dimension tools don't catch. If the old behavior was documented and plausibly relied upon: MAJOR (or MINOR with a compat flag and loud notes, per project policy). The trap in the question is answering from signatures alone.
Q: Design the state machine for a release vote thread. States: DRAFT → VOTING(72h floor) → {PASSED | FAILED | CANCELLED}; PASSED → PUBLISHED. Guards: VOTING requires artifacts+sigs staged; PASSED requires ≥3 binding +1 and binding +1 > -1 and clock ≥72h; CANCELLED from VOTING on RC-sinking defect (records reason). Events append-only for the audit trail; tally recomputed from events, never stored mutable. — Which is exactly Phase 10's Lab 03, so building it twice is the point.
References
- GNU diffutils: Unified Format — the format spec
git help diff— the git-specific extensions (renames, binary, mode)- Semantic Versioning 2.0.0 — read §9–11 (pre-release/build/precedence) twice
- Maven version ordering (ComparableVersion) — where ecosystems diverge from semver
- Spark Versioning Policy and Hadoop's InterfaceClassification
- Alexis King, Parse, don't validate — the Chapter 7 design principle, canonically stated
- A real workflow: Apache JIRA workflow documentation for Hadoop — see the PATCH_AVAILABLE conventions
Hitchhiker's Guide — Apache Contribution Mechanics
(0) 30-Second Mental Model
Patch review Semantic versioning Issue lifecycle
───────────── ─────────────────── ───────────────
diff --git a/X b/X 1 . 2 . 3 - alpha.1 OPEN
@@ -10,4 +10,6 @@ │ │ │ └── pre-release ↓
context │ │ └─── patch (bug fix) IN_PROGRESS
+addition │ └─────── minor (additive) ↓
-removal └─────────── major (breaking) IN_REVIEW
context ↓
1.2.4 > 1.2.3 RESOLVED
reviewer asks: 2.0.0 > 1.9.9 (breaking!) ↓
"where is the test?" 1.2.3-rc.1 < 1.2.3 CLOSED
(1) Unified Diff Format
A git format-patch email or GitHub diff is a unified diff. Every Apache
review thread references this format.
diff --git a/src/main/java/Foo.java b/src/main/java/Foo.java ← file header
index abc1234..def5678 100644
--- a/src/main/java/Foo.java
+++ b/src/main/java/Foo.java
@@ -10,7 +10,8 @@ public class Foo { ← hunk header
public class Foo { ← context (space)
- return "hello"; ← deletion (-)
+ return "Hello, World!"; ← insertion (+)
+ // new comment ← insertion (+)
}
Parsing rules:
diff --git a/X b/Y→ new file starts;Yis the new path@@ -old_start,old_count +new_start,new_count @@→ hunk header; not counted- Lines starting with
+(not+++) → insertions - Lines starting with
-(not---) → deletions - Lines starting with
→ context (unchanged)
Test coverage signal: Apache reviewers expect a test file for every production
change. A patch that only modifies src/main/java/ with no change to
src/test/java/ is a red flag.
(2) Semantic Versioning 2.0.0
Apache project releases follow SemVer:
MAJOR . MINOR . PATCH [-pre-release] [+build-metadata]
| Part | Increment when | Example |
|---|---|---|
| MAJOR | Breaking API change | 2.0.0 |
| MINOR | Backward-compatible new feature | 1.3.0 |
| PATCH | Backward-compatible bug fix | 1.2.4 |
| Pre-release | Unstable milestone | 1.3.0-rc.1 |
Precedence Rules
- Compare
MAJOR, thenMINOR, thenPATCHnumerically. - A release version (
1.2.3) has higher precedence than the same version with a pre-release label (1.2.3-rc.1). - Pre-release identifiers (dot-separated) are compared left-to-right:
- Both numeric → compare as integers (
9 < 10) - Numeric vs alphanumeric → numeric is lower (
1 < alpha) - Both alphanumeric → lexicographic ASCII order
- Both numeric → compare as integers (
- Shorter tuple is lower when all common identifiers are equal
(
1.0.0-alpha < 1.0.0-alpha.1). - Build metadata (
+build.1) is ignored for comparison.
Classic ordering example
1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta
< 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11
< 1.0.0-rc.1 < 1.0.0
(3) Apache JIRA Issue Lifecycle
Apache projects use JIRA for all issue tracking. The canonical state machine:
OPEN ──────────────────────────────┐
│ │
▼ │ (quick close)
IN_PROGRESS ◄────────────────────┐ │
│ │ │
▼ (needs work)
IN_REVIEW ──────────────────────┘ │
│ │
▼ ▼
RESOLVED ◄───────────────────────────┘
│ │
▼ ▼
CLOSED REOPENED ──► IN_PROGRESS
Key transition rules:
IN_REVIEW → IN_PROGRESS— reviewer requested changes; author needs to reworkRESOLVED → REOPENED— regression found after closing; happens at release timeCLOSED → REOPENED— re-opened from a fully closed issue; requires a comment- No transition from
CLOSEDdirectly toIN_PROGRESS— must go throughREOPENED
Fix version field — committers set fixVersion when resolving. This is how
the release manager knows what to include in the release notes.
(4) What Apache Reviewers Actually Check
When reviewing a patch for an Apache project, committers check:
| Dimension | What they look at |
|---|---|
| Correctness | Does the code do what the JIRA says? Edge cases covered? |
| Test coverage | Is there a new test for the new behavior? Does it fail before the fix? |
| Diff hygiene | No unrelated whitespace changes; no reformatted-only lines burying real changes |
| Compatibility | Does this break existing callers? (SemVer) |
| JIRA link | Is the commit message formatted SPARK-12345: Fix description? |
| License header | Every new file needs the ASF license header |
(5) Interview Q&As
| Question | Strong answer |
|---|---|
| How do you read a unified diff? | Explain file headers, hunk headers, insertion/deletion/context lines; mention git diff --stat for summary |
| What is a breaking change in SemVer? | Any change that requires callers to update their code: removed method, changed signature, changed exception |
When does 1.0.0-rc.1 have lower precedence than 1.0.0? | Always — any pre-release label makes a version lower than the bare release |
| How does Apache track work for a release? | JIRA issues with fixVersion set; the release manager filters issues by version to build release notes |
| What makes a patch "committer-quality"? | Test for every behavior change, atomic (one concern per commit), green CI, JIRA link in commit message, no license violations |
| What is coordinated omission in benchmarks? | (From Phase 08) — the benchmark doesn't measure the waiting time, only the service time; artificially makes slow systems look fast |
(6) References
- Unified diff format — GNU diffutils manual
- Semantic Versioning 2.0.0
- Apache JIRA
- How to Contribute to Apache Spark
- Apache Release Policy
Lab 01 — Unified Diff Parser
Parse git format-patch output into structured data and detect whether a patch
includes test coverage — the first thing an Apache committer checks in a review.
What you build
| Class | Responsibility |
|---|---|
FileDiff | Holds file name, insertion count, deletion count; detects test files |
PatchSummary | Aggregate totals for an entire patch |
PatchParser | Parses unified diff text into FileDiff list; produces PatchSummary |
Run the tests
mvn clean test
Expected: 12 tests, 0 failures.
Key concepts
Unified diff structure
diff --git a/Foo.java b/Foo.java ← file boundary
@@ -10,4 +10,6 @@ ← hunk header (not counted)
context ← space prefix
-deleted line ← minus prefix
+inserted line ← plus prefix
+++/---lines are file-header markers, not counted.- The
b/path in thediff --githeader is the canonical file name.
Test coverage signal
A patch that only modifies src/main/java/ with no changes to
src/test/java/ (or files ending in Test.java) will cause most Apache
reviewers to ask "Where is the test?" before any further review.
Interview talking points
- "A bare
git diffshows context lines (space), additions (+), and removals (-)." - "I always check
git diff --statfirst to get a quick overview before reading the hunk detail." - "Missing test coverage is the single most common review comment on first-time Apache patches."
Lab 02 — Semantic Versioning Engine
Implement a SemVer 2.0.0-compliant parser and comparator — the tool every release manager uses to decide whether a dependency upgrade is safe to ship.
What you build
| Class | Responsibility |
|---|---|
SemanticVersion | Parses and compares SemVer strings; implements Comparable |
CompatibilityLevel | Enum: MAJOR / MINOR / PATCH / PRE_RELEASE / EQUAL |
VersionComparator | Classifies the impact of a version bump |
Run the tests
mvn clean test
Expected: 20 tests, 0 failures.
Key concepts
Precedence rules (SemVer 2.0.0 §11)
- Compare MAJOR, then MINOR, then PATCH numerically.
- Release (
1.2.3) > same version with pre-release (1.2.3-rc.1). - Pre-release identifiers, dot-separated, left-to-right:
- Both numeric → integer comparison (
beta.9 < beta.10) - Numeric vs alphanumeric → numeric is lower (
1 < alpha) - Both alphanumeric → ASCII order
- Both numeric → integer comparison (
- Shorter tuple is lower when shared identifiers are equal.
- Build metadata (
+build.1) is ignored for comparison.
Canonical ordering (from the spec)
1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta
< 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11
< 1.0.0-rc.1 < 1.0.0
Interview talking points
- "Bumping MAJOR means callers must update their code; that requires a deprecation cycle in Apache projects."
- "Two versions with different build metadata are equal — build metadata is for CI traceability, not release identity."
- "Numeric pre-release IDs are compared as integers, not strings —
beta.9 < beta.10, not the other way around."
Lab 03 — Issue State Machine
Model the Apache JIRA issue lifecycle as an explicit state machine with validated transitions — the same lifecycle every Apache committer uses to track work from "reported" to "shipped in a release".
What you build
| Class | Responsibility |
|---|---|
IssueState | Enum: OPEN / IN_PROGRESS / IN_REVIEW / RESOLVED / CLOSED / REOPENED |
IssuePriority | Enum: BLOCKER / CRITICAL / MAJOR / MINOR / TRIVIAL |
IllegalTransitionException | Thrown when an invalid state transition is attempted |
Issue | Holds issue metadata; enforces valid state transitions |
Run the tests
mvn clean test
Expected: 11 tests, 0 failures.
Valid transitions
OPEN → IN_PROGRESS, RESOLVED
IN_PROGRESS → IN_REVIEW, OPEN
IN_REVIEW → RESOLVED, IN_PROGRESS
RESOLVED → CLOSED, REOPENED
CLOSED → REOPENED
REOPENED → IN_PROGRESS, RESOLVED
Key rules:
- No direct
CLOSED → IN_PROGRESS— must go throughREOPENED. OPEN → RESOLVEDis valid (quick-close for duplicates / won't-fix).- The
fixVersionfield is set by the committer atRESOLVEDtime.
Interview talking points
- "An explicit transition table prevents impossible states — the compiler (or the test) tells you when you skip a step."
- "In Apache JIRA,
fixVersionis how the release manager generates release notes — it's set at RESOLVED, not at CLOSED." - "REOPENED → IN_PROGRESS allows the assignee to pick it back up without going through the full triage cycle again."
Phase 10 — Release Engineering
Apache releases are not deployments. They are legal artifacts — signed, voted on by the community, and distributed under the Apache License. Every PMC member is personally accountable for every release they vote +1 on.
Labs
| Lab | Title | What you build |
|---|---|---|
| 01 | Release Checklist Engine | Validates a simulated release directory against the ASF release policy checklist |
| 02 | API Compatibility Checker | Detects REMOVED / ADDED / MODIFIED public methods between two API snapshots; classifies binary-breaking changes |
| 03 | RC Vote Parser | Parses a [VOTE] email thread; counts binding votes; determines pass/fail per Apache quorum rules |
Core Concepts
| Concept | What it means in practice |
|---|---|
| RC (Release Candidate) | A versioned, signed artifact submitted for community vote; may go through multiple iterations (RC1, RC2, …) |
| Binding vote | A +1 / 0 / -1 cast by a PMC member; only binding votes count toward quorum |
| Quorum rule | ≥ 3 binding +1 votes AND no binding -1 votes → release passes |
| Binary-breaking change | A change that prevents existing compiled code from running without recompilation (removed method, changed signature) |
| Source-compatible change | A change that requires recompilation but no code edits (new overload, new default method) |
| GPG detached signature | .asc file alongside the artifact; lets downloaders verify the release was signed by a known committer |
| SHA-512 checksum | .sha512 file; lets downloaders verify artifact integrity after download |
| LICENSE / NOTICE | Required in every source release; LICENSE covers the ASL-2.0 grant + bundled deps; NOTICE contains required attribution |
Deliverables
- 3 Maven projects, all
mvn clean testgreen - Phase-level HITCHHIKERS-GUIDE.md
Interview Relevance
- "Walk me through the Apache release process" — Phase 10 gives you the full lifecycle answer
- "What is a binary-breaking change?" — Lab 02 forces you to implement the detection logic
- "How does a lazy consensus vote differ from a full vote?" — covered in HITCHHIKERS-GUIDE.md
Warmup Guide — Release Engineering
Zero-to-expert primer for Phase 10: turning Phase 04's release process knowledge into release tooling — checklist engines, API-compatibility analyzers, and vote tallying — the automation a release manager actually relies on.
Table of Contents
- Chapter 1: From Process to Tooling — Why RMs Automate
- Chapter 2: Release Checklists as Executable Specifications
- Chapter 3: Designing a Checklist Engine
- Chapter 4: API Compatibility Checking — The Analyzer's View
- Chapter 5: Classifying Changes — The Rules Table
- Chapter 6: Vote Threads as Parseable Data
- Chapter 7: Binding-ness, Tallies, and Edge Cases
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: From Process to Tooling — Why RMs Automate
Phase 04 taught the release process as ceremony; this phase builds the machines that make the ceremony reliable. The motivating fact: an RC involves dozens of verifications (build, sigs, licenses, compat, docs, JIRA hygiene), each individually trivial, and a missed one sinks the RC days later in the vote thread — the vote round-trip (72h minimum, plus re-spin) is the most expensive unit of time in the process. Tooling collapses human-checklist error rates and turns "RM" from a heroic role into a repeatable one. Every mature project converges on the same three tools, which are exactly the three labs: a checklist runner, a compat checker, and vote-thread automation.
Chapter 2: Release Checklists as Executable Specifications
The conceptual move: a checklist item like "all artifacts are signed" is not prose —
it's a predicate over artifacts (∀ artifact: ∃ artifact.asc that verifies against KEYS). Re-reading Phase 04's checklist as predicates:
| Prose item | Predicate over what |
|---|---|
| version is not SNAPSHOT | the version string |
| tarball builds from scratch | a build execution's exit code |
| checksums match | computed vs published digests |
| signatures verify, key in KEYS | artifacts × KEYS file |
| LICENSE/NOTICE present and current | archive contents |
| no category-X deps bundled | dependency report × license policy |
| compat report clean or waived | japicmp output × exclusion file |
| JIRA: no open blockers; fixVersion hygiene | tracker query results |
Some predicates are cheap and local (version string), some expensive (full build), some advisory rather than blocking (docs freshness). That classification — blocking vs advisory, cheap vs expensive — drives the engine design in Chapter 3 and is Lab 01's core modeling decision.
Chapter 3: Designing a Checklist Engine
The design (Lab 01's ReleaseValidator), which generalizes to any policy engine:
- Item = (id, description, severity, check function) — severity ∈ {BLOCKER, WARNING}: blockers fail the release; warnings annotate the report. Severity is data, not code, because projects re-tune it.
- Result ≠ boolean: PASS / FAIL(reason, evidence) / SKIPPED(why) — a failed check must say what it observed, because the report's audience is the vote thread (people re-verify your claims).
- Independence and order: checks must be order-independent (each takes the artifact set, returns a result) so they parallelize and so one failure doesn't mask others — run all checks, report all failures: an RM wants the complete defect list before re-spinning, not one-failure-per-cycle.
- The report is the product: machine-readable (JSON for CI) + human-readable (markdown for the vote thread). The vote email's "here's what I verified" section is generated, not typed — which is also why voters can trust-but-verify it.
- The pattern's reach: swap "release artifacts" for "PR" and severity for gate policy and you have Phase 09's accuracy-regression CI from the model-accuracy track — policy engines over evidence are one design, many domains.
Chapter 4: API Compatibility Checking — The Analyzer's View
Phase 04 ran japicmp; Lab 02 builds a simplified one — because implementing the analyzer is what makes binary compatibility precise instead of folklore:
- Input model: two versions of an API = two sets of class descriptions; per class,
the member signatures (name, parameter types, return type, modifiers). Real tools
read bytecode (
ASM) for exactly the reason SpotBugs does — bytecode is the truth the JVM links against; the lab'sMethodSignaturemodel abstracts that step. - The comparison: align classes/members across versions, classify every difference (Chapter 5's table), aggregate to a report with the worst severity surfaced.
- The JVM linkage rules that make this non-obvious — what binary compatibility
actually means (JLS ch. 13): the JVM resolves method calls by exact descriptor, so a
return-type change breaks linkage even when source recompiles (covariant returns
are a compiler fiction implemented with bridge methods); removing a method breaks
callers at run time with
NoSuchMethodError(not compile time — the scariest category); widening a parameter type (ArrayList→List) breaks binary callers too (descriptor changed) even though it's source-compatible and "obviously safer." - Severity ≠ symmetry: additions are mostly safe (new method: fine on classes; on interfaces only safe post-default-methods, and behaviorally risky regardless); removals and signature changes are breaks; modifier changes vary (final-izing a method breaks overriders; widening visibility is safe, narrowing isn't).
Chapter 5: Classifying Changes — The Rules Table
The decision table Lab 02 encodes (and the one to know cold in reviews — it's the mechanical half of interview-prep/04 Scenario 2):
| Change | Source compat | Binary compat | Verdict |
|---|---|---|---|
| Add method (class) | ✅ | ✅ | MINOR |
| Add method (interface, no default) | ❌ implementors | ✅ callers / ❌ implementors | MAJOR |
| Add default method | ✅ | ✅ | MINOR, behavioral review required |
| Remove/rename public method | ❌ | ❌ (NoSuchMethodError) | MAJOR |
| Change return type (any direction) | varies | ❌ (descriptor) | MAJOR |
| Change parameter type | ❌ usually | ❌ | MAJOR |
| Add parameter (no overload kept) | ❌ | ❌ | MAJOR |
| Widen visibility (protected→public) | ✅ | ✅ | MINOR |
| Narrow visibility | ❌ | ❌ | MAJOR |
| Make method/class final | ❌ overriders/subclassers | ❌ same | MAJOR |
| Reorder overloads / rename params | ✅ | ✅ | PATCH-eligible |
Scoping rule on top: the table applies to annotated public surface only
(@InterfaceAudience.Public etc. — Phase 04 Ch. 6); @Private surface is exempt,
which is why annotation hygiene is itself release engineering.
Chapter 6: Vote Threads as Parseable Data
Lab 03 parses RC vote threads — semi-structured email with real-world mess:
- The grammar in the wild: votes appear as
+1 (binding),+1 binding,-1,+0.5(yes, fractional non-votes happen), buried in reply quoting (> +1is a quote, not a vote — strip quoted lines first), with later messages superseding earlier ones from the same voter ("changing my -1 to +1 after RC2 testing"). - Identity: the voter is the From: header, normalized (the same person mails from work and personal addresses; projects maintain informal alias knowledge — the lab uses an explicit alias map, which is also the honest design).
- The parser's contract: extract (voter, vote, binding-claim, timestamp), apply
supersession (last vote per voter wins), and never guess silently — ambiguous lines
go to an
UNPARSEDbucket for human eyes. A vote tallier that misreads a -1 is worse than no tallier; the failure mode is governance, not formatting.
Chapter 7: Binding-ness, Tallies, and Edge Cases
- Binding-ness is not claimed, it's checked: a vote is binding iff the voter is on the PMC roster at vote time — the tool joins against the roster; "(binding)" annotations in email are convention, not authority. (Mismatches — someone marking binding who isn't — are flagged, not silently corrected; that's a human conversation.)
- The pass rule (Phase 04 Ch. 2, now as code):
binding_plus >= 3 AND binding_plus > binding_minus AND now >= start + 72h. Note what's not in the rule: non-binding votes (persuasive, never counted), and -1s as vetoes (release votes are majority, not consensus — encode the rule correctly and you've internalized the governance distinction the interview always probes). - Edge cases the tests must cover (Lab 03): vote changed across messages; vote in a
quoted block; voter not on roster claiming binding; thread spanning RC cancellations
(
[CANCEL][VOTE]resets the tally); the 72h boundary itself (timezone discipline: compare instants, not local times).
Lab Walkthrough Guidance
Order: Lab 01 (checklist) → Lab 02 (compat checker) → Lab 03 (vote parser) — each lab automates the next stage of the release timeline.
- Lab 01: model Item/Result/severity first (Chapter 3); implement three cheap checks end-to-end including the report, then add expensive ones. Test the engine properties (all checks run despite failures; report contains evidence), not just the checks.
- Lab 02: encode Chapter 5's table as data (a rules map), not as a switch-pyramid; test each row with a minimal v1/v2 pair; make the report cite the JLS-level reason ("descriptor changed") — the why is what a vote thread needs.
- Lab 03: build the line-level vote matcher with a quoted-line stripper first; then supersession; then the roster join and pass rule. Feed it a real vote thread from a project's archive as an integration test — reality is the best fuzzer.
Success Criteria
You are ready for Phase 11 when you can, from memory:
- Re-state five checklist items as predicates with their evidence and severity.
- Explain why a policy engine runs all checks and reports evidence (vote-thread audience).
- Recite five rows of the compatibility table including the return-type and interface-method rows, with the JVM-linkage reasons.
- State what makes a vote binding and write the release pass-rule as one boolean expression.
- Name four vote-parsing edge cases and the parser's never-guess contract.
Interview Q&A
Q: What would you automate first as a new release manager, and why? The verification checklist (Lab 01's shape): it's the highest error-rate × highest cost-of-error step — a missed license or signature check costs a 72h+ vote round-trip. Then the compat report generation (it's already mechanical), then vote tallying (lowest risk-reduction, nice ergonomics). Order by (human error rate × cost of the miss), which is the general automation-prioritization answer.
Q: Why does changing a return type from List to ArrayList break binary compat
when every caller's source still compiles?
The JVM resolves calls by exact method descriptor — ()Ljava/util/List; and
()Ljava/util/ArrayList; are different methods at link time. Old client bytecode
references the old descriptor and dies with NoSuchMethodError at runtime. Source
recompilation regenerates descriptors, which is why the break is invisible in
development and fatal in deployment — the precise reason compat checking runs on
bytecode in CI rather than relying on "it compiles."
Q: Your vote tallier says an RC passed; a PMC member disputes it. What does your tool need to win that conversation? Evidence, not conclusions: per-vote provenance (message-id, quoted raw line, timestamp), the roster snapshot used for binding-ness, the supersession chain for any changed votes, and the UNPARSED bucket showing nothing material was dropped. A governance tool's output must be auditable — the design requirement that distinguishes it from a text scraper.
References
- Apache Release Policy — the checklist's source of truth
- JLS Chapter 13, Binary Compatibility — read 13.4.15 (method result type) and 13.5 (interfaces)
- japicmp — compare its change classifications against your Lab 02 table
- ASM bytecode library — how real analyzers read signatures
- Hadoop InterfaceClassification — the annotation scoping
- A real vote thread with an RC re-spin: search
lists.apache.orgfor[VOTE] Release Sparkand read one RC1→RC3 sequence with Chapter 6's eyes
Hitchhiker's Guide — Release Engineering
(0) 30-Second Mental Model
JIRA: fixVersion set RC1 cut [VOTE] thread Distribution
────────────────────── ────────────────── ────────────────────── ──────────────
Committers merge Tag in git PMC members cast: Mirrored to
patches with Sign artifacts +1 (binding) apache.org
fixVersion=3.5.0 Upload to +1 (non-binding) mirrors network
dist/dev/ 0 (abstain)
Generate: -1 (binding VETO!) 72-hour minimum
.tar.gz vote window
.tar.gz.asc Result:
.tar.gz.sha512 ≥ 3 binding +1 AND
LICENSE no binding -1 → PASS
NOTICE
(1) RC Checklist Anatomy
Every Apache source release must pass this checklist before the vote passes. PMC members vote +1 only after personally verifying these items.
| Check | Why it matters |
|---|---|
✅ GPG signature present (.asc) | Proves release was built and signed by a known committer |
✅ SHA-512 checksum present (.sha512) | Lets downloaders verify integrity after transfer |
✅ LICENSE file in archive | Required by ASL 2.0 §4 — must cover bundled dependencies |
✅ NOTICE file in archive | Required by ASL 2.0 §4 — attribution for upstream works |
| ✅ No binary artifacts in source release | Source releases must be reproducibly buildable from source |
✅ DISCLAIMER or DISCLAIMER-WIP if incubating | Incubator projects carry an extra file |
| ✅ All dependencies are Category A | Category X (GPL, LGPL) licenses cannot be bundled |
Verifying GPG locally:
gpg --verify apache-spark-3.5.0.tgz.asc apache-spark-3.5.0.tgz
# GOOD signature from "Release Manager <rm@apache.org>"
Verifying SHA-512:
sha512sum -c apache-spark-3.5.0.tgz.sha512
# apache-spark-3.5.0.tgz: OK
(2) GPG Trust Chain
Apache Root CA
└── Committer GPG key (published at https://people.apache.org/keys/)
└── Detached signature (.asc) for each release artifact
Key points:
- Committer keys are published at
https://people.apache.org/keys/committer/<username>.asc - The project
KEYSfile indist/aggregates all signing keys for that project - Votes that say "+1 but I didn't verify the signature" are non-binding for practical purposes
(3) japicmp — Reading API Compatibility Output
japicmp compares two JARs and reports compatibility issues:
@@@ MODIFIED CLASS: org.apache.spark.sql.Dataset
=== REMOVED METHOD: public Dataset filter(String)
Binary incompatible!
=== MODIFIED METHOD: public Dataset select(Column[])
Return type changed: Dataset -> RelationalGroupedDataset
Binary incompatible!
+++ NEW METHOD: public Dataset filter(Column)
Binary compatible.
Classification rules:
| Change | Binary-breaking? | Source-breaking? |
|---|---|---|
| Method removed | YES | YES |
| Method return type changed | YES | YES |
| Method parameter types changed | YES | YES |
| New method added to class | NO | NO |
| New method added to interface | NO (default) / YES (abstract) | YES |
| Field type changed | YES | YES |
| Field removed | YES | YES |
SemVer implication: Any binary-breaking change requires a MAJOR version bump.
(4) Vote Thread Grammar
Apache votes follow a strict format. The release manager sends a [VOTE]
email; community members reply.
Subject: [VOTE] Release Apache Spark 3.5.0 RC1
+1 (binding) Alice PMC Chair — verified GPG, built from source, ran TCK
+1 (binding) Bob PMC member — signature OK, license headers checked
+1 (binding) Carol Committer — smoke tested on cluster
+1 Dave contributor — built OK on ARM
0 (binding) Eve — abstaining, on vacation
-1 (binding) Frank — SHA-512 mismatch on the .tar.gz artifact
Quorum rules (Apache Release Policy §6.3):
- ≥ 3 binding +1 votes required
- No binding -1 votes (any binding -1 is a veto; release cannot proceed)
- Minimum 72-hour open window to allow all time zones to participate
- Non-binding votes count for morale but not quorum
After a -1 veto:
- Fix the issue (e.g., regenerate artifact if checksum mismatch)
- Cut RC2
- Start a new vote thread with
[VOTE] Release Apache Spark 3.5.0 RC2
(5) Lazy Consensus vs Full Vote
| Mechanism | Used for | Rule |
|---|---|---|
| Lazy consensus | Minor patches, docs, non-binding changes | Anyone can +1 silently; a -1 from any committer blocks |
| Full vote | Releases, new committers, PMC additions | Explicit +1s required; quorum applies |
| Majority vote | Policy changes | Simple majority of binding voters |
Most day-to-day Apache work runs on lazy consensus — if nobody objects in 72 hours, the change is approved.
(6) Interview Q&As
| Question | Strong answer |
|---|---|
| What makes an Apache release different from a CI deployment? | It's a legal artifact: signed, checksummed, voted on, permanently archived, and carries the Apache brand and license obligations |
| What is a binding veto in an Apache vote? | A -1 from a PMC member; it unconditionally blocks the release; the release manager must fix the issue and re-spin as a new RC |
| What files must be in every Apache source release? | LICENSE, NOTICE, and the source code; no pre-compiled binaries |
| When is a change binary-breaking? | When it prevents existing compiled code from linking or running: removed method, changed parameter types, changed return type, removed class |
| What is the difference between a Category A and Category X dependency? | Category A (MIT, BSD, ASL 2.0) can be bundled; Category X (GPL, LGPL) cannot be included in ASF distributions |
| How do you verify a release before voting? | Download artifact + .asc + .sha512; verify GPG signature with gpg --verify; verify checksum; build from source; run tests |
(7) References
- Apache Release Policy
- Apache Software Distribution
- How to Verify a Release
- japicmp
- ASF Category A/X dependency policy
Lab 01 — Release Checklist Engine
What You'll Build
A validator that checks a release artifact against the mandatory Apache Release Policy checklist — GPG signatures, SHA-512 hashes, LICENSE, NOTICE, and absence of binary blobs.
Concepts
| Check | Why it matters |
|---|---|
| GPG signature | Allows downloaders to verify the artifact came from an ASF committer whose key is in the KEYS file |
| SHA-512 checksum | Detects transport corruption; SHA-512 required since Apache phased out MD5/SHA-1 |
| LICENSE file | Every release artifact must carry an Apache 2.0 LICENSE |
| NOTICE file | Attributions required by ALv2 §4(d) |
| No binary artifacts | ASF releases must be source-only; pre-built binaries belong in a separate convenience distribution |
Running
mvn test
Expected: 11 tests, 0 failures.
Key Classes
| Class | Role |
|---|---|
ReleaseArtifact | Value object — name + 5 boolean flags; built with a fluent builder |
ChecklistItem | Enum of the 5 policy checks |
ChecklistResult | Outcome of a single check (item + passed + message) |
ReleaseValidator | Validates an artifact against all 5 checks |
Interview Angle
Be ready to recite the 5 mandatory items from the Apache Release Policy and explain why binary blobs in source releases are forbidden.
Lab 02 — API Compatibility Checker
What You'll Build
A diffing engine that compares two API snapshots (sets of MethodSignature)
and classifies each change as REMOVED, ADDED, or MODIFIED — then reports
which changes are binary-breaking.
Concepts
| Change type | Binary-breaking? | Explanation |
|---|---|---|
| REMOVED | Yes | Callers compiled against the old API get NoSuchMethodError at runtime |
| MODIFIED (return type changed) | Yes | The JVM encodes return type in the method descriptor |
| ADDED | No | Callers compiled against the old API still link correctly |
This is exactly what japicmp does — this lab reimplements the core classification logic from scratch.
Running
mvn test
Expected: 9 tests, 0 failures.
Key Classes
| Class | Role |
|---|---|
MethodSignature | className + methodName + returnType + parameterTypes; methodKey() is the identity key |
ChangeType | Enum: REMOVED, ADDED, MODIFIED |
CompatibilityIssue | One change: method + changeType + isBinaryBreaking() |
CompatibilityReport | Aggregated result with helpers for binary-breaking issues vs. compatible additions |
ApiCompatibilityChecker | check(oldApi, newApi) — the diffing algorithm |
Algorithm
- Build
Map<methodKey, MethodSignature>for each snapshot. - Key in old, absent in new → REMOVED.
- Key in both, return type differs → MODIFIED.
- Key in new, absent in old → ADDED.
Interview Angle
Explain why method return type is part of the JVM method descriptor (§4.3.3 of the JVM spec), and why changing it is binary-breaking even though Java source code only cares about the method name and parameter types.
Lab 03 — RC Vote Parser
What You'll Build
A parser that reads an Apache RC [VOTE] email thread and evaluates it against
the Apache quorum rules: ≥ 3 binding +1 votes and no binding −1 veto.
Vote Thread Format
Subject: [VOTE] Release Apache Spark 3.5.0 RC1
+1 (binding) Alice PMC Chair
+1 (binding) Bob PMC member
+1 (binding) Carol Committer
+1 Eve contributor
0 (binding) Dave abstaining
-1 (binding) Frank — SHA-512 mismatch on .tar.gz
Lines starting with +1, 0, or -1 are votes.
(binding) in the line marks the vote as binding (cast by a PMC member).
All other lines are ignored.
Quorum Rules
| Condition | Result |
|---|---|
Any binding −1 | VETOED — release must be fixed |
≥ 3 binding +1, no binding −1 | PASSED |
< 3 binding +1, no binding −1 | INSUFFICIENT_VOTES |
Running
mvn test
Expected: 11 tests, 0 failures.
Key Classes
| Class | Role |
|---|---|
VoteEntry | voter name + vote value (+1/0/−1) + binding flag |
VoteThread | subject + list of entries; helpers for quorum/veto check |
VoteResult | Enum: PASSED, VETOED, INSUFFICIENT_VOTES |
VoteParser | parse(String) → VoteThread; evaluate(VoteThread) → VoteResult |
Interview Angle
Walk through a real vote thread from the Apache mailing list archives. Explain
the difference between binding (PMC) and non-binding (community) votes, and why
a single binding −1 blocks the release even when 10 people voted +1.
Phase 11 — Issue Journey Level 5: PMC
Summary
PMC-level engineering challenges. Where earlier phases focused on implementing fixes, this phase focuses on the decisions that govern how Apache projects evolve — API deprecation lifecycles, storage format migration planning, and building consensus across a distributed group of engineers who cannot be overruled by any single person.
Labs
| Lab | Title | Core Skill |
|---|---|---|
| 01 | Deprecated API Migration Analyzer | Classify call sites against a deprecation timeline; determine migration urgency |
| 02 | Storage Format Migration Planner | Plan multi-step table format migrations; evaluate capability deltas and rollback safety |
| 03 | Consensus Tracker | Model lazy consensus, explicit approval, and blocking objection in an ASF design thread |
Apache Project Context
| Lab | Real-world reference |
|---|---|
| 01 | Spark RDD API vs. Dataset API evolution; SPARK-28 series deprecation notices |
| 02 | Hive ALTER TABLE … STORED AS format migrations; migration from Text → ORC → Parquet |
| 03 | ASF design proposals on dev@ lists; lazy consensus rules (72-hour minimum window) |
What PMC Members Actually Decide
PMC members don't only merge code — they make binding decisions on questions that affect every downstream user for years:
- When is an API stable enough to deprecate without a migration path?
- Can a storage format change be rolled back if performance regresses?
- Is a design proposal safe to ship when half the PMC hasn't responded?
These labs build the reasoning patterns behind those decisions.
Interview Relevance
At Staff/Principal level, interviewers expect you to describe decisions where you had to build consensus across people you did not manage, navigate a deprecation that broke existing users, or design a system migration that had to be online and reversible. These three labs give you concrete examples.
Warmup Guide — Issue Journey: PMC Level
Zero-to-expert primer for Phase 11: the three problems that define PMC-level judgment — evolving deprecated APIs across an ecosystem, migrating storage formats without losing anyone's data, and driving consensus when committers disagree.
Table of Contents
- Chapter 1: What Makes an Issue "PMC-Level"
- Chapter 2: Deprecation as a Lifecycle, Not a Label
- Chapter 3: Modeling Migration Urgency
- Chapter 4: Storage Format Migration — The Hardest Compatibility Problem
- Chapter 5: Planning a Format Migration
- Chapter 6: Consensus Mechanics When Committers Disagree
- Chapter 7: Tracking and Closing a Contested Decision
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: What Makes an Issue "PMC-Level"
The earlier phases' issues had a correct answer findable in code. PMC-level issues have three different properties:
- The blast radius is the ecosystem, not the codebase: thousands of downstream users with different upgrade cadences, including unmaintained deployments.
- Every option is a tradeoff between constituencies: users vs maintainers, fast movers vs stable-branch operators, this project vs sibling projects sharing the format.
- The decision needs legitimacy, not just correctness: a technically right call made without process gets relitigated forever; a consensus-built call sticks.
Consequently the artifacts change: not patches but analysis tools (Lab 01), plans (Lab 02), and decision records (Lab 03). The labs build software for each because encoding the reasoning in code forces it to be precise — the same encode-the-process philosophy as Phase 09/10, now at governance altitude.
Chapter 2: Deprecation as a Lifecycle, Not a Label
@Deprecated is the start of a multi-year protocol (Phase 04/10 gave the rules;
here's the lifecycle a PMC actually manages):
- Replacement exists and is GA — deprecating without a destination is hostile; "no-replacement" deprecations need their own justification class.
- Annotate + document:
@Deprecated(since, forRemoval)+ Javadoc naming the replacement and the earliest removal version. The docs are the contract. - Migrate yourself first: the project's own code and examples stop using it — internal usage is both hypocrisy and a usage-count confounder.
- Measure adoption: usage telemetry where it exists; otherwise corpus scans (Maven Central reverse-deps, code search). This is Lab 01's input data.
- Escalate signal: release notes → runtime warn-once logging for high-traffic methods → build-breaking only at the major version that removes it.
- Remove at a major release, with the migration recipe linked — and only when the measured cost of removal (Chapter 3) clears the bar.
The institutional failure modes: deprecation rot (marked in 2014, never removed — the annotation loses all meaning project-wide) and stealth removal (removed without the lifecycle — trust damage exceeding any cleanup benefit). A PMC's deprecation hygiene is measurable, and Lab 01's analyzer measures it.
Chapter 3: Modeling Migration Urgency
Lab 01's ApiMigrationAnalyzer encodes the judgment as a model — the intellectual move
worth internalizing: turn a governance argument into a scoring function with explicit
inputs, so disagreement localizes to weights instead of vibes:
- Inputs per deprecated API:
deprecated-since(versions elapsed — staleness),usage count(ecosystem cost of removal),replacement availability(is migration even possible),maintenance burden(what the project pays to keep it — shims, bug surface, blocked refactors). - Output: urgency tiers — e.g., REMOVE_NOW (stale + low usage + replacement GA), ACCELERATE (high burden, replacement GA, usage declining — invest in warnings + tooling), HOLD (high usage or no replacement — the analysis says don't remove, whatever the annotation's age), and BLOCKED (no replacement: removal conversation is premature by definition).
- The model's honest limits: usage counts undercount dark deployments; burden is estimated; weights are policy. The tool's job is to make the inputs auditable and the disagreement explicit — which is precisely what a PMC discussion needs from a staff engineer.
Chapter 4: Storage Format Migration — The Hardest Compatibility Problem
Why formats outrank APIs in difficulty: data outlives code by a decade, and the readers/writers span versions you don't control.
The compatibility vocabulary (precise, because plans depend on it):
- Backward compatibility: new code reads old data. The non-negotiable baseline — losing it strands every existing byte.
- Forward compatibility: old code reads new data (perhaps degraded). Needed whenever readers upgrade after writers (mixed clusters, rolling upgrades, sibling projects reading your files).
- The version-skew matrix: writers {old, new} × readers {old, new} — a migration plan must state the behavior of all four cells explicitly, per phase of the rollout. Most migration postmortems trace to an unstated cell (typically new-writer/old-reader during a partial rollout).
Format-design mechanics that make evolution survivable (and that Lab 02's capability registry models): explicit format version stamps in the data; self-describing schemas (Parquet/ORC footers); field-tagged encodings tolerating unknown fields (protobuf-style) vs positional encodings that can't; and capability negotiation — readers declare what versions they read, writers choose what to emit (Kafka's API-version handshake; HDFS rolling-upgrade machinery).
Chapter 5: Planning a Format Migration
The phase structure every safe migration follows (Lab 02's FormatMigrationPlanner
generates instances of it):
- Dual-read ships first: all readers understand v2 before any v2 byte exists. Cheap, riskless, and it starts the clock on the skew window.
- Dual-write / shadow-write (optional but powerful): write both formats; verify v2 against v1 continuously (the Phase 06 system-design shadow-read pattern, applied to storage). Catches semantic drift while v1 remains the truth.
- Default flip: new data writes v2; old data still v1; readers handle both (forever, or until step 5). Reversible by flag — the property to preserve as long as possible.
- Background rewrite (only if needed): migrate historical data v1→v2 with checksummed verification, rate-limited, resumable. Many migrations never do this — "old data stays v1, readers keep v1 support" is often the right end state (cost of rewrite vs cost of carrying the reader).
- Reader sunset: drop v1 support only at a major version, after telemetry shows v1 reads ≈ 0, with a documented escape (a standalone converter tool).
Each phase has an explicit rollback story; the plan states the skew matrix per phase; and the sibling-project problem (Hive files read by Spark/Impala/Trino) means the ecosystem's slowest reader — not yours — gates the sunset. That last sentence is the PMC-level content: your migration plan is a multi-project treaty.
Chapter 6: Consensus Mechanics When Committers Disagree
The Apache decision stack, from cheapest to heaviest (Phase 04 gave the rules; here's the practice when a real disagreement is live):
- Clarify what kind of disagreement: facts (resolvable by benchmark/prototype — go produce one), priorities (a values conversation — name the constituencies), or taste (defer to whoever maintains it). Misdiagnosing the kind wastes months.
- Write the options down neutrally — a short doc with each option's costs stated by its opponents' lights (steel-manning as process). Most "deadlocks" dissolve when both options' real costs are on one page.
- Seek the synthesis or the experiment: a flag-gated trial, a scoped pilot in one module, a reversible default — anything that converts argument into evidence.
- Lazy consensus → explicit
[DISCUSS]→[VOTE]only as escalation; a vote on a contested design is a failure to converge, acceptable but costly (the losing side's energy often leaves with the vote). - Vetoes: a justified technical veto on a code change stands until addressed — the system is designed to let one informed objection stop the bus. The senior skill is writing vetoes that are clearly technical and clearly addressable, and receiving them without escalation (Phase 12 Ch. 3).
Chapter 7: Tracking and Closing a Contested Decision
Lab 03's ConsensusEvaluator encodes thread state so that "where are we" is computable:
- Positions are per-person, current-state (supersession, as in Phase 10's vote parser): the tracker holds each participant's latest stance + its stated rationale class (technical objection / preference / question).
- Open objections are first-class: a decision isn't "consensus" while a justified technical objection stands unaddressed — the evaluator distinguishes addressed (author responded with change or evidence; objector confirmed) from answered-but- unconfirmed (ping needed) from open.
- Closure is an artifact: the
[RESULT]/decision record states what was decided, the alternatives rejected and why, and who dissented — dissent recorded respectfully is what lets the dissenter commit to the outcome (disagree-and-commit has to be visible to be real). - The cultural payload: decision records are how a 20-year project avoids relitigating 2014 every time a new committer arrives. Writing them well is PMC work.
Lab Walkthrough Guidance
Order: Lab 01 (migration analyzer) → Lab 02 (format planner) → Lab 03 (consensus tracker) — API surface, then data, then people; each is a strict superset in blast radius.
- Lab 01: encode Chapter 3's model; test the tier boundaries (the interesting cases are near thresholds — high-usage-but-ancient, no-replacement-but-zero-usage); write one paragraph defending your weights as if to a skeptical PMC.
- Lab 02: for each plan the planner emits, verify it states all four skew-matrix cells per phase (make that a unit test — plans missing a cell are invalid, which is the lesson); include one scenario where the right answer is "never rewrite history."
- Lab 03: model positions/objections/supersession; the key tests are the not-yet-consensus states (open justified objection blocks closure regardless of +1 count) — the negative space is the governance.
Success Criteria
You are ready for Phase 12 when you can, from memory:
- Name the three properties that make an issue PMC-level and the artifact each lab produces.
- Recite the six-step deprecation lifecycle and both institutional failure modes.
- Define backward/forward compatibility and draw the four-cell skew matrix for a mid-rollout mixed cluster.
- Walk the five-phase migration plan with each phase's rollback story, and state when background rewrite is correctly skipped.
- Distinguish facts/priorities/taste disagreements with the resolution move for each.
- State what blocks consensus-closure regardless of vote counts, and what a decision record must contain.
Interview Q&A
Q: You own a deprecated API used by 40% of downstream projects. The maintainers want it gone. Walk me through your decision. Run the Chapter 3 model out loud: usage is high → removal cost is ecosystem-scale; what's the carrying burden (if it's a thin shim, HOLD and re-measure yearly; if it blocks a major refactor, that changes the math); is the replacement truly equivalent (40% usage often means the replacement misses a case — go find it); can tooling (an OpenRewrite recipe) collapse the migration cost? Likely answer: invest in migration tooling + warnings now, set a measurable sunset criterion ("usage <5% or two majors, whichever later"), and publish that criterion — converting a standoff into a schedule. The structure of the answer is the answer.
Q: Design the migration of a widely-read file format where you don't control all readers. Lead with the treaty problem: enumerate reader projects, get dual-read shipped in their release trains first — the slowest sibling gates everything. Then Chapter 5's phases with the skew matrix per phase, shadow-write verification, flag-reversible default flip, and an explicit "v1 readable forever + offline converter" end-state option if sibling sunset never converges. Mention what kills these migrations: unstated new-writer/old-reader cells and assuming your own release cadence applies ecosystem-wide.
Q: Two committers have argued for three weeks about a design. As the senior person arriving fresh, what do you do? Diagnose the disagreement type first (Ch. 6): read the thread and separate factual claims (benchmark them — volunteer to build the prototype), priority conflicts (name the constituencies and force the "for whom" question), and taste (assign to the maintainer-of-record). Produce the one-page neutral options doc with steel-manned costs; propose the smallest reversible experiment that discriminates. Escalate to a vote only if that fails — and if it does, write the decision record so the argument is never re-run from scratch.
References
- Hadoop Compatibility Specification — including the data/wire sections
- Kafka KIP process — read one contested KIP's thread end-to-end (e.g., KIP-500) with Chapter 6's eyes
- Parquet format spec — footer/schema self-description as evolution design
- Protocol Buffers: language guide on unknown fields — field-tagged tolerance, canonically
- Software Engineering at Google, ch. "Deprecation" — the lifecycle, industrialized
- system-design/03-public-api-evolution.md and 02-hive-metastore-scaling.md — this phase's two walkthrough companions
- Fogel, Producing Open Source Software, ch. "Difficult People" and "Consensus" — the people mechanics
Hitchhiker's Guide to Phase 11 — PMC-Level Engineering
1. Thirty-Second Mental Model
PMC membership is not a reward for writing good code. It is a responsibility to protect the project's users from decisions that will hurt them for years. That means thinking in terms of contracts (what did we promise?), timelines (how long must we support this?), and consensus (who has to agree before we ship?).
2. Apache Deprecation Lifecycle
STABLE → DEPRECATED → REMOVED
Between DEPRECATED and REMOVED there must be at least two major release cycles by ASF convention (Spark enforces this strictly).
| Phase | What it means for users |
|---|---|
| STABLE | No action needed |
| DEPRECATED | Migration path documented; old API still works |
| REMOVED | Callers get NoSuchMethodError at runtime if not migrated |
Real Spark example: DataFrame was deprecated in favor of Dataset[Row] in
Spark 2.0. The old alias was retained as a type alias, not truly removed, to
avoid breaking compiled code. That distinction — source compatible vs. binary
compatible — is exactly what Lab 01 models.
3. Storage Format Migration Sequencing
You cannot jump directly from Text to Parquet on a live production table if downstream jobs are writing in Text format. The migration must be staged:
Text → ORC → Parquet
Each step must be:
- Forward-only readable — new readers can read old data.
- Rollback-safe — if the new format causes query regression, you can rewrite back without data loss.
Parquet is NOT rollback-safe from ORC because of type representation differences (e.g., TIMESTAMP precision). Lab 02 models this decision.
4. Capability Deltas Between Storage Formats
| Capability | Text | ORC | Parquet |
|---|---|---|---|
| Vectorized read | No | Yes (Hive LLAP) | Yes |
| Predicate pushdown | No | Yes | Yes |
| Schema evolution | Limited | Limited | Yes (Parquet spec) |
| ACID transactions | No | Yes (Hive 3.x) | No |
A migration from ORC to Parquet loses ACID transaction support. This is a PMC-level concern: the release notes must call this out explicitly.
5. Lazy Consensus vs. Explicit Vote
ASF uses two consensus mechanisms on the dev@ mailing list:
Lazy consensus — silence = approval, after a minimum 72-hour window. Used for: code commits, minor config changes, patch approvals.
Explicit vote ([VOTE] thread) — requires ≥ 3 binding +1 and no binding
-1. Used for: releases, new committers, PMC membership, major policy changes.
A design proposal (not a release, not a committer vote) typically uses lazy
consensus: the proposer sends [DISCUSS] then [VOTE] or just [PROPOSAL],
waits 72 hours, and proceeds if no binding objection is raised.
Lab 03 models both paths: a proposal that achieves lazy consensus, and one that is blocked by an explicit objection requiring resolution.
6. What Makes an Objection Blocking?
An objection on the dev@ list is binding when:
- It is raised by a PMC member (binding voter status).
- It is a technical objection — not a preference.
- It is unresolved — the proposer has not addressed the concern.
An objection with a proposed fix that the objector agrees resolves the issue is no longer binding. Lab 03 tracks this resolution state.
7. Interview Q&A
Q: You deprecated an API in version 2.0. A customer is still using it in version 3.5. What do you do?
A: Check whether the removal was announced with sufficient notice (at least two majors). If yes, the contract was honored — help them migrate. If no, extend the deprecation period and file a JIRA to track the delayed removal. Never surprise users.
Q: How do you build consensus across 20 engineers on a design decision?
A: Write a clear proposal with alternatives and explicit tradeoffs. Send
[DISCUSS]. Give ≥ 72 hours (more for complex proposals). Address each
technical objection in a follow-up. If a blocking objection remains after
addressing it, it is time for a formal [VOTE]. If you rush consensus you
will get a revert within one release cycle.
Q: What is the difference between lazy consensus and a formal vote?
A: Lazy consensus means no binding objection was raised. It does not mean everyone agreed — it means no one objected. Formal votes require active +1s. Releases, committer additions, and major policy changes require formal votes. Feature proposals generally use lazy consensus.
Lab 01 — Deprecated API Migration Analyzer
What You'll Build
A classifier that takes a set of API methods with their deprecation status and determines the migration urgency for each one, given a target library version.
Apache Context
Apache Spark follows a strict two-major-release deprecation policy. When the
JavaRDD API was deprecated in Spark 2.0 in favor of Dataset, it was
removed from the primary API surface in Spark 3.x. Callers who skipped the
deprecation notices found NoSuchMethodError at runtime after upgrading.
This lab models the logic behind migration scanners like errorprone and the upgrade compatibility reports embedded in tooling such as the Spark Migration Guide.
Running
mvn test
Expected: 10 tests, 0 failures.
Key Classes
| Class | Role |
|---|---|
DeprecationStatus | Enum: STABLE, DEPRECATED, REMOVED |
MigrationUrgency | Enum: NONE, PLANNED, IMMEDIATE |
ApiMethod | Method metadata: class, name, status, versions, replacement |
MigrationResult | One classification result including human-readable rationale |
ApiMigrationAnalyzer | analyze(method, targetVersion) + analyzeAll() + blockingResults() |
Classification Rules
| Status | Urgency | Meaning |
|---|---|---|
| STABLE | NONE | No action needed |
| DEPRECATED | PLANNED | Migrate before the removal version |
| REMOVED | IMMEDIATE | Must migrate — runtime failure guaranteed |
Interview Angle
Be ready to explain the difference between source compatibility and binary
compatibility. A method can be deprecated at source level but still present in
the bytecode (binary-compatible). Removal is what creates NoSuchMethodError.
Spark's move from DataFrame to Dataset[Row] was type-alias-only — source
change, no binary break.
Lab 02 — Storage Format Migration Planner
What You'll Build
A planner that computes the capability delta when migrating a Hive/Spark table from one storage format to another, and determines whether the migration step is rollback-safe.
Apache Context
Hive projects managing petabyte-scale tables face a recurring decision: migrate from older formats (Text, SequenceFile) to columnar formats (ORC, Parquet) for query performance, while preserving operational safety. The key insight is that not all capability gains are free — migrating from ORC to Parquet loses ACID transaction support, which in a Hive 3.x ACID table means you lose INSERT/UPDATE/DELETE semantics and must rewrite your ETL pipeline.
This lab models the static analysis that would precede running ALTER TABLE ... STORED AS PARQUET.
Running
mvn test
Expected: 11 tests, 0 failures.
Key Classes
| Class | Role |
|---|---|
StorageFormat | Enum: TEXT, SEQUENCE_FILE, ORC, PARQUET, AVRO |
FormatCapability | Enum: VECTORIZED_READ, PREDICATE_PUSHDOWN, SCHEMA_EVOLUTION, ACID_TRANSACTIONS, COLUMN_STATISTICS |
FormatCapabilityRegistry | Maps each StorageFormat to its capability set |
MigrationStep | from → to + gained + lost + isRollbackSafe() |
FormatMigrationPlanner | plan(from, to) — computes capability delta |
Capability Matrix
| Capability | TEXT | ORC | PARQUET | AVRO |
|---|---|---|---|---|
| Vectorized read | — | ✓ | ✓ | — |
| Predicate pushdown | — | ✓ | ✓ | — |
| Schema evolution | — | — | ✓ | ✓ |
| ACID transactions | — | ✓ | — | — |
| Column statistics | — | ✓ | ✓ | — |
Interview Angle
Walk through the ORC → Parquet migration trade-off. Parquet is preferred in the Spark ecosystem; ORC is preferred in the Hive ecosystem, especially when ACID transactions are required. Choosing wrong requires a rewrite. This is the kind of decision that lands in a PMC discussion and stays in the release notes.
Lab 03 — Consensus Tracker
What You'll Build
A model of the Apache lazy-consensus process for design proposals on the
dev@ mailing list — tracking +1/0/−1 votes from PMC members, detecting
blocking objections, and evaluating whether lazy consensus has been achieved
after the 72-hour minimum window.
Apache Consensus Rules
A design proposal achieves lazy consensus when:
- The 72-hour minimum discussion window has elapsed.
- No binding (PMC member) −1 objection remains unresolved.
- At least one binding +1 was cast (to confirm the proposal was noticed).
A proposal is blocked when an unresolved binding −1 exists — regardless of how many +1 votes were cast.
An objection becomes resolved when the proposer addresses the stated concern and the objector withdraws or explicitly acknowledges the resolution.
Running
mvn test
Expected: 10 tests, 0 failures.
Key Classes
| Class | Role |
|---|---|
ConsensusVote | Enum: PLUS1, ZERO, MINUS1 |
PMCVoteEntry | voter + vote + binding flag + comment + resolve() |
ConsensusResult | Enum: LAZY_CONSENSUS, BLOCKED, PENDING |
ConsensusProposal | title + proposedAt + vote list + objection helpers |
ConsensusEvaluator | evaluate(proposal, nowMillis) → ConsensusResult |
State Transitions
PENDING
├── blocking objection raised → BLOCKED
├── objection resolved + window elapsed + binding +1 → LAZY_CONSENSUS
└── window elapsed + binding +1 (no objection) → LAZY_CONSENSUS
Interview Angle
Distinguish lazy consensus from a formal [VOTE] thread. Lazy consensus is
appropriate for design proposals and code changes. A formal vote is required
for releases, new committers, and PMC membership changes. Interviewers at
staff/principal level will ask you how you would build consensus across a
distributed team for a controversial design change — describe sending
[DISCUSS], giving ≥ 72 hours, addressing each technical objection in writing,
and calling a formal vote only if lazy consensus fails.
Phase 12 — Final Apprenticeship Milestone
Weeks 23–24: Capstone — Full Contribution Cycle & Reflection
This phase simulates a complete open-source contribution journey, from patch proposal to release note, including a design doc and a final reflection on lessons learned.
| Lab | Description |
|---|---|
| Lab 01 | Full Contribution Cycle: patch, test, review response, release note |
| Lab 02 | Design Doc: technical design for a real or hypothetical feature |
| Lab 03 | Final Reflection: "What I Wish I Knew" — lessons, pitfalls, and advice |
Each lab ships with real technical artifacts (Java code, markdown, or both) and must pass all tests with mvn test where applicable.
Interview Relevance
This phase demonstrates readiness for independent contribution at the PMC level, including:
- Navigating the full review and release process
- Writing clear technical documentation
- Reflecting on growth and knowledge gaps
Labs
Warmup Guide — Final Apprenticeship Milestone
Orientation for Phase 12. The capstone introduces no new concepts — it integrates Phases 01–11 into three committer-grade artifacts. This warmup is the pre-flight check: what to have ready, how the three labs compose, and how to grade yourself before anyone else does.
Table of Contents
- Chapter 1: The Shape of the Milestone
- Chapter 2: Prerequisite Inventory — What You're Assumed to Have
- Chapter 3: The Contribution Cycle as a Single Performance
- Chapter 4: The Design Doc as Your Thesis
- Chapter 5: Reflection — Converting Experience into Transfer
- Chapter 6: After the Milestone — the Real First Patch
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: The Shape of the Milestone
Three labs, three artifact classes, one claim: you could operate as a committer candidate today.
| Lab | Artifact | Phases it integrates |
|---|---|---|
| 01 Full contribution cycle | patch + tests + review response + release note | 02 (navigation), 05–06 (quality/review), 09–10 (mechanics) |
| 02 Design doc | a subsystem-level design document | 03 (case law), 07–08 (systems/perf), 11 (consensus) |
| 03 Final reflection | a "What I Wish I Knew" transfer document | 01 (trust model) + everything |
The deep companion for all three is this phase's HITCHHIKERS-GUIDE — it carries the full mechanics (the eight-artifact cycle, the design-doc skeleton, review-response discipline, release-note format). This warmup deliberately doesn't repeat it; read it next.
Chapter 2: Prerequisite Inventory — What You're Assumed to Have
Honest self-check before starting; gaps here surface mid-capstone at 3× the cost:
- From Phase 02: you can navigate to any symbol, run one test, and execute the blame→JIRA→PR chain without thinking about it.
- From Phases 05–06: the blast-radius review checklist is reflexive; you write severity-labeled comments; characterization-tests-before-refactor is habit.
- From Phases 04/10: you can classify a change's compatibility impact (the Chapter 5 rules table from the Phase 10 warmup) and write the release-note line it requires.
- From Phase 11: you can state the skew matrix and the deprecation lifecycle — Lab 02's design topics live in this territory.
- From Phase 01: you remember what is actually being evaluated (trust axes), which is the grading rubric behind every artifact here.
Anything shaky → re-read that phase's WARMUP success-criteria list; they were written as exactly this checklist.
Chapter 3: The Contribution Cycle as a Single Performance
What changes when the pieces compose (the part that surprises people): the artifacts constrain each other.
- The issue framing determines the patch's reviewability — a falsifiable problem statement ("normalize() drops U+00A0; repro attached") pre-answers the reviewer's first three questions.
- The test is written against the issue, not the patch — it must fail on unpatched code (run it and capture that failure; the discipline catches wrong-fix bugs before review does).
- The review response quality is bounded by the patch's scope — a minimal diff makes every comment addressable; scope creep makes some comments unanswerable without "I'll split this out" (which you should then actually do).
- The release note is bounded by your blast-radius understanding — if the "who is affected" line is hard to write, the patch isn't done being understood.
Treat Lab 01 as a dress rehearsal with the costume on: write every artifact as if the recipient were a real PMC member who'll spend 10 minutes on it, total.
Chapter 4: The Design Doc as Your Thesis
Lab 02 is the single highest-leverage artifact in the curriculum for interviews — a complete design doc demonstrates the altitude that distinguishes staff candidates. The compressed guidance (full skeleton in the HITCHHIKERS-GUIDE §2):
- Pick a problem from Phases 10–11's space (scheduler improvement, metastore scaling, API/format evolution) so the curriculum's case law (Phase 03) and the system-design walkthroughs give you precedent to cite — citing precedent is the senior register.
- The alternatives section is the doc: 2–3 real options with honest costs, including "do nothing." Write each option's case as its proponent would; rejection reasons must survive a hostile reading.
- Non-goals and revert plans are where reviewers test for judgment; budget real effort there.
- Length discipline: ≤6 pages. Over that, decompose the design (sequence of small consensuses — the KIP/SPIP pattern).
Chapter 5: Reflection — Converting Experience into Transfer
Lab 03's bar (anti-platitude rules in the HITCHHIKERS-GUIDE §5): every claim must be falsifiable and costed. The generative prompts that produce real content:
- Which phase's material did you misjudge the importance of at the start, and what did the misjudgment cost in hours? (Almost everyone: under-weighting review culture, over-weighting code volume.)
- What's the earliest detection signature of each mistake you made — the thing a reader could watch for? ("If your characterization tests feel unnecessary, you're about to learn why they aren't.")
- What would you tell someone choosing between this curriculum and "just start submitting patches"? (The honest answer has both halves.)
- Three things you now do by reflex that you did by checklist in Phase 05 — reflexes are the curriculum's actual output; name them.
Chapter 6: After the Milestone — the Real First Patch
The milestone's exit ramp is Phase 01's plan, now executable:
- Return to your Lab 01 (Phase 01) project map — refresh it; six months of project drift is normal.
- Choose the first real issue with the trust calculus: starter-labeled, in your mapped subsystem, reproducible by you, reviewable by the 2–3 people you identified.
- Run the Lab 01 (this phase) cycle for real — same artifacts, real stakes, 72-hour-response discipline.
- In parallel, start the reciprocity loop: one review of someone else's PR per week in that subsystem (the highest trust-signal-per-hour activity available to you — Phase 01 Ch. 4).
- Calibrate to the project's measured timelines (your own Lab 01 numbers), not your impatience: months-scale presence is the unit of progress.
Lab Walkthrough Guidance
Order: Lab 01 → Lab 02 → Lab 03, reflection last while everything is fresh. Detailed per-lab guidance lives in the HITCHHIKERS-GUIDE "Lab Walkthrough Guidance" section — follow it; this warmup's Chapters 3–5 are the why behind each instruction there.
One scheduling note: the three labs are ~2 weeks of honest work. Do not compress Lab 02 below several sittings — design docs written in one sitting read like it, and the re-read-after-three-days step is where half the alternatives-section weaknesses surface.
Success Criteria
The milestone — and the curriculum — is complete when:
- Your Lab 01 package would not embarrass you posted to a real ASF project today, and you can defend every artifact's choices against the Phase 06 checklist.
- Your design doc's alternatives section survives a hostile reviewer (test: have someone — or yourself, post-delay — argue for a rejected option; the doc should already contain the rebuttal).
- Your reflection contains ≥3 falsifiable, costed lessons (Chapter 5's bar) that a Phase-01 reader could act on.
- You have selected the real project, real subsystem, and real first issue for Chapter 6's plan — with dates.
Interview Q&A
The capstone-relevant interview answers are consolidated in the HITCHHIKERS-GUIDE Interview Q&A and in interview-prep/. The one meta-question to prepare from this warmup:
Q: You've never been a committer. Why should we believe you can operate at that level? Point at artifacts, not adjectives: the full contribution cycle executed to ASF norms (show the review-response discipline), a subsystem design doc with steel-manned alternatives, a working review record (your Phase 06 outputs + real reviews from Chapter 6's loop), and the measured project map proving you understand the specific community you're entering. "I have done the job's components in public, here they are" is the only credible answer to a credentials gap — and it's the one this curriculum was built to let you give.
References
- HITCHHIKERS-GUIDE.md — this phase's full mechanics; read immediately after this warmup
- interview-prep/ and system-design/ — the consolidation targets
- Spark: Contributing — re-read now; it reads differently after eleven phases
- Fogel, Producing Open Source Software — final read-through; it is the curriculum's bibliography in one book
Hitchhiker's Guide: Phase 12 — Final Apprenticeship Milestone
The capstone phase. Everything before this trained components: navigation, review, release mechanics, performance, governance. This phase assembles them into the three artifacts that are a committer's working life — a complete contribution, a design document, and the transfer of what you learned to the next person.
Table of Contents
- Section 1: The Full Contribution Cycle
- Section 2: The Design Document
- Section 3: Responding to Review — the Decisive Skill
- Section 4: Release Notes That Earn Their Place
- Section 5: Reflection as an Engineering Artifact
- Lab Walkthrough Guidance
- Interview Q&A
- References
Section 1: The Full Contribution Cycle
A real ASF contribution touches eight distinct artifacts, and committers judge all eight:
- The issue — problem statement with reproduction. A good JIRA is falsifiable: someone else can confirm the bug exists and confirm your fix removes it.
- The patch — minimal diff that fixes the issue and nothing else. Scope creep is the #1 first-patch killer: every extra refactor multiplies review cost.
- The tests — one that fails without the patch (proves you fixed the right thing) plus edge cases. A patch whose test passes on unpatched code tests nothing.
- Commit message —
PROJECT-1234: <what changed and why>with body explaining the approach and alternatives rejected.git logis the project's long-term memory. - Review responses — Section 3.
- The release note — Section 4.
- Backport decisions — does this go to maintenance branches? Bug fixes usually yes, behavior changes usually no; you must state your recommendation, not leave it implicit.
- Follow-through — watching CI after merge, responding if it breaks something. The merge is the midpoint of the contribution, not the end.
Lab 01's StringNormalizer exercise is deliberately small so that the cycle is the
content — the same eight artifacts at one-day scale.
Section 2: The Design Document
The design doc is the unit of technical leadership in distributed open-source work — it is how you change a system you don't control. The load-bearing sections:
- Problem statement: measurable and agreed-upon before solutions appear. If the problem statement is contested, no design will get consensus; settle it first on the list.
- Goals / Non-goals: non-goals are the more important half — they pre-empt scope arguments and record what you chose not to solve.
- Alternatives considered: 2–3 real designs with honest costs, including "do nothing." Reviewers trust documents that argue against their proposal competently. An alternatives section containing one straw man is a tell.
- Compatibility analysis: what breaks, for whom, at which boundary (source/binary/ behavioral/wire — the Phase 10/11 taxonomy). For ASF projects this section decides votes.
- Rollout & revert: how it ships behind what flag, what the metrics of success are, and — the part people omit — how it is undone if wrong.
- Open questions: explicitly listed. Pretending certainty you don't have reads as inexperience to senior reviewers; a sharp open-questions list reads as judgment.
Length discipline: if the doc exceeds ~6 pages, the design is probably not decomposed enough. Big designs ship as a sequence of small consensuses (cf. Kafka KIPs, Spark SPIPs).
Section 3: Responding to Review — the Decisive Skill
PMC members repeatedly say the same thing: they vote committership on how candidates respond to review, because it predicts decade-scale collaboration. The mechanics:
- Respond to every comment — fixed / pushed back with reasons / question. Silence on a comment reads as either sloppiness or stonewalling.
- Batch your pushes: one revision addressing all comments, not a commit per comment; reviewers re-review whole revisions.
- Distinguish the three comment types and answer each in kind: blocking technical objections (resolve or rebut with evidence), preferences (usually just take them — spend disagreement budget on things that matter), and questions (often a sign your code needs a comment or doc — fix the artifact, not just the thread).
- When you disagree: restate their concern accurately first (proves you understood), then evidence. "You're right that X; however the benchmark in comment 3 shows Y" lands; "no, because" doesn't.
- When you were wrong: say so fast and visibly, then convert your misunderstanding into a code comment or test so the next person doesn't repeat it. (This is Scenario 5 in interview-prep/04-code-review-scenarios.md.)
Lab 01's REVIEWER_RESPONSE.md is graded against exactly this list.
Section 4: Release Notes That Earn Their Place
A release note is written for someone upgrading under time pressure who will read one sentence. Format that works:
PROJECT-1234 —
normalize()now trims Unicode whitespace (was: ASCII only). Who is affected: callers relying on non-ASCII whitespace surviving normalization. Action: none for most users; setlegacy.normalize=trueto restore old behavior (removed in 4.0).
Rules: lead with observable behavior change, not implementation; name the affected audience explicitly; always state the action (including "none"); link the migration path if any. If you cannot write the "who is affected" line, you don't understand your own change's blast radius yet — go back and find out.
Section 5: Reflection as an Engineering Artifact
The "What I Wish I Knew" document (Lab 03) is not a diary — it is transfer of calibration. What makes one useful to the next contributor:
- Specific costly surprises with their detection signature ("a silent CI label means X, here's how to tell").
- Re-ranked priorities: what you over-invested in early vs what actually built trust — almost everyone reports under-investing in reviewing others and over-investing in writing more code.
- Honest failure inventory with what each cost and what guard now exists.
- Advice that is falsifiable rather than platitudes ("respond to review within 48h even if just 'looking this week' — silence decays reviewer attention" beats "communicate well").
This genre is real in the ASF world: incoming committers are routinely pointed at exactly such documents.
Lab Walkthrough Guidance
Order: Lab 01 → Lab 02 → Lab 03 (reflection last, with the experience fresh).
- Lab 01: run
mvn testfirst; read the existingStringNormalizerand its tests as a reviewer would before touching anything. Then execute the eight-artifact cycle. Grade yourself against Section 1; the REVIEWER_RESPONSE.md against Section 3. - Lab 02: pick a design problem from Phases 10–11 (scheduler improvement, metastore scaling, API evolution — the system-design/ walkthroughs show the target altitude). Write to the Section 2 skeleton; have someone (or a later re-read after a week) attack the alternatives section.
- Lab 03: write against Section 5's anti-platitude rules.
Success criteria — you are done when: your Lab 01 package could be posted to a real ASF project without embarrassment; your design doc's alternatives section would survive a hostile reviewer; your reflection contains at least three things you genuinely did not know at Phase 01 with their costs attached.
Interview Q&A
Q: Walk me through a contribution end-to-end. Where do contributions actually fail? Strong answer enumerates the cycle (Section 1) and locates the real failure points: scope creep at the patch stage, tests that don't fail without the fix, silence during review, and abandonment after the first round of feedback — not the code itself.
Q: How do you structure a design doc for a distributed system change? Problem (agreed first) → goals/non-goals → alternatives with honest costs → compatibility across all four dimensions → rollout and revert → open questions. Emphasize non-goals and revert: those are the sections that distinguish staff-level docs.
Q: A reviewer's blocking comment is wrong. What do you do? Restate their concern to prove comprehension; bring evidence (test, benchmark, spec citation); if they hold, take it to the list rather than grinding in the PR; and accept that on a justified technical veto, consensus — not being right — ships code.
Q: What do you wish you'd known before your first contribution? Have a real answer from your Lab 03. The generic shape that rings true: trust accrues from reviewing and follow-through more than from code volume; small patches compound; the mailing list is the project.
References
- Apache contributor guides (per-project, e.g. Spark)
- Kafka Improvement Proposals (KIP) — design-doc process in the wild
- Spark Project Improvement Proposals (SPIP)
- Producing Open Source Software — Karl Fogel, ch. "Communications"
- Design Docs at Google (industry mirror)
- Software Engineering at Google — ch. "Documentation" and "Code Review"
Lab 01 — Full Contribution Cycle
Simulate a real ASF patch lifecycle:
- Submit a patch (Java code change)
- Write and run tests
- Respond to reviewer feedback (code or doc update)
- Draft a release note for the change
Deliverables
- Java code (compilable, tested)
- Reviewer response (markdown)
- Release note (markdown)
Context
This lab models the end-to-end process for a meaningful contribution to an Apache project, including technical and communication artifacts.
Lab 02 — Design Doc
Write a technical design document for a real or hypothetical feature relevant to an Apache project. The doc should include:
- Problem statement
- Proposed solution
- Alternatives considered
- Impact analysis
- Test plan
Deliverables
- Markdown design doc (well-structured, clear)
Context
This lab demonstrates the ability to communicate complex technical ideas and justify design decisions to a distributed team.
Lab 03 — Final Reflection
Write a personal reflection: "What I Wish I Knew Before Starting My PMC Journey".
- Lessons learned
- Pitfalls encountered
- Advice for future contributors
Deliverables
- Markdown reflection (honest, actionable, 1–2 pages)
Context
This lab encourages metacognition and knowledge sharing, essential for senior open-source contributors.
Interview Prep — Apache Committer Track & Staff/Principal Data Infrastructure
This section prepares you for two distinct evaluation processes:
- The committer "interview" that isn't an interview. Apache committership is granted by PMC vote based on your public track record — every JIRA comment, patch, review, and mailing-list thread is the interview. The governance Q&A here makes sure you understand the system you are being evaluated by.
- Staff/Principal interviews at data-infrastructure companies (Databricks, Confluent, Cloudera, LinkedIn, Uber, Netflix, Stripe…), where Apache-ecosystem depth, JVM performance fluency, distributed-systems design, and review judgment are the bar.
Contents
| File | Covers |
|---|---|
| 01-apache-governance-questions.md | ASF structure, releases, votes, vetoes, committer/PMC process — with complete answers |
| 02-distributed-systems-questions.md | Consensus, replication, partitioning, scheduling, fault tolerance — with complete answers |
| 03-jvm-performance-questions.md | GC, JIT, memory model, concurrency, benchmarking — with complete answers |
| 04-code-review-scenarios.md | Review exercises with model reviewer responses |
| 05-behavioral-star-stories.md | STAR frameworks for staff-level behavioral rounds, OSS-flavored |
How to use this section
- After Phases 01–04: work through file 01. You should be able to answer every question without looking, in the words a PMC member would use.
- After Phases 07–08: files 02 and 03. These map directly to the labs — every answer references the lab where you built the thing being asked about.
- After Phases 05–06, 11: file 04. Review judgment is the single most-weighted signal for committer candidacy and for staff interviews alike.
- Two weeks before any interview loop: file 05, written in your own words from your own labs.
The meta-skill
In every answer, demonstrate the same three habits the curriculum trains:
- Mechanism before opinion — explain how the thing works before judging it.
- Compatibility and blast radius — every change is discussed in terms of who it breaks.
- Evidence — benchmarks, tests, and references to real systems (Spark, Hadoop, Hive, Kafka) instead of generalities.
Apache Governance & Process — Questions with Complete Answers
These are the questions a PMC member, a release manager, or an interviewer at an ASF-adjacent company will expect you to answer precisely. Vague answers signal you have never actually shipped through the process.
Table of Contents
- Roles and trust
- Releases and voting
- Vetoes and consensus
- Compatibility policy
- Becoming a committer
- References
Roles and trust
Q: What is the difference between a contributor, a committer, and a PMC member?
A contributor is anyone who submits patches, files issues, improves docs, or answers questions — no formal status. A committer has earned write access to the repository by PMC vote; commits still go through review norms, but the committer can merge. A PMC (Project Management Committee) member holds binding governance authority: their votes are binding on releases, they elect new committers and PMC members, and the PMC collectively is accountable to the ASF Board for the project's health and legal compliance. Every PMC member is a committer; the reverse is not true.
Q: What does "Apache is a meritocracy built on trust" mean operationally?
Status is granted retroactively for sustained behavior, never requested. The PMC is not evaluating one patch; it is answering "would we give this person the keys and sleep well?" That is why review tone, follow-through on stuck patches, willingness to maintain what you ship, and how you take criticism all weigh as much as code quality. The trust audit in Phase 01 maps exactly this.
Q: What is the ASF's stance on corporate influence?
Individuals, not companies, hold merit. A committer's status is personal and survives changing employers. Projects must not be controlled by a single vendor ("independence" is reviewed by the Board); decisions happen on public mailing lists — "if it didn't happen on the list, it didn't happen."
Releases and voting
Q: Walk me through an Apache release from code freeze to announcement.
- Release manager (RM) cuts a release branch; blockers tracked in JIRA against the version.
- RM produces a release candidate: source tarball (the official artifact — binaries are
convenience), signed with the RM's GPG key, checksums published, staged (commonly to a
Nexus staging repo and
dist.apache.orgdev area). - RM starts a
[VOTE]thread on the dev list, linking artifacts, signatures, and tag. - Voting runs a minimum of 72 hours. Release votes require at least 3 binding +1 votes (PMC members) and more binding +1 than -1. A -1 on a release is not a veto — it is a vote, and the release can still pass, though a serious -1 usually sinks an RC socially.
- Anyone can vote; only PMC votes are binding. Voters are expected to state what they checked (signatures, checksums, build from source, license files, no unexpected binaries).
- On pass: RM publishes to
downloads.apache.org/mirrors, releases the staging repo to Maven Central, tags, updates the website, and sends[ANNOUNCE].
Q: What must you verify before casting +1 on a release candidate?
Signature verifies against a key in the project's KEYS file; checksums match; the source tarball builds and passes tests from a clean environment; LICENSE/NOTICE are present and accurate for bundled dependencies; no compiled binaries or category-X licensed code in the source release; the git tag matches the tarball contents. Phase 04's signing lab makes you do each step by hand.
Q: Why is the source tarball the official release rather than the git tag or binaries?
Legally, the ASF releases source code under the Apache License; the foundation's infrastructure, signing, and voting process protect exactly that artifact. Binaries are unofficial conveniences built from it. This is also why license hygiene of the tarball is a release-blocking concern while a bad binary is merely embarrassing.
Vetoes and consensus
Q: When does -1 mean veto, and what makes a veto valid?
On code modifications (commits), -1 from a committer/PMC member is a veto and must be respected — but only if accompanied by a technical justification. An unjustified veto is invalid. Vetoes cannot be overridden by majority; they are resolved by addressing the objection or convincing the vetoer. On procedural matters (releases, electing people), -1 is just a vote — majority/3×+1 rules apply instead.
Q: What is lazy consensus and when do you rely on it?
Stating intent ("I plan to merge this in 72 hours unless objections") and proceeding if nobody objects. It keeps low-controversy work moving without requiring explicit +1s from busy people. You do not rely on it for API changes, behavior changes with compatibility impact, or anything previously contested — those need explicit review.
Q: A committer merges your patch, then another committer posts a justified -1. What happens?
The veto stands even post-merge: the change is reverted or fixed forward to address the objection. The right response from you is to engage the technical objection on its merits, not to argue process. How you behave in exactly this moment is committer-candidacy signal.
Compatibility policy
Q: How do mature Apache projects define compatibility promises?
Via annotated audience and stability levels — Hadoop's
@InterfaceAudience.{Public,LimitedPrivate,Private} × @InterfaceStability.{Stable,Evolving,Unstable},
Spark's @DeveloperApi/@Experimental/@Since. Public+Stable APIs cannot break within a
major line; semver-like rules govern what minor and patch releases may change. Tools like
japicmp (Phase 04 lab) and explicit MiMa rules (Spark) enforce this in CI.
Q: Why is removing a deprecated method still a big deal?
Deprecation is a signal, not a license. Removal is a major-version event, requires the deprecation to have shipped in at least one minor release with a documented migration path, and the cost lands on every downstream user simultaneously. Phase 11's migration analyzer lab encodes the urgency model: deprecated-since × usage-count × replacement-availability.
Becoming a committer
Q: What does a PMC actually look at when voting on a new committer?
A months-long public record: merged patches across more than one area; review comments on other people's patches that show judgment; mailing-list answers that are correct and patient; JIRA hygiene (good reports, reproductions, follow-through); no abandoned messes. Quantity matters less than the question "does this person act like an owner already?"
Q: How do you pick a first issue in a large Apache project without wasting reviewer time?
Start with issues labeled starter/newbie; reproduce the bug first and say so on the JIRA; read the relevant module's recent commits to learn local conventions; keep the first patch small and test-covered; state explicitly what you tested. The fastest trust-builder is a patch the reviewer can merge without a second round.
Q: Your patch has been ignored for three weeks. What do you do?
One polite ping on the JIRA/PR mentioning the reviewer(s) who touched the file recently; if silent, raise it in the dev list's review-request thread conventions for the project. Never cross-post repeatedly or DM-pressure. Meanwhile, review someone else's patch — reciprocity is the unwritten queue discipline of OSS review.
References
- Apache Voting Process
- Apache Release Policy
- Apache Release Distribution Policy
- How the ASF works
- Hadoop Compatibility Guide
- Spark Versioning Policy
- Producing Open Source Software — Karl Fogel (the unofficial manual for all of this)
Distributed Systems — Questions with Complete Answers
Staff/Principal data-infrastructure loops test whether you can reason about consensus, replication, partitioning, and failure from mechanism, not vocabulary. Every answer below ties back to a lab or a real Apache system.
Table of Contents
- Consensus
- Replication and consistency
- Partitioning and rebalancing
- Scheduling and resource management
- Failure handling
- References
Consensus
Q: Explain Raft leader election precisely. What prevents two leaders in the same term?
Nodes are followers until an election timeout (randomized, e.g. 150–300 ms) fires without a heartbeat. The follower increments its term, becomes candidate, votes for itself, and requests votes. A node grants at most one vote per term (persisted), and only to a candidate whose log is at least as up-to-date (last term, then last index). A candidate needs a majority; two leaders in one term would require two disjoint majorities — impossible in one quorum set. Two leaders in different terms can transiently coexist, but the stale leader's AppendEntries are rejected by followers on term comparison, forcing it to step down. Randomized timeouts make split votes rare rather than impossible; a split vote just re-elects in the next term. You built and tested all of this in Phase 07 Lab 01.
Q: Why can't a Raft leader commit entries from previous terms by counting replicas?
The Figure-8 scenario in the Raft paper: an entry from an old term can be replicated to a majority and still be overwritten by a later leader whose log diverged. A leader may only advance commitIndex for entries of its own current term; older entries commit implicitly once a current-term entry on top of them commits. This is the subtlest rule in Raft and a favorite follow-up question.
Q: Where does ZooKeeper fit in the Hadoop/Kafka ecosystem, and what replaced it in Kafka?
ZooKeeper provides a replicated, totally-ordered, small-data store (ZAB protocol) used for coordination: HDFS/YARN HA failover via ephemeral-node leader locks, HBase meta, Kafka's old controller metadata. Kafka replaced it with KRaft — a Raft-based internal metadata quorum — eliminating the external dependency, the dual-system consistency gap (controller state vs ZK state), and scaling metadata to millions of partitions.
Replication and consistency
Q: Compare HDFS replication with Kafka ISR replication.
HDFS: pipeline replication — client writes a block through a chain of 3 datanodes; the
namenode tracks block locations; acks flow back through the pipeline. Consistency is
simple because blocks are immutable once closed. Kafka: leader-based log replication —
followers fetch from the leader; the leader maintains the ISR (in-sync replica set);
a message is committed when all ISR members have it; acks=all + min.insync.replicas=2
gives durability against one broker loss. The key design difference: HDFS replicates
immutable blocks, Kafka replicates an ordered mutable log, so Kafka needs the
high-watermark and leader-epoch machinery to prevent divergence on failover.
Q: What does "exactly-once" mean in Kafka/Spark, really?
There is no exactly-once delivery; there is exactly-once processing effect. Kafka: idempotent producers (sequence numbers per partition) remove duplicates from retries, and transactions make produce+offset-commit atomic across partitions. Spark Structured Streaming: replayable sources + deterministic computation + idempotent/transactional sinks gives end-to-end effective-once. If asked, always decompose into source replay, processing determinism, and sink idempotence.
Partitioning and rebalancing
Q: How does Kafka's default partitioner work and why murmur2?
partition = murmur2(keyBytes) % numPartitions (with the sign bit masked). Murmur2 is
fast, non-cryptographic, and uniform across realistic key distributions — quality matters
because a biased hash creates hot partitions that cap throughput at one broker. Keyless
records use sticky batching (fill one partition's batch, then switch) to amortize batching
overhead. You implemented and chi-square-tested this in Phase 07 Lab 02.
Q: What goes wrong when you increase the partition count of a keyed topic?
hash(key) % N changes for almost every key, so key→partition affinity breaks: ordering
per key is no longer continuous across the boundary, and any state keyed by partition
(consumer-local stores, Kafka Streams state stores) is suddenly wrong. The fixes are
unpleasant — re-key into a new topic, or over-provision partitions up front. This is the
classic "modulo is not consistent hashing" interview probe.
Q: Range vs round-robin consumer assignment — tradeoffs?
Range assigns contiguous partition blocks per topic; co-partitioned topics get aligned assignments (needed for joins) but it skews when partitions per topic ≪ consumers × topics. Round-robin spreads evenly but breaks co-partition alignment. Cooperative sticky assignment (modern default) minimizes movement during rebalances, which matters because every moved partition flushes caches and stalls consumption.
Scheduling and resource management
Q: Explain YARN's architecture and the capacity/fair scheduler tradeoff.
ResourceManager (scheduler + ApplicationsManager) arbitrates cluster resources; NodeManagers report and enforce per-node containers; each application runs its own ApplicationMaster that negotiates containers. The scheduler is policy-pluggable: capacity scheduler partitions the cluster into hierarchical queues with guaranteed capacities and elastic sharing (multi-tenant orgs); fair scheduler converges all running apps toward equal (weighted) shares (ad-hoc analytic clusters). The recurring design tension: utilization (let queues borrow) vs isolation (preemption to reclaim guarantees) — preemption cost is wasted work in killed containers.
Q: How does Spark's DAG scheduler decide stage boundaries?
Narrow dependencies (map, filter — each parent partition feeds one child partition) are pipelined into a single stage. Wide dependencies (shuffle: groupByKey, repartition, joins without co-partitioning) cut a stage boundary: the parent stage materializes shuffle files and the child stage fetches them. Stage failure granularity, speculative re-execution, and shuffle cost all follow from this cut. The interview follow-up is invariably "and what does AQE change?" — answer: post-shuffle statistics let Spark coalesce partitions, switch join strategies, and split skewed partitions at runtime.
Failure handling
Q: A long-running job fails on one task repeatedly on the same node. Walk through your reasoning.
Differentiate deterministic data problems from environmental ones. Same task id failing on different nodes → bad record/skew/code path (fix data handling, look at the partition's key range). Failing only on one node → environment (disk, memory, JVM version, local library); blacklist/decommission the node and check NodeManager health logs. Speculative execution masks slow nodes but not failing ones. Mention the observability you would check in order: application logs → executor/container logs → node health → cluster metrics for the time window.
Q: Why is retry-with-backoff insufficient as a fault-tolerance strategy?
Retries amplify load exactly when the system is least able to take it (retry storms), can violate idempotence assumptions (duplicate side effects), and convert partial failures into latency cliffs. Production-grade handling adds jittered exponential backoff, retry budgets, circuit breaking, idempotency tokens, and — most importantly — distinguishes retryable (transient, safe) from non-retryable (deterministic, unsafe) errors at the type level.
References
- In Search of an Understandable Consensus Algorithm (Raft) — Ongaro & Ousterhout
- ZooKeeper: Wait-free coordination
- KIP-500: Replace ZooKeeper with a Self-Managed Metadata Quorum
- The Hadoop Distributed File System (HDFS paper)
- Apache Hadoop YARN paper (SoCC '13)
- Designing Data-Intensive Applications — Kleppmann, ch. 5–9
JVM Performance — Questions with Complete Answers
The JD says "deep Java expertise (concurrent programming, JVM internals, GC behavior, heap analysis)". These are the questions that test it, with answers grounded in the Phase 08 labs.
Table of Contents
- Garbage collection
- JIT and benchmarking
- Memory model and concurrency
- Heap analysis war stories
- References
Garbage collection
Q: Compare G1, ZGC, and Parallel GC. When would you pick each for a data-infrastructure JVM?
Parallel GC maximizes throughput with stop-the-world collections — right for batch jobs (Spark executors doing CPU-bound work) where a 500 ms pause is irrelevant. G1 targets predictable pauses by collecting region-by-region with a pause-time goal — the default general-purpose choice for services and most brokers. ZGC gives sub-millisecond pauses via colored pointers and concurrent relocation at some throughput and memory cost — right for latency-SLO services with large heaps (100 GB+). For Kafka brokers, note that the page cache, not the heap, does the heavy lifting — keep heaps modest (~6 GB) regardless of collector.
Q: What is allocation rate and why does it matter more than heap size?
Bytes allocated per second drives the frequency of young collections; most "GC problems"
in data systems are allocation-rate problems (per-record garbage in hot loops: boxing,
iterator churn, string concatenation, temporary arrays). Halving allocation rate halves
young-GC frequency without touching the heap. Phase 08 Lab 02 measures exactly this:
boxing (Long vs long accumulators) and stream-vs-loop allocation profiles via
JFR/-verbose:gc.
Q: A Spark executor spends 40% of time in GC. What do you look at?
In order: (1) allocation rate and what's allocating — JFR allocation profiling or async
profiler alloc mode; usual suspects are wide-row deserialization, UDF object churn, and
collect-like operators; (2) memory config — executor memory vs spark.memory.fraction,
whether execution spills are thrashing; (3) data skew creating one giant partition (fix the
partitioning, not the GC); (4) only then GC tuning — bigger young gen for batch, or G1
region size for humongous allocations (records > 16 MB). Saying "increase the heap" first
is a junior answer; the heap often just makes pauses longer.
JIT and benchmarking
Q: Why do naive Java microbenchmarks lie? Name the specific JIT effects.
Dead-code elimination (unused results delete your benchmark body — counter with JMH
Blackhole), constant folding (compile-time-known inputs fold away — use @State fields),
loop unrolling combined with OSR producing unrepresentative compilation, warmup (first N
calls run interpreted/C1; steady state is C2), inlining differences between the benchmark
harness and real call sites, and profile pollution (a megamorphic call site in one test
deoptimizes another's monomorphic fast path). JMH exists to control all of these —
forked JVMs, warmup iterations, blackholes — which is Phase 08 Lab 01.
Q: What is a safepoint and how does it bite production systems?
A point where all threads are at known states so the VM can do stop-the-world work (GC
phases, deoptimization, biased-lock revocation, thread dumps). Threads poll for safepoint
requests; a thread in a long counted loop without a poll (classic: int-indexed hot loop)
delays everyone — time-to-safepoint spikes show up as mysterious tail latency even when
"GC pauses" look short. Diagnose with -Xlog:safepoint.
Memory model and concurrency
Q: What does volatile guarantee, and what does it not?
Visibility (a read sees the latest write, via happens-before on the volatile variable) and
ordering (no reordering of surrounding accesses across the volatile access). It does not
make compound operations atomic — count++ on a volatile is still a lost-update race. For
counters use AtomicLong (CAS) or LongAdder (striped cells); Phase 08 Lab 03 benchmarks
why: under contention, CAS retry storms make AtomicLong degrade while LongAdder scales,
at the cost of a more expensive sum().
Q: synchronized vs ReentrantLock vs StampedLock — when does each win?
synchronized: simplest, JIT-friendly (lock elision, coarsening, biased locking
historically), right default for uncontended or briefly-held locks. ReentrantLock:
tryLock with timeout, interruptible acquisition, fairness, multiple conditions — choose it
when you need those features, not for speed. StampedLock: optimistic reads make
read-mostly structures very fast, but it's non-reentrant and easy to misuse. The deeper
answer: at high contention the win is not having the lock — striping (LongAdder,
ConcurrentHashMap), immutability, or per-thread accumulation then merge.
Q: Explain a memory leak pattern specific to long-running JVM data services.
Unbounded caches keyed by something with unstable hashCode/equals; listener/callback
registration without deregistration; ThreadLocals on pooled threads (the pool keeps the
thread, the thread keeps the value); classloader leaks on redeploy (a static somewhere
holds an instance whose class came from the old loader); and accumulating metrics
registries with per-entity tags of unbounded cardinality. Heap-dump triage: dominator tree
in MAT/jhat → biggest retained sets → ask "what owns this and why is it still reachable?"
Heap analysis war stories
Use these as templates — replace with your own lab numbers.
- The boxing regression: a refactor changed a hot aggregation from
longtoLong; allocation rate ×6, young GC every 300 ms. Found via JFR allocation profile; fixed with a primitive accumulator; wrote a JMH benchmark into CI to pin it. (Phase 08 Lab 02 is this.) - The contended counter: metrics singleton with
AtomicLong.incrementAndGet()on every record; 32 cores, throughput plateaued at 8. Async-profiler showed 40% cycles in CAS retries;LongAdderrestored linear scaling. (Phase 08 Lab 03 reproduces it.) - The "clever" partitioner: bit-twiddled hash that was fast and wrong — skewed 70% of keys to 3 partitions. Chi-square test in the review caught it. (Phase 05 Lab 03 / Phase 07 Lab 02.)
References
- JMH — official samples (read all 40; they are the JIT-pitfall curriculum)
- Java Memory Model — JSR-133 FAQ
- Java Concurrency in Practice — Goetz et al.
- G1 GC tuning guide (Oracle)
- async-profiler — allocation and CPU flamegraphs
- JDK Flight Recorder docs
Code Review Scenarios — with Model Reviewer Responses
Review judgment is the most-weighted committer signal and a standard staff-interview exercise ("review this PR live"). Each scenario below gives the situation, the trap, and a model response written the way a good Apache reviewer would write it.
Table of Contents
- Scenario 1: Correct but unmaintainable
- Scenario 2: The hidden compatibility break
- Scenario 3: The concurrency patch with a benchmark
- Scenario 4: The drive-by mega-patch
- Scenario 5: You are wrong
- Review checklist
Scenario 1: Correct but unmaintainable
A patch replaces a readable partitioning loop with a branchless bit-twiddling version, tests pass, and includes a benchmark showing 15% improvement on the hot path.
Trap: rejecting it as "unreadable" (taste-based veto) or accepting it on the benchmark alone.
Model response:
The 15% win is real and this is a genuinely hot path (it shows in the shuffle profile), so I want to land the idea. Two requests: (1) keep the readable version as the reference in a comment or a
*Slowtest-only twin and assert equivalence property-style across random inputs — the next person to touch this needs a way to verify changes; (2) the(h ^ (h >>> 16)) & masktrick silently assumes power-of-two partition counts — either assert that invariant or handle the general case. With those, +1.
The skill: convert taste into maintainability requirements tied to who maintains it next. This is Phase 05 Lab 03's refactor exercise as a review.
Scenario 2: The hidden compatibility break
A cleanup patch changes a public method's return type from List to the more specific
ArrayList, "since that's what it always returns anyway."
Trap: it compiles, tests pass, and it is still a break — source-compatible but binary-incompatible in the other direction for overriders, and it freezes an implementation detail into the contract forever.
Model response:
-1 (technical): this widens the API contract. Returning
ArrayListpublicly means we can never switch to an immutable or lazy implementation without a breaking change, and any subclass overriding this method now fails to compile.o.e.compatis@InterfaceAudience.Public/@Stable, so this needs to wait for a major release even if we wanted it. japicmp flags this asMETHOD_RETURN_TYPE_CHANGED(binary incompatible). Suggest keepingListand documenting mutability instead.
The skill: every signature change is checked against audience/stability annotations and a compat tool, reflexively. Phase 04 Lab 03 and Phase 10 Lab 02 train exactly this reflex.
Scenario 3: The concurrency patch with a benchmark
A patch replaces synchronized with a spin-lock "for performance", attaching a
single-threaded JMH run showing improvement.
Model response:
The benchmark doesn't test the claim: a spin lock wins exactly when contention is low and hold times are tiny, and burns CPU otherwise — a single-threaded run can't distinguish these. Please add contended runs (
@Threads(8), and one with a deliberately long critical section). Also: the spin loop as written has noThread.onSpinWait()and spins without backoff, which will look great in JMH and terrible on a loaded broker. My expectation issynchronizedwins the contended case post-JDK-15 biased-locking removal; happy to be shown otherwise by data.
The skill: review the experiment design, not the numbers. Phase 08 Labs 1–3 give you the vocabulary.
Scenario 4: The drive-by mega-patch
A first-time contributor submits a 4,000-line patch that reformats every file it touches while also fixing a real bug somewhere inside.
Model response:
Thanks — there's a real fix in here and I want to get it in. As-is this can't be reviewed: 95% of the diff is formatting churn that hides the behavior change and will wreck
git blamefor these files. Could you split it: (1) the bug fix with its test, minimal diff; (2) if you feel strongly about formatting, a separate discussion on the dev list first — project-wide style changes need consensus, not a patch. Happy to fast-track the fix once split.
The skill: protect reviewability and history without crushing a newcomer. Tone is the test.
Scenario 5: You are wrong
You -1'd a patch claiming a race condition; the author responds with a happens-before argument showing the publication is safe.
Model response:
You're right — I missed that the field is final and the object is safely published via the volatile map write, so the JMM guarantees visibility of the constructor writes. Withdrawing my -1. Could you add a short comment citing the final-field guarantee? The fact that I misread it suggests the next reader will too. +1 with that.
The skill: update fast, in public, and convert your own confusion into a documentation improvement. PMC members specifically watch how candidates handle being wrong.
Review checklist
The order matters — it's blast-radius first:
- Contract: does the change alter any public API, wire format, config default, or on-disk format? Check annotations, run the compat tooling.
- Correctness: new states, error paths, concurrency. What test would fail if this were wrong — does that test exist?
- Failure behavior: what happens on partial failure / retry / weird input? Is it worse than before?
- Performance: hot path? Then demand a benchmark whose design matches the claim.
- Maintainability: can the next non-author modify this safely? Naming, comments stating invariants (not narration), test readability.
- Tone of your own review: every comment actionable, reasons given, severity labeled (blocking vs nit), and at least one sentence acknowledging what's good.
Behavioral / STAR Stories — Staff-Level, OSS-Flavored
Staff+ behavioral rounds probe influence without authority, judgment under ambiguity, and ownership over years — exactly the muscles the committer track builds. Below are the recurring prompts with story frameworks; fill each with your own lab artifacts and real contributions. Never recite a template verbatim.
Table of Contents
- How to build a story bank
- The eight prompts that always come up
- OSS-specific behavioral questions
- Anti-patterns
How to build a story bank
Maintain 8–10 stories, each written as: Situation (2 sentences, quantified), Task (your specific responsibility), Action (5–6 concrete decisions you made, with the alternatives you rejected and why), Result (numbers + what changed durably), plus a one-line "what I'd do differently". Each story should be retellable at three lengths: 30 seconds, 2 minutes, 10 minutes with follow-ups.
The curriculum generates story raw material on purpose:
| Curriculum artifact | Story it seeds |
|---|---|
| Phase 03 post-mortem (NameNode SPOF) | "Tell me about learning from a system design failure" |
| Phase 04 release labs | "Tell me about shipping something with a hard quality bar" |
| Phase 05–06 review labs | "Tell me about giving difficult feedback" |
| Phase 08 benchmarks | "Tell me about a time data changed your mind" |
| Phase 11 consensus tracker / design docs | "Tell me about driving a decision across teams" |
| Real first contributions to an ASF project | Every "influence without authority" question |
The eight prompts that always come up
1. Disagreement with a senior engineer / decision you opposed. Framework: technical disagreement → you gathered evidence (benchmark, prototype, compatibility analysis) → escalated through the idea, not the hierarchy → committed fully to the outcome either way. The committer-track version: a -1 you received, engaged on merits, and either overturned with data or accepted and improved the patch.
2. Influence without authority. OSS is this, structurally: you got code into a project where nobody reports to you, by making the reviewer's job easy, building trust in small increments, and doing the unglamorous work (reviews, doc fixes, triage) that bought credibility.
3. Most technically challenging problem. Pick depth over drama: the Phase 08-style performance investigation (symptom → profile → hypothesis → fix → regression guard) interviews better than "big migration" stories because every step shows method. Have the actual numbers.
4. A mistake / failure. Requirements: a real failure (not "I worked too hard"), fast detection, honest communication, durable prevention (a test, a checklist item, a process change), and what it changed about how you work. The release-engineering labs give the right shape: what gets caught by checklist exists because someone once didn't catch it.
5. Mentoring / raising the bar. The review-culture phases are the material: how you review to teach rather than gatekeep, codified norms (review checklist, style configs), and a measurable change (review turnaround, defect escape rate).
6. Ambiguity / underspecified problem. Use the design-doc capstone: how you turned a vague "make the scheduler better" into problem statement → constraints → alternatives considered with rejection reasons → smallest reversible first step.
7. Delivering under deadline pressure with quality risk. The honest staff answer is about scope triage with explicit risk communication: what you cut, what you refused to cut (tests on the irreversible parts, compatibility), and who you told. Release-vote discipline is the OSS version: you don't ship an RC with a known licensing problem no matter the date.
8. Why this company / why this role. For data-infra companies built on ASF projects, the genuine answer connects your contribution history to their product: you already work on the thing, upstream; you've been on the dependent side of their compatibility promises; cite the specific JIRAs/KIPs.
OSS-specific behavioral questions
- "Walk me through a contribution you're proud of" — pick one with review friction; the friction is the story. Structure: issue selection → reproduction → patch → review rounds (quote real feedback) → merge → maintenance afterward.
- "How do you handle a patch rejected after weeks of work?" — the mature answer separates sunk cost from learning: why the rejection was (or wasn't) right, what design conversation should have happened first, and the smaller patch that landed later.
- "How would you grow contributors into committers?" — show you understand trust as the unit: graduated responsibility, public encouragement, review shadowing, nominating when the record is undeniable.
Anti-patterns
- "We" throughout — the interviewer can't find you in the story. Say what you did.
- Outcome with no mechanism ("improved performance by 40%") — staff interviews live in the how and the rejected alternatives.
- Conflict stories where you were simply right and the other person dumb — shows you haven't been wrong recently enough to notice.
- Heroics as the resolution — staff-level resolutions are systems (tests, checklists, designs), not all-nighters.
- Memorized delivery — bring notes structure to mind, not sentences.
System Design — Apache Ecosystem Walkthroughs
Three full design walkthroughs at the altitude expected of a staff/principal candidate or a PMC-level design-doc author. Each follows the same discipline the Phase 12 design-doc capstone requires: problem → constraints → alternatives with rejection reasons → chosen design → failure modes → rollout/compatibility plan.
| Doc | Design problem | Trains |
|---|---|---|
| 01-yarn-scheduler-design.md | Multi-tenant cluster scheduler (YARN-style) | Resource arbitration, preemption, fairness vs utilization |
| 02-hive-metastore-scaling.md | Scaling a metadata service from thousands to millions of partitions | Hot metadata stores, caching, API-compatibility under load |
| 03-public-api-evolution.md | Evolving a public API used by thousands of downstream projects | Deprecation lifecycle, binary vs source compat, migration tooling |
How to use these
- Before reading each doc, spend 45 minutes designing it yourself on paper from the problem statement at the top. Then compare — the deltas are your study list.
- In interviews, the differentiator is not the final architecture but the order you surface concerns: workload characterization → consistency/compat constraints → the 2–3 real alternatives → failure modes — before drawing boxes.
- Each doc ends with interviewer follow-up questions. Practice answering them aloud.
Design Walkthrough 01 — A Multi-Tenant Cluster Scheduler (YARN-Style)
Problem statement: Design the scheduler for a 5,000-node cluster shared by ~40 teams running a mix of batch ETL (hours), interactive SQL (seconds–minutes), and long-running services. Teams have budget-derived entitlements. The cluster must run hot (>80% utilization) without letting any team starve.
Table of Contents
- Step 1: Characterize the workload
- Step 2: Constraints and non-goals
- Step 3: Architecture
- Step 4: The scheduling policy
- Step 5: Preemption
- Step 6: Failure modes
- Step 7: Evolution pressures
- Interviewer follow-ups
- References
Step 1: Characterize the workload
Before any boxes: the three workload classes have conflicting scheduling needs.
| Class | Container lifetime | Latency sensitivity | Failure cost |
|---|---|---|---|
| Batch ETL | minutes–hours | none (throughput) | re-run a task |
| Interactive SQL | seconds | high (human waiting) | query retry, user anger |
| Services | days–months | SLO-bound | outage |
A single queue with one policy cannot serve all three — this observation drives the whole design and is the first thing to say out loud in an interview.
Step 2: Constraints and non-goals
- Scale: 5,000 nodes × ~50 containers ⇒ ~250K concurrent containers; scheduling decisions must be O(ms) — the scheduler is a single logical chokepoint.
- Multi-tenancy: entitlements are organizational facts (budgets), so the queue structure should be hierarchical, mirroring the org.
- Run hot: guaranteed capacity must be borrowable when idle, which forces preemption into the design (you cannot have all three of: guarantees, borrowing, no preemption).
- Non-goals: gang scheduling and global optimality. State them — choosing heuristic per-heartbeat scheduling over an optimal solver is a decision.
Step 3: Architecture
YARN's split is the reference answer and worth defending from first principles:
- ResourceManager (RM): the arbiter. Holds queue state and node state; makes allocation decisions. Keep it application-logic-free so its failure domain is small.
- NodeManager (NM) per node: enforcement (cgroups), health, container lifecycle. Heartbeats carry node status in and allocation decisions out — the heartbeat is the scheduling clock, which bounds decision latency and gives natural batching.
- ApplicationMaster (AM) per application: all app-specific logic (task graphs, retries, speculation) is pushed out of the RM. This is the architecturally load-bearing decision: it caps RM complexity and lets frameworks (Spark, Tez, services) innovate independently.
State & HA: RM state (app registry, delegation tokens) to a replicated store; leader election for active/standby; NMs and AMs re-register on failover (work-preserving restart — running containers keep running, the new RM rebuilds the picture from re-registration).
Step 4: The scheduling policy
Hierarchical queues with capacity + elasticity:
- Each queue has
guaranteed(sums to 100% per level) andmax(elastic ceiling). - Idle capacity is borrowable; accounting tracks each queue's steady-state fair share vs current usage — the delta drives both allocation priority and preemption targeting.
- Within a queue: FIFO with size-based weighting for batch; fair-share for interactive (or a dedicated low-latency queue with small max container size and strict limits); services get a queue with no preemption-out and placement constraints.
- Locality via delay scheduling: skip up to N heartbeats waiting for a node-local slot before relaxing node → rack → any. Cheap and effective for HDFS-resident inputs.
- Dominant Resource Fairness for multi-resource (CPU/mem/GPU) fairness — per-resource fairness is gameable; DRF compares each tenant's dominant share.
Step 5: Preemption
The part most candidates hand-wave; be concrete:
- Compute over-capacity queues vs starved queues from guaranteed/usage deltas.
- Select victims newest-first within over-capacity queues (minimize lost work), respecting per-app disruption budgets and never-preempt flags (services, AMs themselves).
- Warn before kill: send the AM a preemption message with a grace period (e.g. 30 s) so frameworks can checkpoint or drain; kill only on expiry.
- Rate-limit global preemption (e.g. ≤2% of cluster per minute) to prevent oscillation — preemption + elastic borrowing forms a feedback loop that will thrash without damping.
Step 6: Failure modes
- RM failover: covered by work-preserving restart; the subtle bug class is double accounting during re-registration (container reported by both NM re-registration and the recovered state store) — dedupe by container id, version the epoch.
- Heartbeat storms: 5,000 NMs × 1 s heartbeats is fine; 50K is not — move to event batching / longer intervals with async node updates.
- Rogue AM: an AM requesting pathological container shapes (1 GB × 100K) — per-queue and per-app caps on outstanding requests; admission control at submission.
- Skewed node attrition: rack failure shifts load; scheduler must re-spread without violating constraints — this is where placement constraints earn their complexity.
Step 7: Evolution pressures
What changed in the real world since YARN's design, and how the design absorbs it:
- GPUs: indivisible, topology-sensitive resources break "resources are fungible scalars" — resource types + placement constraints, and bin-packing rather than spreading.
- Long-running services: pushed YARN toward container reuse, rolling restart of NMs, and eventually lost ground to Kubernetes — be ready to compare (K8s: declarative reconciliation, richer pod model; YARN: locality-aware throughput scheduling at lower per-decision cost).
- Federation: past ~10K nodes, shard the RM (sub-clusters + router) rather than scaling one scheduler loop.
Interviewer follow-ups
- Why per-application masters instead of a smarter central scheduler? — failure isolation, framework extensibility, RM simplicity; cost: per-app overhead, AM scheduling becomes two-level (and two-level schedulers suffer information hiding — concede it).
- How would you add SLA-based scheduling? — admission control with deadline feasibility check, reserved capacity windows (resource reservations), backfill around reservations.
- Capacity vs fair scheduler — when is each wrong? — capacity wrong for ad-hoc shared research clusters (idle guarantees waste); fair wrong when org-level isolation is a contractual requirement.
- What metric tells you the scheduler itself is the bottleneck? — allocation latency percentiles vs heartbeat interval, pending-request queue age, and scheduler-loop utilization; not cluster utilization, which confounds demand.
References
- Apache Hadoop YARN (SoCC '13 paper)
- Dominant Resource Fairness (NSDI '11)
- Delay Scheduling (EuroSys '10)
- Omega: flexible, scalable schedulers (EuroSys '13) — the two-level-scheduler critique
- YARN Federation
Design Walkthrough 02 — Scaling a Metadata Service (Hive Metastore-Style)
Problem statement: A Hive-style metastore (databases → tables → partitions, schemas,
stats, locations) backed by one RDBMS serves a company that grew from 10K to 5M partitions
and from 50 to 5,000 queries/minute. getPartitions calls now take 30+ seconds, planner
latency dominates short queries, and the DBA is paged weekly. Fix it without breaking the
thousands of jobs using the Thrift API.
Table of Contents
- Step 1: Why metastores melt
- Step 2: Constraints
- Step 3: Alternatives
- Step 4: Chosen design
- Step 5: Consistency details
- Step 6: Migration plan
- Interviewer follow-ups
- References
Step 1: Why metastores melt
Name the actual pathologies before designing — they determine which fixes work:
- Partition explosion: partition-per-hour × years × thousands of tables ⇒ tens of
millions of rows in
PARTITIONS+ per-partition key/value tables; ORM-generated joins over them are super-linear. - Chatty planning: a single query plan can issue thousands of metastore calls
(
getTable,getPartitionsByFilter, per-partition stats). Latency multiplies. - Listing as a primitive:
getAllPartitions(table)returns megabytes; clients call it because the filtered API is historically unreliable — an API-shape problem, not a database problem. - Write amplification on stats: per-partition stats updates after every load job.
Step 2: Constraints
- API compatibility is the hard wall: thousands of jobs speak the Thrift API; clients embed old versions for years. Anything that changes call semantics needs versioned endpoints and long deprecation (Phase 11 discipline).
- Reads ≫ writes (~100:1), and reads are partition-metadata-shaped.
- Planner correctness tolerates bounded staleness for stats, but not for schema/location (wrong location = wrong data read).
Step 3: Alternatives
A. Scale the RDBMS (bigger box, read replicas, drop the ORM for hand-written SQL,
proper indexes). Cheap, buys 5–10×, no compat risk. Rejected as the answer but accepted
as step one — hand-written SQL via directSQL was exactly Hive's own move.
B. Cache layer in front (per-instance + distributed cache, TTL + invalidation). Buys read latency; the hard part is invalidation across multiple metastore instances — needs a change journal. Accepted as step two.
C. Shard the metastore by database/table hash. Solves table-count scale, but cross-shard ops (cross-db queries, global listing) get painful, and partition explosion within one table is unsolved. Deferred.
D. Change the data model — partition metadata out of the RDBMS into the table format itself (Iceberg/Delta-style manifests in object storage; the metastore keeps only the table pointer). Solves the root cause: planning reads manifests directly, scaling with the table not the service. This is the industry's actual answer, but it is a multi-year migration, not a fix. Accepted as the destination.
The staff-level move is sequencing A → B → D with compat preserved throughout, not picking one.
Step 4: Chosen design
Phase 1 — stop the bleeding (weeks): hand-written SQL paths for the hot calls;
covering indexes for getPartitionsByFilter; server-side pagination + result-size caps on
listing calls (new API variants, old ones kept but capped with loud deprecation warnings);
connection pooling and statement caching audits.
Phase 2 — read path (months): stateless metastore instances behind LB; write-through
journal table (monotonic change id per object); per-instance caches subscribe to the
journal and invalidate; getPartitionsByFilter served from cache keyed by (table,
filter-normal-form) with change-id validity. Stats reads get TTL staleness (planner-safe);
schema/location reads validate change-id (correctness-critical).
Phase 3 — data model (quarters): new tables created as Iceberg; metastore stores the
metadata pointer only; partition APIs for those tables are served by a shim that reads
manifests — old clients keep calling getPartitionsByFilter and get correct answers
without the RDBMS holding per-partition rows. Existing tables migrate by snapshot +
catchup, heaviest first (top 1% of tables typically hold >50% of partitions).
Step 5: Consistency details
- Journal as the ordering primitive: every metadata write commits the object change and journal row in one RDBMS transaction; cache validity = "my change-id for this object ≥ journal head for this object". Avoids distributed invalidation races without distributed transactions.
- Read-your-writes for the writing client: client carries the change-id returned by
its write; reads route with
min_change_idso a stale cache either catches up or forwards to the source. - Concurrent DDL: optimistic — writes carry expected version, conflict ⇒ retry with rebase or fail to the user (matching Iceberg's optimistic commit protocol at the table layer).
Step 6: Migration plan
Compatibility-first sequencing, the part interviewers actually probe:
- Every new behavior behind a server-side flag, default off; canary instances first.
- Shadow-read validation: new path computes, old path serves, diffs logged (count and content hashes) until diff rate < 1e-6 for two weeks.
- API deprecations announced with metrics: instrument who still calls the unpaginated listing (user-agent/job tags), chase the top offenders, then cap.
- Rollback story at each step: caches can be bypassed by flag; Iceberg-pointer tables keep a frozen RDBMS snapshot of their last pre-migration state for emergency fallback (read-only).
Interviewer follow-ups
- Why not just put the metastore in a NoSQL store? — the workload is relational (filters over typed partition keys, transactional DDL); the problem is the data model keeping per-partition rows centrally, not the engine. Moving engines re-platforms the pain.
- How do you size the cache and what's the eviction story? — partition metadata is highly skewed (hot tables); LRU with per-table weight caps; measure hit rate per call type, not globally.
- What breaks first at another 10×? — the journal table write rate and the single RDBMS as the write master ⇒ that is when sharding (C) returns, by database, with the journal per shard.
- How does this compare to what actually happened in the ecosystem? — Hive directSQL + AWS Glue-style hosted metastores + the Iceberg/Delta migration; HMS remains as a catalog-of-pointers (or is replaced by REST catalogs).
References
- Hive Metastore design docs
- Apache Iceberg spec — metadata & manifests
- Iceberg REST catalog spec
- Metastore directSQL (HIVE-4051 lineage)
- Designing Data-Intensive Applications — Kleppmann, ch. 5 (replication) and 7 (transactions)
Design Walkthrough 03 — Evolving a Public API Used by Thousands of Projects
Problem statement: You own a widely-used public API surface (think Spark's
DataFrameReader, Hadoop's FileSystem, or Kafka's client API). A redesign is needed —
the current API leaks implementation details, blocks a major performance improvement, and
has accumulated 40 deprecated methods. Downstream: thousands of projects, some unmaintained,
with binaries compiled against versions up to 8 years old. Design the evolution.
Table of Contents
- Step 1: What "compatible" means, precisely
- Step 2: Inventory before design
- Step 3: The evolution strategy
- Step 4: Mechanics that make it survivable
- Step 5: The removal endgame
- Step 6: Governance
- Interviewer follow-ups
- References
Step 1: What "compatible" means, precisely
Four independent compatibility dimensions; conflating them is the #1 design-doc failure:
- Source: old code compiles against the new version. Broken by: removing members, narrowing visibility, adding abstract methods, generics changes.
- Binary: old compiled jars link at runtime. Broken by: signature changes (including return-type changes that are source-compatible!), moving methods to superclasses in some cases, interface→abstract-class. Checked mechanically — japicmp/MiMa/Revapi (you built a simplified checker in Phase 10 Lab 02).
- Behavioral: same calls, same observable semantics — defaults, ordering, error types, thread-safety. No tool checks this; only tests and discipline do. Most production breakage is behavioral.
- Wire/data: serialized forms, file formats, RPC schemas outlive code by years and need their own versioning (cf. Phase 11's format-migration lab).
The interface-vs-abstract-class question lives here: adding a method to an interface broke
all implementors pre-Java-8; default methods fix source/binary but not behavioral
compatibility (the default may be wrong for an implementor). Hadoop chose abstract class
for FileSystem for exactly this reason.
Step 2: Inventory before design
You cannot evolve what you cannot see being used:
- Annotate the whole surface first (
@Public/@LimitedPrivate/@Private×@Stable/@Evolving) — undeclared surface is implicitly public forever (Hyrum's Law). - Measure usage: corpus scans (Maven Central reverse deps, GitHub code search, internal telemetry where it exists) per deprecated method. The Phase 11 migration analyzer's urgency model — deprecated-since × usage × replacement-availability — comes from here.
- Classify the 40 deprecated methods into: trivially-replaceable (delegate exists), semantically-changed (replacement behaves differently — the dangerous bucket), and no-replacement (you must build one before removal is even discussable).
Step 3: The evolution strategy
Rejected: big-bang v2 namespace (org.foo.v2.*). Clean on paper; in practice the
ecosystem bifurcates (libraries must support both, diamond dependencies force both on one
classpath) and v1 never dies. Python 2→3 is the cautionary tale at language scale.
Rejected: break-in-major-and-apologize. With unmaintained downstream binaries, a hard break orphans them permanently and burns trust that took a decade to build.
Chosen: strangler evolution within the existing surface:
- Ship the new API alongside the old, in the same artifact, marked
@Evolvingfor ≥1 minor cycle so early adopters bear the iteration risk knowingly. - Reimplement the old API as a thin adapter over the new one — one implementation, two surfaces; behavioral compat is preserved by construction and verified by running the old API's full test suite against the adapter (the tests are the contract).
- Deprecate old methods only when their replacement is GA, with
@deprecatedJavadoc that names the replacement and the earliest removal version, plus a runtime warn-once-per-JVM log for the highest-traffic methods. - Performance improvement lands inside the new core; old-API users get most of it for free through the adapter — this is the carrot that makes migration voluntary.
Step 4: Mechanics that make it survivable
- CI compatibility gates: japicmp against the last release on every PR; the build fails on undeclared binary breakage; declared breakage requires an explicit exclusion-file entry whose PR needs PMC sign-off (process encoded in tooling).
- Behavioral pinning tests: golden tests for error messages/types, ordering, and defaults that people are known to depend on — they fail when someone "fixes" behavior.
- Migration tooling, not migration documents: an OpenRewrite/Scalafix recipe per deprecated method published with the release; a migration is real when it's executable.
@Sinceeverywhere: lets users and tools reason about availability windows.- Canary downstreams: build the top-20 dependent projects against snapshots weekly (Spark does this with key libraries); ecosystem breakage is detected pre-release, not post.
Step 5: The removal endgame
Removal is a project decision, not a code change:
- Eligibility: deprecated ≥ 2 minor releases AND measured usage below threshold AND executable migration exists.
- Dev-list
[DISCUSS]with the usage data attached; lazy consensus is not sufficient for high-traffic methods — explicit vote. - Removal lands only in a major release, with the migration recipe linked from the release notes' breaking-changes table.
- For the unmaintained-binaries long tail: consider a separately-versioned
compatjar carrying the adapters for one extra major cycle — opt-in life support that keeps the core clean while not orphaning anyone overnight.
Step 6: Governance
API evolution at this scale is mostly a people process wearing a technical costume:
- A standing API review group (3–4 maintainers) over every
@Publicsurface change — cheap to run as a PR label + required review. - Design docs (the Phase 12 capstone format) required for any new
@Publictype: problem, alternatives, compat analysis, and the explicit cost of keeping it forever — every public API is a perpetual liability accepted on purpose. - Compatibility promises published as policy (semver mapping, support windows) so downstream can plan: predictability is itself a feature, often worth more than any individual improvement.
Interviewer follow-ups
- A return type needs to change from
List<T>toStream<T>. Options? — you can't, in place (binary-incompatible). Add a new method (stream()), deprecate the old, adapter both ways; name the cost of each. - How do you deprecate a config default rather than a method? — staged: warn on implicit reliance on the old default in N, flip in N+1 with an escape-hatch config, remove the hatch in N+2. Behavioral compat needs its own ladder.
- What does Hyrum's Law change about your plan? — observable behavior is the real API; hence behavioral pinning tests and canary downstreams, and humility about "internal" changes (someone parses your exception messages).
- When is a hard break the right call anyway? — security-broken designs, correctness bugs that compat would perpetuate, or surface that demonstrably has ~zero usage. Bring data and a vote, not vibes.
References
- Hadoop Compatibility Specification
- Spark Versioning Policy and MiMa
- japicmp / Revapi
- Kafka KIP process — API change governance done in public
- Hyrum's Law
- Software Engineering at Google — ch. "Deprecation"