AlgoMaster Logo

Lab: Build a Reliable Order Producer

26 min readUpdated September 13, 2026
Listen to this chapter
Unlock Audio

In this lab, you will build a keyed JSON producer for OrderPlaced events, measure how batching settings change the number of Produce requests, break delivery by stopping and pausing the broker, and show what idempotence does and does not protect against. You will finish by routing one hot key to a dedicated partition with a custom partitioner.

Every run publishes the same workload: 1,000 events with ids evt-7001 through evt-8000, spread over 50 order ids ord-1000 through ord-1049, so ord-1042 receives exactly 20 events. Each run prints confirmed and failed counts and a few producer metrics. Keeping the workload fixed helps isolate configuration and failure effects. Repeat runs to account for scheduling, broker state, and measurement variation.

Learning Objectives

  • Prove from callback metadata that each key stayed on one partition.
  • Relate request-total and batch-size-avg from producer.metrics() to linger.ms and batch.size.
  • Explain a request count change using the accumulator and sender thread model.
  • Implement Serializer<OrderPlaced> and show what a consumer receives when its deserializer does not match the bytes.
  • Capture TimeoutException in a callback after delivery.timeout.ms expires, and distinguish it from a retry that succeeds.
  • Compare retries with and without idempotence, and explain why duplicate records may appear only when retry protection is off.
  • Implement a Partitioner and verify its routing with print.partition.

Time and Environment

  • Setup and Phase 1: 40 minutes
  • Phases 2 and 3: 40 minutes
  • Phases 4 and 5: 60 minutes
  • Phase 6 and report: 40 minutes

The broker is environment E1: the single kafka-local container running apache/kafka:4.3.1, reachable from the host at localhost:9092. Every Kafka command-line tool runs inside that container through docker exec. The Java code is environment E3 and runs on the host with Java 21 and Maven. This is the first lab in this module that needs Java, so start from the kafka-producer-demo project from the building-a-producer chapter.

Safety Boundaries

  • Stop, start, pause, and unpause only the kafka-local container. Never run these phases against a shared cluster.
  • Use docker stop and docker start, not docker rm, during the lab. Removing the container discards the topics you need for the counts.
  • Keep the short timeouts in this lab out of production configuration.

Project Layout

Setup

Copy the producer project and rename it:

Run the remaining project commands from kafka-labs/reliable-order-producer/. Create observations/ before saving results. Update this project’s pom.xml:

  1. Set <artifactId> to reliable-order-producer.
  2. Set the exec plugin's <mainClass> to ${exec.mainClass}. Add <exec.mainClass>com.example.kafka.OrderProducerLab</exec.mainClass> inside <properties>. Phase 3 can then select another class with -Dexec.mainClass.
  3. Add the JSON dependency from the serializers chapter:

Start the broker and create the first topic with three partitions. If the kafka-local container exists but is stopped, start it:

If no kafka-local container exists (the earlier labs remove it during cleanup), create it:

Later phases need fresh topics so earlier runs do not affect the counts. Create orders.placed.timeout, orders.placed.dup-off, orders.placed.dup-on, and orders.placed.hot with the same command, changing only the name.

Phase 1: Keyed Producer With Callback Accounting

Save this as OrderProducerLab.java. The first argument is the topic. Every further key=value argument becomes a producer property, except lab.pace.ms, which sleeps between sends so that you have time to break the broker mid-run.

The callback normally runs on the producer's I/O thread, so it only updates counters and maps. The loop also records exceptions that send() throws immediately in immediateErrors. This finite experiment attempts all 1,000 events, checks that every attempt has an outcome, and exits with an error if any send failed. A long-running service should stop or apply backpressure during a persistent failure instead of continuing indefinitely. The program reads metrics after flush() and before closing the producer. Run the baseline:

Save the last lines of output as observations/phase-1.txt.

What you should see: confirmed=1000, empty errors and immediateErrors maps, three entries in lastOffsets whose values plus one sum to 1,000 on a fresh topic, ord-1042 mapped to a single partition, and splitKeys=0. Record which partition ord-1042 used; Phase 6 changes it.

Phase 2: Batching Experiment

Run the same workload three times with a 1 ms pace so that records arrive one at a time rather than in one burst:

For each run, record request-total, batch-size-avg, and records-per-request-avg in observations/phase-2.txt.

What to compare: Increasing linger.ms from 0 to 50 gives records more time to share a batch. Look for fewer requests and larger average batches, and record the measured change. Scheduling and request overhead affect these metrics, so they need not match exact record counts or change by a fixed amount. At this pace, batches are likely to stay below 16 KiB, so raising batch.size to 64 KiB may have little effect. Explain your observed results rather than treating that expectation as a required outcome.

Phase 3: Custom Serializer and a Mismatched Consumer

Add OrderPlaced.java and OrderPlacedSerializer.java exactly as written in the serializers chapter: a six-field Java record, and a Serializer<OrderPlaced> whose serialize() returns null for a null event, otherwise ObjectMapper.writeValueAsBytes(event), wrapping any JsonProcessingException in a SerializationException.

Change the value serializer to OrderPlacedSerializer.class and the producer type to KafkaProducer<String, OrderPlaced>. Replace the loop's String value = json(...) with:

The ProducerRecord infers the new value type. Delete json(). Create a fresh three-partition topic named orders.placed.serialized using the setup command, run the producer against it, and inspect one record:

Compare the JSON fields with the original fixture. The fresh topic keeps this check independent of earlier runs.

To see what a consumer receives when the bytes are not JSON, write WireFormatProbe.java: a main method that builds a KafkaProducer<String, Long> with StringSerializer for keys and LongSerializer for values, sends key ord-1042 and value 129900L to orders.placed, calls get() on the future, and prints partition() and offset() from the RecordMetadata.

Read that one record with the console consumer, substituting the printed partition and offset:

Run it twice more: once adding --formatter-property value.deserializer=org.apache.kafka.common.serialization.LongDeserializer, and once with that same deserializer but pointing at offset 0 of the same partition, which holds a JSON record.

What you should see: the default string decoding prints the eight bytes of the long as unreadable characters, because StringDeserializer treats any bytes as UTF-8 text. The LongDeserializer run prints 129900. The third run fails with a SerializationException, because a JSON document is not eight bytes long. Save all three outputs in observations/phase-3.txt.

Phase 4: Stopped Broker and Delivery Timeouts

Use a short request timeout and delivery budget, and a 20 ms pace so the run lasts about 20 seconds. Leave retries at its default; the delivery timeout bounds recovery.

About five seconds into the run, stop the broker from a second terminal, wait at least 20 seconds, then start it again:

The producer may continue accepting sends using cached metadata while buffer space remains. If buffers fill or usable metadata becomes unavailable, send() can block or fail. Batches the producer creates during the outage age in the accumulator. Any batch older than delivery.timeout.ms fails, and the callback receives org.apache.kafka.common.errors.TimeoutException. The producer can retry batches whose delivery budget has not expired when the broker returns; success still depends on recovery conditions.

Repeat the run, but start the broker immediately after stopping it, so the outage is only the restart time:

What you should see: after a completed loop, confirmed plus the counts in errors and immediateErrors equals 1,000. Failed sends make Maven report an unsuccessful execution after printing the summary. Producer error/retry metrics describe client activity and need not equal the total application error count. A shorter outage may let all attempts succeed, but measure that outcome instead of assuming it. If records still expire, repeat with a larger delivery budget and record the settings and result. Use a fresh topic for each comparison when inspecting stored record counts. Save both summaries in observations/phase-4.txt.

Phase 5: Idempotence and Duplicates

A paused broker differs from a stopped one: the kernel still accepts bytes into the socket buffer, but the process cannot respond. A request timeout can cause a retry. If the broker accepts the original attempt and the retry, it may append duplicates without idempotence. Pausing alone does not guarantee that both attempts reach the append stage. With idempotence it recognises the producer sequence on the retry and appends once.

Run with idempotence off and five in-flight requests:

While it runs, pause the broker for about five seconds, twice:

Repeat against orders.placed.dup-on with enable.idempotence=true and the same pauses. Dump each topic to a file:

Count event ids that appear more than once:

Check submission order per key. With fixed routing, each key belongs to one partition and the output follows log order. Internal retries without idempotence can make event numbers decrease; the following command reports any such case:

Run both checks on dup-on.txt as well.

What you should see: record confirmed sends, both error maps, retry metrics, record count, duplicate event IDs, and any decreases in event number. Without idempotence, a retry may create duplicates or change submission order; a particular pause can also produce neither. With idempotence, successful internal retries preserve per-partition submission order and avoid adding a second copy of the same send. A fresh topic with 1,000 confirmed sends and no other writes should contain 1,000 records. Failed or uncertain sends need separate interpretation. For another attempt, create fresh topics such as orders.placed.dup-off-2 and orders.placed.dup-on-2 with the setup command and use those names in the producer and dump commands. Never mix a second application run into the first run's counts. Save the results and pause timings in observations/phase-5.txt. A run with zero duplicates while idempotence is off is a valid observation, not a reason to manufacture duplicates.

Phase 6: A Dedicated Partition for One Key

Save HotKeyPartitioner.java. The name illustrates a possible use for custom routing; this fixture gives every key the same 20 events. It reserves the last partition for ord-1042 and hashes every other key over the remaining partitions with the same Murmur2 calculation the built-in routing uses:

The partitioner requires at least two partitions and rejects null keys. Select it through partitioner.class:

Verify with the console consumer on partition 2 only, reading from offset 0 with --max-messages 20, print.partition=true, and print.key=true.

What you should see: the summary shows ord-1042=[2] and splitKeys=0. All 20 records on partition 2 carry key ord-1042, and the lastOffsets entry for partition 2 is 19 on a fresh topic. Compare with the partition ord-1042 used in Phase 1: the key bytes did not change, only the routing rule. Save the output as observations/phase-6.txt.

Required Deliverables

  • The five source files and the modified pom.xml.
  • observations/phase-1.txt through phase-6.txt, plus dup-off.txt and dup-on.txt.
  • report.md with a results table, one row per run from Phases 2, 4, and 5, with columns: setting or scenario, request-total, batch-size-avg, record-retry-total, errors, duplicates.
  • Answers in report.md to these named questions:
    • Q1 Accumulator: how did linger.ms=50 and the larger batch.size affect your metrics? Explain the observed difference using arrival rate and batch formation.
    • Q2 Wire format: why did the broker accept the long value, and where did the failure surface?
    • Q3 Timeout: what does a TimeoutException in the callback tell you about whether the broker stored the record?
    • Q4 Idempotence: how does idempotence prevent supported internal-retry duplicates, and which Phase 5 observations support that explanation, and why would it not remove a duplicate created by calling send() a second time with the same eventId?
    • Q5 Acknowledgment: what does acks=all promise on a single broker with replication factor 1, and what would change with min.insync.replicas=2?

Acceptance Checks

  • mvn compile succeeds with all five classes in com.example.kafka.
  • Phase 1 output shows confirmed=1000, splitKeys=0, and an empty errors map.
  • Phase 2 observations compare request-total for linger.ms=50 and linger.ms=0 and explain the measured difference, including little or no change.
  • Phase 3 observations contain 129900 from LongDeserializer and a SerializationException from the mismatched read.
  • Phase 4 accounts for all 1,000 attempts across confirmed sends, callback errors, and immediate exceptions, and records the observed timeout and recovery behavior.
  • Compare confirmed sends and stored event IDs on fresh topics. Explain any duplicates and distinguish internal retries from a new application run.
  • Phase 6 partition 2 read shows only key ord-1042.
  • The report answers all five questions and the table has a row for every run.

Grading Rubric

AreaPoints
Producer with callback accounting and key-to-partition assertion15
Batching measurements and accumulator explanation15
Custom serializer and mismatched deserializer evidence10
Timeout capture and recovery comparison15
Duplicate and ordering evidence with and without idempotence20
Custom partitioner and verification10
Report answers and results table15
Total100

Optional Extensions

  • Add compression.type=lz4 to the Phase 2 runs and compare batch-size-avg and compression-rate-avg after adding compression-rate-avg to the METRICS filter.
  • Set max.in.flight.requests.per.connection=1 with idempotence off and repeat Phase 5 to see whether duplicates remain while the producer preserves submission order.
  • Set lab.pace.ms=0 and buffer.memory=65536 and observe bufferpool-wait-ratio during a broker pause after adding it to the METRICS filter.

Cleanup

Stop the broker and remove the container and its data:

Verify that nothing is left:

The output should contain only the header line. Keep the project directory and observations for your report.

Summary

You produced the same 1,000 keyed events under different producer settings and let the evidence show the differences. Callback metadata proved that key hashing keeps an order on one partition. The metrics showed that request count depends on arrival rate, routing, batch capacity, and waiting time. Your comparisons show which settings affected this workload. Swapping the serializer changed the value bytes, but the broker still treated them as bytes, so a mismatched deserializer failed only on the consumer side.

The failure phases let you distinguish expired sends, successful retries, and possible duplicate appends when retry protection is off. The observed outcome depends on when requests reach the broker. Idempotence gives the broker sequence information to recognize supported internal retries; it does not remove earlier records. With one replica, acks=all confirms only that single copy. A second send() of the same event remains a new operation that no producer setting deduplicates.