Java · Apache Kafka

A live Wikipedia edit firehose, bridged into Kafka

Every edit made to any Wikipedia page, anywhere in the world, arrives over a public live feed. This project listens to that feed and publishes every single edit onto a Kafka topic — so any number of independent systems can consume it, at their own pace, without ever touching each other.

Java 25 Apache Kafka OkHttp LaunchDarkly EventSource Server-Sent Events Gradle (Kotlin DSL)
In plain English

Why bridge a Wikipedia feed into Kafka at all?

Wikimedia already broadcasts every edit as it happens. The interesting problem isn't getting the data — it's what happens once several different systems want to react to it, at different speeds, without getting in each other's way.

One system counts edits per language

It only cares about a running tally. It can be slow, batchy, and lag behind by minutes — that's fine, it just reads the topic at its own pace.

Another watches for vandalism

It needs to react within seconds. It reads the exact same stream of edits, independently, with no coordination with the counting system.

A third just archives everything

It writes every edit to cold storage, forever. If it falls a day behind, it simply catches up — Kafka kept every record.

None of them talk to each other

They don't call each other's APIs, they don't share a database row, and none of them can slow another one down. If one goes offline and comes back an hour later, it resumes exactly where it left off — because Kafka remembers the offset, not the consumer.

Why Kafka, and not just a database write

The actual engineering argument

You could have the SSE handler write straight into a database table. Here's specifically what that gives up.

Decoupling

The producer knows nothing about who's downstream, or how many consumers there are. New consumers can be added later with zero changes to the pipeline.

Replay from an offset

A consumer isn't reading "the current state" — it's reading a durable, ordered log it can rewind. Fix a bug, reset the offset, reprocess history.

Durability

Records are written to disk and replicated across brokers (when a real cluster is configured), so a crashed consumer doesn't lose data — it just isn't there to read it yet.

Back-pressure absorption

The Wikimedia feed doesn't wait for a slow consumer. Kafka absorbs the burst; a slower downstream system just falls behind on the topic instead of blocking the producer.

Horizontal scale by partition

A topic is split into partitions that can be consumed in parallel by a consumer group — the unit this page's animation makes visible.

Architecture

From a public SSE feed to a Kafka topic

The real class names and the real topic name, exactly as they appear in kafka-producer-wikimedia.

Pipeline architecture diagram Wikimedia EventStreams SSE feed connects over a persistent HTTP connection to WikiMediaChangeHandler.onMessage, which calls KafkaProducer.send asynchronously into the topic wikimedia.recentchange, split across three partitions, which a downstream consumer group — not included in this repository — would read from. Wikimedia EventStreams public SSE feed stream.wikimedia.org/v2/…/recentchange persistent HTTP connection WikiMediaChangeHandler .onMessage() logs, then forwards each event producer.send() — async KafkaProducer <String, String> StringSerializer key + value topic: wikimedia.recentchange Partition 0 Partition 1 Partition 2 consumers pull via poll() Consumer group not included in this repository — see "Honest scope"
Live-feeling demo

Watch a Wikipedia edit move through the pipeline

Sample edits flow from the SSE connection, through the handler, into the producer, onto a partition, and out to a consumer group. Toggle keys and consumers to see two of Kafka's core mechanics happen in front of you.

Illustrative animation using sample event data — this page does not connect to the live Wikimedia stream.

1 consumer
Your system prefers reduced motion, so the pipeline above is shown as a static snapshot instead of animating. The key and consumer-count toggles still update it.

No key: records round-robin across all three partitions. Turn on the key toggle to see the same wiki domain always land on the same partition.

Inside the pipeline

The real code, and why it looks like that

Excerpts straight from kafka-producer-wikimedia, trimmed for length but otherwise unchanged.

1. Building the producer's Properties

Properties props = new Properties();
props.setProperty("bootstrap.servers", bootstrapServers);
props.setProperty("key.serializer", StringSerializer.class.getName());
props.setProperty("value.serializer", StringSerializer.class.getName());

Kafka clients are configured through a plain Properties map — no framework, no annotations. Key and value are both serialized as strings here, because a Wikimedia change event arrives as a JSON string and is forwarded as-is.

2. Constructing the KafkaProducer

KafkaProducer<String, String> producer = new KafkaProducer<>(props);
String topic = "wikimedia.recentchange";
EventHandler eventhandler = new WikiMediaChangeHandler(producer, topic);

One producer instance is created for the lifetime of the stream and handed to the SSE handler, which is the only thing that will ever call send() on it.

3. Opening the SSE connection with EventSource.Builder

String url = "https://stream.wikimedia.org/v2/stream/recentchange";
EventSource.Builder builder = new EventSource.Builder(eventhandler, URI.create(url));
EventSource eventSource = builder.build();
eventSource.start();

The LaunchDarkly EventSource client (built on OkHttp) owns the reconnecting HTTP connection to Wikimedia's public endpoint and dispatches every event to the handler — the actual network plumbing never appears in application code.

4. onMessage — an asynchronous send

@Override
public void onMessage(String s, MessageEvent messageEvent){
    LOGGER.info("Received message : {}", messageEvent.getData());
    //asynchronus
    producer.send(new ProducerRecord<>(topic, messageEvent.getData()));
}

producer.send() returns immediately — it hands the record to the producer's internal buffer and a background I/O thread does the actual network write. That's what lets a single callback thread keep up with a firehose of edits without ever blocking on Kafka.

5. onClosed — closing flushes what's buffered

@Override
public void onClosed() {
    producer.close();
}

KafkaProducer.close() blocks until every record still sitting in the internal buffer has been sent (it behaves like an implicit flush()). Because sends are async, this is the moment that guarantees nothing in flight is silently dropped when the SSE connection ends.

The fundamentals module

kafka-examples: a deliberate lab of core mechanics

Six focused main() classes, each isolating exactly one producer or consumer concept — built while working out how the pipeline above should behave.

Plain send

ProducerDemo

Builds the minimal Properties, sends one ProducerRecord to notification-email-queue, then calls flush() and close(). The shape every other producer example builds on.

Async callbacks & RecordMetadata

ProducerDemoWIthCallback

Sends batches of records in a loop, attaching a callback to each send(). On success, the callback logs the RecordMetadata — topic, partition, offset, timestamp; on failure, it logs the exception. This is how you find out what actually happened to a record you fired off asynchronously.

Keys and partition assignment

ProducerDemoWIthCallbackKeys

Same pattern, but every record now carries a key ("id_" + i). The callback logs the key next to the partition it landed on — proving that a given key always maps to the same partition, the exact mechanism this page's key toggle visualizes.

The poll loop

ConsumerDemo

Subscribes to notification-email-queue in group test-group, then loops forever calling consumer.poll(1000) and logging each record. Consumers don't get pushed data — they pull it in bounded batches. This version has no shutdown handling: killing the process just kills it mid-poll.

Graceful shutdown

ConsumerDemoWithShutDown

Adds a JVM shutdown hook that calls consumer.wakeup() and joins the main thread; the poll loop catches the resulting WakeupException and closes the consumer in a finally block. wakeup() is the only thread-safe way to interrupt a blocked poll() — without it, a killed consumer just vanishes mid-batch instead of leaving the group cleanly.

Cooperative-sticky rebalancing

ConsumerDemoWithCooperative

Identical to the shutdown-safe consumer, with one line added: partition.assignment.strategy set to CooperativeStickyAssignor. With Kafka's eager default, every consumer in the group revokes all its partitions and stops for the duration of any rebalance. Cooperative-sticky only revokes the specific partitions that need to move — everyone else keeps consuming, which is what the add/remove-consumer toggle above illustrates.

What this demonstrates

Skills the code actually evidences

Honest scope

What this is, and what it isn't

It's a focused ingestion pipeline plus a learning lab of producer/consumer mechanics. It is not a production deployment, and it doesn't pretend to be.

Known gaps, stated plainly

  • The broker address is hardcoded (127.0.0.1:9092 in the pipeline, localhost:9092 in the examples) — point it at your own broker before running
  • No schema registry — events are forwarded as raw JSON strings via StringSerializer, with no enforced or evolvable contract
  • No dead-letter topic or retry policy — a send failure is only logged
  • No consumer reads wikimedia.recentchange in this repository — the pipeline is producer-only
  • No delivery-guarantee tuning configured explicitly (acks, enable.idempotence, retries) — the producer runs on Kafka's client defaults, not a deliberately chosen guarantee

What I'd add next

  • Avro (or JSON Schema) with a schema registry, so the topic has an enforced, evolvable contract instead of raw strings
  • An idempotent producer with acks=all and bounded retries, for a deliberate exactly-once-per-partition guarantee
  • A consumer — plain or Kafka Streams — doing per-language or per-wiki aggregation off wikimedia.recentchange
  • A containerised broker via Docker Compose, so the whole pipeline runs with one command instead of a hand-run broker
Run it yourself

Prerequisites and commands

./gradlew :kafka-producer-wikimedia:run

Opens an SSE connection to Wikimedia's recent-changes stream and publishes every event to wikimedia.recentchange.

./gradlew build

Builds both modules. The kafka-examples classes have no application plugin configured — run them directly from your IDE (right-click a class → Run), as the README suggests.