« All Roles

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

WeekPhaseFocus
101Apache contributor ladder, trust model, role definitions
201Trust audit — mapping contributor → committer journeys
302Maven module navigation, IntelliJ IDEA setup for Spark
402Tracing a Spark execution path from API to DAG scheduler
503Hadoop 1.x HDFS NameNode SPOF — design failure post-mortem
603Backward compatibility burden — Hadoop FileSystem, Hive SerDe
704ASF release lifecycle — JIRA → RC → vote → distribution
804Compatibility validation with japicmp; release checklist
905Code quality dimensions; annotating good vs poor Java
1005Refactor challenge — making "clever" code maintainable
1106Patch review philosophy; writing reviewer comments
1206Responding to review; patch refactoring for acceptance
1307Raft leader election — implement and test consensus behavior
1407Kafka partitioning strategies; multi-module Maven + CI
1508JMH microbenchmarks — methodology, pitfalls, dead-code elimination
1608GC/allocation profiling; lock contention (synchronized vs LongAdder)
1709Unified diff parser; semantic versioning engine
1809JIRA-style issue state machine; contribution mechanics writeup
1910Release checklist engine; API compatibility checker
2010RC vote thread parser; simulated release vote
2111Deprecated API migration analyzer — PMC-level review
2211Storage format migration planner; consensus tracker
2312Full contribution cycle capstone
2412Final reflection: "What I Wish I Knew"

Lab Structure

Every lab follows a consistent format:

FilePurpose
README.mdGoal, steps, expected output, talking points, resume bullet
*-template.mdStructured prompts you fill in (document-based labs, Phases 01–03)
src/main/java + src/test/javaWorking reference implementation with a test suite you study, break, extend, and re-verify (code labs, Phases 04–12)
pom.xmlMaven 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:

  1. Navigate any large Apache JVM codebase without a guide
  2. Identify, reproduce, and fix issues in distributed systems under review scrutiny
  3. Write patches that pass the Apache review bar — correct, tested, documented, backward-compatible
  4. Review others' patches at the level a committer would
  5. Understand and participate in ASF governance: releases, votes, and consensus
  6. Write design documents that subsystem-level changes require
  7. Operate and reason about Spark, YARN, Hive, and Tez at production scale
  8. Demonstrate the stewardship mindset required for committer and PMC candidacy

Salary Benchmarks (2025–2026, US market)

LevelRange
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

FieldValue
GoalResearch a real Apache project and map five contributors at different stages of the contributor ladder, producing a structured profile for each.
ConceptsContributor vs committer vs PMC; visible participation history; JIRA/GitHub/Whimsy as evidence trails.
ProjectApache Spark (primary) — or Apache Hadoop if you prefer a Hadoop focus.
Steps1) 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.
OutputFive completed contributor-profile-[name].md files plus a 200-word journey summary.
Talking PointsWhat 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."
ExtensionsCompare the journey timelines across Hadoop and Spark. Do different projects have different norms?

Lab 02 — Trust Audit

FieldValue
GoalEvaluate a fictional contributor profile against the six trust dimensions used by Apache maintainers and produce a structured gap analysis with recommended actions.
ConceptsTrust dimensions; committer evaluation criteria; stewardship mindset; incremental participation.
Steps1) 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.
OutputOne completed trust-audit-[name].md with dimension scores, gap analysis, action plan, and nomination draft or blocker explanation.
Talking PointsWhich 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)."
ExtensionsApply 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

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:

  1. Community over code: a project with healthy reviewers and a mediocre codebase outlives the reverse. Decisions optimize for the decade, not the sprint.
  2. Merit, earned and remembered: status is granted retroactively for sustained contribution. You cannot apply to be a committer; you are noticed into it.
  3. 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.
  4. 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.
  5. 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

RoleGranted byWhat it actually means
UserUses the software; bug reports already count as contribution
ContributorPatches, docs, reviews, list answers — no formal status
CommitterPMC voteWrite access; judgment trusted on day-to-day changes
PMC memberPMC voteBinding votes on releases/people; accountable to the Board
PMC ChairBoard appointAdministrative officer, not a boss — files the reports
ASF MemberMembers voteFoundation-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:

  1. Explain committer vs PMC vs Chair, and who grants each.
  2. State the five Apache Way principles with one operational consequence each.
  3. Describe the trust model as a risk assessment and name its five evaluation axes.
  4. Name where decisions are binding (the list) and why that rule protects you.
  5. 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

🛸 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:

  1. Can we trust this person with the codebase long-term?
  2. Will this change help or hurt future maintainers?
  3. 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

DimensionWhat it means in practice
Technical credibilityYou find real bugs. Your fixes are correct and don't introduce regressions. Your reviews identify real problems, not style preferences.
Review qualityYour 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.
ReliabilityYou 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.
CommunicationYou write clearly on mailing lists and JIRA. You respond to questions. You acknowledge disagreement without hostility. You explain why, not just what.
Stewardship mindsetYou 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 participationYou 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

ListPurpose
dev@project.apache.orgTechnical discussion, patch reviews, design proposals, release coordination
user@project.apache.orgEnd-user questions and support
private@project.apache.orgPMC-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@:

  1. Search the archive at lists.apache.org — the question may have been answered
  2. Search JIRA — there may be an open or closed issue
  3. 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

SourceWhat to look for
Apache JIRAIssues filed, patches attached, review comments left, issues assigned and completed vs abandoned
GitHubPull requests authored and reviewed, comments, consistency over time
dev@ archivesDesign proposals, DISCUSS threads, vote participation, response quality
Apache Whimsywhimsy.apache.org/roster/committee/ — official PMC and committer rosters
Apache ReporterProject 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

TopicOne-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

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:

ToolURLPurpose
Apache Whimsy Rosterhttps://whimsy.apache.org/roster/committee/sparkCurrent PMC members and committers
Apache JIRAhttps://issues.apache.org/jira/projects/SPARKIssue history, patch attachments, comments
GitHub — apache/sparkhttps://github.com/apache/sparkPR history, review activity
Apache Mailing List Archiveshttps://lists.apache.org/list.html?dev@spark.apache.orgdev@ thread history
Apache Reporterhttps://reporter.apache.org/Project health and activity stats

Steps

Step 1 — Orient Yourself on the Roster (30 min)

  1. Open Whimsy Spark Roster.
  2. Note the total number of PMC members and committers.
  3. Pick two PMC members whose names you recognize (e.g., from conference talks or blogs).
  4. Search their names in the JIRA archive and GitHub. Find their first ever Spark-related commit or merged PR. Note the date.
  5. 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:

SlotCriteria
Profile AA current PMC member with 5+ years of history
Profile BA committer (not PMC) with 2–4 years of history
Profile CA committer who became one relatively recently (< 2 years ago)
Profile DAn active contributor visible in recent PRs who is not yet a committer
Profile EYourself — 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:

  1. What patterns appear across all committer profiles that are absent in Profile D (the non-committer)?
  2. What is the minimum visible track record you observed before someone was elected committer (in time and contribution breadth)?
  3. 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/ and core/ 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 TaskSchedulerImpl that 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

trust-audit-template.md

Step 2 — Identify the Top Two Gaps (30 min)

After scoring all six dimensions:

  1. Which two dimensions have the lowest scores?
  2. 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:

  1. State the nominee's name and Apache ID
  2. Summarize their contributions (specific patches, reviews, community work)
  3. State your assessment of their trustworthiness
  4. Explicitly invite discussion

Option B — If you believe Alex is NOT ready for nomination today:

Write a 150–200 word explanation of:

  1. The single most important blocker
  2. What specifically Alex would need to demonstrate before you could write Option A
  3. 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

trust-audit-template.md


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.xml hierarchies: 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

FieldValue
GoalClone 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().
ConceptsMaven multi-module structure; Spark module topology; logical → physical plan pipeline; IntelliJ IDEA navigation.
Projectapache/spark — branch master or latest stable tag (e.g., v3.5.1)
Steps1) 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.
OutputA 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 VerifyYour trace should match the actual class hierarchy — verify by running Find Usages on DataFrameReader.load() in IntelliJ and confirming the call chain.
Talking PointsWhy 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."
ExtensionsTrace a spark.sql("SELECT ...") query through the same pipeline. Where does it rejoin the CSV read path?

Lab 02 — Issue Trace

FieldValue
GoalSelect 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.
Conceptsgit log / git blame / git show; connecting JIRA to commits; reading diffs with context; root cause vs symptom.
Steps1) 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.
OutputOne 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 VerifyYour 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 PointsHow 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."
ExtensionsFind 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.xml module 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-*.md completed (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

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 package builds 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 ~compile for incremental; -DskipTests everywhere).
  • 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":

  1. Find the public entry point (the method users call — df.collect(), producer.send()).
  2. 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).
  3. 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.
  4. 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):

  1. 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.
  2. 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, Future callbacks) restart stacks. At each gap, find the enqueue site for the dequeue you're looking at.
  3. 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.
  4. Then bisect versions if needed: git bisect with 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):

  1. RDD.collect()SparkContext.runJobthe action entry; everything funnels here.
  2. DAGScheduler.submitJob — posts JobSubmitted onto an event loop (DAGSchedulerEventProcessLoop) — first async boundary; driver-side.
  3. handleJobSubmitted → stage graph built by walking RDD lineage backward, cutting at shuffle dependencies (wide deps = stage boundaries — the Phase 07/interview staple).
  4. submitMissingTasksTaskScheduler.submitTasksSchedulerBackend — tasks serialized, offered to executors via RPC (second boundary: process hop).
  5. Executor: Executor.TaskRunner.run → deserialize → Task.runTaskShuffleMapTask/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:

  1. Name the recurring module anatomy and the 80/20 reading order for a new module.
  2. Build one module with upstream deps and run one named test, in one command each.
  3. Execute the blame→JIRA→PR→review chain on an arbitrary confusing line.
  4. Reproduce the five-hop Spark action trace (or your project's equivalent) on a whiteboard.
  5. 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

🛸 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 SparkSession to Executor without 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:

  1. The boundary between logical and physical plans is the optimizer. Anything above FileSourceScanExec is logical. Anything from it downward is physical/runtime.
  2. Scheduling is separate from execution. DAGScheduler breaks the job into stages. TaskScheduler assigns tasks to executors. The executor runs them.
  3. Storage is abstracted behind RDDs. Whether you read CSV, Parquet, or JDBC, you eventually get an RDD[InternalRow] with a compute() 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

ModuleDirectoryWhat Lives Here
corecore/SparkContext, DAGScheduler, TaskScheduler, RDD, BlockManager, SparkEnv, Serializer, Broadcast
SQL Catalystsql/catalyst/Logical/physical plan trees, optimizer rules, expressions, type system, Encoders
SQL Coresql/core/SparkSession, DataFrame, DataFrameReader, DataSource, FileFormat, execution
SQL Hivesql/hive/Hive metastore integration, HiveContext
Streamingstreaming/DStream, StreamingContext, Receiver, InputDStream
MLlibmllib/ML pipelines, Estimator/Transformer/Param, algorithms
YARNresource-managers/yarn/YARN client/cluster mode, ApplicationMaster, YarnScheduler
Kubernetesresource-managers/kubernetes/K8s resource scheduling, pod management
Network Commonnetwork/common/Netty-based transport layer (TransportServer, TransportClient)
Network Shufflenetwork/shuffle/External shuffle service, ShuffleBlockFetcher
Common Unsafecommon/unsafe/Off-heap memory management (Project Tungsten), Platform class
Common KVStorecommon/kvstore/LevelDB-backed storage for Spark History Server
Launcherlauncher/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/ and sql/core/
  • A shuffle bug may touch core/, network/shuffle/, and resource-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

  1. File → Open → select the spark/ directory
  2. IntelliJ detects the Maven project and auto-imports modules
  3. Wait for indexing to complete (5–15 minutes for initial index)
  4. File → Project Structure → SDKs → confirm Java 11 or 17 is selected
  5. 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 Rule class
  • An injection point in SparkContext or SparkSession.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

LayerTypeKey Classes
LogicalLogicalPlanLogicalRelation, Filter, Project, Aggregate, Join
AnalyzedLogicalPlan (resolved)Same nodes, but all references resolved to typed attributes
OptimizedLogicalPlan (after optimizer rules)Pushdowns, constant folding, filter merging
PhysicalSparkPlanFileSourceScanExec, FilterExec, ProjectExec, HashAggregateExec, SortMergeJoinExec
RuntimeRDD[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 FixSymptom Fix
Fixes the problemYesFor this specific case
Handles related casesYesNo
Correct under concurrencyYesMaybe
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 field can be null (schema inference returns null for all-null columns in some code paths), fix the schema inference to return NullType instead 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

TopicOne-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

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.

  1. List every <module> entry. There are approximately 30–40.

  2. For each of the following 8 modules, write one sentence describing its responsibility:

    • core
    • sql/catalyst
    • sql/core
    • sql/hive
    • resource-managers/yarn
    • network/common
    • network/shuffle
    • common/unsafe
  3. Draw a simple dependency diagram (ASCII is fine) showing which of these 8 modules depend on which others.

Hint: Open each module's pom.xml and 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 Declaration
  • Opt+F7 / Alt+F7 — Find Usages
  • Ctrl+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 where QueryExecution.executedPlan is 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:

  1. QueryExecution (in sql/core/)
  2. SparkPlanner (in sql/core/)

Answer these questions in execution-trace.md under ## Plan Boundary:

  1. What method in QueryExecution triggers physical plan creation? What does it call?
  2. What is SparkPlanner's role? What does it do with an optimized logical plan?
  3. 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:

  1. Every class in your table is a real class you can open in IntelliJ
  2. The handoff methods connect correctly (output of one is input to next)
  3. LogicalRelation appears before FileSourceScanExec in your list
  4. 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 DataSource a pivot point? It is the registry that maps a format name ("csv") to its FileFormat implementation. 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 InternalRow vs Row? InternalRow is the unboxed binary format used inside the execution engine. Row is 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 to FileScanRDD.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.

LevelJIRASummaryModule(s)Why it's good for this lab
⭐ Level 1SPARK-23986CSV reader throws NPE on empty string with enforceSchemasql/coreSingle module, clear stack trace, small diff
⭐ Level 1SPARK-24266Task not serializable when using UDF with closurecore, sql/coreClassic Spark error type, well-documented root cause
⭐⭐ Level 2SPARK-26366Incorrect result in certain complex SQL queries with char/varcharsql/catalystOptimizer rule interaction, good for tracing plan transforms
⭐⭐ Level 2SPARK-33933Inconsistent behavior of first() with ignoreNulls in different execution pathssql/coreExecution path divergence, teaches about physical plan alternatives
⭐⭐⭐ Level 3SPARK-29800DAGScheduler handles FetchFailed incorrectly under speculationcoreDAGScheduler + 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 searching github.com/apache/spark/pulls?q=SPARK-XXXXX.


Steps

Step 1 — Read the Issue Completely (45 min)

Open your chosen JIRA. Read:

  1. The Summary line — this is the symptom
  2. The Description — this is the reproducer and context
  3. Every comment from top to bottom — this is where the diagnosis happens
  4. The Fix Version/s field — which Spark release included the fix
  5. 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:

  1. Use the stack trace or description to identify the first suspicious class/method
  2. Open that class in IntelliJ. Read the method in question.
  3. Use git blame on the specific lines — who wrote this, and when?
  4. Read 50 lines above and below the crash site for context
  5. 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:

  1. What was the broken behavior?
  2. What does the fix do instead?
  3. Is this fixing the root cause or the symptom? How can you tell?
  4. What test was added? What boundary condition does it cover?
  5. 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:

  1. Your root cause location matches the lines changed in the fix commit
  2. Your correctness argument explains why the fix works — not just what it does
  3. Your broken code path connects to the root cause location
  4. 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 blame and 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

ConceptWhere it appears
Single point of failure (SPOF) and its cost at scaleHadoop 1.x NameNode
HDFS HA: Active/Standby NameNode, JournalNodes, ZooKeeper fencingHadoop 2.x
Metadata bottleneck: why a single in-memory namespace doesn't scaleHDFS Federation
YARN: the resource management separation from MapReduceHadoop 2.x
MapReduce's two-phase limitation and disk materializationTez motivation
DAG of tasks vs map-reduce pairsTez vs MapReduce
Hive metastore: what it stores, why it serializes, why it becomes a bottleneckHive scaling
SerDe interface and the cost of backward compatibilityHive
Catalyst vs Hive's query plannerSpark SQL motivation
Backward compatibility as an engineering constraintAll projects

Labs

#LabTypeOutput
01Post-Mortem: A Known Design FailureResearch / DocumentStructured post-mortem
02Compatibility Burden AnalysisCode Reading / DocumentInterface 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

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:

  1. Decision & context — what was decided and what was true at the time (workloads, team size, precedents). Forces steel-manning; no hindsight allowed yet.
  2. 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).
  3. 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.)
  4. Cost of the fix — engineering years, migration burden, complexity added. This is the number that prices future shortcuts.
  5. 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 FileSystem and 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:

  1. Tell each of the three cases as decision → context → mechanism → fix-cost → principle, in two minutes each.
  2. Explain why the Secondary NameNode is not HA, and what QJM/standby actually added.
  3. State the v1 fusion sin (platform = first app) and name two non-Hadoop systems that repeated it.
  4. List six kinds of compatibility surface beyond method signatures.
  5. 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:

  1. Process failure: If the NameNode JVM crashes, the entire HDFS cluster is unavailable — zero reads, zero writes. All running MapReduce jobs fail.
  2. 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.
  3. 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)

FixMechanismCost
Metastore connection poolingHikariCP or DBCP2 in front of RDBMSReduces connection overhead, doesn't fix write contention
Hive LLAP (Live Long And Process)Persistent daemons that cache metadataComplex to operate, more failure modes
Partition filter pushdownPrune partitions at SQL level before metastore callWorks for simple predicates, not all cases
Direct Parquet/ORC schema readingSpark reads schema from file footer, not metastoreBypasses metastore entirely for reads — the most effective
Delta Lake / Iceberg / Hudi table formatsStore table metadata in the data files themselvesEliminates 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 Writable with 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:

ProjectInterfaceAgeConstraint
HiveSerDe2008–presentWritable parameter type cannot change
HadoopFileSystem API2006–presentFSDataInputStream API cannot be removed
MapReduceMapper/Reducer2006–presentOld API and new API both maintained
SparkRDD API2012–presentCannot 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 FileSystem API 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

TopicOne-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

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

OptionSystemFailureDifficulty
AHadoop HDFSSingle NameNode as both availability SPOF and namespace scale ceiling⭐⭐
BHiveMetastore 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:

Option B — Hive Metastore Bottleneck:


Steps

Step 1 — Read the Primary Sources (60–90 min)

Read at least 2 primary sources for your chosen failure. Take notes on:

  1. When was this system built, and what problem did it solve?
  2. What was the original scale assumption?
  3. At what scale did the failure become apparent?
  4. What were the symptoms that users experienced?
  5. 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:

  1. You can explain the failure from first principles in 3 minutes without notes
  2. Your technical root cause section contains a specific class, process, or data structure — not just "it didn't scale"
  3. Your generalizable lessons are stated as patterns applicable to other systems — not just observations about Hadoop
  4. 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

OptionInterfaceFile locationAgeWhy it's interesting
AFileSystemhadoop-common/src/main/java/org/apache/hadoop/fs/FileSystem.java2006–present40+ abstract methods, S3 semantic mismatch, rename() atomicity impossible on object stores
BDeserializer (SerDe)hive-serde/src/main/java/org/apache/hadoop/hive/serde2/Deserializer.java2008–presentWritable-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:

  1. For each abstract/non-default method, write one sentence describing what it is supposed to do
  2. Note the parameter types and return types
  3. Identify which methods are the "core contract" (must be implemented) vs which are optional extensions
  4. Note the @Deprecated annotations — 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:

  1. rename(Path src, Path dst): Documented as atomic (POSIX semantics). S3 implements as copy + delete. Find the Hadoop JIRA where this was debated.
  2. delete(Path f, boolean recursive): Find the consistency semantics on S3 vs HDFS.
  3. append(Path f, ...): Find which implementations return UnsupportedOperationException.
  4. 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:

  1. Writable input type: The deserialize(Writable blob) signature assumes the data arrives as a Hadoop Writable. How does this constrain column-oriented formats like ORC and Parquet?
  2. ObjectInspector pattern: The getObjectInspector() method returns an ObjectInspector tree describing the schema. How does this interact with schema evolution?
  3. initialize(Configuration conf, Properties tbl): All configuration is passed as a flat Properties map. 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:

  1. What is this interface's core abstraction?
  2. What semantic guarantees does it make that cannot be honored by all backends?
  3. What would you change if you could start fresh, with only the knowledge available when it was designed?
  4. 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:

  1. You can name at least 3 methods with semantic gaps and explain the gap precisely
  2. You can explain why adding a new abstract method to FileSystem would break thousands of programs
  3. You can describe an alternative interface design that would reduce the compatibility burden
  4. 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 takes Writable cannot efficiently read Parquet without converting through Writable first.
  • "What would a better interface design look like?" Apache Arrow's RecordBatch as the exchange format; backends implement RecordBatchReader. No Writable. Schema as a first-class object, not Properties. Default no-op implementations for optional capabilities. This is essentially what Apache Iceberg's FileIO interface 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 japicmp to detect binary and source compatibility regressions before a release ships
  • Common release failures and how mature projects prevent them

Concepts Covered

ConceptWhere it appears
Feature freeze and stabilization branchApache release lifecycle
RC numbering (RC0, RC1 …) and why RC0 often failsVote thread conventions
Binding vs non-binding votes, +1 / 0 / -1 semanticsPMC vote thread
GPG detached signatures (.asc), web of trust, KEYS fileArtifact signing
SHA-256 checksums and why they are not a substitute for GPGRelease verification
mvn release:prepare / mvn release:performMaven Release Plugin
Binary compatibility vs source compatibilityjapicmp / API evolution
dist.apache.org vs Maven Central — the distribution flowASF release infrastructure
NOTICE and LICENSE file requirementsLegal obligations
Reproducible builds and why they matterRelease integrity

Labs

#LabPrimary ArtifactWhat Student Produces
01Release Mechanicspom.xml + Java sourceA tagged, versioned Git commit; Maven-generated RELEASE_NOTES.md stub
02Signing and Verificationsign-artifact.sh + verify-release.shTwo working shell scripts; signed + checksummed artifact
03API CompatibilityMulti-module Maven projectjapicmp HTML/XML compatibility report; structured compatibility matrix

Deliverables Checklist

  • mvn release:prepare -DdryRun=true output shows correct version bump and tag name
  • mvn release:prepare produces a Git tag and a bumped SNAPSHOT version
  • sign-artifact.sh produces .asc and .sha256 files for a given input artifact
  • verify-release.sh correctly verifies both signature and checksum, prints clear pass/fail
  • mvn install in lab-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

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:

  1. Branch & stabilize: cut branch-X.Y; only blockers (tracked in JIRA against the version) land; master moves on.
  2. Produce RC: versions set (X.Y.Z no 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 to dist.apache.org/repos/dist/dev.
  3. [VOTE] thread: links to artifacts, tag, staging repo; voters verify and report what they checked. 72h minimum.
  4. Fail → RC2: any real defect sinks the RC; fix, re-spin, re-vote (release threads routinely reach RC4 — normal, not failure).
  5. 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].
  6. Aftercare: track upgrade reports; severe regressions trigger an X.Y.Z+1 patch 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 artifact proves 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/@Stable in 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-plugin or scripts): set release version → commit → tag → build+sign+stage (gpg + deploy to 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:

  1. 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.
  2. Walk the six-stage release lifecycle naming the RM's action and the failure mode at each stage.
  3. Explain the three-step verification (checksum, signature, KEYS fingerprint) and what attack each step closes.
  4. Distinguish LICENSE from NOTICE, and categorize MIT/EPL/GPL bundling.
  5. 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

🛸 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:

  1. 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.
  2. A cryptographic chain of custody: GPG signatures link each artifact back to a committer whose key is in the project's KEYS file. Anyone can verify the chain.
  3. 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:

  1. JIRA release version: Create a version in JIRA (e.g., 4.0.0). Move unresolved issues out of scope.
  2. 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.
  3. 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 from main

1.3 RC Numbering

Release Candidates are numbered RC0, RC1, RC2, etc.

ConventionMeaning
RC0First attempt. Often fails due to packaging issues, wrong KEYS, missing LICENSE/NOTICE.
RC1Fixed the issues from RC0. This is the first one with a real chance of passing.
RC2, RC3Release 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

VoteMeaningBinding?
+1Tested the artifacts; approves releasePMC members only
0Abstain (no objection, no endorsement)Anyone
-1Objects to release; must provide reasonPMC members only

A release passes when:

  1. At least 3 binding +1 votes (from PMC members)
  2. More binding +1 than binding -1 votes
  3. 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

TypeDefinitionBreaks when...
Binary compatibleExisting compiled code still worksMethod removed, signature changed, class removed
Source compatibleExisting source code still compilesSame as above, plus type changes that require source edits
Behavioral compatibleSame observable behaviorSemantics 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.1 dist.apache.org

All Apache releases must be available on Apache infrastructure before they appear anywhere else. The flow:

  1. RM uploads to dist.apache.org/repos/dist/dev/[project]/ (SVN)
  2. Vote passes
  3. RM moves to dist.apache.org/repos/dist/release/[project]/ (another SVN commit)
  4. Apache mirrors sync within 24 hours
  5. 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:

  • LICENSE file: the Apache License 2.0 text, plus any third-party licenses for bundled code
  • NOTICE file: 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

FailureCausePrevention
Unsigned artifactsForgot to sign before uploadsign-artifact.sh as part of the release script
Wrong key in KEYSSigned with personal key not in project KEYSAlways verify gpg --list-keys against project KEYS before signing
Binary incompatibilityMethod removed without major version bumpRun japicmp on every release candidate
Failed reproducible buildTimestamp baked into JAR manifestUse maven-artifact-plugin:compare to verify reproducibility
Missing LICENSE/NOTICEThird-party bundled dependency not attributedRun apache-rat:check and dependency-check in CI
RC built from wrong commitRelease tag points to wrong shagit show v4.0.0-rc1 → verify pom.xml version
Test failure in release buildTests pass in dev but fail in release:performAlways run mvn release:prepare -DdryRun=true first

7. Interview Questions — Concepts You Must Explain Out Loud

TopicOne-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

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:

  1. What is the version string in pom.xml on the tag v0.1.0? What is it on main now?
  2. What does release.properties contain? Why does Maven save this?
  3. What happens if you run mvn release:prepare again 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:prepare make two commits? The first commit sets the version to 0.1.0 (no SNAPSHOT suffix) — this is what gets tagged. The second commit bumps to 0.2.0-SNAPSHOT — this is what developers continue working from.
  • What is release.properties? It stores the state of an in-progress release so release:perform (and release:rollback) know what to do. If you lose this file mid-release, you have to release:rollback and 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:

  1. sign-artifact.sh — sign an artifact with your GPG key and generate a SHA-256 checksum
  2. verify-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:

  1. Check file exists: Fail clearly if the artifact path is not a regular file
  2. GPG detached signature: Produce <artifact>.asc using gpg --detach-sign --armor
  3. SHA-256 checksum: Produce <artifact>.sha256 using shasum (macOS) or sha256sum (Linux)
  4. Print summary: Print the paths to the .asc and .sha256 files

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:

  1. Check required files exist: .asc, .sha256 — fail clearly for each missing file
  2. GPG verify: Run gpg --verify and capture success/failure
  3. Checksum verify: Run OS-appropriate checksum verify and capture success/failure
  4. Final verdict: Print RELEASE VERIFIED or RELEASE FAILED with 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.org URL)
  • 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

ErrorCauseFix
gpg: signing failed: No secret keyNo GPG key with the given key IDRun gpg --list-secret-keys to find your real key ID
gpg: WARNING: not a detached signaturePassed the wrong file order to gpg --verifyOrder must be: gpg --verify <sig-file> <data-file>
shasum: illegal option -- -Wrong shasum syntax on macOSUse shasum -a 256 --check, not sha256sum --check on macOS
sha256sum: WARNING: 1 line is improperly formattedChecksum file has wrong formatThe .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:

  1. Build and install a v1 and v2 API module to your local Maven repository
  2. Run japicmp (Java API Compatibility Checker) to compare the two
  3. Interpret the compatibility report and classify each change
  4. 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 BatchConfig is 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 / Constructorv1 Signaturev2 SignatureYour 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:

Changejapicmp ClassificationBinary Incompatible?Source Incompatible?Caller Impact
process(String) removedNoSuchMethodError 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 incompatible
  • METHOD_ADDED — non-breaking (cannot break existing callers)
  • METHOD_NOW_ABSTRACT — binary incompatible
  • CONSTRUCTOR_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.

  1. Open api-v2/pom.xml and change <version>1.0.0</version> to <version>2.0.0</version>
  2. Open compat-check/pom.xml and update the newVersion dependency from 1.0.0 to 2.0.0
  3. Run mvn install again — the build still fails (binary incompatibilities are there regardless of version)
  4. Add <breakBuildOnBinaryIncompatibleModifications>false</breakBuildOnBinaryIncompatibleModifications> to compat-check/pom.xml and 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:

  1. Developers get immediate feedback when they accidentally break binary compatibility
  2. PMC members can attach the japicmp report to the [VOTE] email as evidence that compatibility was checked
  3. 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-v1DataProcessor.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-v1BatchConfig.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-v2DataProcessor.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-v2BatchConfig.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

ConceptWhere it appears
Checkstyle rules: ConstantName, UnusedImports, MagicNumber, LineLength, MissingJavadocMethodLab 01
Maven Checkstyle Plugin — failsOnError, checkstyle.xml, CI integrationLab 01
SpotBugs bug categories: NP_*, EI_EXPOSE_REP, MS_MUTABLE_COLLECTION, DM_NUMBER_CTORLab 02
SpotBugs confidence levels: HIGH / MEDIUM / LOWLab 02
Bytecode analysis vs source analysis — what each tool seesLabs 01–02
Fibonacci hashing — correctness constraints (power-of-two N requirement)Lab 03
Code review rubric: correctness, tests, API contract, performance, clarityLab 03
What makes a -1 review comment vs a suggestion vs a nitLab 03

Labs

#LabPrimary ArtifactWhat Student Produces
01Checkstyle EnforcementFlawedScheduler.java + checkstyle.xmlGreen mvn checkstyle:check after fixing all 5 planted violations
02SpotBugs Static AnalysisProductionConfig.java + pom.xmlClean mvn spotbugs:check after fixing all 4 planted bugs
03Code ReviewCleverPartitioner.java + failing testsWritten code review; passing test suite after applying fixes

Deliverables Checklist

  • mvn checkstyle:check passes with zero violations in Lab 01
  • git diff of FlawedScheduler.java shows exactly the 5 fixed violations
  • mvn spotbugs:check passes 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

QuestionWhere 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

"Quality" in an Apache review is not aesthetics. It decomposes into checkable properties, in priority order:

  1. Correct under its contract — including error paths, edge inputs, and concurrent callers (the parts the happy-path test doesn't touch).
  2. Comprehensible to a stranger in five years — names that state intent, structure that mirrors the domain, comments that state invariants and why (never what).
  3. Testable and tested — a design you can't test in isolation is a design problem, not a testing problem.
  4. Consistent with its surroundings — local idiom beats global preference; a "better" pattern that fights the codebase costs more than it pays.
  5. 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.add results 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:

  1. 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.
  2. 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.
  3. Extract and name the sub-steps: (h ^ (h >>> 16)) becomes spreadHashBits(h) with a comment citing why (high-bit entropy folding, à la HashMap) — the trick survives, legibly.
  4. 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.
  5. 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:

  1. Recite the five operational quality properties in priority order.
  2. Explain the source-AST vs bytecode-dataflow distinction and one consequence of each.
  3. Name five SpotBugs pattern families with a one-line example bug each.
  4. List four things static analysis cannot catch (and which layer catches each).
  5. 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

🛸 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

RuleWhat it catchesExample violation
ConstantNamestatic final fields not in UPPER_SNAKE_CASEstatic final int maxRetries = 3
UnusedImportsImported types not referenced in the fileimport java.util.ArrayList; never used
MagicNumberNumeric literals other than -1, 0, 1, 2Thread.sleep(5000)
MissingJavadocMethodPublic methods without a Javadoc commentpublic void submit(Runnable task) with no /**
LineLengthLines longer than the configured max (typically 100)A log message with 3 concatenations on one line
VisibilityModifierNon-private instance fieldsprotected String name; in a concrete class
ModifierOrderModifiers in wrong orderstatic 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 prefixMeaningExamples
NP_Null pointer dereferenceNP_NULL_ON_SOME_PATH, NP_ALWAYS_NULL
EI_Expose internal representationEI_EXPOSE_REP, EI_EXPOSE_REP2
MS_Mutable static fieldMS_MUTABLE_COLLECTION, MS_SHOULD_BE_FINAL
DM_Deprecated method / bad idiomDM_NUMBER_CTOR, DM_STRING_CTOR
RCN_Redundant null checkRCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE
OS_Open stream / resource leakOS_OPEN_STREAM, OS_OPEN_STREAM_EXCEPTION_PATH
IS2_Inconsistent synchronizationIS2_INCONSISTENT_SYNC

2.3 Confidence Levels

LevelMeaningShould you fix it?
HIGHVery likely a real bugAlways
MEDIUMPossibly a bug, but context neededUsually yes
LOWSpeculative; many false positivesReview, 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:

  1. Contributor opens a PR with a JIRA issue number in the title
  2. Committers review: automated CI runs first, then humans review after CI is green
  3. Reviewer leaves comments — questions, suggestions, required fixes
  4. Contributor addresses each comment with either a fix or a reasoned disagreement
  5. 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:

CategoryQuestions to ask
CorrectnessDoes this handle null inputs? What happens at the boundary (n=0, empty list)? Does this race condition exist under concurrent access?
TestsAre there tests? Do they cover the failure case? Does the test actually exercise the code path it claims to?
API contractDoes this change break binary or source compatibility? Is the Javadoc accurate? Is the method name precise?
PerformanceIs there a better data structure for this access pattern? Is there an unnecessary copy or allocation in the hot path?
ClarityWhat 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

TopicOne-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

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:

  1. Run mvn checkstyle:check and read the violation report
  2. Identify each violation from the report (rule name, line number, description)
  3. Fix each one so the build goes green
  4. 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)
#RuleLineDescriptionFix
1UnusedImportstop of fileArrayList is imported but never referencedRemove the import
2ConstantNamemaxRetries fieldstatic final fields must be UPPER_SNAKE_CASERename to MAX_RETRIES (update all references)
3MagicNumberThread.sleep(5000)5000 is a magic numberExtract to private static final long RETRY_DELAY_MS = 5000L;
4MissingJavadocMethodsubmit()public method lacks JavadocAdd /** ... */ with @param task
5LineLengthlog warning in runAll()line exceeds 100 charactersSplit 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):

  1. Unused import — easiest, just delete the line
  2. Constant name — rename field + update all references in the same file
  3. Magic number — extract to a constant above the field declarations
  4. Missing Javadoc — add a complete Javadoc block
  5. 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:

  1. What would happen if you left maxRetries as a magic number 3 in the sleep condition?
  2. Why does Checkstyle flag ArrayList when it compiles fine without being used?
  3. What's the downside of the Checkstyle LineLength rule 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:

  1. Run mvn spotbugs:check and read the bug report
  2. Look up each bug category in the SpotBugs documentation
  3. Understand why each pattern is a bug (not just a style issue)
  4. 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 CodeWhereRoot CauseFix
1NP_NULL_ON_SOME_PATHget(String key)properties.get(key) returns null when the key is absent; calling .trim() on null throws NullPointerExceptionNull-check before .trim(), or use getOrDefault()
2EI_EXPOSE_REPgetProperties()Returns a direct reference to the internal HashMap; callers can call .put() or .clear() on itWrap in Collections.unmodifiableMap() or return a copy
3MS_MUTABLE_COLLECTIONREQUIRED_KEYS fieldstatic final List is only final at the reference level — callers can still call .add() / .remove() on the listReplace new ArrayList<>() + static {} with List.of(...)
4DM_NUMBER_CTORputInt(String, int)new Integer(value) uses a deprecated constructor; Integer.valueOf() is faster (uses cached instances) and is the idiomatic replacementReplace 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:

  1. NP_NULL_ON_SOME_PATH — will cause a production NPE on any missing config key
  2. EI_EXPOSE_REP — silent data corruption when callers modify the returned map
  3. MS_MUTABLE_COLLECTION — silent data corruption when callers modify REQUIRED_KEYS
  4. DM_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:

  1. What does final actually guarantee for a reference type?
  2. What does List.of(...) guarantee that Collections.unmodifiableList(new ArrayList<>()) does NOT?
  3. If REQUIRED_KEYS needed to be built dynamically at class load time (the static {} block pattern), what would be a safe alternative to List.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:

  1. Run the tests and observe which pass and which fail
  2. Write a code review comment for each issue you find (5 total)
  3. Fix the correctness bugs so all 4 tests pass
  4. 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:

  1. Null guard: add a null check on key and throw IllegalArgumentException (not NullPointerException — explicit contract is better than an implicit crash)
  2. Non-positive n guard: throw IllegalArgumentException if n <= 0
  3. Power-of-two guard: throw IllegalArgumentException with a clear message if n is not a power of two — (n & (n - 1)) != 0 is the idiomatic check
  4. 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)
  5. 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 bucketing
  • testSingleBucket_alwaysReturnsZero — n=1 must always return 0
  • testNonPowerOfTwo_throwsIllegalArgument — n=5 must throw IllegalArgumentException
  • testNullKey_throwsIllegalArgument — null key must throw IllegalArgumentException

Step 5 — Reassess: is the PR ready to merge?

After your fixes, answer in a comment at the top of CleverPartitioner.java:

  1. Is it still Fibonacci hashing if we restrict n to powers of two? (Yes — this is the standard form.)
  2. 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()) % n is simple but has skew for non-prime n. What's the production alternative?)
  3. Is the 0x45d9f3bL constant 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

LabArtifactWhat You Learn
Lab 01 — FlawedPatchMessageBatcher.java with 6 planted review issuesStructured review comments, severity labeling, finding what tests miss
Lab 02 — Design TradeoffsTwo RateLimiter implementations + benchmarkHow to argue for a design with code and data; the "+1 with conditions" review pattern
Lab 03 — Patch ArchaeologySynthetic Kafka-style .patch fileReading raw git diffs; retrospective review; identifying what slipped through

Core Concepts

ConceptOne-line definition
Binding vs non-binding voteA committer's vote counts toward the required 3 +1s; a contributor's vote is informational
API contract regressionA change that breaks callers compiled against the previous version (binary compat) or breaks callers that recompile (source compat)
Design vote emailA message to dev@ presenting two approaches with tradeoffs, asking for community preference before writing code
Patch archaeologyReading a merged commit retrospectively to understand what the reviewer should have caught and why it wasn't
-1 with vetoA 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 comparing TokenBucketRateLimiter vs SlidingWindowRateLimiter, citing benchmark output
  • kafka-pr-17873-review.md — retrospective review of the synthetic patch with severity labels for each issue found
  • mvn test green in all 3 lab modules

Interview Relevance

QuestionLab
"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)

Review serves four functions, and optimizing only the first produces bad reviewers:

  1. 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).
  2. 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.
  3. 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.
  4. 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):

  1. Contract & compatibility: public API/config/wire/format changes? Annotations respected? japicmp/MiMa consulted? (A perfect implementation of a breaking change is still a -1.)
  2. Correctness: new states, error paths, boundary inputs, concurrency. Ask of every branch: what test would fail if this line were wrong — does it exist?
  3. Failure behavior: partial failure, retries, resource cleanup on the exception path. Most production incidents live in code that only runs when something else broke.
  4. Performance: only for hot paths, and then with evidence — demand the benchmark, review its design (Phase 08's JMH pitfalls are reviewer knowledge).
  5. Maintainability: naming, structure, comment quality, test readability — Phase 05's properties.
  6. 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 diff hides 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 — count is 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:

  1. Make the axes explicit before judging: correctness under burst, memory per key, contention behavior, observability, complexity. Unstated axes are how design arguments go circular.
  2. 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?"
  3. 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.
  4. 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.
  5. 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 MessageBatcher with 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:

  1. Name the four functions of review and why "LGTM-without-reading" damages three of them.
  2. Recite the blast-radius checklist order and justify it with one sentence.
  3. Rewrite a verdict-comment ("this is wrong") into a working comment (reason + severity + suggestion) on demand.
  4. List four diff-reading asymmetries to hunt.
  5. 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

🛸 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

LabelMeaningMerge?
BLOCKERCorrectness regression, API contract violation, or data lossMust fix before merge
MAJORImportant issue that should be fixed but is not strictly blockingShould fix before merge
MINORNon-trivial but low-impact: missing test for rare path, suboptimal algorithmFix or file a follow-up ticket
NITTrivial style: typo, unnecessary blank line, gratuitous final keywordFix or ignore
QUESTIONAsking for clarification — not requesting a changeAnswer 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:

  1. 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)"
  2. Provide evidence — benchmark numbers, not intuition
  3. 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.
  4. 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:

  1. Read the @@ header to understand what method/class you're in
  2. Read the - lines (removed) to understand what the code used to do
  3. Read the + lines (added) to understand what it does now
  4. 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 throws clause 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

TopicOne-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

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 changes flush() to return the flushed list. Also adds autoFlush support 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)
#IssueLocationSeverityType
1batch is an ArrayList accessed from multiple threads without synchronizationbatch fieldBLOCKERThread safety
2flush() return type changed from void to List<String> — binary incompatibleflush() signatureBLOCKERAPI contract regression
3add(null) silently adds null to the batch; no null guardadd()MAJORMissing input validation
4Throws RuntimeException when closed instead of IllegalStateExceptionadd()MINORWrong exception type
5batch.size() > maxSize || batch.size() == maxSize — the == branch is dead (already covered by >)add()NITDead code
6The autoFlush feature added in this PR has no testclose()MAJORMissing 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:

  1. Understand both implementations by reading the code and the tests
  2. Run the benchmark and compare the results
  3. 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:

  1. What data structure stores the state?
  2. What is the time complexity of tryAcquire()?
  3. What is the space complexity relative to the rate limit and window size?
  4. 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-21044RequestQueue.drainRequests() occasionally throws NullPointerException in production under high load. Stack trace points to line 89 of RequestQueue.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:

  1. Find the commit that introduced it: git log --all -S 'drainRequests' -- RequestQueue.java
  2. Read the diff as if you were the reviewer at the time of the PR
  3. 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 diffIssueSeverity
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 validationBLOCKER
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 bugBLOCKER
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 methodMAJOR (API regression)
4No test for empty queueThere 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:

  1. Consensus — how distributed nodes agree on a leader when any node can fail at any time
  2. Partitioning — how data is mapped to shards deterministically and fairly across a consumer group
  3. 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

LabTopicArtifactCommand
Lab 01 — Raft Leader ElectionRaft consensus, terms, votes, heartbeatsRaftCluster.java simulationmvn test
Lab 02 — Kafka Partitioningmurmur2 hash, round-robin, range assignmentMurmurPartitioner.java, RangeAssignor.javamvn test
Lab 03 — Multi-module MavenParent POM, BOM, module dependency graphqueue-api / queue-core / queue-appmvn verify (from parent)

Core Concepts

ConceptOne-line definition
Raft termLogical clock that increments on each election attempt
Majority quorumfloor(N/2) + 1 nodes must agree for any decision to commit
Election timeoutRandom delay before a follower promotes itself to candidate
murmur2Non-cryptographic hash used by Kafka to assign keys to partitions
Range assignmentKafka's default consumer group partitioning: first N consumers get an extra partition if not evenly divisible
Reactor buildMaven builds all modules in dependency order in a single mvn invocation
dependencyManagementParent POM section that pins versions; child modules inherit them without specifying version
BOMBill of Materials — a pom with only dependencyManagement, imported by other projects

Deliverables

  • Raft election simulation: mvn test passes all 5 tests in RaftElectionTest
  • Kafka partitioning: mvn test passes all 8 tests in PartitionerTest
  • Multi-module build: mvn verify from 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 typeWhat 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

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):

  1. 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.
  2. 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.
  3. Majority of votes → leader; begin heartbeats. Tie/split vote → timeout expires → next term, retry. Randomized timeouts make repeated splits exponentially unlikely.
  4. 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 at prevLogIndex/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 % N reassigns ~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's dependencyManagement — 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-api only — 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 -am and explain what rebuilt and why.

Success Criteria

You are ready for Phase 08 when you can, from memory:

  1. State the quorum-intersection argument and apply it (why 5 nodes tolerate 2 failures).
  2. Walk Raft's election including both safety mechanisms (one vote/term persisted; up-to-date restriction) and the role of randomized timeouts.
  3. Explain why a leader can't commit old-term entries by counting replicas (Figure 8).
  4. Defend murmur2-mod-N, name the modulo trap, and say when consistent hashing is and isn't the answer.
  5. Compare range/round-robin/sticky assignment on balance × stability × locality.
  6. 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

🛸 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)      │       │
      └─────────────────────────────────────────────┘
StateWhat it does
FOLLOWERWaits for heartbeats from a leader. If none arrive before a random timeout, promotes to CANDIDATE.
CANDIDATEIncrements term, votes for self, sends RequestVote RPCs to all peers. Becomes LEADER if majority responds.
LEADERSends 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:

PropertyString.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:

  1. Maven reads all pom.xml files declared in <modules>
  2. Builds a dependency graph
  3. Builds modules in topological order: apicoreapp
  4. 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

MechanismWhat it doesWhen to use
Parent POMInherits <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

SystemCommon failure modeRoot cause
Raft clusterRepeated split votes, no leader electedElection timeouts too similar; fixed by randomizing them
Kafka producerHot partition: one partition gets all trafficAll keys hash to same partition, or poor key design (e.g., all keys start with same prefix)
Kafka consumer groupFrequent rebalances under loadConsumer processes records too slowly → heartbeat timeout → evicted from group
Multi-module MavenClassNotFoundException in integration testsStale 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

TopicQuestionOne-line answer
RaftHow 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
RaftWhat happens when a partitioned leader reconnects?It sees a higher term in any RPC and immediately steps down to FOLLOWER
RaftWhy does Raft use random election timeouts?Reduces probability of simultaneous elections, which cause split votes and no leader
KafkaWhy does Kafka use murmur2 instead of hashCode()?hashCode() is not stable across JVM implementations; murmur2 produces the same result everywhere
KafkaWhat is a consumer group rebalance?Kafka reassigns partitions to consumers when group membership changes (consumer added/removed/failed)
KafkaWhen 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)
MavenWhat 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
MavenHow do you build only one module and its dependencies?mvn install -pl queue-app -am (-am = also make dependencies)

6. References

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():

  1. What fields change?
  2. What is the new value of currentTerm?
  3. 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:

  1. Advance the cluster term to 3 (simulate 3 elections)
  2. Create a new "stale" candidate at term 1
  3. 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:

  1. Producer side: which partition does this message go to? (based on key)
  2. 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).

  1. What partitions did c0 have before the restart?
  2. What happens to those partitions while c0 is down?
  3. When c0 rejoins, 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) &amp; Integer.MAX_VALUE) % numPartitions
 * </pre>
 * 
 * The {@code &amp; 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 &lt; 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:

ProblemRoot causeMulti-module fix
Everything recompiles on every changeOne source treeModules only recompile when their inputs change
Dependency versions driftEach dev adds a version independently<dependencyManagement> pins versions once
You can't publish an API jar without implementation detailsAll code in one artifactapi module has zero implementation deps
CI builds the whole world for a 1-line typo fixNo incremental buildmvn -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:

  1. What is the significance of <packaging>pom</packaging>?
  2. What is the difference between <dependencies> and <dependencyManagement>? Add <dependencies> to the parent POM and enqueue a message in a test — what breaks?
  3. What Maven lifecycle phase does mvn verify run through?
Answers
  1. packaging=pom tells Maven: "this artifact is not compiled, there is no jar. Its purpose is to declare child modules and shared configuration."

  2. <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.

  3. mvn verify runs: validate → initialize → compile → test → package → verify. It produces jars AND runs all tests, including integration tests. mvn test stops 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-api appears as a transitive dependency of queue-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:

  1. Checks out the repo
  2. Sets up JDK 11 (Temurin = Eclipse Adoptium) with Maven cache
  3. Runs mvn verify --batch-mode from the lab directory
  4. 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

#TitleCore skillKey class
01JMH MicrobenchmarksWrite and interpret JMH benchmarksStringConcatBenchmark, CollectionLookupBenchmark
02GC & Allocation ProfilingMeasure autoboxing and allocation overheadBoxingBenchmark, StreamVsLoopBenchmark
03Lock Contention AnalysisCompare synchronization primitives under thread contentionContentionBenchmark, ContentionCorrectnessTest

Core Concepts

ConceptWhat it meansWhy it matters
Dead code eliminationJIT removes code whose result is never used — defeats naive benchmarksJMH uses Blackhole to prevent this
Constant foldingJIT computes 2 + 2 at compile time — benchmark measures nothingJMH uses @State fields to prevent this
JIT warm-upFirst 10k–50k iterations are interpreted; they are not representativeJMH @Warmup discards early iterations
Coordinated omissionRecording only when you're ready to record hides high-latency tailsUse JMH SampleTime mode for latency distributions
False sharingTwo fields on the same cache line — writing one invalidates the otherUse @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 Counter implementations (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 synchronized sometimes faster than AtomicLong at low thread counts?"
"What's wrong with using System.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

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 int loop 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 effectThe lieJMH counter
Dead-code eliminationunused result → loop deleted → "0.3 ns"return the value / Blackhole.consume
Constant foldingcompile-time-known input → computed onceinputs from non-final @State fields
Warmupfirst 10K iterations measure the interpreter@Warmup iterations before @Measurement
Profile pollutionbenchmark A's types deopt benchmark B's fast path@Fork — fresh JVM per benchmark
Loop unrolling/OSRmanual timing loop ≠ production loop shapeJMH controls the measurement loop
Run-to-run varianceone number, no error barsmultiple 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

CollectorStrategyPausesChoose when
Parallelstop-the-world, maximum throughput100s of msbatch jobs; pause-insensitive (Spark executors)
G1 (default)region-based, incremental, pause-target~10–200 ms, tunablethe general default; services, brokers
ZGC / Shenandoahfully concurrent, colored pointers / brooks pointers<1–2 mslatency SLOs with big heaps; pay ~10–15% throughput
Serialsingle-threadedlongtiny 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 a Long per put/get outside the small cache (−128..127); a long-keyed primitive collection or plain long accumulator is allocation-free. The classic regression: a refactor flips longLong in 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 += x is O(n²) copying; StringBuilder with a pre-sized capacity avoids both the copies and the resize-doubling garbage.
  • Iterator churn, defensive copies, varargs arrays, Optional in 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):

  1. 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).
  2. 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).
  3. volatile = visibility + ordering, never atomicity: count++ on a volatile is still read-modify-write race. Compound ops need CAS or locks.
  4. 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.
  5. 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. StampedLock for 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 @Fork to 0 and watch profile pollution. The broken variants are the lesson; keep a table of effect→symptom.
  • Lab 02: run with -Xlog:gc and 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:

  1. Walk bytecode → interpreter → C1 → C2 with what each tier adds, and define deoptimization with a trigger example.
  2. Name five benchmark lies and the JMH feature countering each.
  3. Explain the young-gen economy (why cost ∝ survivors) and the two GC-log numbers that matter.
  4. Choose a collector for: Spark executor, Kafka broker, p99-bound trading service — with one-line justifications.
  5. Recite the five JMM rules; state precisely what volatile does and doesn't give.
  6. 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

🛸 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
ColumnMeaning
Mode: avgtAverage time per operation
Mode: thrptOperations per unit time
Mode: ssSingle-shot time (no warmup, measures cold start)
CntNumber of measurement samples
ScoreMean value across samples
Error±1.96 standard deviations (95% confidence interval)
Units: ns/opNanoseconds 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+)

GCTuning goalWhen to use
G1GC (default)Balanced pause timeMost applications
ZGCSub-millisecond pausesLatency-sensitive (Kafka broker, etc.)
ShenandoahConcurrent compactionLow-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

PrimitiveMechanismBest forLimitation
synchronizedOS mutex via monitorenter/monitorexit bytecodeLow-contention, simple codeUncontested is fast; contested has OS overhead
ReentrantLockJUC lock with explicit lock/unlockNeeding tryLock, timeout, fairnessMore verbose; similar overhead to synchronized
AtomicLongCAS (Compare-And-Swap) hardware instructionSingle counter, moderate contentionCAS retry storms under high contention
LongAdderStriped counter: each thread has a cellHigh-contention write-heavy counterssum() 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

ScenarioSymptomRoot causeDiagnosis
JIT deoptimizationThroughput drops suddenly after stable periodNew code path executed → compiled code invalidated-XX:+PrintCompilation / JFR
GC pause spikesp99 latency 10× higher than medianOld gen collection triggeredGC log analysis, -Xlog:gc*
Lock convoyThreads queuing behind one slow lock holderNon-atomic critical section + OS schedulingThread dump, jstack
Memory leakOld gen grows until OOMObjects held by static collectionHeap dump, jmap / Flight Recorder
False sharingCounter throughput degrades at 8+ threadsCache line bouncing between CPU coresperf stat -e cache-misses

6. Interview Questions — Concepts You Must Explain Out Loud

TopicQuestionOne-line answer
JMH basicsWhy can't you use System.nanoTime() for benchmarking?JIT eliminates dead code, and there's no warmup — results are unreliable
JMH outputWhat does the ± in JMH output represent?95% confidence interval — overlapping bars means no significant difference
ModesWhen would you use Mode.SampleTime instead of Mode.AverageTime?When you care about p99/p999 latency tails, not just the mean
AllocationWhy is int[] faster than List<Integer> in a hot loop?Autoboxing allocates one Integer heap object per element; int[] is contiguous stack/heap primitive
GCWhat is escape analysis and how does it affect allocation benchmarks?JIT proves short-lived objects don't escape → eliminates the allocation entirely
ContentionWhen is synchronized faster than AtomicLong?Uncontested synchronized is a fast biased/thin lock — sometimes cheaper than a CAS instruction
LongAdderWhy does LongAdder outperform AtomicLong at high concurrency?Striped cells + cache-line padding eliminate CAS contention storms
False sharingTwo 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

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:

PitfallWhat happensJMH fix
Dead Code EliminationJIT sees result is unused, removes the loopReturn the value from @Benchmark
Constant foldingJIT precomputes loop at compile timeUse @Param so the value is unknown at JIT time
JIT warm-upFirst 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:

  1. The JMH annotation processor generated META-INF/BenchmarkList (required for Runner to find benchmark methods).
  2. Each benchmark method executes without throwing.
  3. 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:

  • concatenation degrades quadratically (O(n²) copies) while stringBuilder is O(n).
  • Pre-sizing the StringBuilder saves one internal array resize per doubling, a small but consistent win.
  • The Error column (± 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&lt;String&gt; 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:

  1. target/classes/META-INF/BenchmarkList — a text file listing every benchmark method. The Runner reads this at startup to discover what to run.
  2. target/generated-sources/annotations/org/example/perf/jmh_generated/ — one *_jmhTest.java per 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.Thread gives each thread its own object (no sharing, no contention), Scope.Benchmark shares 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 Error column 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:

PatternWhat allocatesFix
List<Integer> with int valuesInteger box per elementUse int[] or primitive collections
StringBuilder with default capacityGrows by doubling (System.arraycopy)Pre-size with expected length
.parallel() on small arraysForkJoinTask objects per splitOnly 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:

  1. JMH annotation processor generated META-INF/BenchmarkList.
  2. Each benchmark class executes without throwing.
  3. 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

PrimitiveMechanismBest use
synchronizedOS mutex (biased → thin → inflated)General-purpose mutual exclusion; simple to read
ReentrantLockSame as synchronized + rich APIWhen you need try-lock, timed-lock, or fairness
AtomicLongSingle CAS instructionLow-to-medium contention; result needed immediately
LongAdderStriped 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:

  • LongAdder is ~2× faster than AtomicLong under 4-thread contention because threads never contend on the same cell.
  • ReentrantLock and synchronized are 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 LongAdder over AtomicLong?LongAdder for high-throughput counters/histograms where you only need the total at the end. AtomicLong when 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) vs Scope.Thread) mean for contention?Scope.Benchmark shares one instance across all JMH worker threads (real contention); Scope.Thread gives each thread its own instance (no contention).
  • Why is ReentrantLock not faster than synchronized here? — both park threads in the OS kernel when uncontended paths are taken; the extra API flexibility of ReentrantLock doesn'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

#LabCore conceptTests
01Unified Diff ParserParse git format-patch output; count insertions/deletions; flag missing test coverage9
02Semantic Versioning EngineFull SemVer 2.0.0 parse + comparison + compatibility classification15
03Issue State MachineJIRA-style lifecycle with guarded transitions and IllegalTransitionException9

Core Concepts

ConceptWhy Apache committers care
Unified diff formatReviewing patches in email, JIRA, and GitHub; counting net change
SemVer compatibilityEvery Apache release declares compatibility guarantees; breaking the contract is a blocker
Issue lifecycleJIRA is the canonical record for Apache work; transitions drive release tracking
Test coverage signalApache reviewers routinely reject patches that add production code without a test
Pre-release ordering1.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 PatchParser that processes real git format-patch output and reports per-file insertion/deletion counts and whether the patch touches test files
  • A SemanticVersion that correctly implements SemVer 2.0.0 precedence rules including pre-release identifier ordering
  • An Issue state 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, @Deprecated before 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

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). count omitted means 1; start of 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 file markers, renames (similarity index headers), binary file stanzas, and mode changes.
  • Why context lines exist at all: they let patch apply 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's ComparableVersion has its own additional rules (1.0-SNAPSHOT < 1.0; qualifiers like alpha/beta/milestone/rc/ga have 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, with REOPENED as the loop-back. (Projects customize; the shape is universal.)
  • Transitions carry guards: you can't move to PATCH_AVAILABLE without an attached patch/PR link; RESOLVED requires 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; a REOPENED issue 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 scattered ifs — adding a state then forces a decision at every transition site (exhaustive switch + MissingSwitchDefault from 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 diff with 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:

  1. Parse a hunk header by hand and verify a hunk's count invariants on sight.
  2. Name three patch metrics that predict review outcomes and the norm each enforces.
  3. Map all four compatibility dimensions onto minimum semver bumps with one example each.
  4. Order 1.0.0-alpha.1, 1.0.0-rc.1, 1.0.0, 1.0.0+build5, 1.0.1-SNAPSHOT correctly and justify each comparison.
  5. Sketch the issue FSM with guards, and explain why resolution-type hygiene is release correctness.
  6. 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

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; Y is 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]
PartIncrement whenExample
MAJORBreaking API change2.0.0
MINORBackward-compatible new feature1.3.0
PATCHBackward-compatible bug fix1.2.4
Pre-releaseUnstable milestone1.3.0-rc.1

Precedence Rules

  1. Compare MAJOR, then MINOR, then PATCH numerically.
  2. A release version (1.2.3) has higher precedence than the same version with a pre-release label (1.2.3-rc.1).
  3. 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
  4. Shorter tuple is lower when all common identifiers are equal (1.0.0-alpha < 1.0.0-alpha.1).
  5. 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 rework
  • RESOLVED → REOPENED — regression found after closing; happens at release time
  • CLOSED → REOPENED — re-opened from a fully closed issue; requires a comment
  • No transition from CLOSED directly to IN_PROGRESS — must go through REOPENED

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:

DimensionWhat they look at
CorrectnessDoes the code do what the JIRA says? Edge cases covered?
Test coverageIs there a new test for the new behavior? Does it fail before the fix?
Diff hygieneNo unrelated whitespace changes; no reformatted-only lines burying real changes
CompatibilityDoes this break existing callers? (SemVer)
JIRA linkIs the commit message formatted SPARK-12345: Fix description?
License headerEvery new file needs the ASF license header

(5) Interview Q&As

QuestionStrong 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

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

ClassResponsibility
FileDiffHolds file name, insertion count, deletion count; detects test files
PatchSummaryAggregate totals for an entire patch
PatchParserParses 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 the diff --git header 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 diff shows context lines (space), additions (+), and removals (-)."
  • "I always check git diff --stat first 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

ClassResponsibility
SemanticVersionParses and compares SemVer strings; implements Comparable
CompatibilityLevelEnum: MAJOR / MINOR / PATCH / PRE_RELEASE / EQUAL
VersionComparatorClassifies 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)

  1. Compare MAJOR, then MINOR, then PATCH numerically.
  2. Release (1.2.3) > same version with pre-release (1.2.3-rc.1).
  3. 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
  4. Shorter tuple is lower when shared identifiers are equal.
  5. 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

ClassResponsibility
IssueStateEnum: OPEN / IN_PROGRESS / IN_REVIEW / RESOLVED / CLOSED / REOPENED
IssuePriorityEnum: BLOCKER / CRITICAL / MAJOR / MINOR / TRIVIAL
IllegalTransitionExceptionThrown when an invalid state transition is attempted
IssueHolds 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 through REOPENED.
  • OPEN → RESOLVED is valid (quick-close for duplicates / won't-fix).
  • The fixVersion field is set by the committer at RESOLVED time.

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, fixVersion is 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

LabTitleWhat you build
01Release Checklist EngineValidates a simulated release directory against the ASF release policy checklist
02API Compatibility CheckerDetects REMOVED / ADDED / MODIFIED public methods between two API snapshots; classifies binary-breaking changes
03RC Vote ParserParses a [VOTE] email thread; counts binding votes; determines pass/fail per Apache quorum rules

Core Concepts

ConceptWhat it means in practice
RC (Release Candidate)A versioned, signed artifact submitted for community vote; may go through multiple iterations (RC1, RC2, …)
Binding voteA +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 changeA change that prevents existing compiled code from running without recompilation (removed method, changed signature)
Source-compatible changeA 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 / NOTICERequired in every source release; LICENSE covers the ASL-2.0 grant + bundled deps; NOTICE contains required attribution

Deliverables

  • 3 Maven projects, all mvn clean test green
  • 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

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 itemPredicate over what
version is not SNAPSHOTthe version string
tarball builds from scratcha build execution's exit code
checksums matchcomputed vs published digests
signatures verify, key in KEYSartifacts × KEYS file
LICENSE/NOTICE present and currentarchive contents
no category-X deps bundleddependency report × license policy
compat report clean or waivedjapicmp output × exclusion file
JIRA: no open blockers; fixVersion hygienetracker 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's MethodSignature model 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 (ArrayListList) 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):

ChangeSource compatBinary compatVerdict
Add method (class)MINOR
Add method (interface, no default)❌ implementors✅ callers / ❌ implementorsMAJOR
Add default methodMINOR, behavioral review required
Remove/rename public method❌ (NoSuchMethodError)MAJOR
Change return type (any direction)varies❌ (descriptor)MAJOR
Change parameter type❌ usuallyMAJOR
Add parameter (no overload kept)MAJOR
Widen visibility (protected→public)MINOR
Narrow visibilityMAJOR
Make method/class final❌ overriders/subclassers❌ sameMAJOR
Reorder overloads / rename paramsPATCH-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 (> +1 is 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 UNPARSED bucket 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:

  1. Re-state five checklist items as predicates with their evidence and severity.
  2. Explain why a policy engine runs all checks and reports evidence (vote-thread audience).
  3. Recite five rows of the compatibility table including the return-type and interface-method rows, with the JVM-linkage reasons.
  4. State what makes a vote binding and write the release pass-rule as one boolean expression.
  5. 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.org for [VOTE] Release Spark and 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.

CheckWhy 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 archiveRequired by ASL 2.0 §4 — must cover bundled dependencies
NOTICE file in archiveRequired by ASL 2.0 §4 — attribution for upstream works
✅ No binary artifacts in source releaseSource releases must be reproducibly buildable from source
DISCLAIMER or DISCLAIMER-WIP if incubatingIncubator projects carry an extra file
✅ All dependencies are Category ACategory 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 KEYS file in dist/ 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:

ChangeBinary-breaking?Source-breaking?
Method removedYESYES
Method return type changedYESYES
Method parameter types changedYESYES
New method added to classNONO
New method added to interfaceNO (default) / YES (abstract)YES
Field type changedYESYES
Field removedYESYES

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:

  1. Fix the issue (e.g., regenerate artifact if checksum mismatch)
  2. Cut RC2
  3. Start a new vote thread with [VOTE] Release Apache Spark 3.5.0 RC2

(5) Lazy Consensus vs Full Vote

MechanismUsed forRule
Lazy consensusMinor patches, docs, non-binding changesAnyone can +1 silently; a -1 from any committer blocks
Full voteReleases, new committers, PMC additionsExplicit +1s required; quorum applies
Majority votePolicy changesSimple 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

QuestionStrong 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

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

CheckWhy it matters
GPG signatureAllows downloaders to verify the artifact came from an ASF committer whose key is in the KEYS file
SHA-512 checksumDetects transport corruption; SHA-512 required since Apache phased out MD5/SHA-1
LICENSE fileEvery release artifact must carry an Apache 2.0 LICENSE
NOTICE fileAttributions required by ALv2 §4(d)
No binary artifactsASF releases must be source-only; pre-built binaries belong in a separate convenience distribution

Running

mvn test

Expected: 11 tests, 0 failures.

Key Classes

ClassRole
ReleaseArtifactValue object — name + 5 boolean flags; built with a fluent builder
ChecklistItemEnum of the 5 policy checks
ChecklistResultOutcome of a single check (item + passed + message)
ReleaseValidatorValidates 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 typeBinary-breaking?Explanation
REMOVEDYesCallers compiled against the old API get NoSuchMethodError at runtime
MODIFIED (return type changed)YesThe JVM encodes return type in the method descriptor
ADDEDNoCallers 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

ClassRole
MethodSignatureclassName + methodName + returnType + parameterTypes; methodKey() is the identity key
ChangeTypeEnum: REMOVED, ADDED, MODIFIED
CompatibilityIssueOne change: method + changeType + isBinaryBreaking()
CompatibilityReportAggregated result with helpers for binary-breaking issues vs. compatible additions
ApiCompatibilityCheckercheck(oldApi, newApi) — the diffing algorithm

Algorithm

  1. Build Map<methodKey, MethodSignature> for each snapshot.
  2. Key in old, absent in new → REMOVED.
  3. Key in both, return type differs → MODIFIED.
  4. 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

ConditionResult
Any binding −1VETOED — release must be fixed
≥ 3 binding +1, no binding −1PASSED
< 3 binding +1, no binding −1INSUFFICIENT_VOTES

Running

mvn test

Expected: 11 tests, 0 failures.

Key Classes

ClassRole
VoteEntryvoter name + vote value (+1/0/−1) + binding flag
VoteThreadsubject + list of entries; helpers for quorum/veto check
VoteResultEnum: PASSED, VETOED, INSUFFICIENT_VOTES
VoteParserparse(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

LabTitleCore Skill
01Deprecated API Migration AnalyzerClassify call sites against a deprecation timeline; determine migration urgency
02Storage Format Migration PlannerPlan multi-step table format migrations; evaluate capability deltas and rollback safety
03Consensus TrackerModel lazy consensus, explicit approval, and blocking objection in an ASF design thread

Apache Project Context

LabReal-world reference
01Spark RDD API vs. Dataset API evolution; SPARK-28 series deprecation notices
02Hive ALTER TABLE … STORED AS format migrations; migration from Text → ORC → Parquet
03ASF 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"

The earlier phases' issues had a correct answer findable in code. PMC-level issues have three different properties:

  1. The blast radius is the ecosystem, not the codebase: thousands of downstream users with different upgrade cadences, including unmaintained deployments.
  2. Every option is a tradeoff between constituencies: users vs maintainers, fast movers vs stable-branch operators, this project vs sibling projects sharing the format.
  3. 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):

  1. Replacement exists and is GA — deprecating without a destination is hostile; "no-replacement" deprecations need their own justification class.
  2. Annotate + document: @Deprecated(since, forRemoval) + Javadoc naming the replacement and the earliest removal version. The docs are the contract.
  3. Migrate yourself first: the project's own code and examples stop using it — internal usage is both hypocrisy and a usage-count confounder.
  4. Measure adoption: usage telemetry where it exists; otherwise corpus scans (Maven Central reverse-deps, code search). This is Lab 01's input data.
  5. Escalate signal: release notes → runtime warn-once logging for high-traffic methods → build-breaking only at the major version that removes it.
  6. 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):

  1. Dual-read ships first: all readers understand v2 before any v2 byte exists. Cheap, riskless, and it starts the clock on the skew window.
  2. 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.
  3. 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.
  4. 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).
  5. 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):

  1. 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.
  2. 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.
  3. 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.
  4. 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).
  5. 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:

  1. Name the three properties that make an issue PMC-level and the artifact each lab produces.
  2. Recite the six-step deprecation lifecycle and both institutional failure modes.
  3. Define backward/forward compatibility and draw the four-cell skew matrix for a mid-rollout mixed cluster.
  4. Walk the five-phase migration plan with each phase's rollback story, and state when background rewrite is correctly skipped.
  5. Distinguish facts/priorities/taste disagreements with the resolution move for each.
  6. 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

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).

PhaseWhat it means for users
STABLENo action needed
DEPRECATEDMigration path documented; old API still works
REMOVEDCallers 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:

  1. Forward-only readable — new readers can read old data.
  2. 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

CapabilityTextORCParquet
Vectorized readNoYes (Hive LLAP)Yes
Predicate pushdownNoYesYes
Schema evolutionLimitedLimitedYes (Parquet spec)
ACID transactionsNoYes (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:

  1. It is raised by a PMC member (binding voter status).
  2. It is a technical objection — not a preference.
  3. 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

ClassRole
DeprecationStatusEnum: STABLE, DEPRECATED, REMOVED
MigrationUrgencyEnum: NONE, PLANNED, IMMEDIATE
ApiMethodMethod metadata: class, name, status, versions, replacement
MigrationResultOne classification result including human-readable rationale
ApiMigrationAnalyzeranalyze(method, targetVersion) + analyzeAll() + blockingResults()

Classification Rules

StatusUrgencyMeaning
STABLENONENo action needed
DEPRECATEDPLANNEDMigrate before the removal version
REMOVEDIMMEDIATEMust 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

ClassRole
StorageFormatEnum: TEXT, SEQUENCE_FILE, ORC, PARQUET, AVRO
FormatCapabilityEnum: VECTORIZED_READ, PREDICATE_PUSHDOWN, SCHEMA_EVOLUTION, ACID_TRANSACTIONS, COLUMN_STATISTICS
FormatCapabilityRegistryMaps each StorageFormat to its capability set
MigrationStepfrom → to + gained + lost + isRollbackSafe()
FormatMigrationPlannerplan(from, to) — computes capability delta

Capability Matrix

CapabilityTEXTORCPARQUETAVRO
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:

  1. The 72-hour minimum discussion window has elapsed.
  2. No binding (PMC member) −1 objection remains unresolved.
  3. 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

ClassRole
ConsensusVoteEnum: PLUS1, ZERO, MINUS1
PMCVoteEntryvoter + vote + binding flag + comment + resolve()
ConsensusResultEnum: LAZY_CONSENSUS, BLOCKED, PENDING
ConsensusProposaltitle + proposedAt + vote list + objection helpers
ConsensusEvaluatorevaluate(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.

LabDescription
Lab 01Full Contribution Cycle: patch, test, review response, release note
Lab 02Design Doc: technical design for a real or hypothetical feature
Lab 03Final 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

Three labs, three artifact classes, one claim: you could operate as a committer candidate today.

LabArtifactPhases it integrates
01 Full contribution cyclepatch + tests + review response + release note02 (navigation), 05–06 (quality/review), 09–10 (mechanics)
02 Design doca subsystem-level design document03 (case law), 07–08 (systems/perf), 11 (consensus)
03 Final reflectiona "What I Wish I Knew" transfer document01 (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:

  1. Return to your Lab 01 (Phase 01) project map — refresh it; six months of project drift is normal.
  2. 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.
  3. Run the Lab 01 (this phase) cycle for real — same artifacts, real stakes, 72-hour-response discipline.
  4. 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).
  5. 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:

  1. 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.
  2. 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).
  3. Your reflection contains ≥3 falsifiable, costed lessons (Chapter 5's bar) that a Phase-01 reader could act on.
  4. 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

A real ASF contribution touches eight distinct artifacts, and committers judge all eight:

  1. The issue — problem statement with reproduction. A good JIRA is falsifiable: someone else can confirm the bug exists and confirm your fix removes it.
  2. 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.
  3. 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.
  4. Commit messagePROJECT-1234: <what changed and why> with body explaining the approach and alternatives rejected. git log is the project's long-term memory.
  5. Review responses — Section 3.
  6. The release note — Section 4.
  7. 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.
  8. 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-1234normalize() now trims Unicode whitespace (was: ASCII only). Who is affected: callers relying on non-ASCII whitespace surviving normalization. Action: none for most users; set legacy.normalize=true to 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 test first; read the existing StringNormalizer and 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

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:

  1. 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.
  2. 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

FileCovers
01-apache-governance-questions.mdASF structure, releases, votes, vetoes, committer/PMC process — with complete answers
02-distributed-systems-questions.mdConsensus, replication, partitioning, scheduling, fault tolerance — with complete answers
03-jvm-performance-questions.mdGC, JIT, memory model, concurrency, benchmarking — with complete answers
04-code-review-scenarios.mdReview exercises with model reviewer responses
05-behavioral-star-stories.mdSTAR 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:

  1. Mechanism before opinion — explain how the thing works before judging it.
  2. Compatibility and blast radius — every change is discussed in terms of who it breaks.
  3. 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

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.

  1. Release manager (RM) cuts a release branch; blockers tracked in JIRA against the version.
  2. 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.org dev area).
  3. RM starts a [VOTE] thread on the dev list, linking artifacts, signatures, and tag.
  4. 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.
  5. 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).
  6. 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

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

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

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

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 long to Long; 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; LongAdder restored 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

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

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 *Slow test-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)) & mask trick 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 ArrayList publicly 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.compat is @InterfaceAudience.Public / @Stable, so this needs to wait for a major release even if we wanted it. japicmp flags this as METHOD_RETURN_TYPE_CHANGED (binary incompatible). Suggest keeping List and 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 no Thread.onSpinWait() and spins without backoff, which will look great in JMH and terrible on a loaded broker. My expectation is synchronized wins 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 blame for 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:

  1. Contract: does the change alter any public API, wire format, config default, or on-disk format? Check annotations, run the compat tooling.
  2. Correctness: new states, error paths, concurrency. What test would fail if this were wrong — does that test exist?
  3. Failure behavior: what happens on partial failure / retry / weird input? Is it worse than before?
  4. Performance: hot path? Then demand a benchmark whose design matches the claim.
  5. Maintainability: can the next non-author modify this safely? Naming, comments stating invariants (not narration), test readability.
  6. 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

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 artifactStory 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 projectEvery "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.

DocDesign problemTrains
01-yarn-scheduler-design.mdMulti-tenant cluster scheduler (YARN-style)Resource arbitration, preemption, fairness vs utilization
02-hive-metastore-scaling.mdScaling a metadata service from thousands to millions of partitionsHot metadata stores, caching, API-compatibility under load
03-public-api-evolution.mdEvolving a public API used by thousands of downstream projectsDeprecation lifecycle, binary vs source compat, migration tooling

How to use these

  1. 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.
  2. 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.
  3. 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

Before any boxes: the three workload classes have conflicting scheduling needs.

ClassContainer lifetimeLatency sensitivityFailure cost
Batch ETLminutes–hoursnone (throughput)re-run a task
Interactive SQLsecondshigh (human waiting)query retry, user anger
Servicesdays–monthsSLO-boundoutage

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) and max (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:

  1. Compute over-capacity queues vs starved queues from guaranteed/usage deltas.
  2. Select victims newest-first within over-capacity queues (minimize lost work), respecting per-app disruption budgets and never-preempt flags (services, AMs themselves).
  3. 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.
  4. 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

  1. 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).
  2. How would you add SLA-based scheduling? — admission control with deadline feasibility check, reserved capacity windows (resource reservations), backfill around reservations.
  3. 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.
  4. 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

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

Name the actual pathologies before designing — they determine which fixes work:

  1. 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.
  2. Chatty planning: a single query plan can issue thousands of metastore calls (getTable, getPartitionsByFilter, per-partition stats). Latency multiplies.
  3. 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.
  4. 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_id so 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:

  1. Every new behavior behind a server-side flag, default off; canary instances first.
  2. Shadow-read validation: new path computes, old path serves, diffs logged (count and content hashes) until diff rate < 1e-6 for two weeks.
  3. API deprecations announced with metrics: instrument who still calls the unpaginated listing (user-agent/job tags), chase the top offenders, then cap.
  4. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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

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

Four independent compatibility dimensions; conflating them is the #1 design-doc failure:

  1. Source: old code compiles against the new version. Broken by: removing members, narrowing visibility, adding abstract methods, generics changes.
  2. 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).
  3. 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.
  4. 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:

  1. Ship the new API alongside the old, in the same artifact, marked @Evolving for ≥1 minor cycle so early adopters bear the iteration risk knowingly.
  2. 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).
  3. Deprecate old methods only when their replacement is GA, with @deprecated Javadoc that names the replacement and the earliest removal version, plus a runtime warn-once-per-JVM log for the highest-traffic methods.
  4. 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.
  • @Since everywhere: 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:

  1. Eligibility: deprecated ≥ 2 minor releases AND measured usage below threshold AND executable migration exists.
  2. Dev-list [DISCUSS] with the usage data attached; lazy consensus is not sufficient for high-traffic methods — explicit vote.
  3. Removal lands only in a major release, with the migration recipe linked from the release notes' breaking-changes table.
  4. For the unmaintained-binaries long tail: consider a separately-versioned compat jar 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 @Public surface change — cheap to run as a PR label + required review.
  • Design docs (the Phase 12 capstone format) required for any new @Public type: 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

  1. A return type needs to change from List<T> to Stream<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.
  2. 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.
  3. 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).
  4. 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