Skip to content
Vince.
All case studies

Streaming · Kafka · Quarkus

Stream processor

Subscriber usage in real time, on Kafka Streams

A pipeline that parses raw usage events, de-duplicates redeliveries, aggregates volume per subscriber into event-time windows, raises threshold alerts, and dead-letters anything unusable. The whole topology is tested without a broker.

  • Java 21
  • Kafka Streams
  • Quarkus
  • JUnit

Repository: stream-processor

In short

  • Event-time windowing with an explicit grace period — late data lands in the window it belongs to
  • Suppression until window close, so consumers get one answer per window instead of a stream of partial ones
  • Poison payloads routed to a dead-letter topic rather than killing the stream thread
  • 23 tests drive the real topology through TopologyTestDriver — no broker, no Docker, event time controlled to the millisecond
  • `docker compose up` and one script runs the whole scenario against a real broker

The failure modes are the design

A consumer that reads a topic and adds up numbers is a morning's work. What takes the time is everything that happens when the input is not what you hoped: the same event delivered twice because a producer's acknowledgement timed out, a batch that arrives forty seconds after the window it belongs to, a payload that is not JSON at all.

Each of those has a wrong answer that looks entirely plausible. A duplicate inflates a subscriber's usage and can trip a threshold alert that should never have fired. A late batch gets counted in the wrong hour. A malformed record throws inside the consumer, kills the stream thread, and gets redelivered on restart to kill it again — a poison pill that stops the pipeline until somebody intervenes.

Parse, do not deserialise

Records are consumed as strings and parsed explicitly, rather than being handed to a serde. A serde that throws does so inside the consumer, where there is no opportunity to route the offending record anywhere useful; the exception takes down the thread and the record is still there on restart.

Modelling the failure as data instead of an exception means a bad record becomes a value that can be branched on. Anything unparseable, or well-formed but missing the fields the pipeline needs, goes to a dead-letter topic with the original payload kept verbatim — a dead letter that has been helpfully cleaned up is useless, because the point is to replay exactly what arrived once the defect is fixed.

Suppression, and why one answer beats many

Kafka Streams emits an updated aggregate on every record by default. For a usage total that means a consumer sees a stream of partial answers it must know to discard, and the alerting branch fires on every record after the threshold rather than once per breach.

Suppressing until the window closes fixes both. The test that pins it down sends twelve records that collectively cross the limit and asserts exactly one alert — without suppression that is twelve alerts for one breach, which is how alerting systems get muted.

The bug the tests found

De-duplication keeps recently seen event ids in a state store. The first implementation keyed that store on the event id alone, which looked obviously correct and passed four of the five de-duplication tests.

The fifth sent two different subscribers an event carrying the same id. A Kafka partition holds many subscribers, and event ids are only unique within the system that issued them, so the second subscriber's event looked exactly like a redelivery of the first one's — and was silently discarded. Real usage, dropped, with nothing logged and no error anywhere.

The fix is one line: key the store on subscriber and event id together. The point is not the fix, it is that a plausible-looking implementation lost data in a way that no amount of staring at it would have revealed, and a test that took two minutes to write did.

Testing a distributed system without distributing it

The topology is a pure function from a configuration record to a Topology object. It reads no configuration, opens no connections, and imports nothing from Quarkus — the only framework-aware class in the repository is the one that supplies the config.

That makes the entire pipeline drivable through Kafka's TopologyTestDriver: real parsing, real state stores, real windowing and suppression, with event time under the test's control. The scenarios that are near-impossible to arrange against a live cluster — an event arriving after its grace expired, a producer retry, a poison payload — are ordinary unit tests that run in single-digit milliseconds.

Seeing it run

Captured from an actual run, not an illustration. The repository has the script that produced it.

./demo/run-demo.sh
$ docker compose up -d && ./demo/setup.sh  ready after 1s  topic subscriber-events  topic subscriber-usage-windows  topic subscriber-usage-alerts  topic subscriber-events-dlq $ ./demo/run-demo.sh Producing the scenario  sub-1  600 bytes over three events, plus a REDELIVERY of e1  sub-2  1100 bytes over two events (the alert threshold is 1000)  sub-3  three unusable records: bad JSON, no subscriberId, negative bytes Keeping the stream alive so the windows can close  heartbeat 1/5  heartbeat 5/5 subscriber-usage-windows  sub-1 | {"subscriberId":"sub-1","windowStart":1786325920000,"totalBytes":600,"eventCount":3}  sub-2 | {"subscriberId":"sub-2","windowStart":1786325920000,"totalBytes":1100,"eventCount":2} subscriber-usage-alerts  sub-2 | {"subscriberId":"sub-2","totalBytes":1100,"thresholdBytes":1000} subscriber-events-dlq  sub-3 | {"payload":"{not json at all","reason":"malformed JSON: Unexpected character..."}  sub-3 | {"payload":"{\"eventId\":\"g1\"...}","reason":"subscriberId is required"}  sub-3 | {"payload":"{\"eventId\":\"g2\"...}","reason":"bytes must not be negative"}
Against a real single-node Kafka. sub-1 sent four events and was counted for three — two carried the same eventId. sub-3 sent three unusable records and has no usage window at all.

Source

  • src/main/java/com/dvpalmes/streaming/topology/SubscriberUsageTopology.java
  • src/main/java/com/dvpalmes/streaming/topology/DeduplicationProcessor.java
  • src/test/java/com/dvpalmes/streaming/topology/SubscriberUsageTopologyTest.java