The previous lesson ended with a forty-line coordinator that split chunks of orders.jsonl among processes, added up partials and requeued a dead worker's task. It worked on a laptop; on a thousand-node cluster you would have to solve, for every job, data locality, the shuffle between nodes, failure detection, speculative execution and atomic output. MapReduce is the programming model with which Google (Dean and Ghemawat, 2004) solved all of that once and for all, so that the programmer writes just two functions, map and reduce, and the system takes care of the rest. Hadoop is its open-source implementation, together with HDFS (04-02) and the YARN resource manager, and for a decade it was synonymous with "big data". Hardly anyone writes MapReduce jobs by hand any more, but everything that came afterwards (Spark, Flink, distributed SQL engines) uses its vocabulary and its phases, and its design decisions (always write to disk, independent tasks, a master that reassigns) are still the baseline against which improvements are explained. This lesson presents the model using Kilometre Zero's sales by producer and by market, walks through the execution of a job inside YARN, and implements it three times: in Python with Hadoop Streaming, in Java as the canonical example, and as a local simulation of the shuffle so you can get your hands on what the framework hides.

Contents

  1. The programming model: map, shuffle & sort, reduce
  2. Combiner and partitioner
  3. Execution flow and fault tolerance
  4. Hadoop: HDFS, YARN and MapReduce v2
  5. Anatomy of a job: from submit to _SUCCESS
  6. Input and output formats
  7. Why MapReduce is slow and where it stands today
  8. Hands-on: sales by producer and market in Streaming, in Java and in a simulation
  9. Common Mistakes and Tips
  10. Exercises
  11. Conclusion

  1. The programming model: map, shuffle & sort, reduce

MapReduce borrows two names from functional programming and gives them a precise meaning for distributed data. Every job processes key/value pairs and goes through three phases:

  1. Map. The system divides the input into chunks (splits) and, for every record in every chunk, runs the function map(k1, v1) → list of (k2, v2). The programmer decides what the intermediate key k2 is: it is the key they want to group by. For "sales by producer", map receives a line of orders.jsonl and emits, for each line of the order, (producer, quantity × price).
  2. Shuffle & sort. The system collects all the (k2, v2) pairs from all the mappers, sends them to the reducer responsible for each k2, and delivers them grouped by key and sorted: (k2, [v2, v2, v2, ...]). This is the phase the programmer does not write and the one that costs the most (05-01, section 5).
  3. Reduce. For each key, reduce(k2, list of v2) → list of (k3, v3). For sales, it adds up the list and emits (producer, total).
flowchart LR
    subgraph Input[HDFS: orders.jsonl]
        B1[block 1]
        B2[block 2]
        B3[block 3]
    end
    B1 --> M1[map 1<br/>montblanc 12.50<br/>la-vega 7.80<br/>roble-alto 58.80]
    B2 --> M2[map 2<br/>montblanc 12.60<br/>la-vega 6.40]
    B3 --> M3[map 3<br/>roble-alto 29.40<br/>montblanc 25.00]
    M1 --> SH[[shuffle & sort<br/>group by key]]
    M2 --> SH
    M3 --> SH
    SH --> R1[reduce A<br/>la-vega: 7.80, 6.40 → 14.20<br/>montblanc: 12.50, 12.60, 25.00 → 50.10]
    SH --> R2[reduce B<br/>roble-alto: 58.80, 29.40 → 88.20]
    R1 --> O1[part-r-00000]
    R2 --> O2[part-r-00001]

The example MapReduce is always introduced with is the word count: map emits (word, 1) for every word in a line and reduce adds up the ones. It is the same skeleton as ours with "word" swapped for "producer" and "1" for "amount", which is why MapReduce is said to be a generalised word count: any computation that can be expressed as "extract a key from each record and aggregate by key" fits directly; those that need several chained groupings (sales by producer and then the top-selling producer per market) are expressed as several jobs in a chain, each one reading the previous one's output from HDFS, which is the source of the slowness in section 7.

Three properties of the model explain its success:

  • The functions are local. map sees one record; reduce sees one key and its values. Neither needs to know how many nodes there are or where the data is. The programmer writes business logic, not distribution.
  • The tasks are independent. Each map over a split and each reduce over a partition of keys can run on any node, in any order, and be repeated: this is the deterministic re-execution of 05-01.
  • The shuffle is generic. A single mechanism (partition by key, sort, transfer, merge) serves every job, and the system can optimise it for everyone.

  1. Combiner and partitioner

The basic model has two hooks the programmer can replace:

Combiner. A mapper processing a 128 MB block of orders.jsonl emits about 400,000 pairs, but there are only three distinct producers: 400,000 pairs will travel over the network so that the reducer can add up 133,000 values per key. The combiner is a reduce function local to the mapper that runs over each map's output before the shuffle: it groups the mapper's pairs by key and reduces them to one per key. With it, the mapper sends 3 pairs instead of 400,000. This is the "reduce before you move" of 05-01. It is only valid when the reduction is associative and commutative, and has the same input and output type: a sum qualifies; an average does not, unless you carry (sum, count). Hadoop does not guarantee that the combiner will run (it may run zero, one or several times over the same data), so the result must be identical with or without it.

Partitioner. It decides which reducer each key goes to: partition(k2) = hash(k2) mod number_of_reducers by default. You replace it when you want to control the distribution: sending all the keys in a range to the same reducer (to obtain globally sorted output), or separating two kinds of key into two output files. In the hands-on part of section 8 we emit two families of keys in the same job (p:<producer> and m:<market>) and a partitioner sends them to different reducers, so that part-r-00000 holds sales by producer and part-r-00001 sales by market. The partitioner is also the place where skew is tackled: a partitioner that knows the hot keys can spread them over several reducers, with a second pass to combine.

Component Who writes it Where it runs What for
map Programmer On the split's node (locality) Extracting the key and value from each record
Combiner Programmer (optional; often the same reduce) On the mapper's node, over its output Reducing the volume of the shuffle
Partitioner Programmer (optional; hash by default) In the mapper, when writing the output Deciding which reducer receives each key
Shuffle & sort Framework Mappers (sort and serve) and reducers (fetch and merge) Grouping by key
reduce Programmer On any node with a free container Aggregating the values of each key

  1. Execution flow and fault tolerance

The original paper describes an architecture with one master and many workers, which Hadoop keeps under other names (section 4):

  1. The client submits the job: the code (a jar or scripts), the configuration and the input and output paths.
  2. The master asks HDFS for the input's blocks and creates one map task per split, noting which nodes each block lives on. It also creates R reduce tasks, with R configured by the user.
  3. It assigns map tasks to free workers, preferring the one that has the block on its disk; failing that, one in the same rack; failing that, any.
  4. Each map writes its output, partitioned and sorted, to the worker's local disk, and tells the master where it is.
  5. When all the maps have finished, the master assigns the reduce tasks; each reducer fetches its partition of every map's output over the network, merges it while preserving the order, and runs reduce key by key.
  6. Each reducer writes its output file to HDFS. When they have all finished, the job is complete.

Fault tolerance rests on what we already know:

  • Worker failure. The master detects it through missed heartbeats. The map tasks completed on that node are re-run even though they had finished, because their output was on the local disk of the dead node and the reducers that had not yet fetched it need it. Completed reduce tasks are not re-run: their output is in HDFS. Tasks in progress go back to the queue. Exactly what work_queue.py did, with the added subtlety of the local intermediate output.
  • Task failure (an exception in the code, a corrupt record). It is retried up to four times (mapreduce.map.maxattempts); if it keeps failing, the job fails (or, if so configured, a percentage of failed tasks is tolerated in order to skip poison records, the batch version of the DLQ from 02-05).
  • Stragglers. Speculative execution (05-01): when the phase is close to finishing, a copy of the slow tasks is launched.
  • Atomic output. Each task writes to a temporary directory (_temporary/attempt_.../) and only when the task finishes does the framework do the commit: it renames the task's file to part-r-00001 in the output directory. If two attempts of the same task (re-execution, speculation) finish, only the first one commits. When the job ends, an empty _SUCCESS file is created as a signal to whoever consumes the output (the sensor in 05-05 will wait for it). It is the os.replace of 05-01, institutionalised.
  • The master as a single point. In the original design, if the master went down, the whole job was aborted and the client relaunched it: this was accepted as a reasonable trade-off because a master is one machine among thousands and a job can be relaunched. Hadoop 2 improved on it by creating one master per job (the ApplicationMaster of the next section) that YARN can restart, and the ResourceManager has high availability with ZooKeeper (03-03).

  1. Hadoop: HDFS, YARN and MapReduce v2

Since version 2, Hadoop has been three stacked projects:

Layer Project What it does Lesson
Storage HDFS Files in replicated 128 MB blocks; a NameNode with the metadata, DataNodes with the blocks; exposes the locality of every block 04-02
Resource management YARN (Yet Another Resource Negotiator) Shares out the cluster's CPU and memory among applications in the form of containers; independent of MapReduce This lesson
Compute MapReduce v2 A YARN application that implements the model of section 1. Spark, Flink and Tez are other YARN applications This lesson, 05-03

In Hadoop 1, the MapReduce master (the JobTracker) did two things at once: managing the cluster's resources and coordinating every job. That made it a bottleneck (a limit of about 4,000 nodes) and tied the cluster to MapReduce: nothing else could run on it. YARN separated the two roles:

  • ResourceManager (RM). One per cluster (with high availability). It knows each node's resources and arbitrates between applications with a scheduler (the Capacity or Fair Scheduler, with per-team queues: analytics has its own queue with 40% of the cluster guaranteed). It knows nothing about maps or reduces.
  • NodeManager (NM). One per node. It reports its available CPU and memory to the RM, launches and supervises containers (a process with a CPU and memory quota, implemented today with cgroups) and serves the maps' intermediate output to the reducers (the shuffle service).
  • ApplicationMaster (AM). One per application (per MapReduce job), running in an ordinary container. It is the master of section 3: it negotiates containers with the RM, asks the NMs to launch tasks in them, tracks their progress and re-runs the failed ones. If the AM dies, the RM restarts it (up to yarn.resourcemanager.am.max-attempts times) and the new AM recovers the progress from the log of completed tasks.
  • Container. The unit of allocation: "1 core and 2 GB on node dn-07". Each map or reduce task runs as a JVM inside a container.
sequenceDiagram
    participant C as Client (hadoop jar)
    participant RM as ResourceManager
    participant NM1 as NodeManager dn-01
    participant AM as ApplicationMaster
    participant NM2 as NodeManager dn-07
    C->>RM: submitApplication(jar, conf, splits)
    RM->>NM1: launch container for the AM
    NM1->>AM: start MRAppMaster
    AM->>RM: register; request 3 map containers (preference: nodes holding the blocks)
    RM-->>AM: containers allocated (dn-07, dn-12, dn-03)
    AM->>NM2: launch map task over split 1
    NM2-->>AM: progress, map finished (output on local disk)
    AM->>RM: request 2 reduce containers
    RM-->>AM: containers
    AM->>NM2: launch reduce; fetch outputs via shuffle service
    NM2-->>AM: reduce completed, commit to HDFS
    AM->>RM: application finished; releasing containers
    RM-->>C: state FINISHED / SUCCEEDED

The architectural consequence of YARN is that the cluster is a shared resource where different applications live side by side (a MapReduce job from analytics, a Spark job from the recommendations team, a long-running service), and that the compute model is interchangeable: when we launch Spark on YARN in 05-03, the Spark driver will be the ApplicationMaster and the executors will run in containers, with the same RM arbitrating. Kubernetes plays that same role of generic resource manager today (07-05).

  1. Anatomy of a job: from submit to _SUCCESS

It is worth following closely what happens inside a task, because the names reappear in Hadoop's web UI, in the job counters and in explanations of why a job is slow.

Map side.

  1. The InputFormat (section 6) computes the splits: by default, one per HDFS block, adjusted to the end of a line just as chunks_by_bytes did in 05-01. With a 150 MB orders.jsonl, that is two splits, two maps.
  2. The RecordReader hands records to map: for text, (byte offset, line).
  3. map emits pairs into a circular in-memory buffer (100 MB by default, mapreduce.task.io.sort.mb). When it is 80% full, a thread spills it to disk: it partitions by reducer, sorts by key within each partition, applies the combiner if there is one, and writes a spill file.
  4. When the map finishes, the spill files are merged into a single one, partitioned and sorted, with an index that says where each partition begins. If there were several spills, the combiner is applied again during the merge.

Reduce side.

  1. Copy (fetch). As soon as a map finishes, each reducer asks the shuffle service of that map's NodeManager for its partition, over HTTP, with several threads in parallel. It does not wait for all the maps to finish before it starts copying (but it does before it starts reducing).
  2. Merge and sort. The fragments received, each one already sorted, are merged (in memory if they fit, on disk in rounds if they do not) into a single sequence sorted by key.
  3. Reduce. The sequence is scanned; every time the key changes, reduce(key, iterator of values) is called. That is why the reducer receives an iterator, not a list: the values for one key may not fit in memory (Montblanc Dairy's 133,000 amounts without a combiner).
  4. Commit. The output goes to _temporary/, and on completion it is renamed to part-r-0000N. When the AM confirms that all the reduces have committed, it writes _SUCCESS.

The job counters summarise all of this. For a real job over one campaign day (250,000 events, 150 MB, 2 maps, 2 reduces), with and without a combiner:

Counter Meaning Without combiner With combiner
Map input records Lines read by the maps 250,000 250,000
Map output records Pairs emitted by map 810,000 (two keys per order line) 810,000
Map output bytes Size of those pairs 19.4 MB 19.4 MB
Combine input records Pairs that went into the combiner 0 810,000
Combine output records Pairs that came out 0 14 (7 keys × 2 maps)
Reduce shuffle bytes Bytes copied over the network to the reducers 21.1 MB 612 B
Reduce input groups Distinct keys that reached reduce 7 7
Reduce input records Values reduce iterated over 810,000 14
Spilled records Pairs written to disk in spills (map + reduce) 1,620,000 810,014
GC time elapsed (ms) Time spent in JVM garbage collection 4,100 900
CPU time spent (ms) Total CPU across all tasks 38,000 29,000
Job duration 71 s 52 s

Two things to read from this: the shuffle bytes drop by four orders of magnitude with the combiner, and even so the job takes 52 s for what sales_scatter_gather.py did in 1 s. That minute is the framework's fixed cost: starting the AM, requesting containers, launching four JVMs, writing spills and merging, committing to HDFS. A MapReduce job makes no sense below the gigabyte range, and that is the point Spark goes after.

  1. Input and output formats

The InputFormat decides two things: how the input is divided into splits and how the records in each split are read. The OutputFormat decides how the results are written.

Format Split Record Use
TextInputFormat (default) Per block, adjusted to a line (offset, line) JSONL, CSV, logs: our orders.jsonl
KeyValueTextInputFormat Per block (text up to the tab, the rest) Output of another Streaming job
NLineInputFormat Every N lines (offset, line) When each line is expensive (a URL to download)
SequenceFileInputFormat Per block (sync markers) Binary (key, value) Intermediate output between chained jobs
Avro, Parquet, ORC (libraries) Per file block Records with a schema; Parquet and ORC are columnar Modern data lakes; Spark prefers them (05-03)
CombineFileInputFormat Groups many small files into one split Depends on the inner format The hourly files in /km0/clicks/

Two practical warnings. First: a format is splittable only if you can start reading from the middle, and that also depends on the compression: gzip is not splittable (a 1 GB gzip file is a single split and a single map, however big it is), whereas bzip2, indexed LZO and the container formats (Avro, Parquet, ORC, SequenceFile) are. Second: HDFS and MapReduce suffer with small files (04-02): 10,000 files of 50 KB are 10,000 maps lasting an instant each, and the scheduling cost dominates; they have to be consolidated (CombineFileInputFormat, or better, a prior compaction step).

The output follows the same logic: TextOutputFormat writes key<TAB>value per line into one part-r-NNNNN per reducer; with LazyOutputFormat no empty files are created; MultipleOutputs lets a reducer write to several named files (sales by producer in producers-r-00000, by market in markets-r-00000). The number of output files is always the number of reducers, and that is the reason to choose it carefully: few reducers make for large files and long tasks; many, for small files that will be a problem for the next job.

  1. Why MapReduce is slow and where it stands today

The decisions that made MapReduce robust are the ones that make it slow:

  • Everything goes through disk. Each map's output is written to local disk; the reducer copies it and writes it to disk again when merging; the reduce output goes to HDFS with three replicas. A job with one shuffle means at least four writes of the intermediate data volume. It was done this way so that any task could be re-run by reading its input from disk without depending on the memory of a node that may die.
  • Jobs are chained through HDFS. A computation with several groupings (sales by producer, then a ranking per market, then a join with the catalogue) is three jobs, and between one and the next the complete output goes to HDFS with replication and the next job reads it back. Iterative algorithms (recommendations by gradient descent, PageRank, the BSP of 05-01) are ten or a hundred chained jobs, each one re-reading the entire dataset.
  • Fixed cost per job and per task. Starting a JVM per task, negotiating containers, the commit. Tens of seconds that do not matter in a three-hour job and are everything in an interactive query.
  • Rigid model. Only map and reduce; a join between two datasets has to be expressed by emitting both sides with the same key and telling them apart in the reducer (a reduce-side join), or by loading the small one into each mapper's memory (a map-side join, the forerunner of the broadcast join in 05-03). The framework does none of that for you.

Its place today is that of a historical foundation and mental model: the vocabulary (map, shuffle, reduce, combiner, partitioner, split, counters) is the one Spark and Flink use; YARN and HDFS are still in production at many companies as the resource and storage layers, with Spark on top; and Hive, the SQL data warehouse on Hadoop that Facebook created to avoid writing MapReduce by hand, still exists but runs its queries on Tez or Spark rather than on MapReduce, keeping its table catalogue (the metastore) as a central piece of many data lakes. Seeing a new MapReduce job in 2026 is rare; understanding how it worked is what lets you read a Spark plan or the Flink dashboard without surprises.

  1. Hands-on: sales by producer and market in Streaming, in Java and in a simulation

The job is the same in all three versions: read /km0/events/2026-09-14/orders.jsonl (the file that upload_events_hdfs.py left in HDFS in 04-02, or the one generated by sales_scatter_gather.py in 05-01), and produce total sales by producer and by market. To do it in a single job, the mapper emits two keys per order line, with a prefix: p:montblanc-dairy and m:girona. A partitioner sends the p: keys to reducer 0 and the m: keys to reducer 1, so the output ends up in two clean files.

8.1 Hadoop Streaming in Python

Hadoop Streaming lets you write the mapper and the reducer in any language that reads from standard input and writes to standard output. Hadoop launches the process, feeds it the records line by line and collects what it emits; the key and the value are separated by a tab.

#!/usr/bin/env python3
# km0/services/analytics/mapreduce/mapper.py
"""Hadoop Streaming mapper: for each order line it emits (p:<producer>, amount) and (m:<market>, amount)."""
import json, sys

for line in sys.stdin:                          # Hadoop delivers the split line by line
    line = line.strip()
    if not line:
        continue
    try:
        ev = json.loads(line)
    except json.JSONDecodeError:
        sys.stderr.write("reporter:counter:km0,corrupt_lines,1\n")      # custom counter, visible in the UI
        continue
    if ev.get("type") != "order.created":
        continue
    market = ev["data"]["market"]
    for ln in ev["data"]["lines"]:
        amount = ln["quantity"] * ln["price"]
        print(f"p:{ln['producer']}\t{amount:.2f}")     # key <TAB> value
        print(f"m:{market}\t{amount:.2f}")
#!/usr/bin/env python3
# km0/services/analytics/mapreduce/reducer.py
"""Hadoop Streaming reducer (and combiner): adds up the values of each key.

Hadoop delivers the lines SORTED by key, so it is enough to detect the change of key.
"""
import sys

current_key, total = None, 0.0
for line in sys.stdin:
    key, value = line.rstrip("\n").split("\t", 1)
    if key != current_key:                      # change of key: emit the previous one
        if current_key is not None:
            print(f"{current_key}\t{total:.2f}")
        current_key, total = key, 0.0
    total += float(value)
if current_key is not None:                     # don't forget the last key
    print(f"{current_key}\t{total:.2f}")

The Streaming reducer does not receive (key, list of values) as in Java, but the sorted sequence of pairs: it is the script itself that detects the change of key. That is the reason the shuffle sorts rather than just groups: with sorted input, grouping means comparing with the previous line, with no memory needed. And since the reducer adds up, it also works as a combiner unchanged.

Before touching the cluster, a Unix pipeline reproduces the whole job, with sort playing the part of the shuffle:

$ cat events/2026-09-14/orders.jsonl | python3 mapper.py | sort -k1,1 | python3 reducer.py
m:girona	20.30
m:lleida	58.80
m:valencia	19.00
p:la-vega-farm	14.20
p:montblanc-dairy	25.10
p:roble-alto-winery	58.80

(Over the three sample lines from 05-01: Anna in Girona bought 2 × 3.90 + 12.50 = 20.30; Mark in Lleida 6 × 9.80 = 58.80; Lucy in Valencia 3 × 4.20 + 4 × 1.60 = 19.00; and by producer, La Vega Farm 7.80 + 6.40 = 14.20, Montblanc Dairy 12.50 + 12.60 = 25.10, Roble Alto Winery 58.80.) This local test is worth its weight in gold: most errors in a Streaming job (a split that fails, a non-numeric value) show up here in a second rather than in a minute of failed job.

Launching it on the cluster from the docker-compose.yml of 04-02 (with YARN added: a resourcemanager and a nodemanager from the apache/hadoop image):

docker compose exec resourcemanager hadoop jar \
  $HADOOP_HOME/share/hadoop/tools/lib/hadoop-streaming-*.jar \
  -D mapreduce.job.name="km0 sales 2026-09-14" \
  -D mapreduce.job.reduces=2 \
  -D stream.map.output.field.separator='\t' \
  -files mapper.py,reducer.py \
  -mapper "python3 mapper.py" \
  -combiner "python3 reducer.py" \
  -reducer "python3 reducer.py" \
  -partitioner org.apache.hadoop.mapred.lib.KeyFieldBasedPartitioner \
  -D mapreduce.partition.keypartitioner.options=-k1.1,1.1 \
  -input  /km0/events/2026-09-14/orders.jsonl \
  -output /km0/aggregates/2026-09-14/sales

Line by line: -files copies the scripts to every container (the distributed cache: the code travels to the data); -combiner reuses the reducer locally in each map; -partitioner with KeyFieldBasedPartitioner and the option -k1.1,1.1 partitions by the first character of the key (p or m), so that with two reducers each family goes to one (with the hash of p and m modulo 2 they land on different reducers; if they did not, a custom partitioner would do the job, which in Streaming can only be written in Java). The output directory must not exist; MapReduce refuses to overwrite, precisely to protect the atomic output. The result:

$ docker compose exec namenode hdfs dfs -ls /km0/aggregates/2026-09-14/sales
-rw-r--r--   2 hadoop supergroup          0  _SUCCESS
-rw-r--r--   2 hadoop supergroup         71  part-00000
-rw-r--r--   2 hadoop supergroup         54  part-00001
$ docker compose exec namenode hdfs dfs -cat /km0/aggregates/2026-09-14/sales/part-00000
p:la-vega-farm	14.20
p:montblanc-dairy	25.10
p:roble-alto-winery	58.80

And the state of the job, with its counters, can be checked with yarn application -list -appStates ALL, mapred job -status <job_id> or in the ResourceManager's web UI (http://localhost:8088).

8.2 The same job in Java: SalesByProducer.java

Java is Hadoop's native language, and a job in Java avoids the cost of launching an interpreter per task and of serialising everything as text. This is the canonical example, line by line:

// km0/services/analytics/mapreduce/SalesByProducer.java
package km0.analytics;

import java.io.IOException;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.DoubleWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Partitioner;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

public class SalesByProducer {

    /** Mapper<input key, input value, output key, output value>.
     *  TextInputFormat delivers (offset in bytes: LongWritable, line: Text). */
    public static class SalesMapper extends Mapper<LongWritable, Text, Text, DoubleWritable> {
        private static final ObjectMapper JSON = new ObjectMapper();
        private final Text key = new Text();                    // reused: they avoid creating
        private final DoubleWritable amount = new DoubleWritable();  // millions of objects

        @Override
        protected void map(LongWritable offset, Text line, Context ctx)
                throws IOException, InterruptedException {
            JsonNode ev;
            try {
                ev = JSON.readTree(line.toString());
            } catch (IOException e) {
                ctx.getCounter("km0", "corrupt_lines").increment(1);
                return;
            }
            if (!"order.created".equals(ev.path("type").asText())) return;
            JsonNode data = ev.get("data");
            String market = data.get("market").asText();
            for (JsonNode ln : data.get("lines")) {
                amount.set(ln.get("quantity").asDouble() * ln.get("price").asDouble());
                key.set("p:" + ln.get("producer").asText());
                ctx.write(key, amount);                         // (p:<producer>, amount)
                key.set("m:" + market);
                ctx.write(key, amount);                         // (m:<market>, amount)
            }
        }
    }

    /** Reducer<input key, input value, output key, output value>.
     *  Receives each key with an Iterable of ALL its values, already grouped by the shuffle. */
    public static class SumReducer extends Reducer<Text, DoubleWritable, Text, DoubleWritable> {
        private final DoubleWritable total = new DoubleWritable();

        @Override
        protected void reduce(Text key, Iterable<DoubleWritable> values, Context ctx)
                throws IOException, InterruptedException {
            double sum = 0.0;
            for (DoubleWritable v : values) sum += v.get();      // iterator: the values may not fit in memory
            total.set(Math.round(sum * 100.0) / 100.0);
            ctx.write(key, total);
        }
    }

    /** Partitioner: "p:" keys go to reducer 0 and "m:" keys to reducer 1. */
    public static class PrefixPartitioner extends Partitioner<Text, DoubleWritable> {
        @Override
        public int getPartition(Text key, DoubleWritable value, int numReducers) {
            if (numReducers == 1) return 0;
            return key.charAt(0) == 'p' ? 0 : 1;
        }
    }

    public static void main(String[] args) throws Exception {
        Configuration conf = new Configuration();               // reads core-site.xml, yarn-site.xml, etc.
        Job job = Job.getInstance(conf, "km0 sales by producer and market");
        job.setJarByClass(SalesByProducer.class);               // which jar to ship to the containers

        job.setMapperClass(SalesMapper.class);
        job.setCombinerClass(SumReducer.class);                 // addition is associative: the reducer doubles as combiner
        job.setPartitionerClass(PrefixPartitioner.class);
        job.setReducerClass(SumReducer.class);
        job.setNumReduceTasks(2);

        job.setMapOutputKeyClass(Text.class);                   // intermediate types (k2, v2)
        job.setMapOutputValueClass(DoubleWritable.class);
        job.setOutputKeyClass(Text.class);                      // final types (k3, v3)
        job.setOutputValueClass(DoubleWritable.class);

        FileInputFormat.addInputPath(job, new Path(args[0]));   // /km0/events/2026-09-14/orders.jsonl
        FileOutputFormat.setOutputPath(job, new Path(args[1])); // /km0/aggregates/2026-09-14/sales-java (must not exist)

        System.exit(job.waitForCompletion(true) ? 0 : 1);       // true: prints progress and counters
    }
}

What you need to understand about each block:

  • The Writable types. Hadoop does not use String or double in its interfaces, but Text, DoubleWritable, LongWritable: types that serialise compactly and compare byte by byte, which the shuffle can sort without deserialising. Reusing the instances (key.set(...) instead of new Text(...)) is the most frequently cited MapReduce optimisation: a mapper emits millions of pairs and creating objects would send garbage collection through the roof.
  • Mapper<K1, V1, K2, V2> and Reducer<K2, V2, K3, V3>. The generics document the contract of section 1. Context is the channel through which the task emits pairs (ctx.write) and increments counters.
  • The reducer's Iterable can be traversed only once: Hadoop feeds it from the sorted sequence on disk, and reuses the DoubleWritable object on every iteration (keeping references to the values is a classic mistake: they all point to the same object).
  • Job is the declarative description: which classes, how many reducers, which types, which paths. waitForCompletion submits it to the ResourceManager and blocks until it finishes. setJarByClass tells Hadoop which jar contains the code, which YARN will copy to every container.
  • setCombinerClass(SumReducer.class) is valid because the reducer's input and output are of the same type (Text, DoubleWritable) and addition is associative. If the reducer emitted something else (a ranking, an average) you would need a different combiner or none at all.

Building and launching:

mvn package -q                                              # produces target/km0-analytics.jar (Hadoop as a 'provided' dependency)
docker compose cp target/km0-analytics.jar resourcemanager:/tmp/
docker compose exec resourcemanager hadoop jar /tmp/km0-analytics.jar km0.analytics.SalesByProducer \
  /km0/events/2026-09-14/orders.jsonl /km0/aggregates/2026-09-14/sales-java

The output is identical to the Streaming one (with part-r-00000 and part-r-00001, the r indicating that a reducer wrote it) and it takes a few seconds less thanks to the absence of Python interpreters and intermediate text. In a job over terabytes, that difference is tens of minutes.

8.3 Local simulation of the shuffle

To see what the framework hides, simulations/mapreduce_local.py implements the three phases in a single process, with the shuffle made explicit: partition, sort by key, group.

# km0/simulations/mapreduce_local.py
"""MapReduce in one process: makes the shuffle (partition, sort, group) between map and reduce visible."""
import json, sys
from itertools import groupby
from collections import defaultdict


def map_fn(line: str):
    """map(k1, v1) -> [(k2, v2)]. Same logic as mapper.py."""
    ev = json.loads(line)
    if ev["type"] != "order.created":
        return
    for ln in ev["data"]["lines"]:
        amount = round(ln["quantity"] * ln["price"], 2)
        yield f"p:{ln['producer']}", amount
        yield f"m:{ev['data']['market']}", amount


def partition(key: str, n_reducers: int) -> int:
    """Partitioner: prefix 'p' to reducer 0, 'm' to reducer 1 (with n=2)."""
    return 0 if key[0] == "p" else n_reducers - 1


def reduce_fn(key: str, values):
    """reduce(k2, [v2]) -> (k3, v3)."""
    return key, round(sum(values), 2)


def run(path: str, n_maps: int = 3, n_reducers: int = 2, with_combiner: bool = True):
    lines = open(path, encoding="utf-8").read().splitlines()
    splits = [lines[i::n_maps] for i in range(n_maps)]          # spreading the lines among the mappers

    # --- Map phase: each mapper produces its output partitioned and sorted (the 'spills') ---
    map_outputs = []                                            # [ mapper ][ partition ] -> sorted list
    for i, split in enumerate(splits):
        buffer = defaultdict(list)
        for line in split:
            for k, v in map_fn(line):
                buffer[partition(k, n_reducers)].append((k, v))
        by_partition = []
        for p in range(n_reducers):
            pairs = sorted(buffer[p], key=lambda kv: kv[0])     # sort by key WITHIN the partition
            if with_combiner:                                   # combiner: local reduce by key
                pairs = [reduce_fn(k, (v for _, v in group)) for k, group in groupby(pairs, key=lambda kv: kv[0])]
            by_partition.append(pairs)
        map_outputs.append(by_partition)
        print(f"map {i}: {len(split)} lines -> " + ", ".join(f"partition {p}: {len(by_partition[p])} pairs" for p in range(n_reducers)))

    # --- Shuffle phase: each reducer fetches ITS partition from ALL the mappers and merges them in order ---
    results = {}
    for r in range(n_reducers):
        fragments = [map_outputs[i][r] for i in range(n_maps)]
        reducer_input = sorted((kv for frag in fragments for kv in frag), key=lambda kv: kv[0])   # merge (here, a sort)
        print(f"reduce {r}: receives {sum(len(f) for f in fragments)} pairs from {n_maps} mappers")
        # --- Reduce phase: iterate in order, grouping by change of key ---
        results[r] = [reduce_fn(k, (v for _, v in group)) for k, group in groupby(reducer_input, key=lambda kv: kv[0])]
    return results


if __name__ == "__main__":
    for r, rows in run(sys.argv[1], with_combiner="--no-combiner" not in sys.argv).items():
        print(f"--- part-r-0000{r} ---")
        for k, v in rows:
            print(f"{k}\t{v}")
$ python mapreduce_local.py events/2026-09-14/orders.jsonl --no-combiner
map 0: 1 lines -> partition 0: 2 pairs, partition 1: 2 pairs
map 1: 1 lines -> partition 0: 1 pairs, partition 1: 1 pairs
map 2: 1 lines -> partition 0: 2 pairs, partition 1: 2 pairs
reduce 0: receives 5 pairs from 3 mappers
reduce 1: receives 5 pairs from 3 mappers
--- part-r-00000 ---
p:la-vega-farm	14.2
p:montblanc-dairy	25.1
p:roble-alto-winery	58.8
--- part-r-00001 ---
m:girona	20.3
m:lleida	58.8
m:valencia	19.0
$ python mapreduce_local.py events/2026-09-14/orders.jsonl | head -5
map 0: 1 lines -> partition 0: 2 pairs, partition 1: 1 pairs
...

With the 400,000-order file from 05-01 and --no-combiner, each reducer receives hundreds of thousands of pairs; with the combiner, it receives 3 × n_maps for the producers and 4 × n_maps for the markets. The groupby from itertools over the sorted sequence is literally what the Streaming reducer does when it detects the change of key, and sorted over the fragments is the merge of section 5 (in Hadoop, a merge of already-sorted sequences, cheaper than a full sort).

Common Mistakes and Tips

  • Testing directly on the cluster. A Streaming job is tested with cat | mapper | sort | reducer in a second; on the cluster, every failed attempt costs a minute and a YARN log you have to go and dig out with yarn logs -applicationId.
  • A combiner that is not associative. Computing averages, or "the first one", or emitting a different type in the combiner, produces results that change depending on how many times it has run. Rule: the combiner must be a function such that running it 0, 1 or N times gives the same final result.
  • Keeping references to the values of the Iterable. Hadoop reuses the object; if the reducer does list.add(v) in order to sort later, the list ends up with N copies of the last value. You have to copy (new DoubleWritable(v.get())).
  • Existing output directory. The job fails before it starts. This is intentional: atomic output demands a clean directory. Deleting it is part of the pipeline (05-05), not of the job.
  • gzip on the input. A 2 GB orders.jsonl.gz is one split and one twenty-minute map. Use bzip2, indexed LZO, or better still Parquet.
  • Too many or too few reducers. A single one makes the reduce sequential (Amdahl); a thousand over 20 MB of data create a thousand tiny files. As a guide: have each reducer process between 1 and 5 GB of shuffle, and never more reducers than useful distinct keys.
  • Ignoring the counters. Reduce shuffle bytes and Spilled records are the job's thermometer. If the shuffle is the size of the input, the combiner is missing; if the spills are several times the map output, the sort buffer is short of memory.
  • Mixing job chaining with logic. Three jobs chained by hand with intermediate paths in HDFS turn into a fragile pipeline. That is the job of the scheduler in 05-05, and a compelling reason to move to Spark, where the three phases are a single program.

Exercises

Exercise 1: Top-selling producer per market

Design a MapReduce job (or chain of jobs) that produces, for each market, the producer with the highest sales and its amount. State the intermediate keys and values of each phase, whether you can use a combiner, and how many jobs are needed. Then write the Streaming mapper.py and reducer.py for the first job.

Exercise 2: Failures in the middle of the job

The job in section 8.1 has 2 maps and 2 reduces. Describe what the ApplicationMaster does in each of these cases and which tasks are re-run: (a) the NodeManager dn-07 dies when map 1, which was running there, had already finished and reduce 0 had copied its partition but reduce 1 had not yet done so; (b) reduce 1 throws an exception because of a non-numeric value on its third line; (c) reduce 0 takes five times the median and the AM launches a speculative copy that finishes first. Which files are there in /km0/aggregates/2026-09-14/sales/ during and after each case?

Exercise 3: Skew with a partitioner

During Artisan Cheese Week, p:montblanc-dairy accounts for 50% of the pairs. With the combiner enabled, is that a problem? And without the combiner, or if the reduce were "list of the 100 largest orders for each producer" (where the combiner does not cut the volume as much)? Propose a Java partitioner and the logic of a second job to spread the hot key over four reducers and combine afterwards, following the salting of 05-01.

Solutions

Exercise 1.

Two jobs are needed, because there are two chained groupings: first add up by (market, producer) and then, per market, pick the maximum.

  • Job 1. map: for each order line, (market|producer, amount). Combiner: sum (associative). reduce: sum. Output: girona|montblanc-dairy<TAB>18240.50.
  • Job 2. map: reads the output of job 1 and emits (market, producer|total). Combiner: yes, "keep the maximum" is associative and commutative and does not change the type. reduce: go through each market's values and emit the producer with the highest total.

Job 1 in Streaming:

# mapper1.py
import json, sys
for line in sys.stdin:
    ev = json.loads(line)
    if ev.get("type") != "order.created": continue
    m = ev["data"]["market"]
    for ln in ev["data"]["lines"]:
        print(f"{m}|{ln['producer']}\t{ln['quantity'] * ln['price']:.2f}")
# reducer1.py: identical to reducer.py in section 8.1 (sum by key).

A single job would be possible with a composite key market and values producer|amount, adding up by producer inside the reducer with an in-memory dictionary: it works if one market's producers fit in memory (here they do, there are three), but it loses the combiner and loads the whole volume onto the reduce. With Spark it will be a groupBy followed by a window, in a single program (05-03).

Exercise 2.

(a) The AM stops receiving heartbeats from dn-07 and marks its tasks as lost. Map 1 was completed, but its output lived on the local disk of dn-07, and reduce 1 had not yet copied it, so the AM re-runs map 1 on another node (requesting a container from the RM, preferably on a node with a replica of the block). Reduce 0 keeps its copy and is not affected; reduce 1 waits and copies from the new location. During that time, the output directory contains only _temporary/ with the attempts in progress.

(b) The reduce 1 task fails with an exception; the AM retries it in another container (up to 4 attempts by default). Since the error is deterministic (the same corrupt value), it will fail all four times and the whole job fails; _SUCCESS is not written, and _temporary/ is cleaned up. Solution: have the reducer tolerate the value (try/except with a km0,non_numeric_values counter) or configure mapreduce.reduce.failures.maxpercent. Reduce 0, which had finished and committed, leaves its part-00000 in the directory, but without _SUCCESS no consumer should read it.

(c) The two copies of reduce 0 write to _temporary/attempt_..._r_000000_0/ and _temporary/attempt_..._r_000000_1/. The speculative copy finishes first and requests the commit; the AM grants it and renames its file to part-00000; it then kills the original attempt and discards its temporary directory. In the end: _SUCCESS, part-00000 (from attempt 1) and part-00001. There are never two part-00000 files because the commit is exclusive per task.

Exercise 3.

With a combiner and a sum, it is not a problem: each mapper reduces its 200,000 Montblanc pairs to one, and the reducer receives one per mapper. The skew is in the mappers' input, which is already split by blocks, not by key. Without a combiner, reducer 0 receives half of the job's pairs and takes twice as long as the markets reducer; with a "top 100 per producer", the combiner only reduces each mapper to 100 pairs per producer, which is still not much, so that is not serious either; skew really matters when the reducer needs all the values (the complete list of a producer's orders, an exact median).

Salting partitioner:

public static class SaltPartitioner extends Partitioner<Text, DoubleWritable> {
    public int getPartition(Text key, DoubleWritable v, int n) {
        String k = key.toString();
        if (k.startsWith("p:montblanc-dairy#"))             // the mapper emits p:montblanc-dairy#0..#3
            return Integer.parseInt(k.substring(k.indexOf('#') + 1)) % n;
        return (k.hashCode() & Integer.MAX_VALUE) % n;
    }
}

The mapper adds the suffix #(hash(order_id) % 4) only to the hot key (deterministic: re-runnable). Job 1 produces four partials p:montblanc-dairy#0..3; a job 2 (or a lightweight final step outside MapReduce, because it is four numbers) strips the suffix and adds up. For the "top 100", job 2 merges four lists of 100 and keeps the best 100: correct because the global top-K is contained in the union of the partial top-Ks.

Conclusion

MapReduce turned the patterns of 05-01 into a two-function contract: the programmer writes map, which extracts a key from each record, and reduce, which aggregates the values of a key, and the system supplies locality (one map per block, run where the block lives), the shuffle & sort that groups by key, the re-execution of failed tasks, speculative execution and atomic output with _temporary and _SUCCESS. The combiner is the pre-reduction that saves four orders of magnitude of shuffle in the sales job, and the partitioner is the lever for separating families of keys or breaking up a hot key. Hadoop implements it on top of HDFS with YARN as a generic resource manager, with a ResourceManager that arbitrates, NodeManagers that launch containers and one ApplicationMaster per job acting as a restartable master. We have run it three times: in Python with Hadoop Streaming, tested first with cat | mapper | sort | reducer; in Java, the canonical example with its Writable types, its Job and its single-use iterator; and in a simulation that makes the partitions, the sort and the groupby visible.

We have also seen the price: every phase writes to disk, every additional grouping is another job that goes through HDFS, every task starts a JVM, and a computation that a Python process solves in a second takes a minute on the cluster. For a nightly batch of terabytes that is an acceptable price; for chaining the sum by producer with the catalogue join and the ranking per market, or for training the recommendations in a hundred iterations over the clicks, it is not. The next lesson introduces Spark, which keeps the model (partitions, shuffle, re-runnable tasks) but expresses it as a DAG of operators that runs in memory, with an optimiser that decides the stages: the daily_sales.py of analytics will go from two scripts and a hadoop jar to a thirty-line program with DataFrames.

Distributed Architectures Course

Module 1: Introduction to Distributed Systems

Module 2: Communication in Distributed Systems

Module 3: Consistency and Replication

Module 4: Distributed Storage

Module 5: Distributed Computing

Module 6: Security in Distributed Systems

Module 7: Monitoring and Maintenance

Module 8: Case Studies and Applications

© Copyright 2026. All rights reserved